diff --git a/.prettierrc b/.prettierrc index 9b18f6a..ccf8d0b 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,9 +1,9 @@ { - "semi": true, - "singleQuote": false, - "trailingComma": "es5", + "semi": false, + "singleQuote": true, + "trailingComma": "none", "printWidth": 80, "tabWidth": 2, - "arrowParens": "always", - "endOfLine": "lf" + "arrowParens": "avoid", + "endOfLine": "auto" } diff --git a/README.md b/README.md index dabcdad..e1c9fab 100644 --- a/README.md +++ b/README.md @@ -103,21 +103,21 @@ Example: ```js var appList = [ { - name: "Chat", - link: "/twake", - icon: "/assets/images/svg/app-chat.svg", + name: 'Chat', + link: '/twake', + icon: '/assets/images/svg/app-chat.svg' }, { - name: "Drive", - link: "/drive", - icon: "/assets/images/svg/app-drive.svg", + name: 'Drive', + link: '/drive', + icon: '/assets/images/svg/app-drive.svg' }, { - name: "Mail", - link: "/mail", - icon: "/assets/images/svg/app-mail.svg", - }, -]; + name: 'Mail', + link: '/mail', + icon: '/assets/images/svg/app-mail.svg' + } +] ``` **Note**: `appList.js` is gitignored, so each environment can have its own configuration. The icon files in `public/assets/images/svg/` should be committed to the repository. diff --git a/__test__/components/Calendar.test.tsx b/__test__/components/Calendar.test.tsx index ab8874b..484be77 100644 --- a/__test__/components/Calendar.test.tsx +++ b/__test__/components/Calendar.test.tsx @@ -1,719 +1,712 @@ -import CalendarApp from "@/components/Calendar/Calendar"; -import CalendarLayout from "@/components/Calendar/CalendarLayout"; -import * as calendarDetailThunks from "@/features/Calendars/services"; -import * as servicesModule from "@/features/Calendars/services"; -import { searchUsers } from "@/features/User/userAPI"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { useRef } from "react"; -import { renderWithProviders } from "../utils/Renderwithproviders"; +import CalendarApp from '@/components/Calendar/Calendar' +import CalendarLayout from '@/components/Calendar/CalendarLayout' +import * as calendarDetailThunks from '@/features/Calendars/services' +import * as servicesModule from '@/features/Calendars/services' +import { searchUsers } from '@/features/User/userAPI' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useRef } from 'react' +import { renderWithProviders } from '../utils/Renderwithproviders' -jest.mock("@/features/User/userAPI"); -const mockedSearchUsers = searchUsers as jest.MockedFunction< - typeof searchUsers ->; +jest.mock('@/features/User/userAPI') +const mockedSearchUsers = searchUsers as jest.MockedFunction // Test wrapper component to provide calendarRef function CalendarTestWrapper() { - const calendarRef = useRef(null); - return ; + const calendarRef = useRef(null) + return } -describe("CalendarSelection", () => { +describe('CalendarSelection', () => { beforeEach(() => { - localStorage.clear(); - }); + localStorage.clear() + }) afterEach(() => { - jest.useRealTimers(); - }); - const today = new Date(); - const start = new Date(today); - start.setHours(10, 0, 0, 0); - const end = new Date(today); - end.setHours(11, 0, 0, 0); + jest.useRealTimers() + }) + const today = new Date() + const start = new Date(today) + start.setHours(10, 0, 0, 0) + const end = new Date(today) + end.setHours(11, 0, 0, 0) const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "user1", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: 'user1' }, - tokens: { accessToken: "token" }, // required to avoid redirect + tokens: { accessToken: 'token' } // required to avoid redirect }, calendars: { list: { - "user1/cal1": { - name: "Calendar personal", - id: "user1/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["alice@example.com"] }, + 'user1/cal1': { + name: 'Calendar personal', + id: 'user1/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['alice@example.com'] }, events: { event1: { - id: "event1", - calId: "user1/cal1", - uid: "event1", - title: "Test Event", + id: 'event1', + calId: 'user1/cal1', + uid: 'event1', + title: 'Test Event', start: start.toISOString(), end: end.toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", - }, - ], - }, - }, + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' + } + ] + } + } }, - "user2/cal1": { - name: "Calendar delegated", + 'user2/cal1': { + name: 'Calendar delegated', delegated: true, - id: "user2/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["alice@example.com"], lastname: "alice" }, + id: 'user2/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['alice@example.com'], lastname: 'alice' }, events: { event1: { - id: "event1", - calId: "user2/cal1", - uid: "event1", - title: "Test Event", + id: 'event1', + calId: 'user2/cal1', + uid: 'event1', + title: 'Test Event', start: start.toISOString(), end: end.toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", - }, - ], - }, - }, + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' + } + ] + } + } }, - "user3/cal1": { - name: "Calendar shared", - id: "user3/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["alice@example.com"], lastname: "alice" }, + 'user3/cal1': { + name: 'Calendar shared', + id: 'user3/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['alice@example.com'], lastname: 'alice' }, events: { event1: { - id: "event1", - calId: "user3/cal1", - uid: "event1", - title: "Test Event", + id: 'event1', + calId: 'user3/cal1', + uid: 'event1', + title: 'Test Event', start: start.toISOString(), end: end.toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", - }, - ], - }, - }, - }, + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' + } + ] + } + } + } }, - pending: true, - }, - }; - it("renders calendars", async () => { - const mockCalendarRef = { current: null }; + pending: true + } + } + it('renders calendars', async () => { + const mockCalendarRef = { current: null } await act(async () => { renderWithProviders( , preloadedState - ); - }); - expect(screen.getByText("calendar.personal")).toBeInTheDocument(); - expect(screen.getByText("calendar.delegated")).toBeInTheDocument(); - expect(screen.getByText("calendar.other")).toBeInTheDocument(); + ) + }) + expect(screen.getByText('calendar.personal')).toBeInTheDocument() + expect(screen.getByText('calendar.delegated')).toBeInTheDocument() + expect(screen.getByText('calendar.other')).toBeInTheDocument() - expect(screen.getByLabelText("Calendar personal")).toBeInTheDocument(); - expect(screen.getByLabelText("Calendar delegated")).toBeInTheDocument(); - expect(screen.getByLabelText("Calendar shared")).toBeInTheDocument(); - }); - it("open accordeon when clicking on button only", async () => { - const mockCalendarRef = { current: null }; + expect(screen.getByLabelText('Calendar personal')).toBeInTheDocument() + expect(screen.getByLabelText('Calendar delegated')).toBeInTheDocument() + expect(screen.getByLabelText('Calendar shared')).toBeInTheDocument() + }) + it('open accordeon when clicking on button only', async () => { + const mockCalendarRef = { current: null } await act(async () => { renderWithProviders( , preloadedState - ); - }); - expect(screen.getByText("calendar.personal")).toBeInTheDocument(); - expect(screen.getByText("calendar.delegated")).toBeInTheDocument(); - expect(screen.getByText("calendar.other")).toBeInTheDocument(); + ) + }) + expect(screen.getByText('calendar.personal')).toBeInTheDocument() + expect(screen.getByText('calendar.delegated')).toBeInTheDocument() + expect(screen.getByText('calendar.other')).toBeInTheDocument() - expect(screen.getByLabelText("Calendar personal")).toBeInTheDocument(); - expect(screen.getByLabelText("Calendar delegated")).toBeInTheDocument(); - expect(screen.getByLabelText("Calendar shared")).toBeInTheDocument(); + expect(screen.getByLabelText('Calendar personal')).toBeInTheDocument() + expect(screen.getByLabelText('Calendar delegated')).toBeInTheDocument() + expect(screen.getByLabelText('Calendar shared')).toBeInTheDocument() const sharedAccordionSummary = screen - .getByText("calendar.other") - .closest(".MuiAccordionSummary-root"); + .getByText('calendar.other') + .closest('.MuiAccordionSummary-root') - const addButton = screen.getAllByTestId("AddIcon")[2]; + const addButton = screen.getAllByTestId('AddIcon')[2] await act(async () => { - fireEvent.click(addButton); - }); - expect(sharedAccordionSummary).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(addButton) + }) + expect(sharedAccordionSummary).toHaveAttribute('aria-expanded', 'true') await act(async () => { - fireEvent.click(addButton); - }); - expect(sharedAccordionSummary).toHaveAttribute("aria-expanded", "true"); - }); - it("renders calendars with local storage", async () => { - const mockCalendarRef = { current: null }; + fireEvent.click(addButton) + }) + expect(sharedAccordionSummary).toHaveAttribute('aria-expanded', 'true') + }) + it('renders calendars with local storage', async () => { + const mockCalendarRef = { current: null } localStorage.setItem( - "selectedCalendars", + 'selectedCalendars', JSON.stringify(Object.keys(preloadedState.calendars.list)) - ); + ) await act(async () => { renderWithProviders( , preloadedState - ); - }); + ) + }) - expect(screen.getByLabelText("Calendar personal")).toBeChecked(); - expect(screen.getByLabelText("Calendar delegated")).toBeChecked(); - expect(screen.getByLabelText("Calendar shared")).toBeChecked(); - }); - it("persist selected calendars in local storage", async () => { - const mockCalendarRef = { current: null }; + expect(screen.getByLabelText('Calendar personal')).toBeChecked() + expect(screen.getByLabelText('Calendar delegated')).toBeChecked() + expect(screen.getByLabelText('Calendar shared')).toBeChecked() + }) + it('persist selected calendars in local storage', async () => { + const mockCalendarRef = { current: null } await act(async () => { renderWithProviders( , preloadedState - ); - }); + ) + }) - expect(screen.getByLabelText("Calendar personal")).toBeChecked(); - expect(screen.getByLabelText("Calendar delegated")).not.toBeChecked(); - expect(screen.getByLabelText("Calendar shared")).not.toBeChecked(); + expect(screen.getByLabelText('Calendar personal')).toBeChecked() + expect(screen.getByLabelText('Calendar delegated')).not.toBeChecked() + expect(screen.getByLabelText('Calendar shared')).not.toBeChecked() await act(async () => { - fireEvent.click(screen.getByLabelText("Calendar personal")); - fireEvent.click(screen.getByLabelText("Calendar shared")); - }); + fireEvent.click(screen.getByLabelText('Calendar personal')) + fireEvent.click(screen.getByLabelText('Calendar shared')) + }) - expect(localStorage.getItem("selectedCalendars")).toBe('["user3/cal1"]'); - }); -}); + expect(localStorage.getItem('selectedCalendars')).toBe('["user3/cal1"]') + }) +}) -describe("calendar Availability search", () => { +describe('calendar Availability search', () => { beforeEach(() => { - localStorage.clear(); - }); + localStorage.clear() + }) afterEach(() => { - jest.useRealTimers(); - }); + jest.useRealTimers() + }) const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "user1", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: 'user1' }, organiserData: { - cn: "test", - cal_address: "test@test.com", + cn: 'test', + cal_address: 'test@test.com' }, - tokens: { accessToken: "token" }, + tokens: { accessToken: 'token' } }, calendars: { list: { - "user1/cal1": { - name: "Calendar personal", - id: "user1/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["alice@example.com"] }, - events: {}, - }, + 'user1/cal1': { + name: 'Calendar personal', + id: 'user1/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['alice@example.com'] }, + events: {} + } }, pending: true, - templist: {}, - }, - }; + templist: {} + } + } - it("imports temporary calendars when selecting new users", async () => { + it('imports temporary calendars when selecting new users', async () => { const spy = jest - .spyOn(servicesModule, "getTempCalendarsListAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(servicesModule, 'getTempCalendarsListAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) mockedSearchUsers.mockResolvedValueOnce([ { - email: "newuser@example.com", - displayName: "New User", - avatarUrl: "image.png", - openpaasId: "1234567890", - }, - ]); + email: 'newuser@example.com', + displayName: 'New User', + avatarUrl: 'image.png', + openpaasId: '1234567890' + } + ]) await act(async () => renderWithProviders(, preloadedState) - ); + ) const input = screen.getByPlaceholderText( - "peopleSearch.availabilityPlaceholder" - ); + 'peopleSearch.availabilityPlaceholder' + ) act(() => { - userEvent.type(input, "New"); - }); - const option = await screen.findByText("New User"); + userEvent.type(input, 'New') + }) + const option = await screen.findByText('New User') await act(async () => { - fireEvent.click(option); - }); + fireEvent.click(option) + }) - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - it("open window with attendees filled after temp search on create event button click", async () => { + it('open window with attendees filled after temp search on create event button click', async () => { const spy = jest - .spyOn(servicesModule, "getTempCalendarsListAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(servicesModule, 'getTempCalendarsListAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) mockedSearchUsers.mockResolvedValue([ { - email: "newuser@example.com", - displayName: "New User", - avatarUrl: "image.png", - openpaasId: "1234567890", - }, - ]); + email: 'newuser@example.com', + displayName: 'New User', + avatarUrl: 'image.png', + openpaasId: '1234567890' + } + ]) await act(async () => renderWithProviders(, preloadedState) - ); + ) const input = screen.getByPlaceholderText( - "peopleSearch.availabilityPlaceholder" - ); - await act(async () => userEvent.type(input, "New")); + 'peopleSearch.availabilityPlaceholder' + ) + await act(async () => userEvent.type(input, 'New')) - const option = await screen.findByText("New User"); + const option = await screen.findByText('New User') await act(async () => { - fireEvent.click(option); - }); - expect(spy).toHaveBeenCalled(); + fireEvent.click(option) + }) + expect(spy).toHaveBeenCalled() await act(async () => { - fireEvent.click( - screen.getByRole("button", { name: "event.createEvent" }) - ); - }); + fireEvent.click(screen.getByRole('button', { name: 'event.createEvent' })) + }) await waitFor( () => { - expect(screen.getAllByText("event.createEvent")).toHaveLength(2); - expect(screen.getAllByText(/New User/i)).toHaveLength(2); + expect(screen.getAllByText('event.createEvent')).toHaveLength(2) + expect(screen.getAllByText(/New User/i)).toHaveLength(2) }, { timeout: 10000 } - ); - }, 15000); + ) + }, 15000) - it("open window with attendees filled after temp search on enter in temp input", async () => { + it('open window with attendees filled after temp search on enter in temp input', async () => { const spy = jest - .spyOn(servicesModule, "getTempCalendarsListAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(servicesModule, 'getTempCalendarsListAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) mockedSearchUsers.mockResolvedValue([ { - email: "newuser@example.com", - displayName: "New User", - avatarUrl: "image.png", - openpaasId: "1234567890", - }, - ]); + email: 'newuser@example.com', + displayName: 'New User', + avatarUrl: 'image.png', + openpaasId: '1234567890' + } + ]) await act(async () => renderWithProviders(, preloadedState) - ); + ) const input = screen.getByPlaceholderText( - "peopleSearch.availabilityPlaceholder" - ); - await act(async () => userEvent.type(input, "New")); + 'peopleSearch.availabilityPlaceholder' + ) + await act(async () => userEvent.type(input, 'New')) - const option = await screen.findByText("New User"); + const option = await screen.findByText('New User') await act(async () => { - fireEvent.click(option); - }); - expect(spy).toHaveBeenCalled(); + fireEvent.click(option) + }) + expect(spy).toHaveBeenCalled() await act(async () => { - fireEvent.keyDown(input, { key: "Enter" }); - }); + fireEvent.keyDown(input, { key: 'Enter' }) + }) await waitFor( () => { - expect(screen.getAllByText("event.createEvent")).toHaveLength(2); - expect(screen.getAllByText(/New User/i)).toHaveLength(2); + expect(screen.getAllByText('event.createEvent')).toHaveLength(2) + expect(screen.getAllByText(/New User/i)).toHaveLength(2) }, { timeout: 10000 } - ); - }, 15000); + ) + }, 15000) - it("BUGFIX: can untoggle all calendar.personal", async () => { + it('BUGFIX: can untoggle all calendar.personal', async () => { jest - .spyOn(calendarDetailThunks, "getCalendarDetailAsync") + .spyOn(calendarDetailThunks, 'getCalendarDetailAsync') .mockImplementation( () => ({ - type: "getCalendarDetailAsync", - unwrap: () => Promise.resolve({}), + type: 'getCalendarDetailAsync', + unwrap: () => Promise.resolve({}) }) as any - ); + ) await act(async () => renderWithProviders(, { user: preloadedState.user, calendars: { - list: { "user1/cal1": preloadedState.calendars.list["user1/cal1"] }, - }, + list: { 'user1/cal1': preloadedState.calendars.list['user1/cal1'] } + } }) - ); + ) - const checkbox = screen.getByLabelText("Calendar personal"); - expect(checkbox).toBeChecked(); + const checkbox = screen.getByLabelText('Calendar personal') + expect(checkbox).toBeChecked() - fireEvent.click(checkbox); // toggle off + fireEvent.click(checkbox) // toggle off - expect(checkbox).not.toBeChecked(); - }); + expect(checkbox).not.toBeChecked() + }) it("BUGFIX: monthview doesn't show days numbers in banner", async () => { await act(async () => renderWithProviders(, { user: preloadedState.user, calendars: { - list: { "user1/cal1": preloadedState.calendars.list["user1/cal1"] }, - pending: false, - }, + list: { 'user1/cal1': preloadedState.calendars.list['user1/cal1'] }, + pending: false + } }) - ); + ) - const calendarRef = window.__calendarRef; + const calendarRef = window.__calendarRef await waitFor(() => { - expect(calendarRef.current).not.toBeNull(); - }); + expect(calendarRef.current).not.toBeNull() + }) - const calendarApi = calendarRef.current; + const calendarApi = calendarRef.current await act(async () => { - calendarApi.changeView("dayGridMonth"); - }); + calendarApi.changeView('dayGridMonth') + }) await waitFor(() => { - expect(screen.queryAllByRole("columnheader").length).toBe(14); - }); + expect(screen.queryAllByRole('columnheader').length).toBe(14) + }) - const dayNumbers = screen.getAllByRole("columnheader"); - expect(dayNumbers.length).toBeGreaterThan(0); + const dayNumbers = screen.getAllByRole('columnheader') + expect(dayNumbers.length).toBeGreaterThan(0) - const dayNumbersWithContent = dayNumbers.filter((cell) => { + const dayNumbersWithContent = dayNumbers.filter(cell => { const dayNum = cell.querySelector( - ".fc-daygrid-day-number, .fc-daygrid-day-top" - ); - return dayNum && dayNum.textContent && /\d/.test(dayNum.textContent); - }); - expect(dayNumbersWithContent.length).toBe(0); - }); + '.fc-daygrid-day-number, .fc-daygrid-day-top' + ) + return dayNum && dayNum.textContent && /\d/.test(dayNum.textContent) + }) + expect(dayNumbersWithContent.length).toBe(0) + }) - it("should fetch calendar details for February after navigating to month view and clicking next", async () => { + it('should fetch calendar details for February after navigating to month view and clicking next', async () => { const spy = jest - .spyOn(calendarDetailThunks, "getCalendarDetailAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - const thunk = () => Object.assign(promise, { unwrap: () => promise }); - return thunk as any; - }); - jest.useFakeTimers().setSystemTime(new Date("2025-01-01")); + .spyOn(calendarDetailThunks, 'getCalendarDetailAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + const thunk = () => Object.assign(promise, { unwrap: () => promise }) + return thunk as any + }) + jest.useFakeTimers().setSystemTime(new Date('2025-01-01')) await act(async () => renderWithProviders(, { ...preloadedState, - calendars: { ...preloadedState.calendars, pending: false }, + calendars: { ...preloadedState.calendars, pending: false } }) - ); + ) // Advance past debounce so the initial load fires await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - spy.mockClear(); + spy.mockClear() - const calendarRef = window.__calendarRef; - const calendarApi = calendarRef.current; + const calendarRef = window.__calendarRef + const calendarApi = calendarRef.current await act(async () => { - calendarApi.changeView("dayGridMonth"); - fireEvent.click(screen.getByTestId("ChevronRightIcon")); - }); + calendarApi.changeView('dayGridMonth') + fireEvent.click(screen.getByTestId('ChevronRightIcon')) + }) // Advance past debounce so the navigation fetch fires await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) // Find the call that covers February 2025 (the navigated-to month) - const februaryCall = spy.mock.calls.find((call) => { - const start = call[0].match.start as string; + const februaryCall = spy.mock.calls.find(call => { + const start = call[0].match.start as string // start should be in January or February 2025 (month view includes padding days) - return start.startsWith("2025"); - }); + return start.startsWith('2025') + }) - expect(februaryCall).toBeDefined(); - const callArgs = februaryCall![0]; - expect(callArgs.calId).toBe("user1/cal1"); + expect(februaryCall).toBeDefined() + const callArgs = februaryCall![0] + expect(callArgs.calId).toBe('user1/cal1') const parseDate = (s: string) => new Date( s.replace( /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/, - "$1-$2-$3T$4:$5:$6" + '$1-$2-$3T$4:$5:$6' ) - ); + ) - const startDate = parseDate(callArgs.match.start); - const endDate = parseDate(callArgs.match.end); + const startDate = parseDate(callArgs.match.start) + const endDate = parseDate(callArgs.match.end) // February 2025 month view: range must cover Feb 1 through Mar 1 expect(startDate.getTime()).toBeLessThanOrEqual( - new Date("2025-02-01").getTime() - ); + new Date('2025-02-01').getTime() + ) expect(endDate.getTime()).toBeGreaterThanOrEqual( - new Date("2025-03-01").getTime() - ); - }); + new Date('2025-03-01').getTime() + ) + }) - describe("Batch loading and prefetching", () => { + describe('Batch loading and prefetching', () => { beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - it("loads active calendars in batches of 5", async () => { + it('loads active calendars in batches of 5', async () => { const manyCalendars = Array.from({ length: 12 }, (_, i) => ({ [`user1/cal${i + 1}`]: { name: `Calendar ${i + 1}`, id: `user1/cal${i + 1}`, - color: { light: "#FF0000", dark: "#000" }, - events: {}, - }, - })).reduce((acc, cal) => ({ ...acc, ...cal }), {}); + color: { light: '#FF0000', dark: '#000' }, + events: {} + } + })).reduce((acc, cal) => ({ ...acc, ...cal }), {}) const stateWithManyCalendars = { ...preloadedState, calendars: { ...preloadedState.calendars, list: manyCalendars, - pending: false, - }, - }; + pending: false + } + } const spy = jest - .spyOn(calendarDetailThunks, "getCalendarDetailAsync") + .spyOn(calendarDetailThunks, 'getCalendarDetailAsync') .mockImplementation( () => ({ - type: "getCalendarDetailAsync", - unwrap: () => Promise.resolve({}), + type: 'getCalendarDetailAsync', + unwrap: () => Promise.resolve({}) }) as any - ); + ) await act(async () => { renderWithProviders( , stateWithManyCalendars - ); - }); + ) + }) await waitFor( () => { - expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled() }, { timeout: 3000 } - ); + ) - const callCount = spy.mock.calls.length; - expect(callCount).toBeGreaterThan(0); - expect(callCount).toBeLessThanOrEqual(36); - }); + const callCount = spy.mock.calls.length + expect(callCount).toBeGreaterThan(0) + expect(callCount).toBeLessThanOrEqual(36) + }) - it("prefetches hidden calendars only after active load completes", async () => { + it('prefetches hidden calendars only after active load completes', async () => { const calendarsWithSelected = { ...preloadedState, calendars: { ...preloadedState.calendars, list: { - "user1/cal1": { - name: "Selected Calendar", - id: "user1/cal1", - color: { light: "#FF0000", dark: "#000" }, - events: {}, + 'user1/cal1': { + name: 'Selected Calendar', + id: 'user1/cal1', + color: { light: '#FF0000', dark: '#000' }, + events: {} }, - "user1/cal2": { - name: "Hidden Calendar 1", - id: "user1/cal2", - color: { light: "#00FF00", dark: "#000" }, - events: {}, - }, - "user1/cal3": { - name: "Hidden Calendar 2", - id: "user1/cal3", - color: { light: "#0000FF", dark: "#000" }, - events: {}, + 'user1/cal2': { + name: 'Hidden Calendar 1', + id: 'user1/cal2', + color: { light: '#00FF00', dark: '#000' }, + events: {} }, + 'user1/cal3': { + name: 'Hidden Calendar 2', + id: 'user1/cal3', + color: { light: '#0000FF', dark: '#000' }, + events: {} + } }, - pending: false, - }, - }; + pending: false + } + } const spy = jest - .spyOn(calendarDetailThunks, "getCalendarDetailAsync") + .spyOn(calendarDetailThunks, 'getCalendarDetailAsync') .mockImplementation( () => ({ - type: "getCalendarDetailAsync", - unwrap: () => Promise.resolve({}), + type: 'getCalendarDetailAsync', + unwrap: () => Promise.resolve({}) }) as any - ); + ) await act(async () => { renderWithProviders( , calendarsWithSelected - ); - }); + ) + }) await waitFor( () => { - expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled() }, { timeout: 3000 } - ); + ) const selectedCalls = spy.mock.calls.filter( - (call) => call[0].calId === "user1/cal1" - ); + call => call[0].calId === 'user1/cal1' + ) spy.mock.calls.filter( - (call) => - call[0].calId === "user1/cal2" || call[0].calId === "user1/cal3" - ); + call => call[0].calId === 'user1/cal2' || call[0].calId === 'user1/cal3' + ) - expect(selectedCalls.length).toBeGreaterThan(0); - }); + expect(selectedCalls.length).toBeGreaterThan(0) + }) - it("does not make duplicate API calls for same calendar and range", async () => { + it('does not make duplicate API calls for same calendar and range', async () => { const spy = jest - .spyOn(calendarDetailThunks, "getCalendarDetailAsync") + .spyOn(calendarDetailThunks, 'getCalendarDetailAsync') .mockImplementation( () => ({ - type: "getCalendarDetailAsync", - unwrap: () => Promise.resolve({}), + type: 'getCalendarDetailAsync', + unwrap: () => Promise.resolve({}) }) as any - ); + ) await act(async () => { renderWithProviders(, { ...preloadedState, - calendars: { ...preloadedState.calendars, pending: false }, - }); - }); + calendars: { ...preloadedState.calendars, pending: false } + }) + }) await waitFor( () => { - expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled() }, { timeout: 3000 } - ); + ) const callsForCal1 = spy.mock.calls.filter( - (call) => call[0].calId === "user1/cal1" - ); + call => call[0].calId === 'user1/cal1' + ) const uniqueRanges = new Set( - callsForCal1.map( - (call) => `${call[0].match.start}_${call[0].match.end}` - ) - ); + callsForCal1.map(call => `${call[0].match.start}_${call[0].match.end}`) + ) - expect(uniqueRanges.size).toBeLessThanOrEqual(callsForCal1.length); - }); + expect(uniqueRanges.size).toBeLessThanOrEqual(callsForCal1.length) + }) - it("handles undefined calendars gracefully", async () => { + it('handles undefined calendars gracefully', async () => { const stateWithUndefinedCalendars = { ...preloadedState, calendars: { list: undefined, templist: undefined, - pending: false, - }, - }; + pending: false + } + } await act(async () => { renderWithProviders( , stateWithUndefinedCalendars - ); - }); + ) + }) - expect(screen.getByText("calendar.personal")).toBeInTheDocument(); - }); + expect(screen.getByText('calendar.personal')).toBeInTheDocument() + }) - it("handles undefined tempcalendars gracefully", async () => { + it('handles undefined tempcalendars gracefully', async () => { const stateWithUndefinedTemp = { ...preloadedState, calendars: { ...preloadedState.calendars, - templist: undefined, - }, - }; + templist: undefined + } + } await act(async () => { renderWithProviders( , stateWithUndefinedTemp - ); - }); + ) + }) - expect(screen.getByText("calendar.personal")).toBeInTheDocument(); - }); - }); -}); + expect(screen.getByText('calendar.personal')).toBeInTheDocument() + }) + }) +}) diff --git a/__test__/components/Calendar.timezone.test.tsx b/__test__/components/Calendar.timezone.test.tsx index ad77f50..f688f21 100644 --- a/__test__/components/Calendar.timezone.test.tsx +++ b/__test__/components/Calendar.timezone.test.tsx @@ -1,106 +1,106 @@ -import CalendarLayout from "@/components/Calendar/CalendarLayout"; -import { act, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../utils/Renderwithproviders"; +import CalendarLayout from '@/components/Calendar/CalendarLayout' +import { act, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../utils/Renderwithproviders' -const PIVOT_UTC = new Date("2026-03-16T17:00:00Z"); +const PIVOT_UTC = new Date('2026-03-16T17:00:00Z') const makeState = (timezone: string) => ({ user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "user1", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: 'user1' }, - tokens: { accessToken: "token" }, + tokens: { accessToken: 'token' } }, - settings: { view: "calendar", timeZone: timezone }, + settings: { view: 'calendar', timeZone: timezone }, calendars: { list: { - "user1/cal1": { - name: "Calendar personal", - id: "user1/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["test@test.com"] }, - events: {}, - }, + 'user1/cal1': { + name: 'Calendar personal', + id: 'user1/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['test@test.com'] }, + events: {} + } }, - pending: false, - }, -}); + pending: false + } +}) -describe("Calendar – dayHeaderContent respects selected timezone", () => { +describe('Calendar – dayHeaderContent respects selected timezone', () => { afterEach(() => { - jest.useRealTimers(); - }); + jest.useRealTimers() + }) async function renderAtPivotTime(timezone: string): Promise { // Set fake timers the same way the working test does — single chained call - jest.useFakeTimers().setSystemTime(PIVOT_UTC); + jest.useFakeTimers().setSystemTime(PIVOT_UTC) await act(async () => { - renderWithProviders(, makeState(timezone)); - }); + renderWithProviders(, makeState(timezone)) + }) // Advance past the debounce (300ms) like the working navigation test does await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) - const calendarRef = window.__calendarRef; - await waitFor(() => expect(calendarRef?.current).not.toBeNull()); + const calendarRef = window.__calendarRef + await waitFor(() => expect(calendarRef?.current).not.toBeNull()) await act(async () => { - calendarRef.current?.changeView("timeGridWeek"); - }); + calendarRef.current?.changeView('timeGridWeek') + }) await waitFor(() => { - expect(document.querySelectorAll(".fc-col-header-cell").length).toBe(7); - }); + expect(document.querySelectorAll('.fc-col-header-cell').length).toBe(7) + }) - return Array.from(document.querySelectorAll(".fc-col-header-cell")).map( - (cell) => cell.textContent ?? "" - ); + return Array.from(document.querySelectorAll('.fc-col-header-cell')).map( + cell => cell.textContent ?? '' + ) } - it("UTC: Monday March 16 column exists", async () => { - const cells = await renderAtPivotTime("UTC"); - const monday = cells.find((t) => t.includes("16")); - expect(monday).toBeDefined(); - expect(monday).toMatch(/MON/i); - }); + it('UTC: Monday March 16 column exists', async () => { + const cells = await renderAtPivotTime('UTC') + const monday = cells.find(t => t.includes('16')) + expect(monday).toBeDefined() + expect(monday).toMatch(/MON/i) + }) - it("Europe/Paris: Monday March 16 column exists (UTC+1, no day shift)", async () => { - const cells = await renderAtPivotTime("Europe/Paris"); - const monday = cells.find((t) => t.includes("16")); - expect(monday).toBeDefined(); - expect(monday).toMatch(/MON/i); - }); + it('Europe/Paris: Monday March 16 column exists (UTC+1, no day shift)', async () => { + const cells = await renderAtPivotTime('Europe/Paris') + const monday = cells.find(t => t.includes('16')) + expect(monday).toBeDefined() + expect(monday).toMatch(/MON/i) + }) - it("Asia/Jakarta: Tuesday March 17 column exists (UTC+7, day shifts forward)", async () => { - const cells = await renderAtPivotTime("Asia/Jakarta"); - const tuesday = cells.find((t) => t.includes("17")); - expect(tuesday).toBeDefined(); - expect(tuesday).toMatch(/TUE/i); - }); + it('Asia/Jakarta: Tuesday March 17 column exists (UTC+7, day shifts forward)', async () => { + const cells = await renderAtPivotTime('Asia/Jakarta') + const tuesday = cells.find(t => t.includes('17')) + expect(tuesday).toBeDefined() + expect(tuesday).toMatch(/TUE/i) + }) - it("Asia/Jakarta: no column incorrectly labeled MON 17 – regression", async () => { - const cells = await renderAtPivotTime("Asia/Jakarta"); - const wrongCell = cells.find((t) => t.includes("17") && /MON/i.test(t)); - expect(wrongCell).toBeUndefined(); - }); + it('Asia/Jakarta: no column incorrectly labeled MON 17 – regression', async () => { + const cells = await renderAtPivotTime('Asia/Jakarta') + const wrongCell = cells.find(t => t.includes('17') && /MON/i.test(t)) + expect(wrongCell).toBeUndefined() + }) - it("Asia/Tokyo: Tuesday March 17 column exists (UTC+9)", async () => { - const cells = await renderAtPivotTime("Asia/Tokyo"); - const tuesday = cells.find((t) => t.includes("17")); - expect(tuesday).toBeDefined(); - expect(tuesday).toMatch(/TUE/i); - }); + it('Asia/Tokyo: Tuesday March 17 column exists (UTC+9)', async () => { + const cells = await renderAtPivotTime('Asia/Tokyo') + const tuesday = cells.find(t => t.includes('17')) + expect(tuesday).toBeDefined() + expect(tuesday).toMatch(/TUE/i) + }) - it("America/New_York: Monday March 16 column exists (UTC-4, no day shift)", async () => { - const cells = await renderAtPivotTime("America/New_York"); - const monday = cells.find((t) => t.includes("16")); - expect(monday).toBeDefined(); - expect(monday).toMatch(/MON/i); - }); -}); + it('America/New_York: Monday March 16 column exists (UTC-4, no day shift)', async () => { + const cells = await renderAtPivotTime('America/New_York') + const monday = cells.find(t => t.includes('16')) + expect(monday).toBeDefined() + expect(monday).toMatch(/MON/i) + }) +}) diff --git a/__test__/components/CalendarColors.test.tsx b/__test__/components/CalendarColors.test.tsx index 45b6c25..97b4774 100644 --- a/__test__/components/CalendarColors.test.tsx +++ b/__test__/components/CalendarColors.test.tsx @@ -1,413 +1,411 @@ -import CalendarSearch from "@/components/Calendar/CalendarSearch"; -import * as CalendarApi from "@/features/Calendars/CalendarApi"; -import * as CalendarSlice from "@/features/Calendars/services"; -import { searchUsers } from "@/features/User/userAPI"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../utils/Renderwithproviders"; +import CalendarSearch from '@/components/Calendar/CalendarSearch' +import * as CalendarApi from '@/features/Calendars/CalendarApi' +import * as CalendarSlice from '@/features/Calendars/services' +import { searchUsers } from '@/features/User/userAPI' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../utils/Renderwithproviders' -jest.mock("@/features/User/userAPI"); -jest.mock("@/features/Calendars/CalendarApi"); +jest.mock('@/features/User/userAPI') +jest.mock('@/features/Calendars/CalendarApi') -const mockedSearchUsers = searchUsers as jest.MockedFunction< - typeof searchUsers ->; +const mockedSearchUsers = searchUsers as jest.MockedFunction const mockedGetCalendars = CalendarApi.getCalendars as jest.MockedFunction< typeof CalendarApi.getCalendars ->; +> -describe("CalendarSearch", () => { - const mockOnClose = jest.fn(); +describe('CalendarSearch', () => { + const mockOnClose = jest.fn() const mockUser = { - email: "user@example.com", - displayName: "Test User", - avatarUrl: "https://example.com/avatar.jpg", - openpaasId: "user123", - }; + email: 'user@example.com', + displayName: 'Test User', + avatarUrl: 'https://example.com/avatar.jpg', + openpaasId: 'user123' + } const mockCalendar = { - "dav:name": "Test Calendar", - "apple:color": "#FF0000", + 'dav:name': 'Test Calendar', + 'apple:color': '#FF0000', _links: { self: { - href: "/calendars/user123/cal1.json", - }, - }, - }; + href: '/calendars/user123/cal1.json' + } + } + } const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "user1", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: 'user1' }, - tokens: { accessToken: "token" }, + tokens: { accessToken: 'token' } }, calendars: { list: { - "user1/cal1": { - name: "My Calendar", - id: "user1/cal1", - color: "#0000FF", - owner: { emails: ["test@test.com"] }, - events: {}, - }, + 'user1/cal1': { + name: 'My Calendar', + id: 'user1/cal1', + color: '#0000FF', + owner: { emails: ['test@test.com'] }, + events: {} + } }, - pending: false, - }, - }; + pending: false + } + } beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - it("searches for users and displays their calendars", async () => { - mockedSearchUsers.mockResolvedValueOnce([mockUser]); + it('searches for users and displays their calendars', async () => { + mockedSearchUsers.mockResolvedValueOnce([mockUser]) mockedGetCalendars.mockResolvedValueOnce({ _embedded: { - "dav:calendar": [mockCalendar], - }, - }); + 'dav:calendar': [mockCalendar] + } + }) await act(async () => { renderWithProviders( , preloadedState - ); - }); + ) + }) - const input = screen.getByRole("combobox"); + const input = screen.getByRole('combobox') await act(async () => { - userEvent.type(input, "Test"); - }); + userEvent.type(input, 'Test') + }) - const option = await screen.findByText("Test User"); + const option = await screen.findByText('Test User') await act(async () => { - fireEvent.click(option); - }); + fireEvent.click(option) + }) await waitFor(() => { expect(mockedGetCalendars).toHaveBeenCalledWith( - "user123", - "sharedPublic=true&" - ); - }); + 'user123', + 'sharedPublic=true&' + ) + }) await waitFor(() => { - expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument(); - expect(screen.getByText("user@example.com")).toBeInTheDocument(); - }); - }); + expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument() + expect(screen.getByText('user@example.com')).toBeInTheDocument() + }) + }) - it("adds selected calendars on save", async () => { + it('adds selected calendars on save', async () => { const addSharedCalendarSpy = jest - .spyOn(CalendarSlice, "addSharedCalendarAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(CalendarSlice, 'addSharedCalendarAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) - mockedSearchUsers.mockResolvedValueOnce([mockUser]); + mockedSearchUsers.mockResolvedValueOnce([mockUser]) mockedGetCalendars.mockResolvedValueOnce({ _embedded: { - "dav:calendar": [mockCalendar], - }, - }); + 'dav:calendar': [mockCalendar] + } + }) await act(async () => { renderWithProviders( , preloadedState - ); - }); + ) + }) - const input = screen.getByRole("combobox"); + const input = screen.getByRole('combobox') await act(async () => { - userEvent.type(input, "Test"); - }); + userEvent.type(input, 'Test') + }) - const option = await screen.findByText("Test User"); + const option = await screen.findByText('Test User') await act(async () => { - fireEvent.click(option); - }); + fireEvent.click(option) + }) await waitFor(() => { - expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument(); - }); + expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument() + }) - const addButton = screen.getByRole("button", { name: /add/i }); + const addButton = screen.getByRole('button', { name: /add/i }) await act(async () => { - fireEvent.click(addButton); - }); + fireEvent.click(addButton) + }) - expect(addSharedCalendarSpy).toHaveBeenCalled(); - expect(mockOnClose).toHaveBeenCalled(); - }); + expect(addSharedCalendarSpy).toHaveBeenCalled() + expect(mockOnClose).toHaveBeenCalled() + }) - it("does not add calendars that already exist", async () => { + it('does not add calendars that already exist', async () => { const addSharedCalendarSpy = jest - .spyOn(CalendarSlice, "addSharedCalendarAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(CalendarSlice, 'addSharedCalendarAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) const existingCalendar = { ...mockCalendar, _links: { self: { - href: "/calendars/user1/cal1.json", - }, - }, - }; + href: '/calendars/user1/cal1.json' + } + } + } - mockedSearchUsers.mockResolvedValueOnce([mockUser]); + mockedSearchUsers.mockResolvedValueOnce([mockUser]) mockedGetCalendars.mockResolvedValueOnce({ _embedded: { - "dav:calendar": [existingCalendar], - }, - }); + 'dav:calendar': [existingCalendar] + } + }) await act(async () => { renderWithProviders( , preloadedState - ); - }); + ) + }) - const input = screen.getByRole("combobox"); + const input = screen.getByRole('combobox') await act(async () => { - userEvent.type(input, "Test"); - }); + userEvent.type(input, 'Test') + }) - const option = await screen.findByText("Test User"); + const option = await screen.findByText('Test User') await act(async () => { - fireEvent.click(option); - }); + fireEvent.click(option) + }) await waitFor(() => { expect( - screen.getByText("calendar.noMoreCalendarsFor(name=Test User)") - ).toBeInTheDocument(); - }); + screen.getByText('calendar.noMoreCalendarsFor(name=Test User)') + ).toBeInTheDocument() + }) - const addButton = screen.getByRole("button", { name: /add/i }); + const addButton = screen.getByRole('button', { name: /add/i }) await act(async () => { - fireEvent.click(addButton); - }); + fireEvent.click(addButton) + }) - expect(addSharedCalendarSpy).not.toHaveBeenCalled(); - }); + expect(addSharedCalendarSpy).not.toHaveBeenCalled() + }) - it("displays message when user has no publicly available calendars", async () => { - mockedSearchUsers.mockResolvedValueOnce([mockUser]); - mockedGetCalendars.mockResolvedValueOnce({}); + it('displays message when user has no publicly available calendars', async () => { + mockedSearchUsers.mockResolvedValueOnce([mockUser]) + mockedGetCalendars.mockResolvedValueOnce({}) await act(async () => { renderWithProviders( , preloadedState - ); - }); + ) + }) - const input = screen.getByRole("combobox"); + const input = screen.getByRole('combobox') await act(async () => { - userEvent.type(input, "Test"); - }); + userEvent.type(input, 'Test') + }) - const option = await screen.findByText("Test User"); + const option = await screen.findByText('Test User') await act(async () => { - fireEvent.click(option); - }); + fireEvent.click(option) + }) await waitFor(() => { expect( - screen.getByText("calendar.noPublicCalendarsFor(name=Test User)") - ).toBeInTheDocument(); - }); - }); + screen.getByText('calendar.noPublicCalendarsFor(name=Test User)') + ).toBeInTheDocument() + }) + }) - it("changes calendar color", async () => { - mockedSearchUsers.mockResolvedValueOnce([mockUser]); + it('changes calendar color', async () => { + mockedSearchUsers.mockResolvedValueOnce([mockUser]) mockedGetCalendars.mockResolvedValueOnce({ _embedded: { - "dav:calendar": [mockCalendar], - }, - }); + 'dav:calendar': [mockCalendar] + } + }) await act(async () => { renderWithProviders( , preloadedState - ); - }); + ) + }) - const input = screen.getByRole("combobox"); + const input = screen.getByRole('combobox') await act(async () => { - userEvent.type(input, "Test"); - }); + userEvent.type(input, 'Test') + }) - const option = await screen.findByText("Test User"); + const option = await screen.findByText('Test User') await act(async () => { - fireEvent.click(option); - }); + fireEvent.click(option) + }) await waitFor(() => { - expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument(); - }); + expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument() + }) // ColorPicker would need to be interacted with based on its implementation // This is a placeholder for color change interaction - const colorPicker = document.querySelector('[data-testid="color-picker"]'); + const colorPicker = document.querySelector('[data-testid="color-picker"]') if (colorPicker) { await act(async () => { - fireEvent.click(colorPicker); - }); + fireEvent.click(colorPicker) + }) } - }); + }) - it("handles multiple calendars from the same user", async () => { + it('handles multiple calendars from the same user', async () => { const secondCalendar = { - "dav:name": "Second Calendar", - "apple:color": "#00FF00", + 'dav:name': 'Second Calendar', + 'apple:color': '#00FF00', _links: { self: { - href: "/calendars/user123/cal2.json", - }, - }, - }; + href: '/calendars/user123/cal2.json' + } + } + } - mockedSearchUsers.mockResolvedValueOnce([mockUser]); + mockedSearchUsers.mockResolvedValueOnce([mockUser]) mockedGetCalendars.mockResolvedValueOnce({ _embedded: { - "dav:calendar": [mockCalendar, secondCalendar], - }, - }); + 'dav:calendar': [mockCalendar, secondCalendar] + } + }) await act(async () => { renderWithProviders( , preloadedState - ); - }); + ) + }) - const input = screen.getByRole("combobox"); + const input = screen.getByRole('combobox') await act(async () => { - userEvent.type(input, "Test"); - }); + userEvent.type(input, 'Test') + }) - const option = await screen.findByText("Test User"); + const option = await screen.findByText('Test User') await act(async () => { - fireEvent.click(option); - }); + fireEvent.click(option) + }) await waitFor(() => { - expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument(); - expect(screen.getByText(/Second Calendar/i)).toBeInTheDocument(); - }); - }); + expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument() + expect(screen.getByText(/Second Calendar/i)).toBeInTheDocument() + }) + }) - it("does not call addSharedCalendarAsync when no calendars are selected", async () => { + it('does not call addSharedCalendarAsync when no calendars are selected', async () => { const addSharedCalendarSpy = jest - .spyOn(CalendarSlice, "addSharedCalendarAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(CalendarSlice, 'addSharedCalendarAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) await act(async () => { renderWithProviders( , preloadedState - ); - }); + ) + }) - const addButton = screen.getByRole("button", { name: /add/i }); + const addButton = screen.getByRole('button', { name: /add/i }) await act(async () => { - fireEvent.click(addButton); - }); + fireEvent.click(addButton) + }) - expect(addSharedCalendarSpy).not.toHaveBeenCalled(); - }); - it("BUGFIX : handles calendar with no apple:color", async () => { + expect(addSharedCalendarSpy).not.toHaveBeenCalled() + }) + it('BUGFIX : handles calendar with no apple:color', async () => { const addSharedCalendarSpy = jest - .spyOn(CalendarSlice, "addSharedCalendarAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(CalendarSlice, 'addSharedCalendarAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) const mockCalendarNoColor = { - "dav:name": "Test Calendar", + 'dav:name': 'Test Calendar', _links: { self: { - href: "/calendars/user123/cal2.json", - }, - }, - }; - mockedSearchUsers.mockResolvedValueOnce([mockUser]); + href: '/calendars/user123/cal2.json' + } + } + } + mockedSearchUsers.mockResolvedValueOnce([mockUser]) mockedGetCalendars.mockResolvedValueOnce({ _embedded: { - "dav:calendar": [mockCalendarNoColor], - }, - }); + 'dav:calendar': [mockCalendarNoColor] + } + }) await act(async () => { renderWithProviders( , preloadedState - ); - }); + ) + }) - const input = screen.getByRole("combobox"); + const input = screen.getByRole('combobox') await act(async () => { - userEvent.type(input, "Test"); - }); + userEvent.type(input, 'Test') + }) await waitFor(() => { - expect(mockedSearchUsers).toHaveBeenCalledWith("Test", expect.anything()); - }); + expect(mockedSearchUsers).toHaveBeenCalledWith('Test', expect.anything()) + }) - const option = await screen.findByText("Test User"); + const option = await screen.findByText('Test User') await act(async () => { - fireEvent.click(option); - }); + fireEvent.click(option) + }) await waitFor(() => { expect(mockedGetCalendars).toHaveBeenCalledWith( - "user123", - "sharedPublic=true&" - ); - }); + 'user123', + 'sharedPublic=true&' + ) + }) await waitFor(() => { - expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument(); - expect(screen.getByText("user@example.com")).toBeInTheDocument(); - }); + expect(screen.getByText(/Test Calendar/i)).toBeInTheDocument() + expect(screen.getByText('user@example.com')).toBeInTheDocument() + }) - const addButton = screen.getByRole("button", { name: /add/i }); + const addButton = screen.getByRole('button', { name: /add/i }) await act(async () => { - fireEvent.click(addButton); - }); + fireEvent.click(addButton) + }) await waitFor(() => expect(addSharedCalendarSpy).toHaveBeenCalledWith({ cal: { cal: { - _links: { self: { href: "/calendars/user123/cal2.json" } }, - "dav:name": "Test Calendar", + _links: { self: { href: '/calendars/user123/cal2.json' } }, + 'dav:name': 'Test Calendar' }, - color: { dark: "#329655", light: "#D0ECDA" }, + color: { dark: '#329655', light: '#D0ECDA' }, owner: { - avatarUrl: "https://example.com/avatar.jpg", - displayName: "Test User", - email: "user@example.com", - openpaasId: "user123", - }, + avatarUrl: 'https://example.com/avatar.jpg', + displayName: 'Test User', + email: 'user@example.com', + openpaasId: 'user123' + } }, calId: expect.any(String), - userId: "user1", + userId: 'user1' }) - ); + ) expect(mockOnClose).toHaveBeenCalledWith( - expect.arrayContaining(["user123/cal2"]) - ); - }); -}); + expect.arrayContaining(['user123/cal2']) + ) + }) +}) diff --git a/__test__/components/CalendarResources.test.tsx b/__test__/components/CalendarResources.test.tsx index e46afe8..c80f59e 100644 --- a/__test__/components/CalendarResources.test.tsx +++ b/__test__/components/CalendarResources.test.tsx @@ -1,188 +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"; +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", () => ({ +jest.mock('@/features/Calendars/CalendarApi') +jest.mock('@/features/Calendars/api/addCalendarResourceAsync') +jest.mock('@/components/Attendees/ResourceSearch', () => ({ ResourceSearch: ({ - onChange, + onChange }: { onChange: ( event: null, value: { displayName: string; openpaasId: string }[] - ) => void; + ) => void }) => (
- ), -})); + ) +})) -const mockedGetCalendars = getCalendars as jest.Mock; +const mockedGetCalendars = getCalendars as jest.Mock const mockedAddCalendarResourceAsync = - addCalendarResourceAsync as unknown as jest.Mock; + addCalendarResourceAsync as unknown as jest.Mock -describe("CalendarResources", () => { +describe('CalendarResources', () => { beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) const baseUser = { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "user1", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: 'user1' }, - tokens: { accessToken: "token" }, - }; + tokens: { accessToken: 'token' } + } const setup = (isOpen = true) => { - const onClose = jest.fn(); + const onClose = jest.fn() renderWithProviders(, { - user: baseUser, - }); - return { onClose }; - }; + user: baseUser + }) + return { onClose } + } - it("renders correctly and closes on cancel", () => { - const { onClose } = setup(); + it('renders correctly and closes on cancel', () => { + const { onClose } = setup() - expect(screen.getByText("calendar.browseResources")).toBeInTheDocument(); + expect(screen.getByText('calendar.browseResources')).toBeInTheDocument() - const cancelButton = screen.getByRole("button", { - name: "common.cancel", - }); - fireEvent.click(cancelButton); + const cancelButton = screen.getByRole('button', { + name: 'common.cancel' + }) + fireEvent.click(cancelButton) - expect(onClose).toHaveBeenCalled(); - }); + expect(onClose).toHaveBeenCalled() + }) - it("does not render when isOpen is false", () => { - setup(false); + it('does not render when isOpen is false', () => { + setup(false) expect( - screen.queryByText("calendar.browseResources") - ).not.toBeInTheDocument(); - }); + screen.queryByText('calendar.browseResources') + ).not.toBeInTheDocument() + }) - it("fetches resource calendars and adds them using Promise.allSettled logic", async () => { + it('fetches resource calendars and adds them using Promise.allSettled logic', async () => { const mockCalendarDataA = { _embedded: { - "dav:calendar": [ + '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", - }, - ], - }, - }; + _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); + 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([]); - }); + 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); + unwrap: () => Promise.resolve({ type: 'success' }) + } + mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult) - const { onClose } = setup(); + const { onClose } = setup() // Trigger resource selection - const mockSelectBtn = screen.getByTestId("mock-resource-search-select"); + const mockSelectBtn = screen.getByTestId('mock-resource-search-select') await act(async () => { - fireEvent.click(mockSelectBtn); - }); + 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(); + const addButton = await screen.findByRole('button', { + name: 'actions.add' + }) + expect(addButton).not.toBeDisabled() // Click add to trigger addCalendarResourceAsync await act(async () => { - fireEvent.click(addButton); - }); + fireEvent.click(addButton) + }) await waitFor(() => { // It should only have been called for Room A because Room B failed (Promise.allSettled) - expect(mockedAddCalendarResourceAsync).toHaveBeenCalledTimes(1); + expect(mockedAddCalendarResourceAsync).toHaveBeenCalledTimes(1) - const payload = mockedAddCalendarResourceAsync.mock.calls[0][0]; + const payload = mockedAddCalendarResourceAsync.mock.calls[0][0] expect(payload).toEqual( expect.objectContaining({ - userId: "user1", + userId: 'user1', calId: expect.any(String), cal: expect.objectContaining({ - cal: mockCalendarDataA._embedded["dav:calendar"][0], - }), + cal: mockCalendarDataA._embedded['dav:calendar'][0] + }) }) - ); - }); + ) + }) - expect(onClose).toHaveBeenCalled(); - }); + expect(onClose).toHaveBeenCalled() + }) - it("adds existing user details fallback logic within submit handler", async () => { + it('adds existing user details fallback logic within submit handler', async () => { const mockCalendarDataA = { _embedded: { - "dav:calendar": [ + '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", - }, - ], - }, - }; + _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) + 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); + unwrap: () => Promise.resolve() + } + mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult) - const { onClose } = setup(); + const { onClose } = setup() // Trigger resource selection - const mockSelectBtn = screen.getByTestId("mock-resource-search-select"); + const mockSelectBtn = screen.getByTestId('mock-resource-search-select') await act(async () => { - fireEvent.click(mockSelectBtn); - }); + fireEvent.click(mockSelectBtn) + }) - const addButton = await screen.findByRole("button", { - name: "actions.add", - }); + const addButton = await screen.findByRole('button', { + name: 'actions.add' + }) await act(async () => { - fireEvent.click(addButton); - }); + fireEvent.click(addButton) + }) await waitFor(() => { - expect(onClose).toHaveBeenCalled(); - }); - }); -}); + expect(onClose).toHaveBeenCalled() + }) + }) +}) diff --git a/__test__/components/CalendarSelection.test.tsx b/__test__/components/CalendarSelection.test.tsx index 609fdb4..1342f75 100644 --- a/__test__/components/CalendarSelection.test.tsx +++ b/__test__/components/CalendarSelection.test.tsx @@ -1,80 +1,80 @@ -import CalendarSelection from "@/components/Calendar/CalendarSelection"; -import * as calendarThunks from "@/features/Calendars/services"; -import "@testing-library/jest-dom"; -import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../utils/Renderwithproviders"; +import CalendarSelection from '@/components/Calendar/CalendarSelection' +import * as calendarThunks from '@/features/Calendars/services' +import '@testing-library/jest-dom' +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../utils/Renderwithproviders' -describe("CalendarSelection", () => { +describe('CalendarSelection', () => { beforeEach(() => { - localStorage.clear(); - }); + localStorage.clear() + }) const baseUser = { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "user1", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: 'user1' }, - tokens: { accessToken: "token" }, - }; + tokens: { accessToken: 'token' } + } const calendarsMock = { - "user1/cal1": { - name: "Calendar personal", - id: "user1/cal1", - color: "#FF0000", - owner: { emails: ["alice@example.com"], lastname: "alice" }, + 'user1/cal1': { + name: 'Calendar personal', + id: 'user1/cal1', + color: '#FF0000', + owner: { emails: ['alice@example.com'], lastname: 'alice' } }, - "user2/cal1": { - name: "Calendar delegated", + 'user2/cal1': { + name: 'Calendar delegated', delegated: true, - id: "user2/cal1", - color: "#00FF00", + id: 'user2/cal1', + color: '#00FF00', owner: { - firstname: "Bob", - lastname: "Builder", - emails: ["bob@example.com"], - }, + firstname: 'Bob', + lastname: 'Builder', + emails: ['bob@example.com'] + } }, - "user3/cal1": { - name: "Calendar shared", - id: "user3/cal1", - color: "#0000FF", + 'user3/cal1': { + name: 'Calendar shared', + id: 'user3/cal1', + color: '#0000FF', owner: { - firstname: "Charlie", - lastname: "Chaplin", - emails: ["charlie@example.com"], - }, - }, - }; + firstname: 'Charlie', + lastname: 'Chaplin', + emails: ['charlie@example.com'] + } + } + } beforeAll(() => { - jest.clearAllMocks(); - cleanup(); - }); - it("renders personal, delegated and calendar.other", () => { + jest.clearAllMocks() + cleanup() + }) + it('renders personal, delegated and calendar.other', () => { renderWithProviders( , { user: baseUser, - calendars: { list: calendarsMock, pending: false }, + calendars: { list: calendarsMock, pending: false } } - ); + ) - expect(screen.getByText("calendar.personal")).toBeInTheDocument(); - expect(screen.getByText("calendar.delegated")).toBeInTheDocument(); - expect(screen.getByText("calendar.other")).toBeInTheDocument(); + expect(screen.getByText('calendar.personal')).toBeInTheDocument() + expect(screen.getByText('calendar.delegated')).toBeInTheDocument() + expect(screen.getByText('calendar.other')).toBeInTheDocument() - expect(screen.getByLabelText("Calendar personal")).toBeChecked(); - expect(screen.getByLabelText("Calendar delegated")).not.toBeChecked(); - expect(screen.getByLabelText("Calendar shared")).not.toBeChecked(); - }); + expect(screen.getByLabelText('Calendar personal')).toBeChecked() + expect(screen.getByLabelText('Calendar delegated')).not.toBeChecked() + expect(screen.getByLabelText('Calendar shared')).not.toBeChecked() + }) - it("toggles a calendar selection on click", () => { - const setSelectedCalendars = jest.fn(); + it('toggles a calendar selection on click', () => { + const setSelectedCalendars = jest.fn() renderWithProviders( { />, { user: baseUser, - calendars: { list: calendarsMock, pending: false }, + calendars: { list: calendarsMock, pending: false } } - ); + ) - const checkbox = screen.getByLabelText("Calendar personal"); - fireEvent.click(checkbox); + const checkbox = screen.getByLabelText('Calendar personal') + fireEvent.click(checkbox) - expect(setSelectedCalendars).toHaveBeenCalledWith(expect.any(Function)); + expect(setSelectedCalendars).toHaveBeenCalledWith(expect.any(Function)) - const updater = setSelectedCalendars.mock.calls[0][0]; - expect(updater([])).toEqual(["user1/cal1"]); - }); + const updater = setSelectedCalendars.mock.calls[0][0] + expect(updater([])).toEqual(['user1/cal1']) + }) - it("removes calendar from selection if already selected", () => { - const setSelectedCalendars = jest.fn(); + it('removes calendar from selection if already selected', () => { + const setSelectedCalendars = jest.fn() renderWithProviders( , { user: baseUser, - calendars: { list: calendarsMock, pending: false }, + calendars: { list: calendarsMock, pending: false } } - ); + ) - const checkbox = screen.getByLabelText("Calendar personal"); - fireEvent.click(checkbox); + const checkbox = screen.getByLabelText('Calendar personal') + fireEvent.click(checkbox) - const updater = setSelectedCalendars.mock.calls[0][0]; - expect(updater(["user1/cal1"])).toEqual([]); - }); + const updater = setSelectedCalendars.mock.calls[0][0] + expect(updater(['user1/cal1'])).toEqual([]) + }) - it("opens CalendarPopover modal when personal Add button is clicked", async () => { + it('opens CalendarPopover modal when personal Add button is clicked', async () => { renderWithProviders( { />, { user: baseUser, - calendars: { list: calendarsMock, pending: false }, + calendars: { list: calendarsMock, pending: false } } - ); + ) - const addButtons = screen.getAllByRole("button"); - fireEvent.click(addButtons[1]); + const addButtons = screen.getAllByRole('button') + fireEvent.click(addButtons[1]) await waitFor(() => expect( - screen.getByText("calendarPopover.tabs.addNew") + screen.getByText('calendarPopover.tabs.addNew') ).toBeInTheDocument() - ); - }); + ) + }) - it("Navigates to deletion dialog and deletes personal cal", async () => { + it('Navigates to deletion dialog and deletes personal cal', async () => { const spy = jest - .spyOn(calendarThunks, "removeCalendarAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(calendarThunks, 'removeCalendarAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) renderWithProviders( { />, { user: baseUser, - calendars: { list: calendarsMock, pending: false }, + calendars: { list: calendarsMock, pending: false } } - ); + ) - const addButtons = screen.getAllByTestId("MoreHorizIcon"); - fireEvent.click(addButtons[0]); + const addButtons = screen.getAllByTestId('MoreHorizIcon') + fireEvent.click(addButtons[0]) - userEvent.click(screen.getByText(/delete/i)); + userEvent.click(screen.getByText(/delete/i)) await waitFor(() => expect( - screen.getByText("calendar.delete.title(name=Calendar personal)") + screen.getByText('calendar.delete.title(name=Calendar personal)') ).toBeInTheDocument() - ); - fireEvent.click(screen.getByRole("button", { name: /delete/i })); + ) + fireEvent.click(screen.getByRole('button', { name: /delete/i })) - await waitFor(() => expect(spy).toHaveBeenCalled()); - }); + await waitFor(() => expect(spy).toHaveBeenCalled()) + }) - it("Navigates to deletion dialog and deletes other cal", async () => { + it('Navigates to deletion dialog and deletes other cal', async () => { const spy = jest - .spyOn(calendarThunks, "removeCalendarAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(calendarThunks, 'removeCalendarAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) renderWithProviders( { />, { user: baseUser, - calendars: { list: calendarsMock, pending: false }, + calendars: { list: calendarsMock, pending: false } } - ); + ) - const addButtons = screen.getAllByTestId("MoreHorizIcon"); - fireEvent.click(addButtons[1]); + const addButtons = screen.getAllByTestId('MoreHorizIcon') + fireEvent.click(addButtons[1]) - userEvent.click(screen.getByText(/remove/i)); + userEvent.click(screen.getByText(/remove/i)) await waitFor(() => expect( - screen.getByText("calendar.delete.title(name=Calendar delegated)") + screen.getByText('calendar.delete.title(name=Calendar delegated)') ).toBeInTheDocument() - ); - fireEvent.click(screen.getByRole("button", { name: /remove/i })); + ) + fireEvent.click(screen.getByRole('button', { name: /remove/i })) - await waitFor(() => expect(spy).toHaveBeenCalled()); - }); + await waitFor(() => expect(spy).toHaveBeenCalled()) + }) - it("opens CalendarSearch modal when Other Add button is clicked", () => { + it('opens CalendarSearch modal when Other Add button is clicked', () => { renderWithProviders( { />, { user: baseUser, - calendars: { list: calendarsMock, pending: false }, + calendars: { list: calendarsMock, pending: false } } - ); + ) - const addButtons = screen.getAllByTestId("AddIcon"); - fireEvent.click(addButtons[1]); // seccond Add button (other) + const addButtons = screen.getAllByTestId('AddIcon') + fireEvent.click(addButtons[1]) // seccond Add button (other) expect( - screen.getByText("calendar.browseOtherCalendars") - ).toBeInTheDocument(); - }); + screen.getByText('calendar.browseOtherCalendars') + ).toBeInTheDocument() + }) - it("when only calendar.personal are in the state, only calendar.personal and the title for other to be added are shown", () => { + it('when only calendar.personal are in the state, only calendar.personal and the title for other to be added are shown', () => { renderWithProviders( { user: baseUser, calendars: { list: { - "user1/cal1": calendarsMock["user1/cal1"], + 'user1/cal1': calendarsMock['user1/cal1'] }, - pending: false, - }, + pending: false + } } - ); + ) - expect(screen.getByText("calendar.personal")).toBeInTheDocument(); - expect(screen.queryByText("calendar.delegated")).not.toBeInTheDocument(); - expect(screen.queryByText("calendar.other")).toBeInTheDocument(); - }); + expect(screen.getByText('calendar.personal')).toBeInTheDocument() + expect(screen.queryByText('calendar.delegated')).not.toBeInTheDocument() + expect(screen.queryByText('calendar.other')).toBeInTheDocument() + }) - it("renders nothing when no calendars are present", () => { + it('renders nothing when no calendars are present', () => { renderWithProviders( { />, { user: baseUser, - calendars: { list: {}, pending: false }, + calendars: { list: {}, pending: false } } - ); + ) - expect(screen.queryByLabelText(/Calendar/)).not.toBeInTheDocument(); - }); + expect(screen.queryByLabelText(/Calendar/)).not.toBeInTheDocument() + }) - it("expands and collapses accordions when clicked", () => { + it('expands and collapses accordions when clicked', () => { renderWithProviders( { />, { user: baseUser, - calendars: { list: calendarsMock, pending: false }, + calendars: { list: calendarsMock, pending: false } } - ); + ) const delegatedAccordionSummary = screen - .getByText("calendar.delegated") - .closest(".MuiAccordionSummary-root"); + .getByText('calendar.delegated') + .closest('.MuiAccordionSummary-root') - fireEvent.click(delegatedAccordionSummary!); - expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(delegatedAccordionSummary!) + expect(delegatedAccordionSummary).toHaveAttribute('aria-expanded', 'false') - fireEvent.click(delegatedAccordionSummary!); - expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "true"); - }); + fireEvent.click(delegatedAccordionSummary!) + expect(delegatedAccordionSummary).toHaveAttribute('aria-expanded', 'true') + }) - it("renders owner name caption for non-personal, non-default calendars", () => { + it('renders owner name caption for non-personal, non-default calendars', () => { renderWithProviders( , { user: baseUser, calendars: { list: calendarsMock, pending: false } } - ); - expect(screen.getByText("Bob Builder")).toBeInTheDocument(); - expect(screen.getByText("Charlie Chaplin")).toBeInTheDocument(); + ) + expect(screen.getByText('Bob Builder')).toBeInTheDocument() + expect(screen.getByText('Charlie Chaplin')).toBeInTheDocument() // personal calendar should NOT show a caption - expect(screen.queryByText("alice")).not.toBeInTheDocument(); - }); -}); + expect(screen.queryByText('alice')).not.toBeInTheDocument() + }) +}) diff --git a/__test__/components/DateTimeField.test.tsx b/__test__/components/DateTimeField.test.tsx index 522cfd8..f32806c 100644 --- a/__test__/components/DateTimeField.test.tsx +++ b/__test__/components/DateTimeField.test.tsx @@ -1,343 +1,331 @@ import { DateTimeFields, - DateTimeFieldsProps, -} from "@/components/Event/components/DateTimeFields"; -import { - act, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; + DateTimeFieldsProps +} from '@/components/Event/components/DateTimeFields' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' -jest.mock("twake-i18n", () => ({ +jest.mock('twake-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, - lang: "en", - }), -})); + lang: 'en' + }) +})) -describe("DateTimeFields", () => { +describe('DateTimeFields', () => { const mockHandlers = { onStartDateChange: jest.fn(), onStartTimeChange: jest.fn(), onEndDateChange: jest.fn(), onEndTimeChange: jest.fn(), - onToggleEndDate: jest.fn(), - }; + onToggleEndDate: jest.fn() + } const defaultProps: DateTimeFieldsProps = { - startDate: "2025-07-18", - startTime: "09:00", - endDate: "2025-07-18", - endTime: "10:00", + startDate: '2025-07-18', + startTime: '09:00', + endDate: '2025-07-18', + endTime: '10:00', allday: false, showMore: true, hasEndDateChanged: false, showEndDate: false, validation: { errors: { - dateTime: "", - }, + dateTime: '' + } }, - ...mockHandlers, - }; + ...mockHandlers + } const renderField = async (props: Partial = {}) => { await act(async () => render() - ); - }; + ) + } beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - it("moves END forward when START moves after END (normal mode)", async () => { + it('moves END forward when START moves after END (normal mode)', async () => { await renderField({ - startDate: "2025-01-01", - startTime: "10:00", - endDate: "2025-01-01", - endTime: "11:00", - showMore: true, - }); + startDate: '2025-01-01', + startTime: '10:00', + endDate: '2025-01-01', + endTime: '11:00', + showMore: true + }) - const startTimeInput = screen.getByTestId("start-time-input"); + const startTimeInput = screen.getByTestId('start-time-input') - fireEvent.change(startTimeInput, { target: { value: "12:00" } }); - fireEvent.blur(startTimeInput); + fireEvent.change(startTimeInput, { target: { value: '12:00' } }) + fireEvent.blur(startTimeInput) await waitFor(() => - expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("12:00") - ); + expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith('12:00') + ) await waitFor(() => - expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("13:00") - ); - }); + expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith('13:00') + ) + }) - it("moves END forward by full duration when START date jumps after END date", async () => { + it('moves END forward by full duration when START date jumps after END date', async () => { await renderField({ - startDate: "2025-01-01", - startTime: "09:00", - endDate: "2025-01-01", - endTime: "10:00", - showMore: true, - }); + startDate: '2025-01-01', + startTime: '09:00', + endDate: '2025-01-01', + endTime: '10:00', + showMore: true + }) - await userEvent.click(screen.getByTestId("start-date-input")); - const dayButton = screen.getByRole("gridcell", { name: "3" }); - await userEvent.click(dayButton); + await userEvent.click(screen.getByTestId('start-date-input')) + const dayButton = screen.getByRole('gridcell', { name: '3' }) + await userEvent.click(dayButton) await waitFor(() => - expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-01-03") - ); + expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith('2025-01-03') + ) await waitFor(() => - expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-01-03") - ); - }); + expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith('2025-01-03') + ) + }) - it("does NOT move START backward when END moves before START (normal mode)", async () => { + it('does NOT move START backward when END moves before START (normal mode)', async () => { await renderField({ - startDate: "2025-01-01", - startTime: "10:00", - endDate: "2025-01-01", - endTime: "11:00", - showMore: true, - }); + startDate: '2025-01-01', + startTime: '10:00', + endDate: '2025-01-01', + endTime: '11:00', + showMore: true + }) - const endTimeInput = screen.getByTestId("end-time-input"); + const endTimeInput = screen.getByTestId('end-time-input') - fireEvent.change(endTimeInput, { target: { value: "08:00" } }); - fireEvent.blur(endTimeInput); + fireEvent.change(endTimeInput, { target: { value: '08:00' } }) + fireEvent.blur(endTimeInput) await waitFor(() => - expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("08:00") - ); + expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith('08:00') + ) // Start time should NOT be automatically adjusted when end time changes - expect(mockHandlers.onStartTimeChange).not.toHaveBeenCalled(); - expect(mockHandlers.onStartDateChange).not.toHaveBeenCalled(); - }); + expect(mockHandlers.onStartTimeChange).not.toHaveBeenCalled() + expect(mockHandlers.onStartDateChange).not.toHaveBeenCalled() + }) - it("moves START backward properly when END date jumps before START date", async () => { + it('moves START backward properly when END date jumps before START date', async () => { await renderField({ - startDate: "2025-01-05", - startTime: "09:00", - endDate: "2025-01-05", - endTime: "10:00", - showMore: true, - }); + startDate: '2025-01-05', + startTime: '09:00', + endDate: '2025-01-05', + endTime: '10:00', + showMore: true + }) - await userEvent.click(screen.getByTestId("end-date-input")); - const dayButton = screen.getByRole("gridcell", { name: "3" }); - await userEvent.click(dayButton); + await userEvent.click(screen.getByTestId('end-date-input')) + const dayButton = screen.getByRole('gridcell', { name: '3' }) + await userEvent.click(dayButton) await waitFor(() => - expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-01-03") - ); + expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith('2025-01-03') + ) await waitFor(() => - expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-01-03") - ); - }); + expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith('2025-01-03') + ) + }) - it("pushes END forward in whole days for allday events", async () => { + it('pushes END forward in whole days for allday events', async () => { await renderField({ allday: true, - startDate: "2025-02-01", - endDate: "2025-02-03", - showEndDate: true, - }); + startDate: '2025-02-01', + endDate: '2025-02-03', + showEndDate: true + }) - await userEvent.click(screen.getByTestId("start-date-input")); - const dayButton = screen.getByRole("gridcell", { name: "10" }); - await userEvent.click(dayButton); + await userEvent.click(screen.getByTestId('start-date-input')) + const dayButton = screen.getByRole('gridcell', { name: '10' }) + await userEvent.click(dayButton) await waitFor(() => - expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-02-10") - ); + expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith('2025-02-10') + ) await waitFor(() => - expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-02-12") - ); - }); + expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith('2025-02-12') + ) + }) - it("moves START backward in whole days for allday when END moves earlier", async () => { + it('moves START backward in whole days for allday when END moves earlier', async () => { await renderField({ allday: true, - startDate: "2025-05-10", - endDate: "2025-05-15", - showEndDate: true, - }); + startDate: '2025-05-10', + endDate: '2025-05-15', + showEndDate: true + }) - await userEvent.click(screen.getByTestId("end-date-input")); - const dayButton = screen.getByRole("gridcell", { name: "1" }); - await userEvent.click(dayButton); + await userEvent.click(screen.getByTestId('end-date-input')) + const dayButton = screen.getByRole('gridcell', { name: '1' }) + await userEvent.click(dayButton) await waitFor(() => - expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-05-01") - ); + expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith('2025-05-01') + ) await waitFor(() => - expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-04-26") - ); - }); + expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith('2025-04-26') + ) + }) - it("does not call handlers if invalid (null) date value", async () => { + it('does not call handlers if invalid (null) date value', async () => { await renderField({ - showMore: true, - }); + showMore: true + }) - const startDateInput = screen.getByTestId("start-date-input"); + const startDateInput = screen.getByTestId('start-date-input') - fireEvent.change(startDateInput, { target: { value: "" } }); + fireEvent.change(startDateInput, { target: { value: '' } }) - expect(mockHandlers.onStartDateChange).not.toHaveBeenCalled(); - expect(mockHandlers.onEndDateChange).not.toHaveBeenCalled(); - }); + expect(mockHandlers.onStartDateChange).not.toHaveBeenCalled() + expect(mockHandlers.onEndDateChange).not.toHaveBeenCalled() + }) - it("shift to preserve original duration (normal case)", async () => { + it('shift to preserve original duration (normal case)', async () => { await renderField({ - startDate: "2025-01-01", - startTime: "09:00", - endDate: "2025-01-01", - endTime: "10:00", - showMore: true, - }); + startDate: '2025-01-01', + startTime: '09:00', + endDate: '2025-01-01', + endTime: '10:00', + showMore: true + }) - const startTimeInput = screen.getByTestId("start-time-input"); + const startTimeInput = screen.getByTestId('start-time-input') - fireEvent.change(startTimeInput, { target: { value: "09:30" } }); - fireEvent.blur(startTimeInput); + fireEvent.change(startTimeInput, { target: { value: '09:30' } }) + fireEvent.blur(startTimeInput) await waitFor(() => - expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("09:30") - ); + expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith('09:30') + ) await waitFor(() => - expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("10:30") - ); - }); + expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith('10:30') + ) + }) - it("preserves 1-hour duration across midnight when changing start time from 22:30 to 23:45", async () => { + it('preserves 1-hour duration across midnight when changing start time from 22:30 to 23:45', async () => { await renderField({ - startDate: "2025-01-15", - startTime: "22:30", - endDate: "2025-01-15", - endTime: "23:30", - showMore: true, - }); + startDate: '2025-01-15', + startTime: '22:30', + endDate: '2025-01-15', + endTime: '23:30', + showMore: true + }) - const startTimeInput = screen.getByTestId("start-time-input"); + const startTimeInput = screen.getByTestId('start-time-input') // Change start time from 22:30 to 23:45 - fireEvent.change(startTimeInput, { target: { value: "23:45" } }); - fireEvent.blur(startTimeInput); + fireEvent.change(startTimeInput, { target: { value: '23:45' } }) + fireEvent.blur(startTimeInput) await waitFor(() => - expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("23:45") - ); + expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith('23:45') + ) // End date should move to the next day await waitFor(() => expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith( - "2025-01-16", - "00:45" + '2025-01-16', + '00:45' ) - ); - }); + ) + }) - it("should have aria-label for accessibility and testing", async () => { + it('should have aria-label for accessibility and testing', async () => { await renderField({ - startDate: "2025-01-01", - startTime: "10:00", - endDate: "2025-01-01", - endTime: "11:00", - showMore: true, - }); + startDate: '2025-01-01', + startTime: '10:00', + endDate: '2025-01-01', + endTime: '11:00', + showMore: true + }) - const startDateInput = screen.getByTestId("start-date-input"); - const startTimeInput = screen.getByTestId("start-time-input"); - const endDateInput = screen.getByTestId("end-date-input"); - const endTimeInput = screen.getByTestId("end-time-input"); + const startDateInput = screen.getByTestId('start-date-input') + const startTimeInput = screen.getByTestId('start-time-input') + const endDateInput = screen.getByTestId('end-date-input') + const endTimeInput = screen.getByTestId('end-time-input') expect(startDateInput).toHaveAttribute( - "aria-label", - "dateTimeFields.startDate" - ); + 'aria-label', + 'dateTimeFields.startDate' + ) expect(startTimeInput).toHaveAttribute( - "aria-label", - "dateTimeFields.startTime" - ); - expect(endDateInput).toHaveAttribute( - "aria-label", - "dateTimeFields.endDate" - ); - expect(endTimeInput).toHaveAttribute( - "aria-label", - "dateTimeFields.endTime" - ); - }); + 'aria-label', + 'dateTimeFields.startTime' + ) + expect(endDateInput).toHaveAttribute('aria-label', 'dateTimeFields.endDate') + expect(endTimeInput).toHaveAttribute('aria-label', 'dateTimeFields.endTime') + }) - describe("DateTimeFields - Drag and Drop Display Logic", () => { + describe('DateTimeFields - Drag and Drop Display Logic', () => { // Test 1.1: Display 2 fields when drag from allday slot (multiple days) - it("displays only 2 date fields when allday=true and multiple days", async () => { + it('displays only 2 date fields when allday=true and multiple days', async () => { await renderField({ allday: true, hasEndDateChanged: false, - startDate: "2025-07-18", - endDate: "2025-07-20", + startDate: '2025-07-18', + endDate: '2025-07-20', showEndDate: true, - showMore: false, - }); + showMore: false + }) - expect(screen.getByTestId("start-date-input")).toBeInTheDocument(); - expect(screen.getByTestId("end-date-input")).toBeInTheDocument(); - expect(screen.queryByTestId("start-time-input")).not.toBeInTheDocument(); - expect(screen.queryByTestId("end-time-input")).not.toBeInTheDocument(); - }); + expect(screen.getByTestId('start-date-input')).toBeInTheDocument() + expect(screen.getByTestId('end-date-input')).toBeInTheDocument() + expect(screen.queryByTestId('start-time-input')).not.toBeInTheDocument() + expect(screen.queryByTestId('end-time-input')).not.toBeInTheDocument() + }) // Test 1.2: Display 4 fields when drag from week view (multiple days) - it("displays 4 fields when allday=false, hasEndDateChanged=true, and multiple days", async () => { + it('displays 4 fields when allday=false, hasEndDateChanged=true, and multiple days', async () => { await renderField({ allday: false, hasEndDateChanged: true, - startDate: "2025-07-18", - endDate: "2025-07-20", - startTime: "09:00", - endTime: "10:00", + startDate: '2025-07-18', + endDate: '2025-07-20', + startTime: '09:00', + endTime: '10:00', showEndDate: true, - showMore: false, - }); + showMore: false + }) - expect(screen.getByTestId("start-date-input")).toBeInTheDocument(); - expect(screen.getByTestId("start-time-input")).toBeInTheDocument(); - expect(screen.getByTestId("end-date-input")).toBeInTheDocument(); - expect(screen.getByTestId("end-time-input")).toBeInTheDocument(); - }); + expect(screen.getByTestId('start-date-input')).toBeInTheDocument() + expect(screen.getByTestId('start-time-input')).toBeInTheDocument() + expect(screen.getByTestId('end-date-input')).toBeInTheDocument() + expect(screen.getByTestId('end-time-input')).toBeInTheDocument() + }) // Test 1.3: Display single date + time fields - it("displays single date field with time fields for single day event", async () => { + it('displays single date field with time fields for single day event', async () => { await renderField({ allday: false, hasEndDateChanged: false, - startDate: "2025-07-18", - endDate: "2025-07-18", - startTime: "09:00", - endTime: "10:00", + startDate: '2025-07-18', + endDate: '2025-07-18', + startTime: '09:00', + endTime: '10:00', showEndDate: false, - showMore: false, - }); + showMore: false + }) - const startDateInput = screen.getByTestId("start-date-input"); + const startDateInput = screen.getByTestId('start-date-input') expect(startDateInput).toHaveAttribute( - "aria-label", - "dateTimeFields.date" - ); - expect(screen.getByTestId("start-time-input")).toBeInTheDocument(); - expect(screen.getByTestId("end-time-input")).toBeInTheDocument(); - expect(screen.queryByTestId("end-date-input")).not.toBeInTheDocument(); - }); - }); -}); + 'aria-label', + 'dateTimeFields.date' + ) + expect(screen.getByTestId('start-time-input')).toBeInTheDocument() + expect(screen.getByTestId('end-time-input')).toBeInTheDocument() + expect(screen.queryByTestId('end-date-input')).not.toBeInTheDocument() + }) + }) +}) diff --git a/__test__/components/DuplicateEvent.test.tsx b/__test__/components/DuplicateEvent.test.tsx index bbae6ac..f5a0c07 100644 --- a/__test__/components/DuplicateEvent.test.tsx +++ b/__test__/components/DuplicateEvent.test.tsx @@ -1,93 +1,93 @@ -import { RootState } from "@/app/store"; -import EventDuplication from "@/components/Event/EventDuplicate"; -import EventPreviewModal from "@/features/Events/EventPreview"; -import EventPopover from "@/features/Events/EventModal"; -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../utils/Renderwithproviders"; +import { RootState } from '@/app/store' +import EventDuplication from '@/components/Event/EventDuplicate' +import EventPreviewModal from '@/features/Events/EventPreview' +import EventPopover from '@/features/Events/EventModal' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../utils/Renderwithproviders' -const day = new Date(); +const day = new Date() const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro', + openpaasId: '667037022b752d0026472254' }, organiserData: { - cn: "test", - cal_address: "mailto:test@test.com", - }, + cn: 'test', + cal_address: 'mailto:test@test.com' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "Calendar", - color: "#FF0000", - owner: { emails: ["test@test.com"] }, + '667037022b752d0026472254/cal1': { + id: '667037022b752d0026472254/cal1', + name: 'Calendar', + color: '#FF0000', + owner: { emails: ['test@test.com'] }, events: { event1: { - uid: "event1", - URL: "calendars/667037022b752d0026472254/cal1/event1.ics", - title: "Test Event", - calId: "667037022b752d0026472254/cal1", + uid: 'event1', + URL: 'calendars/667037022b752d0026472254/cal1/event1.ics', + title: 'Test Event', + calId: '667037022b752d0026472254/cal1', start: day.toISOString(), end: day.toISOString(), - timezone: "UTC", - organizer: { cn: "test", cal_address: "test@test.com" }, + timezone: 'UTC', + organizer: { cn: 'test', cal_address: 'test@test.com' }, attendee: [ { - cn: "test", - cal_address: "test@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'test', + cal_address: 'test@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "John", - cal_address: "john@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ], - }, - }, - }, + cn: 'John', + cal_address: 'john@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] + } + } + } }, - pending: false, - }, -} as unknown as RootState; + pending: false + } +} as unknown as RootState -describe("EventDuplication", () => { - it("calls onOpenDuplicate when button clicked", () => { - const handleClose = jest.fn(); - const onOpenDuplicate = jest.fn(); +describe('EventDuplication', () => { + it('calls onOpenDuplicate when button clicked', () => { + const handleClose = jest.fn() + const onOpenDuplicate = jest.fn() renderWithProviders( , preloadedState - ); + ) fireEvent.click( - screen.getByRole("menuitem", { name: "eventDuplication.duplicateEvent" }) - ); + screen.getByRole('menuitem', { name: 'eventDuplication.duplicateEvent' }) + ) - expect(onOpenDuplicate).toHaveBeenCalled(); - }); -}); + expect(onOpenDuplicate).toHaveBeenCalled() + }) +}) -describe("EventPopover", () => { - it("renders with event data", () => { +describe('EventPopover', () => { + it('renders with event data', () => { renderWithProviders( { setSelectedRange={jest.fn()} calendarRef={{ current: null }} event={ - preloadedState.calendars.list["667037022b752d0026472254/cal1"].events + preloadedState.calendars.list['667037022b752d0026472254/cal1'].events .event1 } />, preloadedState - ); + ) - expect(screen.getByDisplayValue(/Test Event/i)).toBeInTheDocument(); - }); + expect(screen.getByDisplayValue(/Test Event/i)).toBeInTheDocument() + }) - it("saves duplicated event when Save is clicked", () => { - const onClose = jest.fn(); + it('saves duplicated event when Save is clicked', () => { + const onClose = jest.fn() renderWithProviders( { setSelectedRange={jest.fn()} calendarRef={{ current: null }} event={ - preloadedState.calendars.list["667037022b752d0026472254/cal1"].events + preloadedState.calendars.list['667037022b752d0026472254/cal1'].events .event1 } />, preloadedState - ); + ) fireEvent.change(screen.getByLabelText(/Title/i), { - target: { value: "Duplicated Event" }, - }); - fireEvent.click(screen.getByRole("button", { name: "actions.save" })); + target: { value: 'Duplicated Event' } + }) + fireEvent.click(screen.getByRole('button', { name: 'actions.save' })) - waitFor(() => expect(onClose).toHaveBeenCalled()); - }); -}); + waitFor(() => expect(onClose).toHaveBeenCalled()) + }) +}) -describe("EventDisplayModal", () => { - it("shows duplication button and opens duplication form", () => { +describe('EventDisplayModal', () => { + it('shows duplication button and opens duplication form', () => { renderWithProviders( { onClose={jest.fn()} />, preloadedState - ); + ) - fireEvent.click(screen.getByTestId("MoreVertIcon")); + fireEvent.click(screen.getByTestId('MoreVertIcon')) fireEvent.click( - screen.getByRole("menuitem", { name: "eventDuplication.duplicateEvent" }) - ); + screen.getByRole('menuitem', { name: 'eventDuplication.duplicateEvent' }) + ) expect( - screen.getAllByText("eventDuplication.duplicateEvent")[1] - ).toBeInTheDocument(); + screen.getAllByText('eventDuplication.duplicateEvent')[1] + ).toBeInTheDocument() expect( screen.getByDisplayValue( - preloadedState.calendars.list["667037022b752d0026472254/cal1"].events + preloadedState.calendars.list['667037022b752d0026472254/cal1'].events .event1.title as string ) - ).toBeInTheDocument(); - }); -}); + ).toBeInTheDocument() + }) +}) diff --git a/__test__/components/Event/utils/dateTimeHelpers.test.ts b/__test__/components/Event/utils/dateTimeHelpers.test.ts index d012b1e..2250f62 100644 --- a/__test__/components/Event/utils/dateTimeHelpers.test.ts +++ b/__test__/components/Event/utils/dateTimeHelpers.test.ts @@ -3,176 +3,174 @@ import { convertFormDateTimeToISO, DATETIME_WITH_SECONDS_LENGTH, DATETIME_FORMAT_WITH_SECONDS, - DATETIME_FORMAT_WITHOUT_SECONDS, -} from "@/components/Event/utils/dateTimeHelpers"; + DATETIME_FORMAT_WITHOUT_SECONDS +} from '@/components/Event/utils/dateTimeHelpers' -describe("dateTimeHelpers", () => { - describe("Constants", () => { - it("should have correct constant values", () => { - expect(DATETIME_WITH_SECONDS_LENGTH).toBe(19); - expect(DATETIME_FORMAT_WITH_SECONDS).toBe("YYYY-MM-DDTHH:mm:ss"); - expect(DATETIME_FORMAT_WITHOUT_SECONDS).toBe("YYYY-MM-DDTHH:mm"); - }); - }); +describe('dateTimeHelpers', () => { + describe('Constants', () => { + it('should have correct constant values', () => { + expect(DATETIME_WITH_SECONDS_LENGTH).toBe(19) + expect(DATETIME_FORMAT_WITH_SECONDS).toBe('YYYY-MM-DDTHH:mm:ss') + expect(DATETIME_FORMAT_WITHOUT_SECONDS).toBe('YYYY-MM-DDTHH:mm') + }) + }) - describe("detectDateTimeFormat", () => { - it("should return format with seconds for length >= 19", () => { - expect(detectDateTimeFormat("2024-01-15T10:30:45")).toBe( + describe('detectDateTimeFormat', () => { + it('should return format with seconds for length >= 19', () => { + expect(detectDateTimeFormat('2024-01-15T10:30:45')).toBe( DATETIME_FORMAT_WITH_SECONDS - ); - expect(detectDateTimeFormat("2024-01-15T10:30:45")).toBe( - "YYYY-MM-DDTHH:mm:ss" - ); - }); + ) + expect(detectDateTimeFormat('2024-01-15T10:30:45')).toBe( + 'YYYY-MM-DDTHH:mm:ss' + ) + }) - it("should return format without seconds for length < 19", () => { - expect(detectDateTimeFormat("2024-01-15T10:30")).toBe( + it('should return format without seconds for length < 19', () => { + expect(detectDateTimeFormat('2024-01-15T10:30')).toBe( DATETIME_FORMAT_WITHOUT_SECONDS - ); - expect(detectDateTimeFormat("2024-01-15T10:30")).toBe("YYYY-MM-DDTHH:mm"); - }); + ) + expect(detectDateTimeFormat('2024-01-15T10:30')).toBe('YYYY-MM-DDTHH:mm') + }) - it("should return format with seconds for length exactly 19", () => { - const datetime = "2024-01-15T10:30:45"; - expect(datetime.length).toBe(19); - expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS); - }); + it('should return format with seconds for length exactly 19', () => { + const datetime = '2024-01-15T10:30:45' + expect(datetime.length).toBe(19) + expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS) + }) - it("should return format without seconds for length 16", () => { - const datetime = "2024-01-15T10:30"; - expect(datetime.length).toBe(16); + it('should return format without seconds for length 16', () => { + const datetime = '2024-01-15T10:30' + expect(datetime.length).toBe(16) expect(detectDateTimeFormat(datetime)).toBe( DATETIME_FORMAT_WITHOUT_SECONDS - ); - }); + ) + }) - it("should return format with seconds for length > 19", () => { - const datetime = "2024-01-15T10:30:45.123"; - expect(datetime.length).toBeGreaterThan(19); - expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS); - }); + it('should return format with seconds for length > 19', () => { + const datetime = '2024-01-15T10:30:45.123' + expect(datetime.length).toBeGreaterThan(19) + expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS) + }) - it("should handle empty string", () => { - expect(detectDateTimeFormat("")).toBe(DATETIME_FORMAT_WITHOUT_SECONDS); - }); + it('should handle empty string', () => { + expect(detectDateTimeFormat('')).toBe(DATETIME_FORMAT_WITHOUT_SECONDS) + }) - it("should handle very short strings", () => { - expect(detectDateTimeFormat("2024")).toBe( - DATETIME_FORMAT_WITHOUT_SECONDS - ); - }); - }); + it('should handle very short strings', () => { + expect(detectDateTimeFormat('2024')).toBe(DATETIME_FORMAT_WITHOUT_SECONDS) + }) + }) - describe("convertFormDateTimeToISO", () => { - const originalConsoleWarn = console.warn; - let consoleWarnSpy: jest.SpyInstance; + describe('convertFormDateTimeToISO', () => { + const originalConsoleWarn = console.warn + let consoleWarnSpy: jest.SpyInstance beforeEach(() => { - consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); - }); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation() + }) afterEach(() => { - consoleWarnSpy.mockRestore(); - }); + consoleWarnSpy.mockRestore() + }) - it("should convert valid datetime without seconds to ISO string", () => { + it('should convert valid datetime without seconds to ISO string', () => { const result = convertFormDateTimeToISO( - "2024-01-15T10:30", - "America/New_York" - ); - expect(result).toBeTruthy(); - expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); + '2024-01-15T10:30', + 'America/New_York' + ) + expect(result).toBeTruthy() + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + expect(consoleWarnSpy).not.toHaveBeenCalled() + }) - it("should convert valid datetime with seconds to ISO string", () => { + it('should convert valid datetime with seconds to ISO string', () => { const result = convertFormDateTimeToISO( - "2024-01-15T10:30:45", - "America/New_York" - ); - expect(result).toBeTruthy(); - expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); + '2024-01-15T10:30:45', + 'America/New_York' + ) + expect(result).toBeTruthy() + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + expect(consoleWarnSpy).not.toHaveBeenCalled() + }) - it("should use Etc/UTC as default timezone when timezone is empty", () => { - const result = convertFormDateTimeToISO("2024-01-15T10:30", ""); - expect(result).toBeTruthy(); - expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); - }); + it('should use Etc/UTC as default timezone when timezone is empty', () => { + const result = convertFormDateTimeToISO('2024-01-15T10:30', '') + expect(result).toBeTruthy() + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + }) - it("should return empty string for empty datetime input", () => { - const result = convertFormDateTimeToISO("", "America/New_York"); - expect(result).toBe(""); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); + it('should return empty string for empty datetime input', () => { + const result = convertFormDateTimeToISO('', 'America/New_York') + expect(result).toBe('') + expect(consoleWarnSpy).not.toHaveBeenCalled() + }) - it("should return empty string and log warning for invalid datetime", () => { + it('should return empty string and log warning for invalid datetime', () => { const result = convertFormDateTimeToISO( - "invalid-date", - "America/New_York" - ); - expect(result).toBe(""); + 'invalid-date', + 'America/New_York' + ) + expect(result).toBe('') expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining("[convertFormDateTimeToISO] Invalid datetime:") - ); + expect.stringContaining('[convertFormDateTimeToISO] Invalid datetime:') + ) expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('"invalid-date"') - ); - }); + ) + }) - it("should return empty string and log warning for invalid format", () => { + it('should return empty string and log warning for invalid format', () => { const result = convertFormDateTimeToISO( - "2024-13-45T25:99:99", - "America/New_York" - ); - expect(result).toBe(""); - expect(consoleWarnSpy).toHaveBeenCalled(); - }); + '2024-13-45T25:99:99', + 'America/New_York' + ) + expect(result).toBe('') + expect(consoleWarnSpy).toHaveBeenCalled() + }) - it("should handle different timezones correctly", () => { + it('should handle different timezones correctly', () => { const result1 = convertFormDateTimeToISO( - "2024-01-15T10:30", - "America/New_York" - ); + '2024-01-15T10:30', + 'America/New_York' + ) const result2 = convertFormDateTimeToISO( - "2024-01-15T10:30", - "Europe/London" - ); - expect(result1).toBeTruthy(); - expect(result2).toBeTruthy(); - expect(result1).not.toBe(result2); - }); + '2024-01-15T10:30', + 'Europe/London' + ) + expect(result1).toBeTruthy() + expect(result2).toBeTruthy() + expect(result1).not.toBe(result2) + }) - it("should handle edge case with null/undefined timezone", () => { + it('should handle edge case with null/undefined timezone', () => { const result = convertFormDateTimeToISO( - "2024-01-15T10:30", + '2024-01-15T10:30', // @ts-ignore - testing edge case null - ); - expect(result).toBeTruthy(); - }); + ) + expect(result).toBeTruthy() + }) - it("should convert correctly for UTC timezone", () => { - const result = convertFormDateTimeToISO("2024-01-15T10:30", "Etc/UTC"); - expect(result).toBeTruthy(); - expect(result).toContain("T10:30:00"); - }); + it('should convert correctly for UTC timezone', () => { + const result = convertFormDateTimeToISO('2024-01-15T10:30', 'Etc/UTC') + expect(result).toBeTruthy() + expect(result).toContain('T10:30:00') + }) - it("should handle datetime at boundary (exactly 19 characters)", () => { - const datetime = "2024-01-15T10:30:45"; - expect(datetime.length).toBe(19); - const result = convertFormDateTimeToISO(datetime, "Etc/UTC"); - expect(result).toBeTruthy(); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); + it('should handle datetime at boundary (exactly 19 characters)', () => { + const datetime = '2024-01-15T10:30:45' + expect(datetime.length).toBe(19) + const result = convertFormDateTimeToISO(datetime, 'Etc/UTC') + expect(result).toBeTruthy() + expect(consoleWarnSpy).not.toHaveBeenCalled() + }) - it("should handle datetime at boundary (exactly 16 characters)", () => { - const datetime = "2024-01-15T10:30"; - expect(datetime.length).toBe(16); - const result = convertFormDateTimeToISO(datetime, "Etc/UTC"); - expect(result).toBeTruthy(); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - }); -}); + it('should handle datetime at boundary (exactly 16 characters)', () => { + const datetime = '2024-01-15T10:30' + expect(datetime.length).toBe(16) + const result = convertFormDateTimeToISO(datetime, 'Etc/UTC') + expect(result).toBeTruthy() + expect(consoleWarnSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/__test__/components/EventErrorHandling.test.tsx b/__test__/components/EventErrorHandling.test.tsx index c929004..1441436 100644 --- a/__test__/components/EventErrorHandling.test.tsx +++ b/__test__/components/EventErrorHandling.test.tsx @@ -1,102 +1,100 @@ -import CalendarLayout from "@/components/Calendar/CalendarLayout"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../utils/Renderwithproviders"; +import CalendarLayout from '@/components/Calendar/CalendarLayout' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../utils/Renderwithproviders' -describe("Event Error Handling", () => { +describe('Event Error Handling', () => { beforeEach(() => { - localStorage.clear(); - jest.clearAllMocks(); - }); + localStorage.clear() + jest.clearAllMocks() + }) - const today = new Date(); - const start = new Date(today); - start.setHours(10, 0, 0, 0); - const end = new Date(today); - end.setHours(11, 0, 0, 0); + const today = new Date() + const start = new Date(today) + start.setHours(10, 0, 0, 0) + const end = new Date(today) + end.setHours(11, 0, 0, 0) const erroredState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "user1", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: 'user1' }, - tokens: { accessToken: "token" }, + tokens: { accessToken: 'token' } }, calendars: { list: { - "user1/cal1": { - name: "Calendar personal", - id: "user1/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["alice@example.com"] }, + 'user1/cal1': { + name: 'Calendar personal', + id: 'user1/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['alice@example.com'] }, events: { event1: { - id: "event1", - calId: "user1/cal1", - uid: "event1", - title: "Test Event", + id: 'event1', + calId: 'user1/cal1', + uid: 'event1', + title: 'Test Event', start: start.toISOString(), end: start.toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", - }, - ], - }, - }, - }, + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' + } + ] + } + } + } }, - pending: false, - }, - }; + pending: false + } + } - it("BUGFIX: does not re-report errors after clearing error snackbar", async () => { - const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); + it('BUGFIX: does not re-report errors after clearing error snackbar', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation() - await act(async () => - renderWithProviders(, erroredState) - ); + await act(async () => renderWithProviders(, erroredState)) await waitFor( () => { - expect(screen.getByText("Test Event")); - expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText('Test Event')) + expect(screen.getByRole('alert')).toBeInTheDocument() }, { timeout: 10000 } - ); - const closeButton = screen.queryByRole("button", { name: "common.ok" }); + ) + const closeButton = screen.queryByRole('button', { name: 'common.ok' }) if (closeButton) { - const initialWarnCount = consoleWarnSpy.mock.calls.length; + const initialWarnCount = consoleWarnSpy.mock.calls.length await act(async () => { - fireEvent.click(closeButton); - }); + fireEvent.click(closeButton) + }) await waitFor( () => { - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument() }, { timeout: 5000 } - ); + ) - const afterCloseWarnCount = consoleWarnSpy.mock.calls.length; + const afterCloseWarnCount = consoleWarnSpy.mock.calls.length - expect(afterCloseWarnCount).toBe(initialWarnCount); + expect(afterCloseWarnCount).toBe(initialWarnCount) } - consoleWarnSpy.mockRestore(); - }, 15000); -}); + consoleWarnSpy.mockRestore() + }, 15000) +}) diff --git a/__test__/components/EventModifications.test.tsx b/__test__/components/EventModifications.test.tsx index 57b8f74..c7b58bd 100644 --- a/__test__/components/EventModifications.test.tsx +++ b/__test__/components/EventModifications.test.tsx @@ -1,350 +1,340 @@ -import * as appHooks from "@/app/hooks"; -import { AppDispatch } from "@/app/store"; -import CalendarApp from "@/components/Calendar/Calendar"; +import * as appHooks from '@/app/hooks' +import { AppDispatch } from '@/app/store' +import CalendarApp from '@/components/Calendar/Calendar' import { createEventHandlers, - EventHandlersProps, -} from "@/components/Calendar/handlers/eventHandlers"; -import * as eventThunks from "@/features/Calendars/services"; -import EventUpdateModal from "@/features/Events/EventUpdateModal"; -import { CalendarApi } from "@fullcalendar/core"; -import { jest } from "@jest/globals"; -import "@testing-library/jest-dom"; -import { - act, - fireEvent, - screen, - waitFor, - within, -} from "@testing-library/react"; -import { renderWithProviders } from "../utils/Renderwithproviders"; + EventHandlersProps +} from '@/components/Calendar/handlers/eventHandlers' +import * as eventThunks from '@/features/Calendars/services' +import EventUpdateModal from '@/features/Events/EventUpdateModal' +import { CalendarApi } from '@fullcalendar/core' +import { jest } from '@jest/globals' +import '@testing-library/jest-dom' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { renderWithProviders } from '../utils/Renderwithproviders' -describe("CalendarApp integration", () => { - const today = new Date(); - const start = new Date(today); - start.setHours(10, 0, 0, 0); - const end = new Date(today); - end.setHours(11, 0, 0, 0); +describe('CalendarApp integration', () => { + const today = new Date() + const start = new Date(today) + start.setHours(10, 0, 0, 0) + const end = new Date(today) + end.setHours(11, 0, 0, 0) beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) const renderCalendar = () => { const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, - tokens: { accessToken: "token" }, // required to avoid redirect + tokens: { accessToken: 'token' } // required to avoid redirect }, calendars: { list: { - "667037022b752d0026472254/cal1": { - name: "Calendar 1", - id: "667037022b752d0026472254/cal1", - color: { light: "#FFFFFF", dark: "#000000" }, - owner: { emails: ["alice@example.com"] }, + '667037022b752d0026472254/cal1': { + name: 'Calendar 1', + id: '667037022b752d0026472254/cal1', + color: { light: '#FFFFFF', dark: '#000000' }, + owner: { emails: ['alice@example.com'] }, events: { event1: { - id: "event1", - calId: "667037022b752d0026472254/cal1", - uid: "event1", - title: "Test Event", + id: 'event1', + calId: '667037022b752d0026472254/cal1', + uid: 'event1', + title: 'Test Event', start: start.toISOString(), end: end.toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", - }, - ], - }, - }, - }, + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' + } + ] + } + } + } }, - pending: false, - }, - }; + pending: false + } + } - const mockCalendarRef = { current: null }; + const mockCalendarRef = { current: null } renderWithProviders( , preloadedState - ); - }; + ) + } - it("renders the event on the calendar and calendarRef works", async () => { + it('renders the event on the calendar and calendarRef works', async () => { const dispatch = jest.fn().mockReturnValue( Object.assign(Promise.resolve({}), { - unwrap: () => Promise.resolve({}), + unwrap: () => Promise.resolve({}) }) - ) as unknown as AppDispatch; - jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch); + ) as unknown as AppDispatch + jest.spyOn(appHooks, 'useAppDispatch').mockReturnValue(dispatch) - renderCalendar(); + renderCalendar() const calendarRef: React.RefObject = (window as any) - .__calendarRef; + .__calendarRef - const calendarApi = calendarRef.current; + const calendarApi = calendarRef.current // Wait for the FullCalendar DOM to populate - const eventEl = await screen.findByText( - "Test Event", - {}, - { timeout: 3000 } - ); - expect(eventEl).toBeInTheDocument(); + const eventEl = await screen.findByText('Test Event', {}, { timeout: 3000 }) + expect(eventEl).toBeInTheDocument() act(() => { if (calendarApi) { - const fcEvent = calendarApi.getEventById("event1"); - expect(fcEvent?.title).toBe("Test Event"); - const oldEnd = new Date(today.getTime() + 3600000); // +1 hour - const newEnd = new Date(oldEnd.getTime() + 1800000); // +30 min + const fcEvent = calendarApi.getEventById('event1') + expect(fcEvent?.title).toBe('Test Event') + const oldEnd = new Date(today.getTime() + 3600000) // +1 hour + const newEnd = new Date(oldEnd.getTime() + 1800000) // +30 min - fcEvent?.setEnd(newEnd); + fcEvent?.setEnd(newEnd) - waitFor(() => expect(dispatch).toHaveBeenCalled()); + waitFor(() => expect(dispatch).toHaveBeenCalled()) } - }); - }); + }) + }) const createPreloadedState = (eventProps = {}) => ({ user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, tokens: { - accessToken: "token", - }, + accessToken: 'token' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - name: "Calendar 1", - id: "667037022b752d0026472254/cal1", - color: { light: "#FFFFFF", dark: "#000000" }, - owner: { emails: ["alice@example.com"] }, + '667037022b752d0026472254/cal1': { + name: 'Calendar 1', + id: '667037022b752d0026472254/cal1', + color: { light: '#FFFFFF', dark: '#000000' }, + owner: { emails: ['alice@example.com'] }, events: { event1: { - id: "event1", - calId: "667037022b752d0026472254/cal1", - uid: "event1", + id: 'event1', + calId: '667037022b752d0026472254/cal1', + uid: 'event1', start: new Date().toISOString(), end: new Date(Date.now() + 3600000).toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", - }, + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' + } ], - ...eventProps, - }, - }, - }, + ...eventProps + } + } + } }, - pending: false, - }, - }); + pending: false + } + }) - it("renders lock icon for private events on the calendar", async () => { - Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + it('renders lock icon for private events on the calendar', async () => { + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, - value: 120, - }); + value: 120 + }) const preloadedState = createPreloadedState({ - class: "PRIVATE", - title: "Private Event", - }); - const mockCalendarRef = { current: null }; + class: 'PRIVATE', + title: 'Private Event' + }) + const mockCalendarRef = { current: null } renderWithProviders( , preloadedState - ); - const card = screen.getByTestId("event-card-event1"); - const lockIcon = within(card).getByTestId("LockOutlineIcon"); - expect(lockIcon).toBeInTheDocument(); - }); + ) + const card = screen.getByTestId('event-card-event1') + const lockIcon = within(card).getByTestId('LockOutlineIcon') + expect(lockIcon).toBeInTheDocument() + }) - it("renders lock icon for confidential events on the calendar", async () => { - Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + it('renders lock icon for confidential events on the calendar', async () => { + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, - value: 120, - }); + value: 120 + }) const preloadedState = createPreloadedState({ - class: "CONFIDENTIAL", - title: "Confidential Event", - }); - const mockCalendarRef = { current: null }; + class: 'CONFIDENTIAL', + title: 'Confidential Event' + }) + const mockCalendarRef = { current: null } renderWithProviders( , preloadedState - ); + ) - const card = screen.getByTestId("event-card-event1"); - const lockIcon = within(card).getByTestId("LockOutlineIcon"); - expect(lockIcon).toBeInTheDocument(); - }); + const card = screen.getByTestId('event-card-event1') + const lockIcon = within(card).getByTestId('LockOutlineIcon') + expect(lockIcon).toBeInTheDocument() + }) - it("does NOT render a lock icon for public events on the calendar", async () => { - Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + it('does NOT render a lock icon for public events on the calendar', async () => { + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, - value: 120, - }); + value: 120 + }) const preloadedState = createPreloadedState({ - class: "PUBLIC", - title: "Public Event", - }); - const mockCalendarRef = { current: null }; + class: 'PUBLIC', + title: 'Public Event' + }) + const mockCalendarRef = { current: null } renderWithProviders( , preloadedState - ); + ) - const card = screen.getByTestId("event-card-event1"); - const lockIcon = within(card).queryByTestId("LockOutlineIcon"); - expect(lockIcon).not.toBeInTheDocument(); - }); + const card = screen.getByTestId('event-card-event1') + const lockIcon = within(card).queryByTestId('LockOutlineIcon') + expect(lockIcon).not.toBeInTheDocument() + }) - it("does render a title for events without any attendees or user as organizer", async () => { - const mockCalendarRef = { current: null }; + it('does render a title for events without any attendees or user as organizer', async () => { + const mockCalendarRef = { current: null } renderWithProviders(, { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, tokens: { - accessToken: "token", - }, + accessToken: 'token' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - name: "Calendar 1", - id: "667037022b752d0026472254/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["alice@example.com"] }, + '667037022b752d0026472254/cal1': { + name: 'Calendar 1', + id: '667037022b752d0026472254/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['alice@example.com'] }, events: { event1: { - id: "event1", - calId: "667037022b752d0026472254/cal1", - uid: "event1", + id: 'event1', + calId: '667037022b752d0026472254/cal1', + uid: 'event1', start: new Date().toISOString(), end: new Date(Date.now() + 3600000).toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, - class: "PUBLIC", - title: "Public Event", - }, - }, - }, + class: 'PUBLIC', + title: 'Public Event' + } + } + } }, - pending: false, - }, - }); + pending: false + } + }) - expect(screen.getByText("Public Event")).toBeInTheDocument(); - }); - describe("BUGFIX", () => { + expect(screen.getByText('Public Event')).toBeInTheDocument() + }) + describe('BUGFIX', () => { const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, tokens: { - accessToken: "token", - }, + accessToken: 'token' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - name: "Calendar 1", - id: "667037022b752d0026472254/cal1", - color: { light: "#FFFFFF", dark: "#000000" }, - owner: { emails: ["alice@example.com"] }, + '667037022b752d0026472254/cal1': { + name: 'Calendar 1', + id: '667037022b752d0026472254/cal1', + color: { light: '#FFFFFF', dark: '#000000' }, + owner: { emails: ['alice@example.com'] }, events: { event1: { - id: "event1", - calId: "667037022b752d0026472254/cal1", - uid: "event1", - title: "Original Event", - start: new Date("2025-11-14T10:31:00.000Z").toISOString(), - end: new Date("2025-11-14T11:31:00.000Z").toISOString(), - class: "PUBLIC", - partstat: "ACCEPTED", + id: 'event1', + calId: '667037022b752d0026472254/cal1', + uid: 'event1', + title: 'Original Event', + start: new Date('2025-11-14T10:31:00.000Z').toISOString(), + end: new Date('2025-11-14T11:31:00.000Z').toISOString(), + class: 'PUBLIC', + partstat: 'ACCEPTED', sequence: 2, organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "CHAIR", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'CHAIR', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' }, { - cn: "Bob", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "bob@example.com", - }, - ], - }, - }, - }, + cn: 'Bob', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'bob@example.com' + } + ] + } + } + } }, - pending: false, - }, - }; + pending: false + } + } - it("keeps all attendees event participation on title update", async () => { + it('keeps all attendees event participation on title update', async () => { const updateSpy = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); - const onClose = jest.fn(); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) + const onClose = jest.fn() renderWithProviders( { typeOfAction="solo" />, preloadedState - ); + ) - const titleInput = await screen.findByDisplayValue("Original Event"); + const titleInput = await screen.findByDisplayValue('Original Event') await act(async () => { - fireEvent.change(titleInput, "Updated Event"); - }); + fireEvent.change(titleInput, 'Updated Event') + }) - const saveButton = screen.getByRole("button", { name: /save/i }); + const saveButton = screen.getByRole('button', { name: /save/i }) await act(async () => { - saveButton.click(); - }); + saveButton.click() + }) - await waitFor(() => expect(updateSpy).toHaveBeenCalled()); + await waitFor(() => expect(updateSpy).toHaveBeenCalled()) - const dispatchedCalls = updateSpy.mock.calls; - expect(dispatchedCalls.length).toBeGreaterThan(0); - const updatedEvent = dispatchedCalls[0][0].newEvent; + const dispatchedCalls = updateSpy.mock.calls + expect(dispatchedCalls.length).toBeGreaterThan(0) + const updatedEvent = dispatchedCalls[0][0].newEvent // Ensure organizer attendee info is preserved const organizerAttendee = updatedEvent?.attendee?.find( - (a: any) => a.cal_address === "alice@example.com" - ); + (a: any) => a.cal_address === 'alice@example.com' + ) - expect(organizerAttendee).toBeTruthy(); - expect(organizerAttendee?.partstat).toBe("ACCEPTED"); - expect(organizerAttendee?.role).toBe("CHAIR"); + expect(organizerAttendee).toBeTruthy() + expect(organizerAttendee?.partstat).toBe('ACCEPTED') + expect(organizerAttendee?.role).toBe('CHAIR') // Ensure normal attendee info is preserved too const normalAttendee = updatedEvent?.attendee?.find( - (a: any) => a.cal_address === "bob@example.com" - ); + (a: any) => a.cal_address === 'bob@example.com' + ) - expect(normalAttendee).toBeTruthy(); - expect(normalAttendee?.partstat).toBe("ACCEPTED"); - expect(normalAttendee?.role).toBe("REQ-PARTICIPANT"); + expect(normalAttendee).toBeTruthy() + expect(normalAttendee?.partstat).toBe('ACCEPTED') + expect(normalAttendee?.role).toBe('REQ-PARTICIPANT') // Verify SEQUENCE is incremented - expect(updatedEvent?.sequence).toBe(3); // 2 + 1 - }); + expect(updatedEvent?.sequence).toBe(3) // 2 + 1 + }) - it("changes normal attendee to need action on time update and no organizer changes", async () => { + it('changes normal attendee to need action on time update and no organizer changes', async () => { const updateSpy = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); - const onClose = jest.fn(); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) + const onClose = jest.fn() renderWithProviders( { typeOfAction="solo" />, preloadedState - ); + ) // Expand to show date/time inputs (normal mode shows DateTimeSummary) fireEvent.click( - screen.getByRole("button", { name: "common.moreOptions" }) - ); - const startDateInput = await screen.findByTestId("start-time-input"); + screen.getByRole('button', { name: 'common.moreOptions' }) + ) + const startDateInput = await screen.findByTestId('start-time-input') await act(async () => { fireEvent.change(startDateInput, { - target: { value: "08:00" }, - }); - fireEvent.blur(startDateInput); - }); + target: { value: '08:00' } + }) + fireEvent.blur(startDateInput) + }) // Wait for blur handler to complete await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); + await new Promise(resolve => setTimeout(resolve, 150)) + }) - const saveButton = screen.getByRole("button", { name: /save/i }); + const saveButton = screen.getByRole('button', { name: /save/i }) await act(async () => { - saveButton.click(); - }); + saveButton.click() + }) - await waitFor(() => expect(updateSpy).toHaveBeenCalled()); + await waitFor(() => expect(updateSpy).toHaveBeenCalled()) - const dispatchedCalls = updateSpy.mock.calls; - expect(dispatchedCalls.length).toBeGreaterThan(0); - const updatedEvent = dispatchedCalls[0][0].newEvent; + const dispatchedCalls = updateSpy.mock.calls + expect(dispatchedCalls.length).toBeGreaterThan(0) + const updatedEvent = dispatchedCalls[0][0].newEvent // Ensure organizer attendee info is preserved const organizerAttendee = updatedEvent?.attendee?.find( - (a: any) => a.cal_address === "alice@example.com" - ); + (a: any) => a.cal_address === 'alice@example.com' + ) - expect(organizerAttendee).toBeTruthy(); - expect(organizerAttendee?.partstat).toBe("ACCEPTED"); - expect(organizerAttendee?.role).toBe("CHAIR"); + expect(organizerAttendee).toBeTruthy() + expect(organizerAttendee?.partstat).toBe('ACCEPTED') + expect(organizerAttendee?.role).toBe('CHAIR') // Ensure normal attendee partstat is updated const normalAttendee = updatedEvent?.attendee?.find( - (a: any) => a.cal_address === "bob@example.com" - ); + (a: any) => a.cal_address === 'bob@example.com' + ) - expect(normalAttendee).toBeTruthy(); - expect(normalAttendee?.partstat).toBe("NEEDS-ACTION"); - expect(normalAttendee?.role).toBe("REQ-PARTICIPANT"); + expect(normalAttendee).toBeTruthy() + expect(normalAttendee?.partstat).toBe('NEEDS-ACTION') + expect(normalAttendee?.role).toBe('REQ-PARTICIPANT') // Verify SEQUENCE is incremented - expect(updatedEvent?.sequence).toBe(3); // 2 + 1 - }); + expect(updatedEvent?.sequence).toBe(3) // 2 + 1 + }) - it("update event attendees on drag", async () => { + it('update event attendees on drag', async () => { // Mock dispatch locally — this test calls createEventHandlers directly // and does not go through the Redux store or useAppDispatch. const mockDispatch = jest.fn().mockReturnValue( Object.assign(Promise.resolve({}), { - unwrap: () => Promise.resolve({}), + unwrap: () => Promise.resolve({}) }) - ) as unknown as AppDispatch; + ) as unknown as AppDispatch jest - .spyOn(appHooks, "useAppDispatch") - .mockReturnValue(mockDispatch as unknown as AppDispatch); + .spyOn(appHooks, 'useAppDispatch') + .mockReturnValue(mockDispatch as unknown as AppDispatch) const updateSpy = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) const eventHandlers = createEventHandlers({ setSelectedRange: jest.fn(), @@ -499,70 +489,70 @@ describe("CalendarApp integration", () => { calendars: preloadedState.calendars.list, setSelectedEvent: jest.fn(), setAfterChoiceFunc: jest.fn(), - setOpenEditModePopup: jest.fn(), - } as unknown as EventHandlersProps); + setOpenEditModePopup: jest.fn() + } as unknown as EventHandlersProps) const mockArg = { event: { _def: { extendedProps: { - uid: "event1", - calId: "667037022b752d0026472254/cal1", - }, - }, + uid: 'event1', + calId: '667037022b752d0026472254/cal1' + } + } }, // drag event → move by 1 day - delta: { years: 0, months: 0, days: 1, milliseconds: 0 }, - }; + delta: { years: 0, months: 0, days: 1, milliseconds: 0 } + } - renderCalendar(); - eventHandlers.handleEventDrop(mockArg); + renderCalendar() + eventHandlers.handleEventDrop(mockArg) - expect(updateSpy).toHaveBeenCalled(); + expect(updateSpy).toHaveBeenCalled() // Extract the dispatched update event - const dispatched = updateSpy.mock.calls[0]; + const dispatched = updateSpy.mock.calls[0] - expect(dispatched).toBeTruthy(); + expect(dispatched).toBeTruthy() - const updatePayload = dispatched[0]; - const updatedEvent = updatePayload.newEvent; + const updatePayload = dispatched[0] + const updatedEvent = updatePayload.newEvent // Organizer should remain unchanged const organizer = updatedEvent.attendee.find( - (a: any) => a.cal_address === "alice@example.com" - ); - expect(organizer?.partstat).toBe("ACCEPTED"); + (a: any) => a.cal_address === 'alice@example.com' + ) + expect(organizer?.partstat).toBe('ACCEPTED') // Normal attendee must become NEEDS-ACTION const normal = updatedEvent.attendee.find( - (a: any) => a.cal_address === "bob@example.com" - ); - expect(normal?.partstat).toBe("NEEDS-ACTION"); + (a: any) => a.cal_address === 'bob@example.com' + ) + expect(normal?.partstat).toBe('NEEDS-ACTION') // Verify SEQUENCE is incremented - expect(updatedEvent?.sequence).toBe(3); // 2 + 1 - }); + expect(updatedEvent?.sequence).toBe(3) // 2 + 1 + }) - it("update event attendees on resize", async () => { + it('update event attendees on resize', async () => { // Mock dispatch locally — this test calls createEventHandlers directly // and does not go through the Redux store or useAppDispatch. const mockDispatch = jest.fn().mockReturnValue( Object.assign(Promise.resolve({}), { - unwrap: () => Promise.resolve({}), + unwrap: () => Promise.resolve({}) }) - ) as unknown as AppDispatch; + ) as unknown as AppDispatch jest - .spyOn(appHooks, "useAppDispatch") - .mockReturnValue(mockDispatch as unknown as AppDispatch); + .spyOn(appHooks, 'useAppDispatch') + .mockReturnValue(mockDispatch as unknown as AppDispatch) const updateSpy = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) const eventHandlers = createEventHandlers({ setSelectedRange: jest.fn(), @@ -577,49 +567,49 @@ describe("CalendarApp integration", () => { calendars: preloadedState.calendars.list, setSelectedEvent: jest.fn(), setAfterChoiceFunc: jest.fn(), - setOpenEditModePopup: jest.fn(), - } as unknown as EventHandlersProps); + setOpenEditModePopup: jest.fn() + } as unknown as EventHandlersProps) const mockArg = { event: { _def: { extendedProps: { - uid: "event1", - calId: "667037022b752d0026472254/cal1", - }, - }, + uid: 'event1', + calId: '667037022b752d0026472254/cal1' + } + } }, startDelta: { years: 0, months: 0, days: 0, milliseconds: 0 }, - endDelta: { years: 0, months: 0, days: 0, milliseconds: 3600000 }, // 1 hour - }; + endDelta: { years: 0, months: 0, days: 0, milliseconds: 3600000 } // 1 hour + } - renderCalendar(); - eventHandlers.handleEventResize(mockArg); + renderCalendar() + eventHandlers.handleEventResize(mockArg) - expect(updateSpy).toHaveBeenCalled(); + expect(updateSpy).toHaveBeenCalled() // Extract the dispatched update event - const dispatched = updateSpy.mock.calls[0]; + const dispatched = updateSpy.mock.calls[0] - expect(dispatched).toBeTruthy(); + expect(dispatched).toBeTruthy() - const updatePayload = dispatched[0]; - const updatedEvent = updatePayload.newEvent; + const updatePayload = dispatched[0] + const updatedEvent = updatePayload.newEvent // Organizer should remain unchanged const organizer = updatedEvent.attendee.find( - (a: any) => a.cal_address === "alice@example.com" - ); - expect(organizer?.partstat).toBe("ACCEPTED"); + (a: any) => a.cal_address === 'alice@example.com' + ) + expect(organizer?.partstat).toBe('ACCEPTED') // Normal attendee must become NEEDS-ACTION const normal = updatedEvent.attendee.find( - (a: any) => a.cal_address === "bob@example.com" - ); - expect(normal?.partstat).toBe("NEEDS-ACTION"); + (a: any) => a.cal_address === 'bob@example.com' + ) + expect(normal?.partstat).toBe('NEEDS-ACTION') // Verify SEQUENCE is incremented - expect(updatedEvent?.sequence).toBe(3); // 2 + 1 - }); - }); -}); + expect(updatedEvent?.sequence).toBe(3) // 2 + 1 + }) + }) +}) diff --git a/__test__/components/Menubar.test.tsx b/__test__/components/Menubar.test.tsx index 2e39e87..4fdf15f 100644 --- a/__test__/components/Menubar.test.tsx +++ b/__test__/components/Menubar.test.tsx @@ -1,35 +1,35 @@ -import { Menubar } from "@/components/Menubar/Menubar"; -import * as oidcAuth from "@/features/User/oidcAuth"; -import { redirectTo } from "@/utils/navigation"; -import "@testing-library/jest-dom"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../utils/Renderwithproviders"; +import { Menubar } from '@/components/Menubar/Menubar' +import * as oidcAuth from '@/features/User/oidcAuth' +import { redirectTo } from '@/utils/navigation' +import '@testing-library/jest-dom' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../utils/Renderwithproviders' -jest.mock("@/utils/navigation", () => ({ - redirectTo: jest.fn(), -})); +jest.mock('@/utils/navigation', () => ({ + redirectTo: jest.fn() +})) -describe("Calendar App Component Display Tests", () => { +describe('Calendar App Component Display Tests', () => { const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", - }, - }, - }; - test("renders the Menubar component", () => { - (window as any).appList = [ - { name: "Twake", link: "/twake", icon: "twake.svg" }, - { name: "Calendar", link: "/calendar", icon: "calendar.svg" }, - ]; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' + } + } + } + test('renders the Menubar component', () => { + ;(window as any).appList = [ + { name: 'Twake', link: '/twake', icon: 'twake.svg' }, + { name: 'Calendar', link: '/calendar', icon: 'calendar.svg' } + ] + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); - const logoElement = screen.getByAltText("menubar.logoAlt"); - expect(logoElement).toBeInTheDocument(); - }); - it("renders the main title", () => { - (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + ) + const logoElement = screen.getByAltText('menubar.logoAlt') + expect(logoElement).toBeInTheDocument() + }) + it('renders the main title', () => { + ;(window as any).appList = [{ name: 'test', icon: 'test', link: 'test' }] + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); - expect(screen.getByAltText("menubar.logoAlt")).toBeInTheDocument(); - }); + ) + expect(screen.getByAltText('menubar.logoAlt')).toBeInTheDocument() + }) - it("shows avatar with user initials", () => { - (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('shows avatar with user initials', () => { + ;(window as any).appList = [{ name: 'test', icon: 'test', link: 'test' }] + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); - expect(screen.getByText("JD")).toBeInTheDocument(); - }); + ) + expect(screen.getByText('JD')).toBeInTheDocument() + }) - it("shows avatar with email initials when no user name", () => { - (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + it('shows avatar with email initials when no user name', () => { + ;(window as any).appList = [{ name: 'test', icon: 'test', link: 'test' }] const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", - }, - }, - }; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' + } + } + } + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); - expect(screen.getByText("T")).toBeInTheDocument(); - }); + ) + expect(screen.getByText('T')).toBeInTheDocument() + }) // Edge cases for avatar display logic - it("handles user with only family_name", () => { - (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + it('handles user with only family_name', () => { + ;(window as any).appList = [{ name: 'test', icon: 'test', link: 'test' }] const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Smith", + sub: 'test', + email: 'test@test.com', + family_name: 'Smith', name: null, - sid: "mockSid", - openpaasId: "667037022b752d0026472254", - }, - }, - }; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' + } + } + } + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); - expect(screen.getByText("T")).toBeInTheDocument(); - }); + ) + expect(screen.getByText('T')).toBeInTheDocument() + }) - it("handles user with only name", () => { - (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + it('handles user with only name', () => { + ;(window as any).appList = [{ name: 'test', icon: 'test', link: 'test' }] const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", + sub: 'test', + email: 'test@test.com', family_name: null, - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", - }, - }, - }; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' + } + } + } + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); - expect(screen.getByText("T")).toBeInTheDocument(); - }); + ) + expect(screen.getByText('T')).toBeInTheDocument() + }) - it("handles user with both name and family_name", () => { - (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + it('handles user with both name and family_name', () => { + ;(window as any).appList = [{ name: 'test', icon: 'test', link: 'test' }] const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", - }, - }, - }; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' + } + } + } + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); - expect(screen.getByText("JD")).toBeInTheDocument(); - }); + ) + expect(screen.getByText('JD')).toBeInTheDocument() + }) - it("handles user with no name and family_name", () => { - (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + it('handles user with no name and family_name', () => { + ;(window as any).appList = [{ name: 'test', icon: 'test', link: 'test' }] const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", + sub: 'test', + email: 'test@test.com', family_name: null, name: null, - sid: "mockSid", - openpaasId: "667037022b752d0026472254", - }, - }, - }; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' + } + } + } + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); - expect(screen.getByText("T")).toBeInTheDocument(); - }); + ) + expect(screen.getByText('T')).toBeInTheDocument() + }) - it("handles user with empty email", () => { - (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + it('handles user with empty email', () => { + ;(window as any).appList = [{ name: 'test', icon: 'test', link: 'test' }] const preloadedState = { user: { userData: { - sub: "test", - email: "", + sub: 'test', + email: '', family_name: null, name: null, - sid: "mockSid", - openpaasId: "667037022b752d0026472254", - }, - }, - }; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' + } + } + } + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) // Should not crash and show empty string - const avatar = screen.getByRole("img", { hidden: true }); - expect(avatar).toBeInTheDocument(); - }); + const avatar = screen.getByRole('img', { hidden: true }) + expect(avatar).toBeInTheDocument() + }) - it("handles user with null email", () => { - (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + it('handles user with null email', () => { + ;(window as any).appList = [{ name: 'test', icon: 'test', link: 'test' }] const preloadedState = { user: { userData: { - sub: "test", + sub: 'test', email: null, family_name: null, name: null, - sid: "mockSid", - openpaasId: "667037022b752d0026472254", - }, - }, - }; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' + } + } + } + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) // Should not crash and show empty string - const avatar = screen.getByRole("img", { hidden: true }); - expect(avatar).toBeInTheDocument(); - }); + const avatar = screen.getByRole('img', { hidden: true }) + expect(avatar).toBeInTheDocument() + }) - it("handles user with undefined email", () => { - (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + it('handles user with undefined email', () => { + ;(window as any).appList = [{ name: 'test', icon: 'test', link: 'test' }] const preloadedState = { user: { userData: { - sub: "test", + sub: 'test', email: undefined, family_name: null, name: null, - sid: "mockSid", - openpaasId: "667037022b752d0026472254", - }, - }, - }; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' + } + } + } + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) // Should not crash and show empty string - const avatar = screen.getByRole("img", { hidden: true }); - expect(avatar).toBeInTheDocument(); - }); + const avatar = screen.getByRole('img', { hidden: true }) + expect(avatar).toBeInTheDocument() + }) - it("shows apps button when applist is not empty", () => { - (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('shows apps button when applist is not empty', () => { + ;(window as any).appList = [{ name: 'test', icon: 'test', link: 'test' }] + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); - expect(screen.getByLabelText("menubar.apps")).toBeInTheDocument(); - }); + ) + expect(screen.getByLabelText('menubar.apps')).toBeInTheDocument() + }) - it("opens popover when clicking apps button", () => { - (window as any).appList = [ - { name: "Twake", icon: "twake.svg", link: "/twake" }, - { name: "Calendar", icon: "calendar.svg", link: "/calendar" }, - ]; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('opens popover when clicking apps button', () => { + ;(window as any).appList = [ + { name: 'Twake', icon: 'twake.svg', link: '/twake' }, + { name: 'Calendar', icon: 'calendar.svg', link: '/calendar' } + ] + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); - const appsButton = screen.getByLabelText("menubar.apps"); - fireEvent.click(appsButton); - expect(screen.getByText("Twake")).toBeInTheDocument(); - expect(screen.getByText("Calendar")).toBeInTheDocument(); - }); + ) + const appsButton = screen.getByLabelText('menubar.apps') + fireEvent.click(appsButton) + expect(screen.getByText('Twake')).toBeInTheDocument() + expect(screen.getByText('Calendar')).toBeInTheDocument() + }) - it("renders app icons as links", () => { - (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('renders app icons as links', () => { + ;(window as any).appList = [{ name: 'test', icon: 'test', link: 'test' }] + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); - const appsButton = screen.getByLabelText("menubar.apps"); - fireEvent.click(appsButton); + ) + const appsButton = screen.getByLabelText('menubar.apps') + fireEvent.click(appsButton) - const testLink = screen.getByRole("link", { name: /test/i }); - expect(testLink).toHaveAttribute("href", "test"); - }); -}); + const testLink = screen.getByRole('link', { name: /test/i }) + expect(testLink).toHaveAttribute('href', 'test') + }) +}) -describe("Menubar interaction with expanded Dialog", () => { +describe('Menubar interaction with expanded Dialog', () => { const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", - }, + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' + } }, settings: { - language: "en", - }, - }; + language: 'en' + } + } beforeEach(() => { - (window as any).appList = [ - { name: "Twake", link: "/twake", icon: "twake.svg" }, - { name: "Calendar", link: "/calendar", icon: "calendar.svg" }, - ]; - }); + ;(window as any).appList = [ + { name: 'Twake', link: '/twake', icon: 'twake.svg' }, + { name: 'Calendar', link: '/calendar', icon: 'calendar.svg' } + ] + }) afterEach(() => { - document.body.classList.remove("fullscreen-view"); - }); + document.body.classList.remove('fullscreen-view') + }) - it("has navigation controls element with correct class", () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('has navigation controls element with correct class', () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') const { container } = renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) - const navigationControls = container.querySelector(".navigation-controls"); - expect(navigationControls).toBeInTheDocument(); - expect(navigationControls).toHaveClass("navigation-controls"); - }); + const navigationControls = container.querySelector('.navigation-controls') + expect(navigationControls).toBeInTheDocument() + expect(navigationControls).toHaveClass('navigation-controls') + }) - it("has current date time element with correct class", () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('has current date time element with correct class', () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') const { container } = renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) - const dateTimeDisplay = container.querySelector(".current-date-time"); - expect(dateTimeDisplay).toBeInTheDocument(); - expect(dateTimeDisplay).toHaveClass("current-date-time"); - }); + const dateTimeDisplay = container.querySelector('.current-date-time') + expect(dateTimeDisplay).toBeInTheDocument() + expect(dateTimeDisplay).toHaveClass('current-date-time') + }) - it("has refresh button element with correct class", () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('has refresh button element with correct class', () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') const { container } = renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) - const refreshButton = container.querySelector(".refresh-button"); - expect(refreshButton).toBeInTheDocument(); - expect(refreshButton).toHaveClass("refresh-button"); - }); + const refreshButton = container.querySelector('.refresh-button') + expect(refreshButton).toBeInTheDocument() + expect(refreshButton).toHaveClass('refresh-button') + }) - it("has view selector element with correct class", () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('has view selector element with correct class', () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') const { container } = renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) - const selectDisplay = container.querySelector(".select-display"); - expect(selectDisplay).toBeInTheDocument(); - expect(selectDisplay).toHaveClass("select-display"); - }); + const selectDisplay = container.querySelector('.select-display') + expect(selectDisplay).toBeInTheDocument() + expect(selectDisplay).toHaveClass('select-display') + }) - it("keeps logo visible when dialog is expanded", () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('keeps logo visible when dialog is expanded', () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') - document.body.classList.add("fullscreen-view"); + document.body.classList.add('fullscreen-view') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) - const logoElement = screen.getByAltText("menubar.logoAlt"); - expect(logoElement).toBeInTheDocument(); - expect(logoElement).toBeVisible(); - }); + const logoElement = screen.getByAltText('menubar.logoAlt') + expect(logoElement).toBeInTheDocument() + expect(logoElement).toBeVisible() + }) - it("keeps apps icon clickable when dialog is expanded", () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('keeps apps icon clickable when dialog is expanded', () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') - document.body.classList.add("fullscreen-view"); + document.body.classList.add('fullscreen-view') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) - const appsButton = screen.getByLabelText("menubar.apps"); - expect(appsButton).toBeVisible(); + const appsButton = screen.getByLabelText('menubar.apps') + expect(appsButton).toBeVisible() - fireEvent.click(appsButton); - expect(screen.getByText("Twake")).toBeInTheDocument(); - }); + fireEvent.click(appsButton) + expect(screen.getByText('Twake')).toBeInTheDocument() + }) - it("opens user dropdown menu when clicking avatar", async () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('opens user dropdown menu when clicking avatar', async () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) - const avatar = screen.getByText("JD"); - expect(avatar).toBeVisible(); + const avatar = screen.getByText('JD') + expect(avatar).toBeVisible() - fireEvent.click(avatar.closest("button")!); + fireEvent.click(avatar.closest('button')!) await waitFor(() => { - expect(screen.getByText("John Doe")).toBeInTheDocument(); - expect(screen.getByText("test@test.com")).toBeInTheDocument(); - }); - }); + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(screen.getByText('test@test.com')).toBeInTheDocument() + }) + }) - it("closes user dropdown menu when clicking outside", async () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('closes user dropdown menu when clicking outside', async () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) - const avatar = screen.getByText("JD"); - fireEvent.click(avatar.closest("button")!); + const avatar = screen.getByText('JD') + fireEvent.click(avatar.closest('button')!) await waitFor(() => { - expect(screen.getByText("John Doe")).toBeInTheDocument(); - }); + expect(screen.getByText('John Doe')).toBeInTheDocument() + }) // Click outside the menu - MUI Menu closes on backdrop click - const backdrop = document.querySelector(".MuiBackdrop-root"); + const backdrop = document.querySelector('.MuiBackdrop-root') if (backdrop) { - fireEvent.click(backdrop); + fireEvent.click(backdrop) } else { // Fallback: click on body - fireEvent.mouseDown(document.body); + fireEvent.mouseDown(document.body) } await waitFor( () => { - expect(screen.queryByText("John Doe")).not.toBeInTheDocument(); + expect(screen.queryByText('John Doe')).not.toBeInTheDocument() }, { timeout: 2000 } - ); - }); + ) + }) - it("displays user name and email in dropdown menu", async () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('displays user name and email in dropdown menu', async () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) - const avatar = screen.getByText("JD"); - fireEvent.click(avatar.closest("button")!); + const avatar = screen.getByText('JD') + fireEvent.click(avatar.closest('button')!) await waitFor(() => { - expect(screen.getByText("John Doe")).toBeInTheDocument(); - expect(screen.getByText("test@test.com")).toBeInTheDocument(); - }); - }); + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(screen.getByText('test@test.com')).toBeInTheDocument() + }) + }) - it("navigates to settings page when clicking Settings in dropdown", async () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('navigates to settings page when clicking Settings in dropdown', async () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') const preloadedStateWithSettings = { ...preloadedState, settings: { - language: "en", - view: "calendar", - }, - }; + language: 'en', + view: 'calendar' + } + } const { store } = renderWithProviders( { currentView="dayGridMonth" />, preloadedStateWithSettings - ); + ) - const avatar = screen.getByText("JD"); - fireEvent.click(avatar.closest("button")!); + const avatar = screen.getByText('JD') + fireEvent.click(avatar.closest('button')!) await waitFor(() => { - expect(screen.getByText(/Settings/i)).toBeInTheDocument(); - }); + expect(screen.getByText(/Settings/i)).toBeInTheDocument() + }) - const settingsButton = screen.getByText(/Settings/i); - fireEvent.click(settingsButton); + const settingsButton = screen.getByText(/Settings/i) + fireEvent.click(settingsButton) await waitFor(() => { - const state = store.getState(); - expect(state.settings.view).toBe("settings"); - }); - }); + const state = store.getState() + expect(state.settings.view).toBe('settings') + }) + }) - it("shows all elements in normal mode (not expanded)", () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('shows all elements in normal mode (not expanded)', () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') const { container } = renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) - const navigationControls = container.querySelector(".navigation-controls"); - const dateTimeDisplay = container.querySelector(".current-date-time"); - const refreshButton = container.querySelector(".refresh-button"); - const selectDisplay = container.querySelector(".select-display"); + const navigationControls = container.querySelector('.navigation-controls') + const dateTimeDisplay = container.querySelector('.current-date-time') + const refreshButton = container.querySelector('.refresh-button') + const selectDisplay = container.querySelector('.select-display') - expect(navigationControls).toBeVisible(); - expect(dateTimeDisplay).toBeVisible(); - expect(refreshButton).toBeVisible(); - expect(selectDisplay).toBeVisible(); - }); + expect(navigationControls).toBeVisible() + expect(dateTimeDisplay).toBeVisible() + expect(refreshButton).toBeVisible() + expect(selectDisplay).toBeVisible() + }) - it("verifies CSS classes are correctly applied to elements", () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('verifies CSS classes are correctly applied to elements', () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') const { container } = renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) - expect(container.querySelector(".menubar")).toBeInTheDocument(); - expect(container.querySelector(".navigation-controls")).toBeInTheDocument(); - expect(container.querySelector(".current-date-time")).toBeInTheDocument(); - expect(container.querySelector(".refresh-button")).toBeInTheDocument(); - expect(container.querySelector(".select-display")).toBeInTheDocument(); - }); -}); + expect(container.querySelector('.menubar')).toBeInTheDocument() + expect(container.querySelector('.navigation-controls')).toBeInTheDocument() + expect(container.querySelector('.current-date-time')).toBeInTheDocument() + expect(container.querySelector('.refresh-button')).toBeInTheDocument() + expect(container.querySelector('.select-display')).toBeInTheDocument() + }) +}) -describe("Menubar logout flow", () => { - const redirectToMock = redirectTo as jest.Mock; +describe('Menubar logout flow', () => { + const redirectToMock = redirectTo as jest.Mock beforeEach(() => { - localStorage.clear(); - sessionStorage.clear(); - jest.clearAllMocks(); - redirectToMock.mockReset(); - }); + localStorage.clear() + sessionStorage.clear() + jest.clearAllMocks() + redirectToMock.mockReset() + }) afterEach(() => { - redirectToMock.mockReset(); - }); + redirectToMock.mockReset() + }) - function renderMenubar(user = { name: "John", email: "test@example.com" }) { + function renderMenubar(user = { name: 'John', email: 'test@example.com' }) { const preloadedState = { user: { userData: user }, - settings: { view: "calendar" }, - }; + settings: { view: 'calendar' } + } return renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) } - it("clears storage before redirecting", async () => { - sessionStorage.setItem("tokenSet", "dummy"); + it('clears storage before redirecting', async () => { + sessionStorage.setItem('tokenSet', 'dummy') const logoutSpy = jest - .spyOn(oidcAuth, "Logout") - .mockResolvedValue(new URL("https://logout.url")); + .spyOn(oidcAuth, 'Logout') + .mockResolvedValue(new URL('https://logout.url')) await act(async () => { - renderMenubar(); - }); + renderMenubar() + }) await act(async () => { - fireEvent.click(screen.getByLabelText("menubar.userProfile")); - }); + fireEvent.click(screen.getByLabelText('menubar.userProfile')) + }) await act(async () => { - fireEvent.click(screen.getByText("menubar.logout")); - }); - expect(logoutSpy).toHaveBeenCalled(); - expect(redirectToMock).toHaveBeenCalledWith("https://logout.url/"); + fireEvent.click(screen.getByText('menubar.logout')) + }) + expect(logoutSpy).toHaveBeenCalled() + expect(redirectToMock).toHaveBeenCalledWith('https://logout.url/') - expect(sessionStorage.length).toBe(0); - }); -}); + expect(sessionStorage.length).toBe(0) + }) +}) -describe("Logo click navigation to current week", () => { +describe('Logo click navigation to current week', () => { const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", - }, + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' + } }, settings: { - view: "calendar", - language: "en", - }, - }; + view: 'calendar', + language: 'en' + } + } beforeEach(() => { - (window as any).appList = []; - }); + ;(window as any).appList = [] + }) - it("navigates to week view and current week when clicking logo from month view", async () => { - const mockChangeView = jest.fn(); - const mockToday = jest.fn(); - const mockGetDate = jest.fn(() => new Date()); + it('navigates to week view and current week when clicking logo from month view', async () => { + const mockChangeView = jest.fn() + const mockToday = jest.fn() + const mockGetDate = jest.fn(() => new Date()) const mockCalendarApi = { changeView: mockChangeView, today: mockToday, - getDate: mockGetDate, - }; - const mockCalendarRef = { current: mockCalendarApi as any }; - const mockOnRefresh = jest.fn(); - const mockOnViewChange = jest.fn(); - const mockOnDateChange = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + getDate: mockGetDate + } + const mockCalendarRef = { current: mockCalendarApi as any } + const mockOnRefresh = jest.fn() + const mockOnViewChange = jest.fn() + const mockOnDateChange = jest.fn() + const mockCurrentDate = new Date('2024-04-15') const { store } = renderWithProviders( { onDateChange={mockOnDateChange} />, preloadedState - ); + ) - const logoElement = screen.getByAltText("menubar.logoAlt"); + const logoElement = screen.getByAltText('menubar.logoAlt') await act(async () => { - fireEvent.click(logoElement); - }); + fireEvent.click(logoElement) + }) await waitFor(() => { - expect(store.getState().settings.view).toBe("calendar"); - }); + expect(store.getState().settings.view).toBe('calendar') + }) - expect(mockChangeView).toHaveBeenCalledWith("timeGridWeek"); - expect(mockToday).toHaveBeenCalled(); - expect(mockOnViewChange).toHaveBeenCalledWith("timeGridWeek"); - expect(mockOnDateChange).toHaveBeenCalled(); - }); + expect(mockChangeView).toHaveBeenCalledWith('timeGridWeek') + expect(mockToday).toHaveBeenCalled() + expect(mockOnViewChange).toHaveBeenCalledWith('timeGridWeek') + expect(mockOnDateChange).toHaveBeenCalled() + }) - it("navigates to week view and current week when clicking logo from day view", async () => { - const mockChangeView = jest.fn(); - const mockToday = jest.fn(); - const mockGetDate = jest.fn(() => new Date()); + it('navigates to week view and current week when clicking logo from day view', async () => { + const mockChangeView = jest.fn() + const mockToday = jest.fn() + const mockGetDate = jest.fn(() => new Date()) const mockCalendarApi = { changeView: mockChangeView, today: mockToday, - getDate: mockGetDate, - }; - const mockCalendarRef = { current: mockCalendarApi as any }; - const mockOnRefresh = jest.fn(); - const mockOnViewChange = jest.fn(); - const mockOnDateChange = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + getDate: mockGetDate + } + const mockCalendarRef = { current: mockCalendarApi as any } + const mockOnRefresh = jest.fn() + const mockOnViewChange = jest.fn() + const mockOnDateChange = jest.fn() + const mockCurrentDate = new Date('2024-04-15') const { store } = renderWithProviders( { onDateChange={mockOnDateChange} />, preloadedState - ); + ) - const logoElement = screen.getByAltText("menubar.logoAlt"); + const logoElement = screen.getByAltText('menubar.logoAlt') await act(async () => { - fireEvent.click(logoElement); - }); + fireEvent.click(logoElement) + }) await waitFor(() => { - expect(store.getState().settings.view).toBe("calendar"); - }); + expect(store.getState().settings.view).toBe('calendar') + }) - expect(mockChangeView).toHaveBeenCalledWith("timeGridWeek"); - expect(mockToday).toHaveBeenCalled(); - expect(mockOnViewChange).toHaveBeenCalledWith("timeGridWeek"); - expect(mockOnDateChange).toHaveBeenCalled(); - }); + expect(mockChangeView).toHaveBeenCalledWith('timeGridWeek') + expect(mockToday).toHaveBeenCalled() + expect(mockOnViewChange).toHaveBeenCalledWith('timeGridWeek') + expect(mockOnDateChange).toHaveBeenCalled() + }) - it("navigates to current week when clicking logo from week view (not current week)", async () => { - const mockChangeView = jest.fn(); - const mockToday = jest.fn(); - const mockGetDate = jest.fn(() => new Date()); + it('navigates to current week when clicking logo from week view (not current week)', async () => { + const mockChangeView = jest.fn() + const mockToday = jest.fn() + const mockGetDate = jest.fn(() => new Date()) const mockCalendarApi = { changeView: mockChangeView, today: mockToday, - getDate: mockGetDate, - }; - const mockCalendarRef = { current: mockCalendarApi as any }; - const mockOnRefresh = jest.fn(); - const mockOnViewChange = jest.fn(); - const mockOnDateChange = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + getDate: mockGetDate + } + const mockCalendarRef = { current: mockCalendarApi as any } + const mockOnRefresh = jest.fn() + const mockOnViewChange = jest.fn() + const mockOnDateChange = jest.fn() + const mockCurrentDate = new Date('2024-04-15') const { store } = renderWithProviders( { onDateChange={mockOnDateChange} />, preloadedState - ); + ) - const logoElement = screen.getByAltText("menubar.logoAlt"); + const logoElement = screen.getByAltText('menubar.logoAlt') await act(async () => { - fireEvent.click(logoElement); - }); + fireEvent.click(logoElement) + }) await waitFor(() => { - expect(store.getState().settings.view).toBe("calendar"); - }); + expect(store.getState().settings.view).toBe('calendar') + }) - expect(mockChangeView).not.toHaveBeenCalled(); - expect(mockToday).toHaveBeenCalled(); - expect(mockOnDateChange).toHaveBeenCalled(); - }); + expect(mockChangeView).not.toHaveBeenCalled() + expect(mockToday).toHaveBeenCalled() + expect(mockOnDateChange).toHaveBeenCalled() + }) - it("does not crash when calendarRef.current is null", async () => { - const mockCalendarRef = { current: null }; - const mockOnRefresh = jest.fn(); - const mockOnViewChange = jest.fn(); - const mockOnDateChange = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + it('does not crash when calendarRef.current is null', async () => { + const mockCalendarRef = { current: null } + const mockOnRefresh = jest.fn() + const mockOnViewChange = jest.fn() + const mockOnDateChange = jest.fn() + const mockCurrentDate = new Date('2024-04-15') const { store } = renderWithProviders( { onDateChange={mockOnDateChange} />, preloadedState - ); + ) - const logoElement = screen.getByAltText("menubar.logoAlt"); + const logoElement = screen.getByAltText('menubar.logoAlt') await act(async () => { - fireEvent.click(logoElement); - }); + fireEvent.click(logoElement) + }) await waitFor(() => { - expect(store.getState().settings.view).toBe("calendar"); - }); + expect(store.getState().settings.view).toBe('calendar') + }) - expect(mockOnViewChange).not.toHaveBeenCalled(); - expect(mockOnDateChange).not.toHaveBeenCalled(); - }); + expect(mockOnViewChange).not.toHaveBeenCalled() + expect(mockOnDateChange).not.toHaveBeenCalled() + }) - it("handles missing onViewChange and onDateChange callbacks", async () => { - const mockChangeView = jest.fn(); - const mockToday = jest.fn(); - const mockGetDate = jest.fn(() => new Date()); + it('handles missing onViewChange and onDateChange callbacks', async () => { + const mockChangeView = jest.fn() + const mockToday = jest.fn() + const mockGetDate = jest.fn(() => new Date()) const mockCalendarApi = { changeView: mockChangeView, today: mockToday, - getDate: mockGetDate, - }; - const mockCalendarRef = { current: mockCalendarApi as any }; - const mockOnRefresh = jest.fn(); - const mockCurrentDate = new Date("2024-04-15"); + getDate: mockGetDate + } + const mockCalendarRef = { current: mockCalendarApi as any } + const mockOnRefresh = jest.fn() + const mockCurrentDate = new Date('2024-04-15') const { store } = renderWithProviders( { currentView="dayGridMonth" />, preloadedState - ); + ) - const logoElement = screen.getByAltText("menubar.logoAlt"); + const logoElement = screen.getByAltText('menubar.logoAlt') await act(async () => { - fireEvent.click(logoElement); - }); + fireEvent.click(logoElement) + }) await waitFor(() => { - expect(store.getState().settings.view).toBe("calendar"); - }); + expect(store.getState().settings.view).toBe('calendar') + }) - expect(mockChangeView).toHaveBeenCalledWith("timeGridWeek"); - expect(mockToday).toHaveBeenCalled(); - }); -}); + expect(mockChangeView).toHaveBeenCalledWith('timeGridWeek') + expect(mockToday).toHaveBeenCalled() + }) +}) diff --git a/__test__/components/MiniCalendarColor.test.tsx b/__test__/components/MiniCalendarColor.test.tsx index 609a973..071fd1d 100644 --- a/__test__/components/MiniCalendarColor.test.tsx +++ b/__test__/components/MiniCalendarColor.test.tsx @@ -1,65 +1,65 @@ -import * as appHooks from "@/app/hooks"; -import { AppDispatch } from "@/app/store"; -import CalendarApp from "@/components/Calendar/Calendar"; -import { jest } from "@jest/globals"; -import { screen } from "@testing-library/react"; -import { renderWithProviders } from "../utils/Renderwithproviders"; +import * as appHooks from '@/app/hooks' +import { AppDispatch } from '@/app/store' +import CalendarApp from '@/components/Calendar/Calendar' +import { jest } from '@jest/globals' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../utils/Renderwithproviders' -describe("MiniCalendar", () => { - const day = new Date(); +describe('MiniCalendar', () => { + const day = new Date() beforeEach(() => { - jest.clearAllMocks(); + jest.clearAllMocks() const dispatch = jest.fn().mockReturnValue( Object.assign(Promise.resolve({}), { - unwrap: () => Promise.resolve({}), + unwrap: () => Promise.resolve({}) }) - ) as unknown as AppDispatch; - jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch); - jest.useFakeTimers().clearAllTimers(); - }); + ) as unknown as AppDispatch + jest.spyOn(appHooks, 'useAppDispatch').mockReturnValue(dispatch) + jest.useFakeTimers().clearAllTimers() + }) const renderCalendar = () => { const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", - }, + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - name: "Calendar 1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["test@test.com"] }, + '667037022b752d0026472254/cal1': { + name: 'Calendar 1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['test@test.com'] }, events: { event1: { - calId: "667037022b752d0026472254/cal1", - id: "event1", - title: "Test Event", - start: day.toISOString(), - }, - }, - }, + calId: '667037022b752d0026472254/cal1', + id: 'event1', + title: 'Test Event', + start: day.toISOString() + } + } + } }, - pending: false, - }, - }; - const mockCalendarRef = { current: null }; + pending: false + } + } + const mockCalendarRef = { current: null } renderWithProviders( , preloadedState - ); - }; + ) + } - it("renders mini calendar with today in orange", async () => { - renderCalendar(); - const today = new Date(); - const dateTestId = `date-${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; + it('renders mini calendar with today in orange', async () => { + renderCalendar() + const today = new Date() + const dateTestId = `date-${today.getFullYear()}-${today.getMonth()}-${today.getDate()}` - const todayTile = screen.getByTestId(dateTestId); - expect(todayTile).toHaveClass("today"); - }); -}); + const todayTiles = screen.getAllByTestId(dateTestId) + expect(todayTiles[0]).toHaveClass('today') + }) +}) diff --git a/__test__/components/PeopleSearch.test.tsx b/__test__/components/PeopleSearch.test.tsx index c3c9f1a..dd412ae 100644 --- a/__test__/components/PeopleSearch.test.tsx +++ b/__test__/components/PeopleSearch.test.tsx @@ -1,412 +1,410 @@ -import { PeopleSearch, User } from "@/components/Attendees/PeopleSearch"; -import { searchUsers } from "@/features/User/userAPI"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../utils/Renderwithproviders"; +import { PeopleSearch, User } from '@/components/Attendees/PeopleSearch' +import { searchUsers } from '@/features/User/userAPI' +import { act, fireEvent, 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 ->; +jest.mock('@/features/User/userAPI') +const mockedSearchUsers = searchUsers as jest.MockedFunction -describe("PeopleSearch", () => { +describe('PeopleSearch', () => { const baseUser: User = { - email: "test@example.com", - displayName: "Test User", - avatarUrl: "https://example.com/avatar.png", - openpaasId: "1234567890", - }; + email: 'test@example.com', + displayName: 'Test User', + avatarUrl: 'https://example.com/avatar.png', + openpaasId: '1234567890' + } function setup( selectedUsers: User[] = [], props?: Partial> ) { - const onChange = jest.fn(); + const onChange = jest.fn() renderWithProviders( - ); - return { onChange }; + ) + return { onChange } } beforeEach(() => { - jest.useFakeTimers(); - mockedSearchUsers.mockReset(); - }); + jest.useFakeTimers() + mockedSearchUsers.mockReset() + }) afterEach(() => { - jest.useRealTimers(); - }); + jest.useRealTimers() + }) - it("calls searchUsers after debounce when typing", async () => { - mockedSearchUsers.mockResolvedValueOnce([baseUser]); - setup(); + it('calls searchUsers after debounce when typing', async () => { + mockedSearchUsers.mockResolvedValueOnce([baseUser]) + setup() - const input = screen.getByRole("combobox"); - await userEvent.type(input, "Test"); + const input = screen.getByRole('combobox') + await userEvent.type(input, 'Test') await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) await waitFor(() => { - expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]); - }); - }); + expect(mockedSearchUsers).toHaveBeenCalledWith('Test', ['user']) + }) + }) - it("renders search results and allows selection", async () => { - mockedSearchUsers.mockResolvedValueOnce([baseUser]); - const { onChange } = setup(); + it('renders search results and allows selection', async () => { + mockedSearchUsers.mockResolvedValueOnce([baseUser]) + const { onChange } = setup() - const input = screen.getByRole("combobox"); - await userEvent.type(input, "Test"); + const input = screen.getByRole('combobox') + await userEvent.type(input, 'Test') await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) - const option = await screen.findByText("Test User"); - await userEvent.click(option); + const option = await screen.findByText('Test User') + await userEvent.click(option) await waitFor(() => { - expect(onChange).toHaveBeenCalled(); - }); - }); + expect(onChange).toHaveBeenCalled() + }) + }) - it("does not show already selected users in options", async () => { - mockedSearchUsers.mockResolvedValueOnce([baseUser]); - setup([baseUser]); - const input = screen.getByRole("combobox"); - await userEvent.type(input, "Test"); + it('does not show already selected users in options', async () => { + mockedSearchUsers.mockResolvedValueOnce([baseUser]) + setup([baseUser]) + const input = screen.getByRole('combobox') + await userEvent.type(input, 'Test') await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) await waitFor(() => { - expect(screen.queryByText("test@example.com")).not.toBeInTheDocument(); - }); - }); + expect(screen.queryByText('test@example.com')).not.toBeInTheDocument() + }) + }) - it("triggers onToggleEventPreview on Enter key press", () => { - const onToggleEventPreview = jest.fn(); - setup([], { onToggleEventPreview }); + it('triggers onToggleEventPreview on Enter key press', () => { + const onToggleEventPreview = jest.fn() + setup([], { onToggleEventPreview }) - const input = screen.getByRole("combobox"); - fireEvent.keyDown(input, { key: "Enter" }); + const input = screen.getByRole('combobox') + fireEvent.keyDown(input, { key: 'Enter' }) - expect(onToggleEventPreview).toHaveBeenCalled(); - }); + expect(onToggleEventPreview).toHaveBeenCalled() + }) - it("respects disabled state", () => { - setup([], { disabled: true }); - expect(screen.getByRole("combobox")).toBeDisabled(); - }); + 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([baseUser]); - setup(); - const input = screen.getByRole("combobox"); + mockedSearchUsers.mockResolvedValueOnce([baseUser]) + setup() + const input = screen.getByRole('combobox') - userEvent.type(input, "Test"); + userEvent.type(input, 'Test') await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) await waitFor(() => { - expect(screen.getByRole("listbox")).toBeInTheDocument(); - }); + expect(screen.getByRole('listbox')).toBeInTheDocument() + }) - userEvent.clear(input); + userEvent.clear(input) await waitFor(() => { - expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); - }); - }); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + }) + }) it("shows 'No results' when search succeeds but returns empty array", async () => { - mockedSearchUsers.mockResolvedValueOnce([]); - setup(); + mockedSearchUsers.mockResolvedValueOnce([]) + setup() - const input = screen.getByRole("combobox"); - await userEvent.type(input, "Test"); + const input = screen.getByRole('combobox') + await userEvent.type(input, 'Test') await act(async () => { - jest.advanceTimersByTime(300); - await Promise.resolve(); - await Promise.resolve(); - }); + jest.advanceTimersByTime(300) + await Promise.resolve() + await Promise.resolve() + }) const noResults = await screen.findByText( - "peopleSearch.noResults", + 'peopleSearch.noResults', {}, { timeout: 5000 } - ); - expect(noResults).toBeInTheDocument(); - }); + ) + expect(noResults).toBeInTheDocument() + }) - it("does not clear options when search fails and shows error snackbar", async () => { - mockedSearchUsers.mockResolvedValueOnce([baseUser]); - setup(); + it('does not clear options when search fails and shows error snackbar', async () => { + mockedSearchUsers.mockResolvedValueOnce([baseUser]) + setup() - const input = screen.getByRole("combobox"); - await userEvent.type(input, "Test"); + const input = screen.getByRole('combobox') + await userEvent.type(input, 'Test') await act(async () => { - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); + jest.advanceTimersByTime(300) + await Promise.resolve() + }) await waitFor(() => { - expect(screen.getByText("Test User")).toBeInTheDocument(); - }); + expect(screen.getByText('Test User')).toBeInTheDocument() + }) - mockedSearchUsers.mockRejectedValueOnce(new Error("Network error")); - await userEvent.clear(input); - await userEvent.type(input, "Error"); + mockedSearchUsers.mockRejectedValueOnce(new Error('Network error')) + await userEvent.clear(input) + await userEvent.type(input, 'Error') await act(async () => { - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); + jest.advanceTimersByTime(300) + await Promise.resolve() + }) - const errorMessage = await screen.findByText("peopleSearch.searchError"); - expect(errorMessage).toBeInTheDocument(); + const errorMessage = await screen.findByText('peopleSearch.searchError') + expect(errorMessage).toBeInTheDocument() - expect(screen.queryByText("Test User")).not.toBeInTheDocument(); + expect(screen.queryByText('Test User')).not.toBeInTheDocument() - mockedSearchUsers.mockResolvedValueOnce([baseUser]); - await userEvent.clear(input); - await userEvent.type(input, "Test"); + mockedSearchUsers.mockResolvedValueOnce([baseUser]) + await userEvent.clear(input) + await userEvent.type(input, 'Test') await act(async () => { - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); + jest.advanceTimersByTime(300) + await Promise.resolve() + }) await waitFor(() => { - expect(screen.getByText("Test User")).toBeInTheDocument(); - }); - }); + expect(screen.getByText('Test User')).toBeInTheDocument() + }) + }) - it("shows loading text when searching", async () => { - let resolveSearch: (value: User[]) => void; - const searchPromise = new Promise((resolve) => { - resolveSearch = resolve; - }); - mockedSearchUsers.mockReturnValueOnce(searchPromise); - setup(); + it('shows loading text when searching', async () => { + let resolveSearch: (value: User[]) => void + const searchPromise = new Promise(resolve => { + resolveSearch = resolve + }) + mockedSearchUsers.mockReturnValueOnce(searchPromise) + setup() - const input = screen.getByRole("combobox"); - await userEvent.type(input, "Test"); + const input = screen.getByRole('combobox') + await userEvent.type(input, 'Test') await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) const loadingText = await screen.findByText( - "peopleSearch.loading", + 'peopleSearch.loading', {}, { timeout: 5000 } - ); - expect(loadingText).toBeInTheDocument(); + ) + expect(loadingText).toBeInTheDocument() await act(async () => { - resolveSearch!([baseUser]); - await searchPromise; - }); - }); + resolveSearch!([baseUser]) + await searchPromise + }) + }) - describe("paste multiple attendees", () => { + describe('paste multiple attendees', () => { function setupFreeSolo(selectedUsers: User[] = []) { - const onChange = jest.fn(); + const onChange = jest.fn() renderWithProviders( - ); - return { onChange }; + ) + return { onChange } } - it("splits pasted comma-separated emails into individual attendees", async () => { - const { onChange } = setupFreeSolo(); - const input = screen.getByRole("combobox"); + it('splits pasted comma-separated emails into individual attendees', async () => { + const { onChange } = setupFreeSolo() + const input = screen.getByRole('combobox') fireEvent.paste(input, { clipboardData: { - getData: () => "alice@example.com, bob@example.com", - }, - }); + getData: () => 'alice@example.com, bob@example.com' + } + }) await waitFor(() => { expect(onChange).toHaveBeenCalledWith( expect.anything(), expect.arrayContaining([ - expect.objectContaining({ email: "alice@example.com" }), - expect.objectContaining({ email: "bob@example.com" }), + expect.objectContaining({ email: 'alice@example.com' }), + expect.objectContaining({ email: 'bob@example.com' }) ]) - ); - }); - }); + ) + }) + }) - it("splits pasted semicolon-separated emails", async () => { - const { onChange } = setupFreeSolo(); - const input = screen.getByRole("combobox"); + it('splits pasted semicolon-separated emails', async () => { + const { onChange } = setupFreeSolo() + const input = screen.getByRole('combobox') fireEvent.paste(input, { clipboardData: { - getData: () => "alice@example.com;bob@example.com", - }, - }); + getData: () => 'alice@example.com;bob@example.com' + } + }) await waitFor(() => { expect(onChange).toHaveBeenCalledWith( expect.anything(), expect.arrayContaining([ - expect.objectContaining({ email: "alice@example.com" }), - expect.objectContaining({ email: "bob@example.com" }), + expect.objectContaining({ email: 'alice@example.com' }), + expect.objectContaining({ email: 'bob@example.com' }) ]) - ); - }); - }); + ) + }) + }) - it("splits pasted newline-separated emails", async () => { - const { onChange } = setupFreeSolo(); - const input = screen.getByRole("combobox"); + it('splits pasted newline-separated emails', async () => { + const { onChange } = setupFreeSolo() + const input = screen.getByRole('combobox') fireEvent.paste(input, { clipboardData: { - getData: () => "alice@example.com\nbob@example.com", - }, - }); + getData: () => 'alice@example.com\nbob@example.com' + } + }) await waitFor(() => { expect(onChange).toHaveBeenCalledWith( expect.anything(), expect.arrayContaining([ - expect.objectContaining({ email: "alice@example.com" }), - expect.objectContaining({ email: "bob@example.com" }), + expect.objectContaining({ email: 'alice@example.com' }), + expect.objectContaining({ email: 'bob@example.com' }) ]) - ); - }); - }); + ) + }) + }) - it("splits pasted space-separated emails", async () => { - const { onChange } = setupFreeSolo(); - const input = screen.getByRole("combobox"); + it('splits pasted space-separated emails', async () => { + const { onChange } = setupFreeSolo() + const input = screen.getByRole('combobox') fireEvent.paste(input, { clipboardData: { - getData: () => "alice@example.com bob@example.com", - }, - }); + getData: () => 'alice@example.com bob@example.com' + } + }) await waitFor(() => { expect(onChange).toHaveBeenCalledWith( expect.anything(), expect.arrayContaining([ - expect.objectContaining({ email: "alice@example.com" }), - expect.objectContaining({ email: "bob@example.com" }), + expect.objectContaining({ email: 'alice@example.com' }), + expect.objectContaining({ email: 'bob@example.com' }) ]) - ); - }); - }); + ) + }) + }) - it("skips duplicate emails already in selectedUsers", async () => { + it('skips duplicate emails already in selectedUsers', async () => { const existing: User = { - email: "alice@example.com", - displayName: "Alice", - }; - const { onChange } = setupFreeSolo([existing]); - const input = screen.getByRole("combobox"); + email: 'alice@example.com', + displayName: 'Alice' + } + const { onChange } = setupFreeSolo([existing]) + const input = screen.getByRole('combobox') fireEvent.paste(input, { clipboardData: { - getData: () => "alice@example.com, bob@example.com", - }, - }); + getData: () => 'alice@example.com, bob@example.com' + } + }) await waitFor(() => { expect(onChange).toHaveBeenCalledWith( expect.anything(), expect.arrayContaining([ existing, - expect.objectContaining({ email: "bob@example.com" }), + expect.objectContaining({ email: 'bob@example.com' }) ]) - ); + ) // Should NOT have alice duplicated - const call = onChange.mock.calls[0][1]; + const call = onChange.mock.calls[0][1] const aliceCount = call.filter( - (u: User) => u.email === "alice@example.com" - ).length; - expect(aliceCount).toBe(1); - }); - }); + (u: User) => u.email === 'alice@example.com' + ).length + expect(aliceCount).toBe(1) + }) + }) - it("leaves invalid text in input when some emails are invalid", async () => { - const { onChange } = setupFreeSolo(); - const input = screen.getByRole("combobox"); + it('leaves invalid text in input when some emails are invalid', async () => { + const { onChange } = setupFreeSolo() + const input = screen.getByRole('combobox') fireEvent.paste(input, { clipboardData: { - getData: () => "alice@example.com, not-an-email, bob@example.com", - }, - }); + getData: () => 'alice@example.com, not-an-email, bob@example.com' + } + }) await waitFor(() => { expect(onChange).toHaveBeenCalledWith( expect.anything(), expect.arrayContaining([ - expect.objectContaining({ email: "alice@example.com" }), - expect.objectContaining({ email: "bob@example.com" }), + expect.objectContaining({ email: 'alice@example.com' }), + expect.objectContaining({ email: 'bob@example.com' }) ]) - ); - }); - }); + ) + }) + }) - it("does not intercept paste of a single email", async () => { - const { onChange } = setupFreeSolo(); - const input = screen.getByRole("combobox"); + it('does not intercept paste of a single email', async () => { + const { onChange } = setupFreeSolo() + const input = screen.getByRole('combobox') fireEvent.paste(input, { clipboardData: { - getData: () => "alice@example.com", - }, - }); + getData: () => 'alice@example.com' + } + }) // Single email should NOT trigger multi-paste handler - expect(onChange).not.toHaveBeenCalled(); - }); - }); + expect(onChange).not.toHaveBeenCalled() + }) + }) - it("retains input value when field loses focus (blur)", async () => { - let resolveSearch: (value: User[]) => void; - const searchPromise = new Promise((resolve) => { - resolveSearch = resolve; - }); - mockedSearchUsers.mockReturnValueOnce(searchPromise); + it('retains input value when field loses focus (blur)', async () => { + let resolveSearch: (value: User[]) => void + const searchPromise = new Promise(resolve => { + resolveSearch = resolve + }) + mockedSearchUsers.mockReturnValueOnce(searchPromise) await act(async () => { - setup(); - }); + setup() + }) - const input = screen.getByRole("combobox"); + const input = screen.getByRole('combobox') await act(async () => { - userEvent.type(input, "Test"); - }); + userEvent.type(input, 'Test') + }) await waitFor(() => { - expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]); - }); + expect(mockedSearchUsers).toHaveBeenCalledWith('Test', ['user']) + }) - expect(input).toHaveValue("Test"); + expect(input).toHaveValue('Test') await act(async () => { - input.blur(); - }); + input.blur() + }) await waitFor(() => { - expect(input).toHaveValue("Test"); - }); - }); -}); + expect(input).toHaveValue('Test') + }) + }) +}) diff --git a/__test__/components/RepeatEvent.test.tsx b/__test__/components/RepeatEvent.test.tsx index 04aea78..5b88766 100644 --- a/__test__/components/RepeatEvent.test.tsx +++ b/__test__/components/RepeatEvent.test.tsx @@ -1,69 +1,69 @@ -import { RootState } from "@/app/store"; -import RepeatEvent from "@/components/Event/EventRepeat"; -import * as eventThunks from "@/features/Calendars/services"; -import EventPopover from "@/features/Events/EventModal"; -import { RepetitionObject } from "@/features/Events/EventsTypes"; -import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../utils/Renderwithproviders"; +import { RootState } from '@/app/store' +import RepeatEvent from '@/components/Event/EventRepeat' +import * as eventThunks from '@/features/Calendars/services' +import EventPopover from '@/features/Events/EventModal' +import { RepetitionObject } from '@/features/Events/EventsTypes' +import { CalendarApi, DateSelectArg } from '@fullcalendar/core' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../utils/Renderwithproviders' const baseRepetition: RepetitionObject = { - freq: "", + freq: '', interval: 1, occurrences: 0, - endDate: "", -}; + endDate: '' +} -const mockOnClose = jest.fn(); -const mockSetSelectedRange = jest.fn(); +const mockOnClose = jest.fn() +const mockSetSelectedRange = jest.fn() const mockCalendarRef = { - current: { select: jest.fn() } as unknown as CalendarApi, -}; + current: { select: jest.fn() } as unknown as CalendarApi +} const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro', + openpaasId: '667037022b752d0026472254' }, organiserData: { - cn: "test", - cal_address: "test@test.com", - }, + cn: 'test', + cal_address: 'test@test.com' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "Calendar 1", - color: "#FF0000", - }, - "667037022b752d0026472254/cal2": { - id: "667037022b752d0026472254/cal2", - name: "Calendar 2", - color: "#00FF00", + '667037022b752d0026472254/cal1': { + id: '667037022b752d0026472254/cal1', + name: 'Calendar 1', + color: '#FF0000' }, + '667037022b752d0026472254/cal2': { + id: '667037022b752d0026472254/cal2', + name: 'Calendar 2', + color: '#00FF00' + } }, - pending: false, - }, -}; + pending: false + } +} const defaultSelectedRange = { - startStr: "2025-07-18T09:00", - endStr: "2025-07-18T10:00", - start: new Date("2025-07-18T09:00"), - end: new Date("2025-07-18T10:00"), + startStr: '2025-07-18T09:00', + endStr: '2025-07-18T10:00', + start: new Date('2025-07-18T09:00'), + end: new Date('2025-07-18T10:00'), allDay: false, - resource: undefined, -} as unknown as DateSelectArg; + resource: undefined +} as unknown as DateSelectArg function setupRepeatEvent( props?: Partial, state?: RootState ) { - const setRepetition = jest.fn(); + const setRepetition = jest.fn() renderWithProviders( , state - ); - return { setRepetition }; + ) + return { setRepetition } } async function setupEventPopover() { jest - .spyOn(crypto, "randomUUID") - .mockReturnValue("fixed-uuid-with-correct-format"); + .spyOn(crypto, 'randomUUID') + .mockReturnValue('fixed-uuid-with-correct-format') const originalDateResolvedOptions = - new Intl.DateTimeFormat().resolvedOptions(); - jest.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ + new Intl.DateTimeFormat().resolvedOptions() + jest.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({ ...originalDateResolvedOptions, - timeZone: "UTC", - }); + timeZone: 'UTC' + }) renderWithProviders( , preloadedState - ); + ) // Fill in title - const titleInput = screen.getByLabelText("event.form.title"); - fireEvent.change(titleInput, { target: { value: "Meeting" } }); + const titleInput = screen.getByLabelText('event.form.title') + fireEvent.change(titleInput, { target: { value: 'Meeting' } }) // Click More options to expand the dialog - const showMoreButton = screen.getByRole("button", { - name: "common.moreOptions", - }); - fireEvent.click(showMoreButton); + const showMoreButton = screen.getByRole('button', { + name: 'common.moreOptions' + }) + fireEvent.click(showMoreButton) // Check Repeat checkbox to show repeat options - const repeatCheckbox = screen.getByLabelText("event.form.repeat"); - fireEvent.click(repeatCheckbox); + const repeatCheckbox = screen.getByLabelText('event.form.repeat') + fireEvent.click(repeatCheckbox) // Wait for RepeatEvent component to be rendered await waitFor(() => { - expect( - screen.getByText("event.repeat.frequency.weeks") - ).toBeInTheDocument(); - }); + expect(screen.getByText('event.repeat.frequency.weeks')).toBeInTheDocument() + }) } async function expectRRule(expected: Partial) { const spy = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); - const saveButton = screen.getByRole("button", { name: /save/i }); - act(() => fireEvent.click(saveButton)); - await waitFor(() => expect(spy).toHaveBeenCalled()); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) + const saveButton = screen.getByRole('button', { name: /save/i }) + act(() => fireEvent.click(saveButton)) + await waitFor(() => expect(spy).toHaveBeenCalled()) - const received = spy.mock.calls[0][0]; - expect(received.newEvent.repetition).toMatchObject(expected); + const received = spy.mock.calls[0][0] + expect(received.newEvent.repetition).toMatchObject(expected) } -describe("RepeatEvent Component", () => { +describe('RepeatEvent Component', () => { beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); + jest.clearAllMocks() + jest.restoreAllMocks() + }) - it("renders with no repetition by default", () => { - setupRepeatEvent(); + it('renders with no repetition by default', () => { + setupRepeatEvent() // Check that interval input shows default value - const intervalInput = screen.getByTestId("repeat-interval"); - expect(intervalInput).toBeInTheDocument(); + const intervalInput = screen.getByTestId('repeat-interval') + expect(intervalInput).toBeInTheDocument() // Check that frequency dropdown shows default value - const frequencySelect = screen.getByRole("combobox"); - expect(frequencySelect).toBeInTheDocument(); - }); + const frequencySelect = screen.getByRole('combobox') + expect(frequencySelect).toBeInTheDocument() + }) - it("allows selecting repetition frequency", () => { - const { setRepetition } = setupRepeatEvent(); + it('allows selecting repetition frequency', () => { + const { setRepetition } = setupRepeatEvent() // Click on frequency dropdown - const frequencySelect = screen.getByRole("combobox"); - fireEvent.mouseDown(frequencySelect); + const frequencySelect = screen.getByRole('combobox') + fireEvent.mouseDown(frequencySelect) // Select Week(s) - const weeklyOption = screen.getByText("event.repeat.frequency.weeks"); - fireEvent.click(weeklyOption); + const weeklyOption = screen.getByText('event.repeat.frequency.weeks') + fireEvent.click(weeklyOption) expect(setRepetition).toHaveBeenCalledWith( - expect.objectContaining({ freq: "weekly" }) - ); - }); + expect.objectContaining({ freq: 'weekly' }) + ) + }) - it("renders interval input when frequency is selected", () => { - setupRepeatEvent({ freq: "daily" }); + it('renders interval input when frequency is selected', () => { + setupRepeatEvent({ freq: 'daily' }) - const intervalInput = screen.getByTestId("repeat-interval"); - expect(intervalInput).toBeInTheDocument(); - }); + const intervalInput = screen.getByTestId('repeat-interval') + expect(intervalInput).toBeInTheDocument() + }) - it("updates interval value", () => { - const { setRepetition } = setupRepeatEvent(); + it('updates interval value', () => { + const { setRepetition } = setupRepeatEvent() - const intervalInput = screen.getByTestId("repeat-interval"); - fireEvent.change(intervalInput, { target: { value: "3" } }); + const intervalInput = screen.getByTestId('repeat-interval') + fireEvent.change(intervalInput, { target: { value: '3' } }) expect(setRepetition).toHaveBeenCalledWith( expect.objectContaining({ interval: 3 }) - ); - }); + ) + }) - it("toggles day selection for weekly frequency", () => { - const { setRepetition } = setupRepeatEvent({ freq: "weekly" }); + it('toggles day selection for weekly frequency', () => { + const { setRepetition } = setupRepeatEvent({ freq: 'weekly' }) - const mondayChip = screen.getByLabelText("event.repeat.days.monday"); - fireEvent.click(mondayChip); + const mondayChip = screen.getByLabelText('event.repeat.days.monday') + fireEvent.click(mondayChip) expect(setRepetition).toHaveBeenCalledWith( - expect.objectContaining({ byday: ["MO"] }) - ); - }); -}); + expect.objectContaining({ byday: ['MO'] }) + ) + }) +}) -describe("Repeat Event Integration Tests", () => { +describe('Repeat Event Integration Tests', () => { // Increase timeout for all tests in this describe block - jest.setTimeout(30000); + jest.setTimeout(30000) beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); + jest.clearAllMocks() + jest.restoreAllMocks() + }) - it("sends correct CalendarEvent payload for daily repeat", async () => { - await setupEventPopover(); + it('sends correct CalendarEvent payload for daily repeat', async () => { + await setupEventPopover() // When Repeat checkbox is checked, repetition is set to empty object // We need to set the frequency manually - const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); - fireEvent.mouseDown(frequencySelect); - const dailyOption = screen.getByRole("option", { - name: "event.repeat.frequency.days", - }); - fireEvent.click(dailyOption); + const frequencySelect = screen.getByText('event.repeat.frequency.weeks') + fireEvent.mouseDown(frequencySelect) + const dailyOption = screen.getByRole('option', { + name: 'event.repeat.frequency.days' + }) + fireEvent.click(dailyOption) - await expectRRule({ freq: "daily", interval: 1 }); - expect(mockOnClose).toHaveBeenCalledWith(true); - }); + await expectRRule({ freq: 'daily', interval: 1 }) + expect(mockOnClose).toHaveBeenCalledWith(true) + }) - it("sends correct API payload for repeat daily with 2 day interval", async () => { - await setupEventPopover(); + it('sends correct API payload for repeat daily with 2 day interval', async () => { + await setupEventPopover() // Ensure frequency is daily - const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); - fireEvent.mouseDown(frequencySelect); - const dailyOption = screen.getByRole("option", { - name: "event.repeat.frequency.days", - }); - fireEvent.click(dailyOption); + const frequencySelect = screen.getByText('event.repeat.frequency.weeks') + fireEvent.mouseDown(frequencySelect) + const dailyOption = screen.getByRole('option', { + name: 'event.repeat.frequency.days' + }) + fireEvent.click(dailyOption) // Set interval to 2 - const intervalInput = screen.getByTestId("repeat-interval"); - fireEvent.change(intervalInput, { target: { value: "2" } }); + const intervalInput = screen.getByTestId('repeat-interval') + fireEvent.change(intervalInput, { target: { value: '2' } }) - await expectRRule({ freq: "daily", interval: 2 }); - expect(mockOnClose).toHaveBeenCalledWith(true); - }); + await expectRRule({ freq: 'daily', interval: 2 }) + expect(mockOnClose).toHaveBeenCalledWith(true) + }) - it("sends correct API payload for repeat daily for 5 repetitions", async () => { - await setupEventPopover(); + it('sends correct API payload for repeat daily for 5 repetitions', async () => { + await setupEventPopover() // Ensure frequency is daily - const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); - fireEvent.mouseDown(frequencySelect); - const dailyOption = screen.getByRole("option", { - name: "event.repeat.frequency.days", - }); - fireEvent.click(dailyOption); + const frequencySelect = screen.getByText('event.repeat.frequency.weeks') + fireEvent.mouseDown(frequencySelect) + const dailyOption = screen.getByRole('option', { + name: 'event.repeat.frequency.days' + }) + fireEvent.click(dailyOption) // Select "After" end option - const afterRadio = screen.getByLabelText(/after/i); - fireEvent.click(afterRadio); + const afterRadio = screen.getByLabelText(/after/i) + fireEvent.click(afterRadio) // Set occurrences to 5 - const occurrencesInput = screen.getByTestId("occurrences-input"); - fireEvent.change(occurrencesInput, { target: { value: "5" } }); + const occurrencesInput = screen.getByTestId('occurrences-input') + fireEvent.change(occurrencesInput, { target: { value: '5' } }) - await expectRRule({ freq: "daily", interval: 1, occurrences: 5 }); - expect(mockOnClose).toHaveBeenCalledWith(true); - }); + await expectRRule({ freq: 'daily', interval: 1, occurrences: 5 }) + expect(mockOnClose).toHaveBeenCalledWith(true) + }) - it("sends correct API payload for repeat daily until specific date", async () => { - await setupEventPopover(); + it('sends correct API payload for repeat daily until specific date', async () => { + await setupEventPopover() // Ensure frequency is daily - const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); - fireEvent.mouseDown(frequencySelect); - const dailyOption = screen.getByRole("option", { - name: "event.repeat.frequency.days", - }); - fireEvent.click(dailyOption); + const frequencySelect = screen.getByText('event.repeat.frequency.weeks') + fireEvent.mouseDown(frequencySelect) + const dailyOption = screen.getByRole('option', { + name: 'event.repeat.frequency.days' + }) + fireEvent.click(dailyOption) // Select "On" end option const onRadio = screen .getAllByLabelText(/on/i) - .find((el) => el.type === "radio"); - fireEvent.click(onRadio!); + .find(el => el.type === 'radio') + fireEvent.click(onRadio!) // End date is set by UI to some valid date string (YYYY-MM-DD) await expectRRule({ - freq: "daily", + freq: 'daily', interval: 1, - endDate: expect.any(String), - }); - expect(mockOnClose).toHaveBeenCalledWith(true); - }); + endDate: expect.any(String) + }) + expect(mockOnClose).toHaveBeenCalledWith(true) + }) - it("sends correct API payload for repeat weekly on specific days", async () => { - await setupEventPopover(); + it('sends correct API payload for repeat weekly on specific days', async () => { + await setupEventPopover() // Select Week(s) frequency - const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); - fireEvent.mouseDown(frequencySelect); - const weeklyOption = screen.getByRole("option", { - name: "event.repeat.frequency.weeks", - }); - fireEvent.click(weeklyOption); + const frequencySelect = screen.getByText('event.repeat.frequency.weeks') + fireEvent.mouseDown(frequencySelect) + const weeklyOption = screen.getByRole('option', { + name: 'event.repeat.frequency.weeks' + }) + fireEvent.click(weeklyOption) // Select Thursday - const thursdayCheckbox = screen.getByLabelText( - "event.repeat.days.thursday" - ); - fireEvent.click(thursdayCheckbox); + const thursdayCheckbox = screen.getByLabelText('event.repeat.days.thursday') + fireEvent.click(thursdayCheckbox) await expectRRule({ - freq: "weekly", + freq: 'weekly', interval: 1, - byday: ["FR", "TH"], - }); - expect(mockOnClose).toHaveBeenCalledWith(true); - }); + byday: ['FR', 'TH'] + }) + expect(mockOnClose).toHaveBeenCalledWith(true) + }) - it("sends correct API payload for repeat weekly with 3 week interval", async () => { - await setupEventPopover(); + it('sends correct API payload for repeat weekly with 3 week interval', async () => { + await setupEventPopover() // Select Week(s) frequency - const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); - fireEvent.mouseDown(frequencySelect); - const weeklyOption = screen.getByRole("option", { - name: "event.repeat.frequency.weeks", - }); - fireEvent.click(weeklyOption); + const frequencySelect = screen.getByText('event.repeat.frequency.weeks') + fireEvent.mouseDown(frequencySelect) + const weeklyOption = screen.getByRole('option', { + name: 'event.repeat.frequency.weeks' + }) + fireEvent.click(weeklyOption) // Set interval to 3 - const intervalInput = screen.getByTestId("repeat-interval"); - fireEvent.change(intervalInput, { target: { value: "3" } }); + const intervalInput = screen.getByTestId('repeat-interval') + fireEvent.change(intervalInput, { target: { value: '3' } }) - await expectRRule({ freq: "weekly", interval: 3 }); - expect(mockOnClose).toHaveBeenCalledWith(true); - }); + await expectRRule({ freq: 'weekly', interval: 3 }) + expect(mockOnClose).toHaveBeenCalledWith(true) + }) - it("sends correct API payload for repeat monthly", async () => { - await setupEventPopover(); + it('sends correct API payload for repeat monthly', async () => { + await setupEventPopover() // Select Month(s) frequency - const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); - fireEvent.mouseDown(frequencySelect); - const monthlyOption = screen.getByRole("option", { - name: "event.repeat.frequency.months", - }); - fireEvent.click(monthlyOption); + const frequencySelect = screen.getByText('event.repeat.frequency.weeks') + fireEvent.mouseDown(frequencySelect) + const monthlyOption = screen.getByRole('option', { + name: 'event.repeat.frequency.months' + }) + fireEvent.click(monthlyOption) - await expectRRule({ freq: "monthly", interval: 1 }); - expect(mockOnClose).toHaveBeenCalledWith(true); - }); + await expectRRule({ freq: 'monthly', interval: 1 }) + expect(mockOnClose).toHaveBeenCalledWith(true) + }) - it("sends correct API payload for repeat monthly and end after 5 occurrences", async () => { - await setupEventPopover(); + it('sends correct API payload for repeat monthly and end after 5 occurrences', async () => { + await setupEventPopover() // Select Month(s) frequency - const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); - fireEvent.mouseDown(frequencySelect); - const monthlyOption = screen.getByRole("option", { - name: "event.repeat.frequency.months", - }); - fireEvent.click(monthlyOption); + const frequencySelect = screen.getByText('event.repeat.frequency.weeks') + fireEvent.mouseDown(frequencySelect) + const monthlyOption = screen.getByRole('option', { + name: 'event.repeat.frequency.months' + }) + fireEvent.click(monthlyOption) // Select "After" end option - const afterRadio = screen.getByLabelText(/after/i); - fireEvent.click(afterRadio); + const afterRadio = screen.getByLabelText(/after/i) + fireEvent.click(afterRadio) // Set occurrences to 5 - const occurrencesInput = screen.getByTestId("occurrences-input"); - fireEvent.change(occurrencesInput, { target: { value: "5" } }); + const occurrencesInput = screen.getByTestId('occurrences-input') + fireEvent.change(occurrencesInput, { target: { value: '5' } }) - await expectRRule({ freq: "monthly", interval: 1, occurrences: 5 }); - expect(mockOnClose).toHaveBeenCalledWith(true); - }); + await expectRRule({ freq: 'monthly', interval: 1, occurrences: 5 }) + expect(mockOnClose).toHaveBeenCalledWith(true) + }) - it("sends correct API payload for repeat yearly", async () => { - await setupEventPopover(); + it('sends correct API payload for repeat yearly', async () => { + await setupEventPopover() // Select Year(s) frequency - const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); - fireEvent.mouseDown(frequencySelect); - const yearlyOption = screen.getByRole("option", { - name: "event.repeat.frequency.years", - }); - fireEvent.click(yearlyOption); + const frequencySelect = screen.getByText('event.repeat.frequency.weeks') + fireEvent.mouseDown(frequencySelect) + const yearlyOption = screen.getByRole('option', { + name: 'event.repeat.frequency.years' + }) + fireEvent.click(yearlyOption) - await expectRRule({ freq: "yearly", interval: 1 }); - expect(mockOnClose).toHaveBeenCalledWith(true); - }); + await expectRRule({ freq: 'yearly', interval: 1 }) + expect(mockOnClose).toHaveBeenCalledWith(true) + }) - it("sends correct API payload for repeat yearly with end option changes", async () => { - await setupEventPopover(); + it('sends correct API payload for repeat yearly with end option changes', async () => { + await setupEventPopover() // Select Year(s) frequency - const frequencySelect = screen.getByText("event.repeat.frequency.weeks"); - fireEvent.mouseDown(frequencySelect); - const yearlyOption = screen.getByRole("option", { - name: "event.repeat.frequency.years", - }); - fireEvent.click(yearlyOption); + const frequencySelect = screen.getByText('event.repeat.frequency.weeks') + fireEvent.mouseDown(frequencySelect) + const yearlyOption = screen.getByRole('option', { + name: 'event.repeat.frequency.years' + }) + fireEvent.click(yearlyOption) // First choose "After" with 5 occurrences - const afterRadio = screen.getByLabelText(/after/i); - fireEvent.click(afterRadio); - const occurrencesInput = screen.getByTestId("occurrences-input"); - fireEvent.change(occurrencesInput, { target: { value: "5" } }); + const afterRadio = screen.getByLabelText(/after/i) + fireEvent.click(afterRadio) + const occurrencesInput = screen.getByTestId('occurrences-input') + fireEvent.change(occurrencesInput, { target: { value: '5' } }) // Then change mind and choose "Never" - const neverRadio = screen.getByLabelText(/never/i); - fireEvent.click(neverRadio); + const neverRadio = screen.getByLabelText(/never/i) + fireEvent.click(neverRadio) await expectRRule({ - freq: "yearly", - interval: 1, - }); - expect(mockOnClose).toHaveBeenCalledWith(true); - }); -}); + freq: 'yearly', + interval: 1 + }) + expect(mockOnClose).toHaveBeenCalledWith(true) + }) +}) diff --git a/__test__/components/ResourceSearch.test.tsx b/__test__/components/ResourceSearch.test.tsx index e2befca..c459e86 100644 --- a/__test__/components/ResourceSearch.test.tsx +++ b/__test__/components/ResourceSearch.test.tsx @@ -1,224 +1,219 @@ -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"; +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 ->; +jest.mock('@/features/User/userAPI') +const mockedSearchUsers = searchUsers as jest.MockedFunction -describe("ResourceSearch", () => { +describe('ResourceSearch', () => { const baseResource: Resource = { - displayName: "Projector Room", - }; + displayName: 'Projector Room' + } function setup( selectedResources: Resource[] = [], props?: Partial> ) { - const onChange = jest.fn(); + const onChange = jest.fn() renderWithProviders( - ); - return { onChange }; + ) + return { onChange } } beforeEach(() => { - jest.useFakeTimers(); - mockedSearchUsers.mockReset(); - }); + jest.useFakeTimers() + mockedSearchUsers.mockReset() + }) afterEach(() => { - jest.useRealTimers(); - }); + jest.useRealTimers() + }) - it("calls searchUsers after debounce when typing", async () => { + it('calls searchUsers after debounce when typing', async () => { mockedSearchUsers.mockResolvedValueOnce([ - baseResource, - ] as unknown as Awaited>); - setup(); + baseResource + ] as unknown as Awaited>) + setup() - const input = screen.getByRole("combobox"); - await userEvent.type(input, "Room"); + const input = screen.getByRole('combobox') + await userEvent.type(input, 'Room') await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) await waitFor(() => { - expect(mockedSearchUsers).toHaveBeenCalledWith("Room", ["resource"]); - }); - }); + expect(mockedSearchUsers).toHaveBeenCalledWith('Room', ['resource']) + }) + }) - it("renders search results and allows selection", async () => { + it('renders search results and allows selection', async () => { mockedSearchUsers.mockResolvedValueOnce([ - baseResource, - ] as unknown as Awaited>); - const { onChange } = setup(); + baseResource + ] as unknown as Awaited>) + const { onChange } = setup() - const input = screen.getByRole("combobox"); - await userEvent.type(input, "Room"); + const input = screen.getByRole('combobox') + await userEvent.type(input, 'Room') await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) - const option = await screen.findByText("Projector Room"); - await userEvent.click(option); + const option = await screen.findByText('Projector Room') + await userEvent.click(option) await waitFor(() => { - expect(onChange).toHaveBeenCalled(); - }); - }); + expect(onChange).toHaveBeenCalled() + }) + }) - it("does not show already selected resources in options", async () => { + 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"); + baseResource + ] as unknown as Awaited>) + setup([baseResource]) + const input = screen.getByRole('combobox') + await userEvent.type(input, 'Projector') await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) await waitFor(() => { // It shouldn't be in the dropdown options anymore - const options = screen.queryAllByRole("option"); + const options = screen.queryAllByRole('option') expect( - options.find((opt) => opt.textContent === "Projector Room") - ).toBeUndefined(); - }); - }); + options.find(opt => opt.textContent === 'Projector Room') + ).toBeUndefined() + }) + }) - it("respects disabled state", () => { - setup([], { disabled: true }); - expect(screen.getByRole("combobox")).toBeDisabled(); - }); + 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"); + baseResource + ] as unknown as Awaited>) + setup() + const input = screen.getByRole('combobox') - await userEvent.type(input, "Room"); + await userEvent.type(input, 'Room') await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) await waitFor(() => { - expect(screen.getByRole("listbox")).toBeInTheDocument(); - }); + expect(screen.getByRole('listbox')).toBeInTheDocument() + }) - await userEvent.clear(input); + await userEvent.clear(input) await waitFor(() => { - expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); - }); - }); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + }) + }) it("shows 'No results' when search succeeds but returns empty array", async () => { - mockedSearchUsers.mockResolvedValueOnce([]); - setup(); + mockedSearchUsers.mockResolvedValueOnce([]) + setup() - const input = screen.getByRole("combobox"); - await userEvent.type(input, "Room"); + const input = screen.getByRole('combobox') + await userEvent.type(input, 'Room') await act(async () => { - jest.advanceTimersByTime(300); - await Promise.resolve(); - await Promise.resolve(); - }); + jest.advanceTimersByTime(300) + await Promise.resolve() + await Promise.resolve() + }) const noResults = await screen.findByText( - "resourceSearch.noResults", + 'resourceSearch.noResults', {}, { timeout: 5000 } - ); - expect(noResults).toBeInTheDocument(); - }); + ) + expect(noResults).toBeInTheDocument() + }) - it("clears options when search fails and shows error snackbar", async () => { + it('clears options when search fails and shows error snackbar', async () => { mockedSearchUsers.mockResolvedValueOnce([ - baseResource, - ] as unknown as Awaited>); - setup(); + baseResource + ] as unknown as Awaited>) + setup() - const input = screen.getByRole("combobox"); - await userEvent.type(input, "Room"); + const input = screen.getByRole('combobox') + await userEvent.type(input, 'Room') await act(async () => { - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); + jest.advanceTimersByTime(300) + await Promise.resolve() + }) await waitFor(() => { - expect(screen.getByText("Projector Room")).toBeInTheDocument(); - }); + expect(screen.getByText('Projector Room')).toBeInTheDocument() + }) - mockedSearchUsers.mockRejectedValueOnce(new Error("Network error")); - await userEvent.clear(input); - await userEvent.type(input, "Error"); + mockedSearchUsers.mockRejectedValueOnce(new Error('Network error')) + await userEvent.clear(input) + await userEvent.type(input, 'Error') await act(async () => { - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); + jest.advanceTimersByTime(300) + await Promise.resolve() + }) - const errorMessage = await screen.findByText("resourceSearch.searchError"); - expect(errorMessage).toBeInTheDocument(); + const errorMessage = await screen.findByText('resourceSearch.searchError') + expect(errorMessage).toBeInTheDocument() - expect(screen.queryByText("Projector Room")).not.toBeInTheDocument(); + expect(screen.queryByText('Projector Room')).not.toBeInTheDocument() mockedSearchUsers.mockResolvedValueOnce([ - baseResource, - ] as unknown as Awaited>); - await userEvent.clear(input); - await userEvent.type(input, "Room"); + baseResource + ] as unknown as Awaited>) + await userEvent.clear(input) + await userEvent.type(input, 'Room') await act(async () => { - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); + jest.advanceTimersByTime(300) + await Promise.resolve() + }) await waitFor(() => { - expect(screen.getByText("Projector Room")).toBeInTheDocument(); - }); - }); + 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; - }); + 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(); + ) + setup() - const input = screen.getByRole("combobox"); - await userEvent.type(input, "Room"); + const input = screen.getByRole('combobox') + await userEvent.type(input, 'Room') await act(async () => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) const loadingText = await screen.findByText( - "resourceSearch.loading", + 'resourceSearch.loading', {}, { timeout: 5000 } - ); - expect(loadingText).toBeInTheDocument(); + ) + expect(loadingText).toBeInTheDocument() await act(async () => { - resolveSearch!([baseResource]); - await searchPromise; - }); - }); -}); + resolveSearch!([baseResource]) + await searchPromise + }) + }) +}) diff --git a/__test__/components/ResponsiveDialog.test.tsx b/__test__/components/ResponsiveDialog.test.tsx index f0e0d2e..a94f1b4 100644 --- a/__test__/components/ResponsiveDialog.test.tsx +++ b/__test__/components/ResponsiveDialog.test.tsx @@ -1,33 +1,33 @@ -import { ResponsiveDialog } from "@/components/Dialog"; -import { Button, TextField, TwakeMuiThemeProvider } from "@linagora/twake-mui"; -import { fireEvent, render, screen } from "@testing-library/react"; -import React from "react"; +import { ResponsiveDialog } from '@/components/Dialog' +import { Button, TextField, TwakeMuiThemeProvider } from '@linagora/twake-mui' +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' -describe("ResponsiveDialog", () => { - const mockOnClose = jest.fn(); - const mockOnExpandToggle = jest.fn(); +describe('ResponsiveDialog', () => { + const mockOnClose = jest.fn() + const mockOnExpandToggle = jest.fn() const renderWithTheme = (ui: React.ReactElement) => { - return render({ui}); - }; + return render({ui}) + } beforeEach(() => { - mockOnClose.mockClear(); - mockOnExpandToggle.mockClear(); - }); + mockOnClose.mockClear() + mockOnExpandToggle.mockClear() + }) - it("renders in normal mode by default", () => { + it('renders in normal mode by default', () => { renderWithTheme( - ); + ) - expect(screen.getByText("Test Dialog")).toBeInTheDocument(); - expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); - }); + expect(screen.getByText('Test Dialog')).toBeInTheDocument() + expect(screen.getByLabelText(/name/i)).toBeInTheDocument() + }) - it("renders title in normal mode", () => { + it('renders title in normal mode', () => { renderWithTheme( { >
Content
- ); + ) - expect(screen.getByText("My Title")).toBeInTheDocument(); - expect(screen.queryByLabelText("show less")).not.toBeInTheDocument(); - }); + expect(screen.getByText('My Title')).toBeInTheDocument() + expect(screen.queryByLabelText('show less')).not.toBeInTheDocument() + }) - it("renders back arrow in extended mode", () => { + it('renders back arrow in extended mode', () => { renderWithTheme( { >
Content
- ); + ) - expect(screen.queryByText("My Title")).not.toBeInTheDocument(); - expect(screen.getByLabelText("show less")).toBeInTheDocument(); - }); + expect(screen.queryByText('My Title')).not.toBeInTheDocument() + expect(screen.getByLabelText('show less')).toBeInTheDocument() + }) - it("calls onExpandToggle when back arrow is clicked", () => { + it('calls onExpandToggle when back arrow is clicked', () => { renderWithTheme( { >
Content
- ); + ) - const backButton = screen.getByLabelText("show less"); - fireEvent.click(backButton); + const backButton = screen.getByLabelText('show less') + fireEvent.click(backButton) - expect(mockOnExpandToggle).toHaveBeenCalledTimes(1); - }); + expect(mockOnExpandToggle).toHaveBeenCalledTimes(1) + }) - it("renders actions when provided", () => { + it('renders actions when provided', () => { renderWithTheme( { >
Content
- ); + ) - expect(screen.getByText("Custom Action")).toBeInTheDocument(); - }); + expect(screen.getByText('Custom Action')).toBeInTheDocument() + }) - it("does not render actions when not provided", () => { + it('does not render actions when not provided', () => { const { container } = renderWithTheme(
Content
- ); + ) - const dialogActions = container.querySelector(".MuiDialogActions-root"); - expect(dialogActions).not.toBeInTheDocument(); - }); + const dialogActions = container.querySelector('.MuiDialogActions-root') + expect(dialogActions).not.toBeInTheDocument() + }) - it("calls onClose when backdrop is clicked", () => { + it('calls onClose when backdrop is clicked', () => { renderWithTheme(
Content
- ); + ) - const backdrop = document.querySelector(".MuiBackdrop-root"); + const backdrop = document.querySelector('.MuiBackdrop-root') if (backdrop) { - fireEvent.click(backdrop); + fireEvent.click(backdrop) } - expect(mockOnClose).toHaveBeenCalled(); - }); + expect(mockOnClose).toHaveBeenCalled() + }) - it("applies custom normalMaxWidth", () => { + it('applies custom normalMaxWidth', () => { renderWithTheme( { >
Normal Width Content
- ); + ) - expect(screen.getByText("Normal Width Content")).toBeInTheDocument(); - }); + expect(screen.getByText('Normal Width Content')).toBeInTheDocument() + }) - it("wraps children in Stack component", () => { + it('wraps children in Stack component', () => { renderWithTheme( - ); + ) - expect(screen.getByLabelText("Field 1")).toBeInTheDocument(); - expect(screen.getByLabelText("Field 2")).toBeInTheDocument(); - }); + expect(screen.getByLabelText('Field 1')).toBeInTheDocument() + expect(screen.getByLabelText('Field 2')).toBeInTheDocument() + }) - it("uses correct spacing in normal mode", () => { + it('uses correct spacing in normal mode', () => { renderWithTheme( { >
Normal Spacing Content
- ); + ) - expect(screen.getByText("Normal Spacing Content")).toBeInTheDocument(); - }); + expect(screen.getByText('Normal Spacing Content')).toBeInTheDocument() + }) - it("uses correct spacing in extended mode", () => { + it('uses correct spacing in extended mode', () => { renderWithTheme( { >
Extended Spacing Content
- ); + ) - expect(screen.getByText("Extended Spacing Content")).toBeInTheDocument(); - }); + expect(screen.getByText('Extended Spacing Content')).toBeInTheDocument() + }) - it("applies contentSx custom styles", () => { + it('applies contentSx custom styles', () => { renderWithTheme( { >
Custom Styled Content
- ); + ) - expect(screen.getByText("Custom Styled Content")).toBeInTheDocument(); - }); + expect(screen.getByText('Custom Styled Content')).toBeInTheDocument() + }) - it("applies titleSx custom styles", () => { + it('applies titleSx custom styles', () => { const { container } = renderWithTheme(
Content
- ); + ) - const title = screen.getByText("Test"); - expect(title).toBeInTheDocument(); - }); + const title = screen.getByText('Test') + expect(title).toBeInTheDocument() + }) - it("shows dividers when dividers prop is true", () => { + it('shows dividers when dividers prop is true', () => { renderWithTheme( { >
Content with Dividers
- ); + ) - expect(screen.getByText("Content with Dividers")).toBeInTheDocument(); - }); + expect(screen.getByText('Content with Dividers')).toBeInTheDocument() + }) - it("does not show back arrow when onExpandToggle is not provided", () => { + it('does not show back arrow when onExpandToggle is not provided', () => { renderWithTheme( { >
Content
- ); + ) - expect(screen.queryByLabelText("show less")).not.toBeInTheDocument(); - expect(screen.getByText("Test Title")).toBeInTheDocument(); - }); + expect(screen.queryByLabelText('show less')).not.toBeInTheDocument() + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) - it("accepts custom headerHeight", () => { + it('accepts custom headerHeight', () => { renderWithTheme( { >
Custom Header Content
- ); + ) - expect(screen.getByText("Custom Header Content")).toBeInTheDocument(); - }); + expect(screen.getByText('Custom Header Content')).toBeInTheDocument() + }) - it("renders with custom expandedContentMaxWidth", () => { + it('renders with custom expandedContentMaxWidth', () => { renderWithTheme( { >
Wide Content
- ); + ) - expect(screen.getByText("Wide Content")).toBeInTheDocument(); - }); + expect(screen.getByText('Wide Content')).toBeInTheDocument() + }) - it("does not render dialog content when open is false", () => { + it('does not render dialog content when open is false', () => { renderWithTheme(
Test Content
- ); + ) - expect(screen.queryByText("Test Content")).not.toBeInTheDocument(); - }); + expect(screen.queryByText('Test Content')).not.toBeInTheDocument() + }) - it("renders correctly in extended mode", () => { + it('renders correctly in extended mode', () => { renderWithTheme( { >
Extended Content
- ); + ) - expect(screen.getByText("Extended Content")).toBeInTheDocument(); - expect(screen.getByLabelText("show less")).toBeInTheDocument(); - }); + expect(screen.getByText('Extended Content')).toBeInTheDocument() + expect(screen.getByLabelText('show less')).toBeInTheDocument() + }) - it("renders expand and close icons in normal mode when showHeaderActions is true", () => { + it('renders expand and close icons in normal mode when showHeaderActions is true', () => { renderWithTheme( { >
Content
- ); + ) - expect(screen.getByLabelText("expand")).toBeInTheDocument(); - expect(screen.getByLabelText("close")).toBeInTheDocument(); - }); + expect(screen.getByLabelText('expand')).toBeInTheDocument() + expect(screen.getByLabelText('close')).toBeInTheDocument() + }) - it("does not render header icons when showHeaderActions is false", () => { + it('does not render header icons when showHeaderActions is false', () => { renderWithTheme( { >
Content
- ); + ) - expect(screen.queryByLabelText("expand")).not.toBeInTheDocument(); - expect(screen.queryByLabelText("close")).not.toBeInTheDocument(); - expect(screen.getByText("Test Title")).toBeInTheDocument(); - }); + expect(screen.queryByLabelText('expand')).not.toBeInTheDocument() + expect(screen.queryByLabelText('close')).not.toBeInTheDocument() + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) - it("calls onClose when close icon is clicked", () => { + it('calls onClose when close icon is clicked', () => { renderWithTheme( { >
Content
- ); + ) - const closeButton = screen.getByLabelText("close"); - fireEvent.click(closeButton); + const closeButton = screen.getByLabelText('close') + fireEvent.click(closeButton) - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) - it("calls onExpandToggle when expand icon is clicked", () => { + it('calls onExpandToggle when expand icon is clicked', () => { renderWithTheme( { >
Content
- ); + ) - const expandButton = screen.getByLabelText("expand"); - fireEvent.click(expandButton); + const expandButton = screen.getByLabelText('expand') + fireEvent.click(expandButton) - expect(mockOnExpandToggle).toHaveBeenCalledTimes(1); - }); -}); + expect(mockOnExpandToggle).toHaveBeenCalledTimes(1) + }) +}) diff --git a/__test__/components/useUserSearch.test.tsx b/__test__/components/useUserSearch.test.tsx index b4be43f..8d4f3f4 100644 --- a/__test__/components/useUserSearch.test.tsx +++ b/__test__/components/useUserSearch.test.tsx @@ -1,102 +1,102 @@ -import { renderHook, act } from "@testing-library/react"; -import { useUserSearch } from "@/components/Attendees/useUserSearch"; -import { searchUsers } from "@/features/User/userAPI"; +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(), -})); +jest.mock('@/features/User/userAPI', () => ({ + searchUsers: jest.fn() +})) -describe("useUserSearch", () => { - const mockSearchUsers = searchUsers as jest.Mock; +describe('useUserSearch', () => { + const mockSearchUsers = searchUsers as jest.Mock beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - }); + jest.useFakeTimers() + jest.clearAllMocks() + }) afterEach(() => { - jest.useRealTimers(); - }); + jest.useRealTimers() + }) - it("should initialize with default values", () => { + it('should initialize with default values', () => { const { result } = renderHook(() => - useUserSearch({ objectTypes: ["user"], errorMessage: "Error" }) - ); + 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(""); - }); + 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); + 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" }) - ); + useUserSearch({ objectTypes: ['user'], errorMessage: 'Error' }) + ) act(() => { - result.current.setQuery("John"); - }); + result.current.setQuery('John') + }) - expect(result.current.loading).toBe(false); // Before debounce + 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 - }); + 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); - }); + 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", () => { + it('should clear options and handle empty query', () => { const { result } = renderHook(() => - useUserSearch({ objectTypes: ["user"], errorMessage: "Error" }) - ); + useUserSearch({ objectTypes: ['user'], errorMessage: 'Error' }) + ) act(() => { - result.current.setQuery(" "); // empty query with spaces - }); + result.current.setQuery(' ') // empty query with spaces + }) act(() => { - jest.advanceTimersByTime(300); - }); + jest.advanceTimersByTime(300) + }) - expect(mockSearchUsers).not.toHaveBeenCalled(); - expect(result.current.options).toEqual([]); - expect(result.current.loading).toBe(false); - expect(result.current.hasSearched).toBe(false); - }); + 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")); + 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" }) - ); + useUserSearch({ objectTypes: ['user'], errorMessage: 'Custom Error' }) + ) act(() => { - result.current.setQuery("FailedSearch"); - }); + result.current.setQuery('FailedSearch') + }) await act(async () => { - jest.advanceTimersByTime(300); - await Promise.resolve(); // allow microtasks to flush - }); + 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"); - }); -}); + 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/CalendarAPI.test.tsx b/__test__/features/Calendars/CalendarAPI.test.tsx index 21bdb22..eeb97c8 100644 --- a/__test__/features/Calendars/CalendarAPI.test.tsx +++ b/__test__/features/Calendars/CalendarAPI.test.tsx @@ -6,197 +6,197 @@ import { getSecretLink, postCalendar, proppatchCalendar, - removeCalendar, -} from "@/features/Calendars/CalendarApi"; -import { clientConfig } from "@/features/User/oidcAuth"; -import { api } from "@/utils/apiUtils"; -clientConfig.url = "https://example.com"; + removeCalendar +} from '@/features/Calendars/CalendarApi' +import { clientConfig } from '@/features/User/oidcAuth' +import { api } from '@/utils/apiUtils' +clientConfig.url = 'https://example.com' -jest.mock("@/utils/apiUtils"); +jest.mock('@/utils/apiUtils') -describe("Calendar API", () => { +describe('Calendar API', () => { afterEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - it("fetches calendar list for a user", async () => { - const mockUserId = "user123"; - const mockResponse = [{ id: "calendar1" }, { id: "calendar2" }]; + it('fetches calendar list for a user', async () => { + const mockUserId = 'user123' + const mockResponse = [{ id: 'calendar1' }, { id: 'calendar2' }] - (api.get as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue(mockResponse), - }); + ;(api.get as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse) + }) - const calendars = await getCalendars(mockUserId); + const calendars = await getCalendars(mockUserId) expect(api.get).toHaveBeenCalledWith( `dav/calendars/${mockUserId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true`, { - headers: { Accept: "application/calendar+json" }, + headers: { Accept: 'application/calendar+json' } } - ); - expect(calendars).toEqual(mockResponse); - }); + ) + expect(calendars).toEqual(mockResponse) + }) - it("fetches calendar events for a given ID and match window", async () => { - const calendarId = "calendar1"; - const match = { start: "2025-07-01", end: "2025-07-31" }; - const mockCalendarData = { events: ["event1", "event2"] }; + it('fetches calendar events for a given ID and match window', async () => { + const calendarId = 'calendar1' + const match = { start: '2025-07-01', end: '2025-07-31' } + const mockCalendarData = { events: ['event1', 'event2'] } - (api as unknown as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue(mockCalendarData), - }); + ;(api as unknown as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockCalendarData) + }) - const result = await getCalendar(calendarId, match); + const result = await getCalendar(calendarId, match) expect(api).toHaveBeenCalledWith(`dav/calendars/${calendarId}.json`, { - method: "REPORT", + method: 'REPORT', headers: { - Accept: "application/json, text/plain, */*", + Accept: 'application/json, text/plain, */*' }, - body: JSON.stringify({ match }), - }); + body: JSON.stringify({ match }) + }) - expect(result).toEqual(mockCalendarData); - }); - it("postCalendar", async () => { - const calId = "calId"; - const userId = "userId"; - const color = { light: "calId" }; - const name = "new cal"; - const desc = "desc"; + expect(result).toEqual(mockCalendarData) + }) + it('postCalendar', async () => { + const calId = 'calId' + const userId = 'userId' + const color = { light: 'calId' } + const name = 'new cal' + const desc = 'desc' - const result = await postCalendar(userId, calId, color, name, desc); + const result = await postCalendar(userId, calId, color, name, desc) expect(api.post).toHaveBeenCalledWith(`dav/calendars/${userId}.json`, { headers: { - Accept: "application/json, text/plain, */*", + Accept: 'application/json, text/plain, */*' }, body: JSON.stringify({ - id: "calId", - "dav:name": "new cal", - "apple:color": "calId", - "caldav:description": "desc", - }), - }); - }); - it("patch Calendar", async () => { - const calId = "calId"; - const calLink = "/calendars/calId.json"; - const color = { light: "calIdLight", dark: "calIdDark" }; - const name = "new cal"; - const desc = "desc"; + id: 'calId', + 'dav:name': 'new cal', + 'apple:color': 'calId', + 'caldav:description': 'desc' + }) + }) + }) + it('patch Calendar', async () => { + const calId = 'calId' + const calLink = '/calendars/calId.json' + const color = { light: 'calIdLight', dark: 'calIdDark' } + const name = 'new cal' + const desc = 'desc' - const result = await proppatchCalendar(calLink, { color, name, desc }); + const result = await proppatchCalendar(calLink, { color, name, desc }) expect(api).toHaveBeenCalledWith(`dav${calLink}`, { - method: "PROPPATCH", + method: 'PROPPATCH', headers: { - Accept: "application/json, text/plain, */*", + Accept: 'application/json, text/plain, */*' }, body: JSON.stringify({ - "dav:name": "new cal", - "caldav:description": "desc", - "apple:color": "calIdLight", - }), - }); - }); + 'dav:name': 'new cal', + 'caldav:description': 'desc', + 'apple:color': 'calIdLight' + }) + }) + }) - it("remove Calendar", async () => { - const calLink = "/calendars/calId.json"; - const result = await removeCalendar(calLink); + it('remove Calendar', async () => { + const calLink = '/calendars/calId.json' + const result = await removeCalendar(calLink) expect(api).toHaveBeenCalledWith(`dav${calLink}`, { - method: "DELETE", + method: 'DELETE', headers: { - Accept: "application/json, text/plain, */*", - }, - }); - }); + Accept: 'application/json, text/plain, */*' + } + }) + }) - it("get secret link without reset", async () => { - const calLink = "/calendars/calId.json"; - (api.get as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue("link"), - }); + it('get secret link without reset', async () => { + const calLink = '/calendars/calId.json' + ;(api.get as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue('link') + }) - const noreset = await getSecretLink(calLink, false); + const noreset = await getSecretLink(calLink, false) expect(api.get).toHaveBeenCalledWith( `calendar/api${calLink}/secret-link?shouldResetLink=false`, { headers: { - Accept: "application/json, text/plain, */*", - }, + Accept: 'application/json, text/plain, */*' + } } - ); - }); - it("get secret link with reset", async () => { - const calLink = "/calendars/calId.json"; - (api.get as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue("link"), - }); - const reset = await getSecretLink(calLink, true); + ) + }) + it('get secret link with reset', async () => { + const calLink = '/calendars/calId.json' + ;(api.get as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue('link') + }) + const reset = await getSecretLink(calLink, true) expect(api.get).toHaveBeenCalledWith( `calendar/api${calLink}/secret-link?shouldResetLink=true`, { headers: { - Accept: "application/json, text/plain, */*", - }, + Accept: 'application/json, text/plain, */*' + } } - ); - }); + ) + }) - it("get export data ", async () => { - const calLink = "/calendars/calId.json"; - (api.get as jest.Mock).mockReturnValue({ - text: jest.fn().mockResolvedValue("data"), - }); - const data = await exportCalendar(calLink); + it('get export data ', async () => { + const calLink = '/calendars/calId.json' + ;(api.get as jest.Mock).mockReturnValue({ + text: jest.fn().mockResolvedValue('data') + }) + const data = await exportCalendar(calLink) expect(api.get).toHaveBeenCalledWith(`dav${calLink}?export`, { headers: { - Accept: "application/calendar", - }, - }); - }); + Accept: 'application/calendar' + } + }) + }) - it("When adding a sharedCal with #default #default is preserved", async () => { - const mockApiPost = jest.spyOn(api, "post"); + it('When adding a sharedCal with #default #default is preserved', async () => { + const mockApiPost = jest.spyOn(api, 'post') const calData = { cal: { - id: "cal123", - "dav:name": "#default", - "apple:color": "#FF5733", - "caldav:description": "Default calendar", + id: 'cal123', + 'dav:name': '#default', + 'apple:color': '#FF5733', + 'caldav:description': 'Default calendar', acl: [], invite: [], _links: { self: { - href: "/calendars/owner123/cal123.json", - }, - }, + href: '/calendars/owner123/cal123.json' + } + } }, owner: { - displayName: "John Doe", - email: "john.doe@example.com", - openpaasId: "owner123", + displayName: 'John Doe', + email: 'john.doe@example.com', + openpaasId: 'owner123' }, - color: "#FF5733", - }; + color: '#FF5733' + } - await addSharedCalendar("currentUserId", "newCalId123", calData); + await addSharedCalendar('currentUserId', 'newCalId123', calData) expect(mockApiPost).toHaveBeenCalledWith( - "dav/calendars/currentUserId.json", + 'dav/calendars/currentUserId.json', expect.objectContaining({ - body: expect.stringContaining('"dav:name":"#default"'), + body: expect.stringContaining('"dav:name":"#default"') }) - ); + ) - const callBody = JSON.parse(String(mockApiPost.mock.calls[0][1]?.body)); - expect(callBody["dav:name"]).toBe("#default"); - }); -}); + const callBody = JSON.parse(String(mockApiPost.mock.calls[0][1]?.body)) + expect(callBody['dav:name']).toBe('#default') + }) +}) diff --git a/__test__/features/Calendars/CalendarAccessRights.test.tsx b/__test__/features/Calendars/CalendarAccessRights.test.tsx index 40d43da..d6f5f37 100644 --- a/__test__/features/Calendars/CalendarAccessRights.test.tsx +++ b/__test__/features/Calendars/CalendarAccessRights.test.tsx @@ -1,103 +1,103 @@ -import { AccessTab } from "@/components/Calendar/AccessTab"; +import { AccessTab } from '@/components/Calendar/AccessTab' import { CalendarAccessRights, - UserWithAccess, -} from "@/components/Calendar/CalendarAccessRights"; -import CalendarPopover from "@/components/Calendar/CalendarModal"; -import { updateDelegationCalendar } from "@/features/Calendars/api/updateDelegationCalendar"; -import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes"; -import * as eventThunks from "@/features/Calendars/services"; -import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync"; -import { getUserDetails } from "@/features/User/userAPI"; -import { accessRightToDavProp } from "@/utils/accessRightToDavProp"; -import { api } from "@/utils/apiUtils"; -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; + UserWithAccess +} from '@/components/Calendar/CalendarAccessRights' +import CalendarPopover from '@/components/Calendar/CalendarModal' +import { updateDelegationCalendar } from '@/features/Calendars/api/updateDelegationCalendar' +import { AccessRight, Calendar } from '@/features/Calendars/CalendarTypes' +import * as eventThunks from '@/features/Calendars/services' +import * as delegationThunks from '@/features/Calendars/services/updateDelegationCalendarAsync' +import { getUserDetails } from '@/features/User/userAPI' +import { accessRightToDavProp } from '@/utils/accessRightToDavProp' +import { api } from '@/utils/apiUtils' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../../utils/Renderwithproviders' -jest.mock("@/utils/apiUtils", () => ({ - api: { post: jest.fn() }, -})); +jest.mock('@/utils/apiUtils', () => ({ + api: { post: jest.fn() } +})) -jest.mock("@/features/User/userAPI", () => ({ - getUserDetails: jest.fn(), -})); +jest.mock('@/features/User/userAPI', () => ({ + getUserDetails: jest.fn() +})) -jest.mock("@/features/Calendars/CalendarApi", () => ({ - getSecretLink: jest.fn().mockReturnValue(""), - exportCalendar: jest.fn(), -})); +jest.mock('@/features/Calendars/CalendarApi', () => ({ + getSecretLink: jest.fn().mockReturnValue(''), + exportCalendar: jest.fn() +})) const mockThunkWithUnwrap = (resolvedValue: unknown = {}) => jest.fn().mockImplementation(() => { const result = Object.assign(Promise.resolve(resolvedValue), { - unwrap: () => Promise.resolve(resolvedValue), - }); - return jest.fn().mockReturnValue(result); - }); + unwrap: () => Promise.resolve(resolvedValue) + }) + return jest.fn().mockReturnValue(result) + }) -describe("accessRightToDavProp", () => { - it("maps 5 (ADMIN) → dav:administration", () => { - expect(accessRightToDavProp(5)).toBe("dav:administration"); - }); +describe('accessRightToDavProp', () => { + it('maps 5 (ADMIN) → dav:administration', () => { + expect(accessRightToDavProp(5)).toBe('dav:administration') + }) - it("maps 3 (EDITOR) → dav:read-write", () => { - expect(accessRightToDavProp(3)).toBe("dav:read-write"); - }); + it('maps 3 (EDITOR) → dav:read-write', () => { + expect(accessRightToDavProp(3)).toBe('dav:read-write') + }) - it("maps 2 (VIEW) → dav:read", () => { - expect(accessRightToDavProp(2)).toBe("dav:read"); - }); + it('maps 2 (VIEW) → dav:read', () => { + expect(accessRightToDavProp(2)).toBe('dav:read') + }) - it("defaults to dav:read for unknown values (covered by default case)", () => { - expect(accessRightToDavProp(999 as AccessRight)).toBe("dav:read"); - }); -}); + it('defaults to dav:read for unknown values (covered by default case)', () => { + expect(accessRightToDavProp(999 as AccessRight)).toBe('dav:read') + }) +}) -describe("updateDelegationCalendar", () => { - beforeEach(() => jest.clearAllMocks()); +describe('updateDelegationCalendar', () => { + beforeEach(() => jest.clearAllMocks()) - it("posts to the correct DAV endpoint with the share body", async () => { - (api.post as jest.Mock).mockResolvedValue({ ok: true }); + it('posts to the correct DAV endpoint with the share body', async () => { + ;(api.post as jest.Mock).mockResolvedValue({ ok: true }) const share = { - set: [{ "dav:href": "mailto:alice@example.com", "dav:read": true }], - remove: [], - }; + set: [{ 'dav:href': 'mailto:alice@example.com', 'dav:read': true }], + remove: [] + } - await updateDelegationCalendar("/calendars/user/cal1.json", share); + await updateDelegationCalendar('/calendars/user/cal1.json', share) await waitFor(() => expect(api.post).toHaveBeenCalledWith( - "dav/calendars/user/cal1.json", + 'dav/calendars/user/cal1.json', expect.objectContaining({ - body: JSON.stringify({ share }), + body: JSON.stringify({ share }) }) ) - ); - }); -}); + ) + }) +}) const baseCalendar: Calendar = { - id: "user1/cal1", - name: "My Calendar", - description: "", - color: { color: "#0062FF", dark: "#FFF" }, - link: "/calendars/user1/cal1.json", - visibility: "public", + id: 'user1/cal1', + name: 'My Calendar', + description: '', + color: { color: '#0062FF', dark: '#FFF' }, + link: '/calendars/user1/cal1.json', + visibility: 'public', events: {}, invite: [], - owner: { emails: ["user1@example.com"] }, + owner: { emails: ['user1@example.com'] }, access: { write: true } as any, - delegated: true, -}; + delegated: true +} -describe("CalendarAccessRights", () => { - const mockOnChange = jest.fn(); - const mockOnInvitesLoaded = jest.fn(); +describe('CalendarAccessRights', () => { + const mockOnChange = jest.fn() + const mockOnInvitesLoaded = jest.fn() - beforeEach(() => jest.clearAllMocks()); + beforeEach(() => jest.clearAllMocks()) - it("renders the grant access rights section", () => { + it('renders the grant access rights section', () => { renderWithProviders( { onInvitesLoaded={mockOnInvitesLoaded} />, { ...userState } - ); + ) expect( - screen.getByText("calendarPopover.access.grantAccessRights") - ).toBeInTheDocument(); - }); + screen.getByText('calendarPopover.access.grantAccessRights') + ).toBeInTheDocument() + }) - it("shows a list of users already passed in via value prop", () => { + it('shows a list of users already passed in via value prop', () => { const users: UserWithAccess[] = [ { - openpaasId: "user1", - displayName: "Alice", - email: "alice@example.com", - accessRight: 2, - }, - ]; + openpaasId: 'user1', + displayName: 'Alice', + email: 'alice@example.com', + accessRight: 2 + } + ] renderWithProviders( { onChange={mockOnChange} onInvitesLoaded={mockOnInvitesLoaded} /> - ); + ) - expect(screen.getByText("Alice")).toBeInTheDocument(); - expect(screen.getByText("alice@example.com")).toBeInTheDocument(); - }); + expect(screen.getByText('Alice')).toBeInTheDocument() + expect(screen.getByText('alice@example.com')).toBeInTheDocument() + }) - it("calls onChange with user removed when remove button is clicked", () => { + it('calls onChange with user removed when remove button is clicked', () => { const users: UserWithAccess[] = [ { - openpaasId: "user1", - displayName: "Alice", - email: "alice@example.com", - accessRight: 5, - }, - ]; + openpaasId: 'user1', + displayName: 'Alice', + email: 'alice@example.com', + accessRight: 5 + } + ] renderWithProviders( { onInvitesLoaded={mockOnInvitesLoaded} />, { ...userState } - ); + ) - fireEvent.click(screen.getByLabelText(/remove/i)); - expect(mockOnChange).toHaveBeenCalledWith([]); - }); + fireEvent.click(screen.getByLabelText(/remove/i)) + expect(mockOnChange).toHaveBeenCalledWith([]) + }) - it("loads invited users from calendar.invite on mount", async () => { + it('loads invited users from calendar.invite on mount', async () => { const calendarWithInvite: Calendar = { ...baseCalendar, invite: [ { - href: "mailto:bob@example.com", - principal: "/principals/users/bob123", + href: 'mailto:bob@example.com', + principal: '/principals/users/bob123', access: 3, - inviteStatus: 1, - }, - ], - }; + inviteStatus: 1 + } + ] + } - (getUserDetails as jest.Mock).mockResolvedValue({ - preferredEmail: "bob@example.com", - firstname: "Bob", - lastname: "Smith", - emails: ["bob@example.com"], - }); + ;(getUserDetails as jest.Mock).mockResolvedValue({ + preferredEmail: 'bob@example.com', + firstname: 'Bob', + lastname: 'Smith', + emails: ['bob@example.com'] + }) renderWithProviders( { onInvitesLoaded={mockOnInvitesLoaded} />, { ...userState } - ); + ) - await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith("bob123")); + await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith('bob123')) await waitFor(() => expect(mockOnInvitesLoaded).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ - email: "bob@example.com", - accessRight: 3, - }), + email: 'bob@example.com', + accessRight: 3 + }) ]) ) - ); - }); + ) + }) - it("skips invite entries where getUserDetails throws", async () => { + it('skips invite entries where getUserDetails throws', async () => { const calendarWithInvite: Calendar = { ...baseCalendar, invite: [ { - href: "mailto:ghost@example.com", - principal: "/principals/users/ghost", + href: 'mailto:ghost@example.com', + principal: '/principals/users/ghost', access: 2, - inviteStatus: 1, - }, - ], - }; + inviteStatus: 1 + } + ] + } - (getUserDetails as jest.Mock).mockRejectedValue(new Error("Not found")); + ;(getUserDetails as jest.Mock).mockRejectedValue(new Error('Not found')) renderWithProviders( { onInvitesLoaded={mockOnInvitesLoaded} />, { ...userState } - ); + ) - await waitFor(() => expect(mockOnInvitesLoaded).toHaveBeenCalledWith([])); - }); + await waitFor(() => expect(mockOnInvitesLoaded).toHaveBeenCalledWith([])) + }) - it("shows a loading spinner while invite users are being fetched", async () => { + it('shows a loading spinner while invite users are being fetched', async () => { const calendarWithInvite: Calendar = { ...baseCalendar, invite: [ { - href: "mailto:carol@example.com", - principal: "/principals/users/carol", + href: 'mailto:carol@example.com', + principal: '/principals/users/carol', access: 2, - inviteStatus: 1, - }, - ], - }; + inviteStatus: 1 + } + ] + } // Never resolves during the assertion window - (getUserDetails as jest.Mock).mockImplementation( + ;(getUserDetails as jest.Mock).mockImplementation( () => new Promise(() => {}) - ); + ) renderWithProviders( { onInvitesLoaded={mockOnInvitesLoaded} />, { ...userState } - ); + ) - expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument(); - }); + expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument() + }) - it("displays the calendar owner information", () => { + it('displays the calendar owner information', () => { renderWithProviders( { onInvitesLoaded={mockOnInvitesLoaded} />, { ...userState } - ); + ) - expect(screen.getAllByText("user1@example.com").length).toBeGreaterThan(0); - expect( - screen.getByText("calendarPopover.access.owner") - ).toBeInTheDocument(); - }); + expect(screen.getAllByText('user1@example.com').length).toBeGreaterThan(0) + expect(screen.getByText('calendarPopover.access.owner')).toBeInTheDocument() + }) - it("fetches and displays resource administrators for resource calendars", async () => { + it('fetches and displays resource administrators for resource calendars', async () => { const resourceCalendar: any = { ...baseCalendar, owner: { - _id: "resource1", + _id: 'resource1', resource: true, - emails: ["resource1@example.com"], + emails: ['resource1@example.com'], administrators: [ - { id: "admin1" }, - { id: "admin2" }, - { id: "resource1" }, // Owner shouldn't be loaded as an admin - ], - }, - }; + { id: 'admin1' }, + { id: 'admin2' }, + { id: 'resource1' } // Owner shouldn't be loaded as an admin + ] + } + } - (getUserDetails as jest.Mock).mockImplementation((id: string) => { - if (id === "admin1") { + ;(getUserDetails as jest.Mock).mockImplementation((id: string) => { + if (id === 'admin1') { return Promise.resolve({ - preferredEmail: "admin1@example.com", - firstname: "Admin", - lastname: "One", - }); + preferredEmail: 'admin1@example.com', + firstname: 'Admin', + lastname: 'One' + }) } - if (id === "admin2") { + if (id === 'admin2') { return Promise.resolve({ - preferredEmail: "admin2@example.com", - firstname: "Admin", - lastname: "Two", - }); + preferredEmail: 'admin2@example.com', + firstname: 'Admin', + lastname: 'Two' + }) } - return Promise.resolve(null); - }); + return Promise.resolve(null) + }) renderWithProviders( { onInvitesLoaded={mockOnInvitesLoaded} />, { ...userState } - ); + ) await waitFor(() => { - expect(getUserDetails).toHaveBeenCalledWith("admin1"); - expect(getUserDetails).toHaveBeenCalledWith("admin2"); - }); + expect(getUserDetails).toHaveBeenCalledWith('admin1') + expect(getUserDetails).toHaveBeenCalledWith('admin2') + }) - expect(getUserDetails).not.toHaveBeenCalledWith("resource1"); + expect(getUserDetails).not.toHaveBeenCalledWith('resource1') - expect(await screen.findByText("Admin One")).toBeInTheDocument(); - expect(screen.getByText("admin1@example.com")).toBeInTheDocument(); - expect(screen.getByText("Admin Two")).toBeInTheDocument(); - expect(screen.getByText("admin2@example.com")).toBeInTheDocument(); + expect(await screen.findByText('Admin One')).toBeInTheDocument() + expect(screen.getByText('admin1@example.com')).toBeInTheDocument() + expect(screen.getByText('Admin Two')).toBeInTheDocument() + expect(screen.getByText('admin2@example.com')).toBeInTheDocument() expect( - screen.getAllByText("calendarPopover.access.administrator") - ).toHaveLength(2); - }); -}); + screen.getAllByText('calendarPopover.access.administrator') + ).toHaveLength(2) + }) +}) const userState = { user: { - userData: { openpaasId: "user1", email: "user1@example.com" }, - }, -}; + userData: { openpaasId: 'user1', email: 'user1@example.com' } + } +} -describe("AccessTab – conditional rendering of CalendarAccessRights", () => { - const noop = jest.fn(); +describe('AccessTab – conditional rendering of CalendarAccessRights', () => { + const noop = jest.fn() - afterEach(() => jest.clearAllMocks()); + afterEach(() => jest.clearAllMocks()) - it("shows CalendarAccessRights when the current user owns the calendar", () => { + it('shows CalendarAccessRights when the current user owns the calendar', () => { renderWithProviders( { />, { ...userState, - calendars: { list: { "user1/cal1": baseCalendar } }, + calendars: { list: { 'user1/cal1': baseCalendar } } } - ); + ) expect( - screen.getByText("calendarPopover.access.grantAccessRights") - ).toBeInTheDocument(); - }); + screen.getByText('calendarPopover.access.grantAccessRights') + ).toBeInTheDocument() + }) - it("hides CalendarAccessRights textfield for a non-owner without admin delegation", () => { + it('hides CalendarAccessRights textfield for a non-owner without admin delegation', () => { const foreignCalendar: Calendar = { ...baseCalendar, - id: "otherUser/cal1", - invite: [], - }; + id: 'otherUser/cal1', + invite: [] + } renderWithProviders( { />, { ...userState, - calendars: { list: { "otherUser/cal1": foreignCalendar } }, + calendars: { list: { 'otherUser/cal1': foreignCalendar } } } - ); + ) - expect(screen.queryByText("peopleSearch.label")).not.toBeInTheDocument(); + expect(screen.queryByText('peopleSearch.label')).not.toBeInTheDocument() expect( - screen.getByText("calendarPopover.access.accessRights") - ).toBeInTheDocument(); - }); + screen.getByText('calendarPopover.access.accessRights') + ).toBeInTheDocument() + }) - it("shows CalendarAccessRights when the user has access=5 (admin) delegation", () => { + it('shows CalendarAccessRights when the user has access=5 (admin) delegation', () => { const delegatedCalendar: Calendar = { ...baseCalendar, - id: "otherUser/cal1", + id: 'otherUser/cal1', invite: [ { - href: "mailto:user1@example.com", - principal: "/principals/users/user1", + href: 'mailto:user1@example.com', + principal: '/principals/users/user1', access: 5, - inviteStatus: 1, - }, - ], - }; + inviteStatus: 1 + } + ] + } renderWithProviders( { />, { ...userState, - calendars: { list: { "otherUser/cal1": delegatedCalendar } }, + calendars: { list: { 'otherUser/cal1': delegatedCalendar } } } - ); + ) expect( - screen.getByText("calendarPopover.access.grantAccessRights") - ).toBeInTheDocument(); - }); -}); + screen.getByText('calendarPopover.access.grantAccessRights') + ).toBeInTheDocument() + }) +}) const existingCalendar: Calendar = { - id: "user1/cal1", - name: "Existing Cal", - description: "Desc", - color: { color: "#0062FF", dark: "#FFF" }, - link: "/calendars/user/cal1", - visibility: "public", + id: 'user1/cal1', + name: 'Existing Cal', + description: 'Desc', + color: { color: '#0062FF', dark: '#FFF' }, + link: '/calendars/user/cal1', + visibility: 'public', events: {}, invite: [], - owner: { emails: ["user1@example.com"] }, -}; + owner: { emails: ['user1@example.com'] } +} -describe("CalendarModal – updateDelegationCalendarAsync integration", () => { - const mockOnClose = jest.fn(); +describe('CalendarModal – updateDelegationCalendarAsync integration', () => { + const mockOnClose = jest.fn() beforeEach(() => { - jest.clearAllMocks(); + jest.clearAllMocks() jest - .spyOn(eventThunks, "patchCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); + .spyOn(eventThunks, 'patchCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) jest - .spyOn(eventThunks, "patchACLCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); + .spyOn(eventThunks, 'patchACLCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) jest - .spyOn(delegationThunks, "updateDelegationCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); - }); + .spyOn(delegationThunks, 'updateDelegationCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) + }) - it("does NOT call updateDelegationCalendarAsync when no users are added or removed", async () => { + it('does NOT call updateDelegationCalendarAsync when no users are added or removed', async () => { renderWithProviders( , - { ...userState, calendars: { list: { "user1/cal1": existingCalendar } } } - ); + { ...userState, calendars: { list: { 'user1/cal1': existingCalendar } } } + ) - fireEvent.click(screen.getByRole("button", { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })) - await waitFor(() => expect(mockOnClose).toHaveBeenCalled()); + await waitFor(() => expect(mockOnClose).toHaveBeenCalled()) expect( delegationThunks.updateDelegationCalendarAsync - ).not.toHaveBeenCalled(); - }); -}); + ).not.toHaveBeenCalled() + }) +}) -describe("CalendarModal – cancel button", () => { - const mockOnClose = jest.fn(); +describe('CalendarModal – cancel button', () => { + const mockOnClose = jest.fn() beforeEach(() => { - jest.clearAllMocks(); + jest.clearAllMocks() jest - .spyOn(delegationThunks, "updateDelegationCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); + .spyOn(delegationThunks, 'updateDelegationCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) jest - .spyOn(eventThunks, "patchCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); - }); + .spyOn(eventThunks, 'patchCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) + }) - it("calls onClose without saving when Cancel is clicked", async () => { + it('calls onClose without saving when Cancel is clicked', async () => { renderWithProviders( { calendar={existingCalendar} />, { ...userState } - ); + ) - fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) - await waitFor(() => expect(mockOnClose).toHaveBeenCalled()); + await waitFor(() => expect(mockOnClose).toHaveBeenCalled()) expect( delegationThunks.updateDelegationCalendarAsync - ).not.toHaveBeenCalled(); - expect(eventThunks.patchCalendarAsync).not.toHaveBeenCalled(); - }); -}); + ).not.toHaveBeenCalled() + expect(eventThunks.patchCalendarAsync).not.toHaveBeenCalled() + }) +}) diff --git a/__test__/features/Calendars/CalendarModal.test.tsx b/__test__/features/Calendars/CalendarModal.test.tsx index e371b2b..eb0c555 100644 --- a/__test__/features/Calendars/CalendarModal.test.tsx +++ b/__test__/features/Calendars/CalendarModal.test.tsx @@ -1,141 +1,141 @@ -import CalendarPopover from "@/components/Calendar/CalendarModal"; -import { getSecretLink } from "@/features/Calendars/CalendarApi"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; -import * as eventThunks from "@/features/Calendars/services"; -import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync"; -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import CalendarPopover from '@/components/Calendar/CalendarModal' +import { getSecretLink } from '@/features/Calendars/CalendarApi' +import { Calendar } from '@/features/Calendars/CalendarTypes' +import * as eventThunks from '@/features/Calendars/services' +import * as delegationThunks from '@/features/Calendars/services/updateDelegationCalendarAsync' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../../utils/Renderwithproviders' -jest.mock("@/features/Calendars/CalendarApi", () => ({ - getSecretLink: jest.fn(), -})); +jest.mock('@/features/Calendars/CalendarApi', () => ({ + getSecretLink: jest.fn() +})) const mockThunkWithUnwrap = (resolvedValue: unknown = {}) => jest.fn().mockImplementation(() => { const dispatchResult = Object.assign(Promise.resolve(resolvedValue), { - unwrap: () => Promise.resolve(resolvedValue), - }); - return jest.fn().mockReturnValue(dispatchResult); - }); + unwrap: () => Promise.resolve(resolvedValue) + }) + return jest.fn().mockReturnValue(dispatchResult) + }) -describe("CalendarPopover", () => { - const mockOnClose = jest.fn(); +describe('CalendarPopover', () => { + const mockOnClose = jest.fn() beforeEach(() => { - jest.clearAllMocks(); + jest.clearAllMocks() jest - .spyOn(delegationThunks, "updateDelegationCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); - }); + .spyOn(delegationThunks, 'updateDelegationCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) + }) const renderPopover = (open = true) => { const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "667037022b752d0026472254", - }, + sub: 'test', + email: 'test@test.com', + sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro', + openpaasId: '667037022b752d0026472254' + } }, - calendars: { list: {}, pending: true }, - }; + calendars: { list: {}, pending: true } + } renderWithProviders( , preloadedState - ); - }; + ) + } - it("renders popover and inputs", () => { - renderPopover(); + it('renders popover and inputs', () => { + renderPopover() - expect(screen.getByLabelText(/Name/i)).toBeInTheDocument(); - expect(screen.getByText("event.form.addDescription")).toBeInTheDocument(); - }); + expect(screen.getByLabelText(/Name/i)).toBeInTheDocument() + expect(screen.getByText('event.form.addDescription')).toBeInTheDocument() + }) - it("updates name and description fields", () => { - renderPopover(); + it('updates name and description fields', () => { + renderPopover() - const nameInput = screen.getByLabelText(/Name/i); - fireEvent.change(nameInput, { target: { value: "My Calendar" } }); - expect(nameInput).toHaveValue("My Calendar"); + const nameInput = screen.getByLabelText(/Name/i) + fireEvent.change(nameInput, { target: { value: 'My Calendar' } }) + expect(nameInput).toHaveValue('My Calendar') - fireEvent.click(screen.getByText("event.form.addDescription")); - const descInput = screen.getByLabelText(/Description/i); - fireEvent.change(descInput, { target: { value: "Test description" } }); - expect(descInput).toHaveValue("Test description"); - }); + fireEvent.click(screen.getByText('event.form.addDescription')) + const descInput = screen.getByLabelText(/Description/i) + fireEvent.change(descInput, { target: { value: 'Test description' } }) + expect(descInput).toHaveValue('Test description') + }) - it("dispatches createCalendar and calls onClose when Save clicked", async () => { + it('dispatches createCalendar and calls onClose when Save clicked', async () => { jest - .spyOn(eventThunks, "createCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); + .spyOn(eventThunks, 'createCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) - renderPopover(); + renderPopover() fireEvent.change(screen.getByLabelText(/Name/i), { - target: { value: "Test Calendar" }, - }); - fireEvent.click(screen.getByText("event.form.addDescription")); + target: { value: 'Test Calendar' } + }) + fireEvent.click(screen.getByText('event.form.addDescription')) fireEvent.change(screen.getByLabelText(/Description/i), { - target: { value: "Test Description" }, - }); + target: { value: 'Test Description' } + }) - const colorButtons = screen.getAllByRole("button", { - name: /select color/i, - }); - fireEvent.click(colorButtons[0]); + const colorButtons = screen.getAllByRole('button', { + name: /select color/i + }) + fireEvent.click(colorButtons[0]) - fireEvent.click(screen.getByRole("button", { name: /Create/i })); + fireEvent.click(screen.getByRole('button', { name: /Create/i })) await waitFor(() => expect(eventThunks.createCalendarAsync).toHaveBeenCalled() - ); + ) await waitFor(() => - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick") - ); - }); + expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick') + ) + }) - it("calls onClose when Cancel clicked", () => { - renderPopover(); + it('calls onClose when Cancel clicked', () => { + renderPopover() - fireEvent.click(screen.getByRole("button", { name: /Cancel/i })); + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })) - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); - }); -}); + expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick') + }) +}) -describe("CalendarPopover (editing mode)", () => { - const mockOnClose = jest.fn(); +describe('CalendarPopover (editing mode)', () => { + const mockOnClose = jest.fn() const baseUser = { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "user1", - }, - }; + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: 'user1' + } + } const existingCalendar: Calendar = { - id: "user1/cal1", - link: "/calendars/user/cal1", - name: "Work Calendar", - description: "Team meetings", - color: { light: "#33B679" }, - owner: { firstname: "alice", emails: ["alice@example.com"] }, - visibility: "public", - events: {}, - }; + id: 'user1/cal1', + link: '/calendars/user/cal1', + name: 'Work Calendar', + description: 'Team meetings', + color: { light: '#33B679' }, + owner: { firstname: 'alice', emails: ['alice@example.com'] }, + visibility: 'public', + events: {} + } beforeEach(() => { - jest.clearAllMocks(); + jest.clearAllMocks() jest - .spyOn(delegationThunks, "updateDelegationCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); - }); + .spyOn(delegationThunks, 'updateDelegationCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) + }) - it("prefills fields when calendar prop is given", () => { + it('prefills fields when calendar prop is given', () => { renderWithProviders( { calendar={existingCalendar} />, { user: baseUser } - ); + ) - expect(screen.getByLabelText(/Name/i)).toHaveValue("Work Calendar"); - expect(screen.getByLabelText(/Description/i)).toHaveValue("Team meetings"); - }); + expect(screen.getByLabelText(/Name/i)).toHaveValue('Work Calendar') + expect(screen.getByLabelText(/Description/i)).toHaveValue('Team meetings') + }) - test("Save button is disabled when name is empty or whitespace only", () => { + test('Save button is disabled when name is empty or whitespace only', () => { renderWithProviders(, { - user: baseUser, - }); + user: baseUser + }) - const saveButton = screen.getByRole("button", { name: /create/i }); - expect(saveButton).toBeDisabled(); + const saveButton = screen.getByRole('button', { name: /create/i }) + expect(saveButton).toBeDisabled() // only spaces - const nameInput = screen.getByLabelText(/name/i); - fireEvent.change(nameInput, { target: { value: " " } }); + const nameInput = screen.getByLabelText(/name/i) + fireEvent.change(nameInput, { target: { value: ' ' } }) - expect(saveButton).toBeDisabled(); + expect(saveButton).toBeDisabled() // valid name - fireEvent.change(nameInput, { target: { value: "Work Calendar" } }); - expect(saveButton).toBeEnabled(); - }); + fireEvent.change(nameInput, { target: { value: 'Work Calendar' } }) + expect(saveButton).toBeEnabled() + }) - it("allows modifying and saving existing calendar", async () => { + it('allows modifying and saving existing calendar', async () => { jest - .spyOn(eventThunks, "patchCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); + .spyOn(eventThunks, 'patchCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) renderWithProviders( { calendar={existingCalendar} />, { user: baseUser } - ); + ) // Change name fireEvent.change(screen.getByLabelText(/Name/i), { - target: { value: "Updated Calendar" }, - }); + target: { value: 'Updated Calendar' } + }) // Save - fireEvent.click(screen.getByRole("button", { name: "actions.save" })); + fireEvent.click(screen.getByRole('button', { name: 'actions.save' })) await waitFor(() => expect(eventThunks.patchCalendarAsync).toHaveBeenCalledWith( expect.objectContaining({ - calId: "user1/cal1", - calLink: "/calendars/user/cal1", + calId: 'user1/cal1', + calLink: '/calendars/user/cal1', patch: { - color: { light: "#33B679" }, - desc: "Team meetings", - name: "Updated Calendar", - }, + color: { light: '#33B679' }, + desc: 'Team meetings', + name: 'Updated Calendar' + } }) ) - ); - await waitFor(() => expect(mockOnClose).toHaveBeenCalled()); - }); -}); + ) + await waitFor(() => expect(mockOnClose).toHaveBeenCalled()) + }) +}) -describe("CalendarPopover - Tabs Scenarios", () => { - const mockOnClose = jest.fn(); +describe('CalendarPopover - Tabs Scenarios', () => { + const mockOnClose = jest.fn() const baseUser = { userData: { - openpaasId: "user1", - }, - }; + openpaasId: 'user1' + } + } - const writeText = jest.fn(); + const writeText = jest.fn() Object.assign(navigator, { clipboard: { - writeText, - }, - }); + writeText + } + }) const existingCalendar: Calendar = { - id: "user1/cal1", - link: "/calendars/user1/cal1.json", - name: "Work Calendar", - description: "Team meetings", - color: { light: "#33B679" }, - owner: { firstname: "alice", emails: ["alice@example.com"] }, - visibility: "public", - events: {}, - }; + id: 'user1/cal1', + link: '/calendars/user1/cal1.json', + name: 'Work Calendar', + description: 'Team meetings', + color: { light: '#33B679' }, + owner: { firstname: 'alice', emails: ['alice@example.com'] }, + visibility: 'public', + events: {} + } beforeEach(() => { - jest.clearAllMocks(); + jest.clearAllMocks() jest - .spyOn(delegationThunks, "updateDelegationCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); - }); + .spyOn(delegationThunks, 'updateDelegationCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) + }) - it("resets state after closing and reopening", () => { + it('resets state after closing and reopening', () => { const { rerender } = renderWithProviders( , { user: baseUser } - ); + ) // Enter some data fireEvent.change(screen.getByLabelText(/Name/i), { - target: { value: "Temp Calendar" }, - }); - fireEvent.click(screen.getByRole("button", { name: /Cancel/i })); + target: { value: 'Temp Calendar' } + }) + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })) - expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalled() // Reopen: state should be reset - rerender(); - expect(screen.getByLabelText(/Name/i)).toHaveValue(""); - }); + rerender() + expect(screen.getByLabelText(/Name/i)).toHaveValue('') + }) - it("shows Access tab only when editing an existing calendar", () => { + it('shows Access tab only when editing an existing calendar', () => { renderWithProviders( { calendar={existingCalendar} />, { user: baseUser } - ); + ) - expect(screen.getByRole("tab", { name: /Access/i })).toBeInTheDocument(); - }); + expect(screen.getByRole('tab', { name: /Access/i })).toBeInTheDocument() + }) - it("does not show Access tab when creating new calendar", () => { + it('does not show Access tab when creating new calendar', () => { renderWithProviders(, { - user: baseUser, - }); + user: baseUser + }) expect( - screen.queryByRole("tab", { name: /Access/i }) - ).not.toBeInTheDocument(); - }); + screen.queryByRole('tab', { name: /Access/i }) + ).not.toBeInTheDocument() + }) - it("patches ACL when visibility changes", async () => { + it('patches ACL when visibility changes', async () => { jest - .spyOn(eventThunks, "patchCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); + .spyOn(eventThunks, 'patchCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) jest - .spyOn(eventThunks, "patchACLCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); + .spyOn(eventThunks, 'patchACLCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) renderWithProviders( { calendar={existingCalendar} />, { user: baseUser } - ); + ) // By default: "All" (public) is selected - const publicButton = screen.getByRole("button", { name: /All/i }); - const privateButton = screen.getByRole("button", { name: /You/i }); + const publicButton = screen.getByRole('button', { name: /All/i }) + const privateButton = screen.getByRole('button', { name: /You/i }) - expect(publicButton).toHaveAttribute("aria-pressed", "true"); - expect(privateButton).toHaveAttribute("aria-pressed", "false"); + expect(publicButton).toHaveAttribute('aria-pressed', 'true') + expect(privateButton).toHaveAttribute('aria-pressed', 'false') // Change to private - fireEvent.click(privateButton); + fireEvent.click(privateButton) - expect(privateButton).toHaveAttribute("aria-pressed", "true"); - expect(publicButton).toHaveAttribute("aria-pressed", "false"); + expect(privateButton).toHaveAttribute('aria-pressed', 'true') + expect(publicButton).toHaveAttribute('aria-pressed', 'false') // Save - fireEvent.click(screen.getByRole("button", { name: "actions.save" })); + fireEvent.click(screen.getByRole('button', { name: 'actions.save' })) await waitFor(() => expect(eventThunks.patchACLCalendarAsync).toHaveBeenCalledWith( expect.objectContaining({ - calId: "user1/cal1", - request: "", + calId: 'user1/cal1', + request: '' }) ) - ); - }); + ) + }) - it("copies CalDAV link from Access tab", async () => { - window.DAV_BASE_URL = "https://cal.example.org"; + it('copies CalDAV link from Access tab', async () => { + window.DAV_BASE_URL = 'https://cal.example.org' Object.assign(navigator, { - clipboard: { writeText: jest.fn() }, - }); - (getSecretLink as jest.Mock).mockResolvedValue({ - secretLink: "https://example.org/secret/initial", - }); + clipboard: { writeText: jest.fn() } + }) + ;(getSecretLink as jest.Mock).mockResolvedValue({ + secretLink: 'https://example.org/secret/initial' + }) renderWithProviders( { calendar={existingCalendar} />, { user: baseUser } - ); + ) // Switch to Access tab - fireEvent.click(screen.getByRole("tab", { name: /Access/i })); + fireEvent.click(screen.getByRole('tab', { name: /Access/i })) // Expect text field with caldav link - const input = screen.getByLabelText("calendar.caldav_access"); - expect(input).toHaveValue("https://cal.example.org/calendars/user1/cal1"); + const input = screen.getByLabelText('calendar.caldav_access') + expect(input).toHaveValue('https://cal.example.org/calendars/user1/cal1') // Click copy button (find button containing ContentCopyIcon) - const copyIcon = screen.getAllByTestId("ContentCopyIcon")[0]; - const copyButton = copyIcon.closest("button"); + const copyIcon = screen.getAllByTestId('ContentCopyIcon')[0] + const copyButton = copyIcon.closest('button') if (copyButton) { - fireEvent.click(copyButton); + fireEvent.click(copyButton) } expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - "https://cal.example.org/calendars/user1/cal1" - ); + 'https://cal.example.org/calendars/user1/cal1' + ) // Snackbar should appear await waitFor(() => - expect(screen.getByText("common.link_copied")).toBeInTheDocument() - ); - }); + expect(screen.getByText('common.link_copied')).toBeInTheDocument() + ) + }) - describe("Import flow", () => { - const file = new File(["test"], "events.ics", { type: "text/calendar" }); + describe('Import flow', () => { + const file = new File(['test'], 'events.ics', { type: 'text/calendar' }) it("creates a new calendar and imports events when Import with 'new' target", async () => { jest - .spyOn(eventThunks, "createCalendarAsync") - .mockImplementation(mockThunkWithUnwrap()); + .spyOn(eventThunks, 'createCalendarAsync') + .mockImplementation(mockThunkWithUnwrap()) jest - .spyOn(eventThunks, "importEventFromFileAsync") - .mockImplementation(mockThunkWithUnwrap()); + .spyOn(eventThunks, 'importEventFromFileAsync') + .mockImplementation(mockThunkWithUnwrap()) renderWithProviders( , { - user: baseUser, + user: baseUser } - ); + ) // Switch to Import tab - fireEvent.click(screen.getByRole("tab", { name: /Import/i })); + fireEvent.click(screen.getByRole('tab', { name: /Import/i })) // Provide new calendar params fireEvent.change(screen.getByLabelText(/Name/i), { - target: { value: "Imported Calendar" }, - }); - const fileInput = screen.getByLabelText("common.select_file"); - fireEvent.change(fileInput, { target: { files: [file] } }); + target: { value: 'Imported Calendar' } + }) + const fileInput = screen.getByLabelText('common.select_file') + fireEvent.change(fileInput, { target: { files: [file] } }) // Click Import - fireEvent.click(screen.getByRole("button", { name: "actions.import" })); + fireEvent.click(screen.getByRole('button', { name: 'actions.import' })) await waitFor(() => expect(eventThunks.createCalendarAsync).toHaveBeenCalled() - ); + ) await waitFor(() => expect(eventThunks.importEventFromFileAsync).toHaveBeenCalled() - ); - }); + ) + }) - it("imports into an existing calendar when target is set", async () => { + it('imports into an existing calendar when target is set', async () => { jest - .spyOn(eventThunks, "importEventFromFileAsync") - .mockImplementation(mockThunkWithUnwrap()); + .spyOn(eventThunks, 'importEventFromFileAsync') + .mockImplementation(mockThunkWithUnwrap()) const calendars = { - "user1/cal1": existingCalendar, - }; + 'user1/cal1': existingCalendar + } renderWithProviders( { calendar={existingCalendar} />, { user: baseUser, calendars: { list: calendars } } - ); + ) - fireEvent.click(screen.getByRole("tab", { name: /Import/i })); - const fileInput = screen.getByLabelText("common.select_file"); - fireEvent.change(fileInput, { target: { files: [file] } }); + fireEvent.click(screen.getByRole('tab', { name: /Import/i })) + const fileInput = screen.getByLabelText('common.select_file') + fireEvent.change(fileInput, { target: { files: [file] } }) - fireEvent.click(screen.getByRole("button", { name: "actions.import" })); + fireEvent.click(screen.getByRole('button', { name: 'actions.import' })) await waitFor(() => expect(eventThunks.importEventFromFileAsync).toHaveBeenCalledWith( expect.objectContaining({ - calLink: "/calendars/user1/cal1.json", - file, + calLink: '/calendars/user1/cal1.json', + file }) ) - ); - }); + ) + }) - it("disables Import button until a file is uploaded", () => { + it('disables Import button until a file is uploaded', () => { renderWithProviders( , { - user: baseUser, + user: baseUser } - ); + ) - fireEvent.click(screen.getByRole("tab", { name: /Import/i })); + fireEvent.click(screen.getByRole('tab', { name: /Import/i })) - const importButton = screen.getByRole("button", { - name: "actions.import", - }); - expect(importButton).toBeDisabled(); - }); - }); + const importButton = screen.getByRole('button', { + name: 'actions.import' + }) + expect(importButton).toBeDisabled() + }) + }) - it("fetches and resets the secret link", async () => { - window.DAV_BASE_URL = "https://cal.example.org"; - - (getSecretLink as jest.Mock) + it('fetches and resets the secret link', async () => { + window.DAV_BASE_URL = 'https://cal.example.org' + ;(getSecretLink as jest.Mock) .mockResolvedValueOnce({ - secretLink: "https://example.org/secret/initial", + secretLink: 'https://example.org/secret/initial' }) .mockResolvedValueOnce({ - secretLink: "https://example.org/secret/new", - }); + secretLink: 'https://example.org/secret/new' + }) renderWithProviders( { calendar={existingCalendar} />, { user: baseUser } - ); + ) - fireEvent.click(screen.getByRole("tab", { name: /Access/i })); + fireEvent.click(screen.getByRole('tab', { name: /Access/i })) await waitFor(() => expect( - screen.getByDisplayValue("https://example.org/secret/initial") + screen.getByDisplayValue('https://example.org/secret/initial') ).toBeInTheDocument() - ); + ) - fireEvent.click(screen.getByRole("button", { name: /reset/i })); + fireEvent.click(screen.getByRole('button', { name: /reset/i })) await waitFor(() => expect( - screen.getByDisplayValue("https://example.org/secret/new") + screen.getByDisplayValue('https://example.org/secret/new') ).toBeInTheDocument() - ); + ) expect(getSecretLink).toHaveBeenCalledWith( - existingCalendar.link.replace(".json", ""), + existingCalendar.link.replace('.json', ''), true - ); - }); -}); + ) + }) +}) diff --git a/__test__/features/Calendars/CalendarSlice.test.tsx b/__test__/features/Calendars/CalendarSlice.test.tsx index b686e18..55f07e3 100644 --- a/__test__/features/Calendars/CalendarSlice.test.tsx +++ b/__test__/features/Calendars/CalendarSlice.test.tsx @@ -1,12 +1,12 @@ -import * as calAPI from "@/features/Calendars/CalendarApi"; +import * as calAPI from '@/features/Calendars/CalendarApi' import reducer, { addEvent, createCalendar, removeEvent, removeTempCal, - updateEventLocal, -} from "@/features/Calendars/CalendarSlice"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; + updateEventLocal +} from '@/features/Calendars/CalendarSlice' +import { Calendar } from '@/features/Calendars/CalendarTypes' import { addSharedCalendarAsync, createCalendarAsync, @@ -14,523 +14,523 @@ import { getCalendarsListAsync, getEventAsync, getTempCalendarsListAsync, - patchACLCalendarAsync, -} from "@/features/Calendars/services"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import * as userAPI from "@/features/User/userAPI"; -import userReducer, { setUserData } from "@/features/User/userSlice"; -import { configureStore } from "@reduxjs/toolkit"; + patchACLCalendarAsync +} from '@/features/Calendars/services' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import * as userAPI from '@/features/User/userAPI' +import userReducer, { setUserData } from '@/features/User/userSlice' +import { configureStore } from '@reduxjs/toolkit' -jest.mock("@/features/Calendars/CalendarApi"); -jest.mock("@/features/User/userAPI"); -jest.mock("@/features/Events/EventApi"); -jest.mock("@/features/Events/utils"); -jest.mock("@/utils/apiUtils"); +jest.mock('@/features/Calendars/CalendarApi') +jest.mock('@/features/User/userAPI') +jest.mock('@/features/Events/EventApi') +jest.mock('@/features/Events/utils') +jest.mock('@/utils/apiUtils') -describe("CalendarSlice", () => { +describe('CalendarSlice', () => { const initialState = { list: {}, templist: {}, pending: false, - error: null, - }; + error: null + } beforeEach(() => { - jest.resetAllMocks(); - }); + jest.resetAllMocks() + }) - describe("reducers", () => { - it("createCalendar adds new calendar", () => { + describe('reducers', () => { + it('createCalendar adds new calendar', () => { const action = createCalendar({ - name: "Test Cal", - color: "#ff0000", - description: "desc", - }); - const state = reducer(initialState, action); - const values = Object.values(state.list); - expect(values[0].name).toBe("Test Cal"); - expect(values[0].color).toBe("#ff0000"); - }); + name: 'Test Cal', + color: '#ff0000', + description: 'desc' + }) + const state = reducer(initialState, action) + const values = Object.values(state.list) + expect(values[0].name).toBe('Test Cal') + expect(values[0].color).toBe('#ff0000') + }) - it("addEvent adds event to calendar", () => { - const calId = "user/cal"; - const event = { uid: "event1", title: "My Event" } as any; + it('addEvent adds event to calendar', () => { + const calId = 'user/cal' + const event = { uid: 'event1', title: 'My Event' } as any const stateWithCal = { ...initialState, - list: { [calId]: { id: calId, events: {} } as any }, - }; + list: { [calId]: { id: calId, events: {} } as any } + } const state = reducer( stateWithCal, addEvent({ calendarUid: calId, event }) - ); - expect(state.list[calId].events["event1"]).toEqual( - expect.objectContaining({ uid: "event1" }) - ); - }); + ) + expect(state.list[calId].events['event1']).toEqual( + expect.objectContaining({ uid: 'event1' }) + ) + }) - it("removeEvent deletes event", () => { - const calId = "user/cal"; + it('removeEvent deletes event', () => { + const calId = 'user/cal' const stateWithEvent = { ...initialState, list: { [calId]: { id: calId, - events: { e1: { uid: "e1" } }, - } as unknown as Calendar, - }, - }; + events: { e1: { uid: 'e1' } } + } as unknown as Calendar + } + } const state = reducer( stateWithEvent, - removeEvent({ calendarUid: calId, eventUid: "e1" }) - ); - expect(state.list[calId].events).toEqual({}); - }); + removeEvent({ calendarUid: calId, eventUid: 'e1' }) + ) + expect(state.list[calId].events).toEqual({}) + }) - it("updateEventLocal updates an event", () => { - const calId = "user/cal"; + it('updateEventLocal updates an event', () => { + const calId = 'user/cal' const stateWithEvent = { ...initialState, list: { [calId]: { id: calId, - events: { e1: { uid: "e1", title: "Old" } }, - } as unknown as Calendar, - }, - }; + events: { e1: { uid: 'e1', title: 'Old' } } + } as unknown as Calendar + } + } const state = reducer( stateWithEvent, - updateEventLocal({ calId, event: { uid: "e1", title: "New" } as any }) - ); - expect(state.list[calId].events.e1.title).toBe("New"); - }); + updateEventLocal({ calId, event: { uid: 'e1', title: 'New' } as any }) + ) + expect(state.list[calId].events.e1.title).toBe('New') + }) - it("removeTempCal deletes temp calendar", () => { + it('removeTempCal deletes temp calendar', () => { const stateWithTemp = { ...initialState, - templist: { temp1: { id: "temp1" } as any }, - }; - const state = reducer(stateWithTemp, removeTempCal("temp1")); - expect(state.templist).toEqual({}); - }); - }); + templist: { temp1: { id: 'temp1' } as any } + } + const state = reducer(stateWithTemp, removeTempCal('temp1')) + expect(state.templist).toEqual({}) + }) + }) - describe("extraReducers (thunks)", () => { + describe('extraReducers (thunks)', () => { const storeFactory = () => configureStore({ - reducer: { calendars: reducer, user: userReducer }, - }); + reducer: { calendars: reducer, user: userReducer } + }) beforeEach(() => { - jest.resetAllMocks(); - }); - it("getCalendarsListAsync.fulfilled replaces list", async () => { - (userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); - (calAPI.getCalendars as jest.Mock).mockResolvedValue({ - _embedded: { "dav:calendar": [] }, - }); - (userAPI.getUserDetails as jest.Mock).mockResolvedValue({ - firstname: "Alice", - lastname: "Smith", - emails: ["a@b.com"], - }); + jest.resetAllMocks() + }) + it('getCalendarsListAsync.fulfilled replaces list', async () => { + ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' }) + ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({ + _embedded: { 'dav:calendar': [] } + }) + ;(userAPI.getUserDetails as jest.Mock).mockResolvedValue({ + firstname: 'Alice', + lastname: 'Smith', + emails: ['a@b.com'] + }) - const store = storeFactory(); - await store.dispatch(getCalendarsListAsync() as any); - const state = store.getState().calendars; - expect(state.list).toEqual({}); - }); + const store = storeFactory() + await store.dispatch(getCalendarsListAsync() as any) + const state = store.getState().calendars + expect(state.list).toEqual({}) + }) - it("getCalendarsListAsync loads user details in parallel for multiple owners", async () => { + it('getCalendarsListAsync loads user details in parallel for multiple owners', async () => { const mockCalendars = [ { - _links: { self: { href: "/calendars/u1/cal1.json" } }, - "dav:name": "Calendar 1", - "apple:color": "#FF0000", - acl: [], + _links: { self: { href: '/calendars/u1/cal1.json' } }, + 'dav:name': 'Calendar 1', + 'apple:color': '#FF0000', + acl: [] }, { - _links: { self: { href: "/calendars/u2/cal2.json" } }, - "dav:name": "Calendar 2", - "apple:color": "#00FF00", - acl: [], + _links: { self: { href: '/calendars/u2/cal2.json' } }, + 'dav:name': 'Calendar 2', + 'apple:color': '#00FF00', + acl: [] }, { - _links: { self: { href: "/calendars/u3/cal3.json" } }, - "dav:name": "Calendar 3", - "apple:color": "#0000FF", - acl: [], - }, - ]; + _links: { self: { href: '/calendars/u3/cal3.json' } }, + 'dav:name': 'Calendar 3', + 'apple:color': '#0000FF', + acl: [] + } + ] - (userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); - (calAPI.getCalendars as jest.Mock).mockResolvedValue({ - _embedded: { "dav:calendar": mockCalendars }, - }); + ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' }) + ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({ + _embedded: { 'dav:calendar': mockCalendars } + }) - const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; + const getUserDetailsMock = userAPI.getUserDetails as jest.Mock getUserDetailsMock .mockResolvedValueOnce({ - firstname: "Alice", - lastname: "Smith", - emails: ["alice@example.com"], + firstname: 'Alice', + lastname: 'Smith', + emails: ['alice@example.com'] }) .mockResolvedValueOnce({ - firstname: "Bob", - lastname: "Jones", - emails: ["bob@example.com"], + firstname: 'Bob', + lastname: 'Jones', + emails: ['bob@example.com'] }) .mockResolvedValueOnce({ - firstname: "Charlie", - lastname: "Brown", - emails: ["charlie@example.com"], - }); + firstname: 'Charlie', + lastname: 'Brown', + emails: ['charlie@example.com'] + }) - const store = storeFactory(); - await store.dispatch(getCalendarsListAsync() as any); + const store = storeFactory() + await store.dispatch(getCalendarsListAsync() as any) - expect(getUserDetailsMock).toHaveBeenCalledTimes(3); - expect(getUserDetailsMock).toHaveBeenCalledWith("u1"); - expect(getUserDetailsMock).toHaveBeenCalledWith("u2"); - expect(getUserDetailsMock).toHaveBeenCalledWith("u3"); + expect(getUserDetailsMock).toHaveBeenCalledTimes(3) + expect(getUserDetailsMock).toHaveBeenCalledWith('u1') + expect(getUserDetailsMock).toHaveBeenCalledWith('u2') + expect(getUserDetailsMock).toHaveBeenCalledWith('u3') - const state = store.getState().calendars; - expect(state.list["u1/cal1"].owner.firstname).toContain("Alice"); - expect(state.list["u2/cal2"].owner.firstname).toContain("Bob"); - expect(state.list["u3/cal3"].owner.firstname).toContain("Charlie"); - }); + const state = store.getState().calendars + expect(state.list['u1/cal1'].owner.firstname).toContain('Alice') + expect(state.list['u2/cal2'].owner.firstname).toContain('Bob') + expect(state.list['u3/cal3'].owner.firstname).toContain('Charlie') + }) - it("getCalendarsListAsync deduplicates getUserDetails calls for same ownerId", async () => { + it('getCalendarsListAsync deduplicates getUserDetails calls for same ownerId', async () => { const mockCalendars = [ { - _links: { self: { href: "/calendars/u1/cal1.json" } }, - "dav:name": "Calendar 1", - "apple:color": "#FF0000", - acl: [], + _links: { self: { href: '/calendars/u1/cal1.json' } }, + 'dav:name': 'Calendar 1', + 'apple:color': '#FF0000', + acl: [] }, { - _links: { self: { href: "/calendars/u1/cal2.json" } }, - "dav:name": "Calendar 2", - "apple:color": "#00FF00", - acl: [], + _links: { self: { href: '/calendars/u1/cal2.json' } }, + 'dav:name': 'Calendar 2', + 'apple:color': '#00FF00', + acl: [] }, { - _links: { self: { href: "/calendars/u1/cal3.json" } }, - "dav:name": "Calendar 3", - "apple:color": "#0000FF", - acl: [], - }, - ]; + _links: { self: { href: '/calendars/u1/cal3.json' } }, + 'dav:name': 'Calendar 3', + 'apple:color': '#0000FF', + acl: [] + } + ] - (userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); - (calAPI.getCalendars as jest.Mock).mockResolvedValue({ - _embedded: { "dav:calendar": mockCalendars }, - }); + ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' }) + ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({ + _embedded: { 'dav:calendar': mockCalendars } + }) - const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; + const getUserDetailsMock = userAPI.getUserDetails as jest.Mock getUserDetailsMock.mockResolvedValue({ - firstname: "Alice", - lastname: "Smith", - emails: ["alice@example.com"], - }); + firstname: 'Alice', + lastname: 'Smith', + emails: ['alice@example.com'] + }) - const store = storeFactory(); - await store.dispatch(getCalendarsListAsync() as any); + const store = storeFactory() + await store.dispatch(getCalendarsListAsync() as any) - expect(getUserDetailsMock).toHaveBeenCalledTimes(1); - expect(getUserDetailsMock).toHaveBeenCalledWith("u1"); - }); + expect(getUserDetailsMock).toHaveBeenCalledTimes(1) + expect(getUserDetailsMock).toHaveBeenCalledWith('u1') + }) - it("getCalendarsListAsync processes owners in batches of 20", async () => { + it('getCalendarsListAsync processes owners in batches of 20', async () => { const mockCalendars = Array.from({ length: 45 }, (_, i) => ({ _links: { self: { href: `/calendars/u${i + 1}/cal${i + 1}.json` } }, - "dav:name": `Calendar ${i + 1}`, - "apple:color": "#FF0000", - acl: [], - })); + 'dav:name': `Calendar ${i + 1}`, + 'apple:color': '#FF0000', + acl: [] + })) - (userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); - (calAPI.getCalendars as jest.Mock).mockResolvedValue({ - _embedded: { "dav:calendar": mockCalendars }, - }); + ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' }) + ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({ + _embedded: { 'dav:calendar': mockCalendars } + }) - const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; + const getUserDetailsMock = userAPI.getUserDetails as jest.Mock getUserDetailsMock.mockImplementation((ownerId: string) => Promise.resolve({ - firstname: "User", + firstname: 'User', lastname: ownerId, - emails: [`${ownerId}@example.com`], + emails: [`${ownerId}@example.com`] }) - ); + ) - const store = storeFactory(); - await store.dispatch(getCalendarsListAsync() as any); + const store = storeFactory() + await store.dispatch(getCalendarsListAsync() as any) - expect(getUserDetailsMock).toHaveBeenCalledTimes(45); - const state = store.getState().calendars; - expect(Object.keys(state.list)).toHaveLength(45); - }); + expect(getUserDetailsMock).toHaveBeenCalledTimes(45) + const state = store.getState().calendars + expect(Object.keys(state.list)).toHaveLength(45) + }) - it("getCalendarsListAsync doesnt call getUserDetails if userdata exist in store", async () => { + it('getCalendarsListAsync doesnt call getUserDetails if userdata exist in store', async () => { const existingCalendars = { - "u1/cal1": { - id: "u1/cal1", - name: "Existing Calendar", - events: {}, - } as Calendar, - }; + 'u1/cal1': { + id: 'u1/cal1', + name: 'Existing Calendar', + events: {} + } as Calendar + } - const store = storeFactory(); + const store = storeFactory() store.dispatch({ - type: "calendars/getCalendars/fulfilled", - payload: { importedCalendars: existingCalendars, errors: "" }, - }); - store.dispatch(setUserData({ openpaasId: "bla" })); - const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; + type: 'calendars/getCalendars/fulfilled', + payload: { importedCalendars: existingCalendars, errors: '' } + }) + store.dispatch(setUserData({ openpaasId: 'bla' })) + const getUserDetailsMock = userAPI.getUserDetails as jest.Mock - await store.dispatch(getCalendarsListAsync() as any); + await store.dispatch(getCalendarsListAsync() as any) - expect(getUserDetailsMock).not.toHaveBeenCalled(); + expect(getUserDetailsMock).not.toHaveBeenCalled() - const state = store.getState().calendars; - expect(state.list).toEqual(existingCalendars); - }); + const state = store.getState().calendars + expect(state.list).toEqual(existingCalendars) + }) - it("getCalendarsListAsync handles errors in getUserDetails gracefully", async () => { + it('getCalendarsListAsync handles errors in getUserDetails gracefully', async () => { const mockCalendars = [ { - _links: { self: { href: "/calendars/u1/cal1.json" } }, - "dav:name": "Calendar 1", - "apple:color": "#FF0000", - acl: [], + _links: { self: { href: '/calendars/u1/cal1.json' } }, + 'dav:name': 'Calendar 1', + 'apple:color': '#FF0000', + acl: [] }, { - _links: { self: { href: "/calendars/u2/cal2.json" } }, - "dav:name": "Calendar 2", - "apple:color": "#00FF00", - acl: [], - }, - ]; + _links: { self: { href: '/calendars/u2/cal2.json' } }, + 'dav:name': 'Calendar 2', + 'apple:color': '#00FF00', + acl: [] + } + ] - (userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); - (calAPI.getCalendars as jest.Mock).mockResolvedValue({ - _embedded: { "dav:calendar": mockCalendars }, - }); + ;(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: 'u1' }) + ;(calAPI.getCalendars as jest.Mock).mockResolvedValue({ + _embedded: { 'dav:calendar': mockCalendars } + }) - const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; + const getUserDetailsMock = userAPI.getUserDetails as jest.Mock getUserDetailsMock .mockResolvedValueOnce({ - firstname: "Alice", - lastname: "Smith", - emails: ["alice@example.com"], + firstname: 'Alice', + lastname: 'Smith', + emails: ['alice@example.com'] }) - .mockRejectedValueOnce(new Error("Failed to fetch user")); + .mockRejectedValueOnce(new Error('Failed to fetch user')) - const store = storeFactory(); - const result = await store.dispatch(getCalendarsListAsync() as any); + const store = storeFactory() + const result = await store.dispatch(getCalendarsListAsync() as any) - expect(getUserDetailsMock).toHaveBeenCalledTimes(2); - const state = store.getState().calendars; - expect(state.list["u1/cal1"].owner.firstname).toContain("Alice"); - expect(state.list["u2/cal2"].owner.lastname).toContain("Unknown User"); - expect(result.payload.errors).toBeTruthy(); - }); + expect(getUserDetailsMock).toHaveBeenCalledTimes(2) + const state = store.getState().calendars + expect(state.list['u1/cal1'].owner.firstname).toContain('Alice') + expect(state.list['u2/cal2'].owner.lastname).toContain('Unknown User') + expect(result.payload.errors).toBeTruthy() + }) - it("patchACLCalendarAsync.fulfilled sets visibility", () => { + it('patchACLCalendarAsync.fulfilled sets visibility', () => { const prev = { ...initialState, - list: { c1: { id: "c1", visibility: "public" } as any }, - }; + list: { c1: { id: 'c1', visibility: 'public' } as any } + } const state = reducer( prev, patchACLCalendarAsync.fulfilled( - { calId: "c1", calLink: "l", request: "" }, - "req3", - { calId: "c1", calLink: "l", request: "" } + { calId: 'c1', calLink: 'l', request: '' }, + 'req3', + { calId: 'c1', calLink: 'l', request: '' } ) - ); - expect(state.list.c1.visibility).toBe("private"); - }); + ) + expect(state.list.c1.visibility).toBe('private') + }) - it("createCalendarAsync.fulfilled adds a new calendar", () => { + it('createCalendarAsync.fulfilled adds a new calendar', () => { const payload = { userData: { - openpaasId: "u1", - family_name: "Owner", - email: "o@example.com", - sid: "", - sub: "", - given_name: "Test", - name: "Test Owner", + openpaasId: 'u1', + family_name: 'Owner', + email: 'o@example.com', + sid: '', + sub: '', + given_name: 'Test', + name: 'Test Owner' }, - calId: "cal1", - color: { "apple:color": "#f00" }, - name: "Test", - desc: "Desc", - }; + calId: 'cal1', + color: { 'apple:color': '#f00' }, + name: 'Test', + desc: 'Desc' + } const payloadResponse = { - userId: "u1", - calId: "cal1", - color: { "apple:color": "#f00" }, - name: "Test", - desc: "Desc", - owner: { firstname: "Owner", emails: ["o@example.com"] }, - }; + userId: 'u1', + calId: 'cal1', + color: { 'apple:color': '#f00' }, + name: 'Test', + desc: 'Desc', + owner: { firstname: 'Owner', emails: ['o@example.com'] } + } const state = reducer( initialState, - createCalendarAsync.fulfilled(payloadResponse, "req4", payload) - ); - expect(state.list["u1/cal1"].name).toBe("Test"); - expect(state.list["u1/cal1"].color?.["apple:color"]).toBe("#f00"); - }); + createCalendarAsync.fulfilled(payloadResponse, 'req4', payload) + ) + expect(state.list['u1/cal1'].name).toBe('Test') + expect(state.list['u1/cal1'].color?.['apple:color']).toBe('#f00') + }) - it("addSharedCalendarAsync.fulfilled adds shared calendar", () => { + it('addSharedCalendarAsync.fulfilled adds shared calendar', () => { const payload = { - calId: "c1", - color: { "apple:color": "#0f0" }, - link: "/calendars/u1/c1.json", - name: "Shared", - desc: "Shared Desc", - owner: { firstname: "O", emails: ["o@example.com"] }, - }; + calId: 'c1', + color: { 'apple:color': '#0f0' }, + link: '/calendars/u1/c1.json', + name: 'Shared', + desc: 'Shared Desc', + owner: { firstname: 'O', emails: ['o@example.com'] } + } const mockCal = { cal: { - _links: { self: { href: "/calendars/u1/c1.json" } }, - "apple:color": "#0f0", - "caldav:description": "Shared Desc", - "dav:name": "Shared", - }, - }; + _links: { self: { href: '/calendars/u1/c1.json' } }, + 'apple:color': '#0f0', + 'caldav:description': 'Shared Desc', + 'dav:name': 'Shared' + } + } const state = reducer( initialState, - addSharedCalendarAsync.fulfilled(payload, "req5", { - userId: "u1", - calId: "c1", - cal: mockCal, + addSharedCalendarAsync.fulfilled(payload, 'req5', { + userId: 'u1', + calId: 'c1', + cal: mockCal }) - ); - expect(state.list["c1"].name).toBe("Shared"); - }); + ) + expect(state.list['c1'].name).toBe('Shared') + }) - it("getTempCalendarsListAsync.fulfilled updates templist", () => { + it('getTempCalendarsListAsync.fulfilled updates templist', () => { const payload = { t1: { - id: "t1", - name: "Temp", - color: { "apple:color": "#aaa" }, + id: 't1', + name: 'Temp', + color: { 'apple:color': '#aaa' }, events: {}, - visibility: "public", - owner: { firstname: "O", emails: ["o@o.com"] }, - link: "/calendars/t1.json", - description: "desc", - } as Calendar, - }; + visibility: 'public', + owner: { firstname: 'O', emails: ['o@o.com'] }, + link: '/calendars/t1.json', + description: 'desc' + } as Calendar + } const state = reducer( initialState, - getTempCalendarsListAsync.fulfilled(payload, "req7", { - openpaasId: "u1", - color: { "apple:color": "#aaa" }, - displayName: "test", - avatarUrl: "", - email: "test@test.com", + getTempCalendarsListAsync.fulfilled(payload, 'req7', { + openpaasId: 'u1', + color: { 'apple:color': '#aaa' }, + displayName: 'test', + avatarUrl: '', + email: 'test@test.com' }) - ); - expect(state.templist.t1.name).toBe("Temp"); - }); + ) + expect(state.templist.t1.name).toBe('Temp') + }) - it("getEventAsync.fulfilled adds single event", () => { - const payload = { calId: "c1", event: { uid: "e1" } as any }; + it('getEventAsync.fulfilled adds single event', () => { + const payload = { calId: 'c1', event: { uid: 'e1' } as any } const state = reducer( initialState, - getEventAsync.fulfilled(payload, "req9", { uid: "e1" } as any) - ); - expect(state.list.c1.events.e1.uid).toBe("e1"); - }); + getEventAsync.fulfilled(payload, 'req9', { uid: 'e1' } as any) + ) + expect(state.list.c1.events.e1.uid).toBe('e1') + }) - it("getCalendarDetailAsync.fulfilled adds calendar events", () => { - const payload = { calId: "c1", events: [{ uid: "e1" }] as any[] }; + it('getCalendarDetailAsync.fulfilled adds calendar events', () => { + const payload = { calId: 'c1', events: [{ uid: 'e1' }] as any[] } const state = reducer( { ...initialState, list: { - ["c1"]: { - id: "c1", - events: {}, - } as unknown as Calendar, - }, + ['c1']: { + id: 'c1', + events: {} + } as unknown as Calendar + } }, - getCalendarDetailAsync.fulfilled(payload, "req11", { - calId: "c1", - match: { start: "", end: "" }, + getCalendarDetailAsync.fulfilled(payload, 'req11', { + calId: 'c1', + match: { start: '', end: '' } }) - ); - expect(state.list.c1.events.e1.uid).toBe("e1"); - }); + ) + expect(state.list.c1.events.e1.uid).toBe('e1') + }) - it("getEventAsync.fulfilled doesnt create new events when there are already event with base UID", () => { - const baseUid = "recurring-event-base"; + it('getEventAsync.fulfilled doesnt create new events when there are already event with base UID', () => { + const baseUid = 'recurring-event-base' const existingEvent = { uid: `${baseUid}/20240115`, - title: "Existing Recurring Event", - recurrenceId: null, - } as unknown as CalendarEvent; + title: 'Existing Recurring Event', + recurrenceId: null + } as unknown as CalendarEvent const newEventInstance = { uid: baseUid, - title: "Fetched Master Event", - recurrenceId: "20240115", - } as CalendarEvent; + title: 'Fetched Master Event', + recurrenceId: '20240115' + } as CalendarEvent const stateWithEvent = { ...initialState, list: { c1: { - id: "c1", - events: { [`${baseUid}/20240115`]: existingEvent }, - } as unknown as Calendar, - }, - }; + id: 'c1', + events: { [`${baseUid}/20240115`]: existingEvent } + } as unknown as Calendar + } + } - const payload = { calId: "c1", event: newEventInstance }; + const payload = { calId: 'c1', event: newEventInstance } const state = reducer( stateWithEvent, - getEventAsync.fulfilled(payload, "req", newEventInstance) - ); + getEventAsync.fulfilled(payload, 'req', newEventInstance) + ) // Should still only have the base event, not the instance - expect(Object.keys(state.list.c1.events)).toHaveLength(1); - expect(state.list.c1.events[`${baseUid}/20240115`]).toBeDefined(); - expect(state.list.c1.events[baseUid]).toBeUndefined(); - }); + expect(Object.keys(state.list.c1.events)).toHaveLength(1) + expect(state.list.c1.events[`${baseUid}/20240115`]).toBeDefined() + expect(state.list.c1.events[baseUid]).toBeUndefined() + }) it("getEventAsync.fulfilled create new event when there isn't any event with base UID", () => { - const eventUid = "new-event-uid"; + const eventUid = 'new-event-uid' const newEvent = { uid: eventUid, - title: "New Event", - recurrenceId: null, - } as unknown as CalendarEvent; + title: 'New Event', + recurrenceId: null + } as unknown as CalendarEvent const stateWithoutEvent = { ...initialState, list: { c1: { - id: "c1", - events: {}, - } as unknown as Calendar, - }, - }; + id: 'c1', + events: {} + } as unknown as Calendar + } + } - const payload = { calId: "c1", event: newEvent }; + const payload = { calId: 'c1', event: newEvent } const state = reducer( stateWithoutEvent, - getEventAsync.fulfilled(payload, "req", newEvent) - ); + getEventAsync.fulfilled(payload, 'req', newEvent) + ) // Should create the new event - expect(Object.keys(state.list.c1.events)).toHaveLength(1); - expect(state.list.c1.events[eventUid]).toBeDefined(); - expect(state.list.c1.events[eventUid].uid).toBe(eventUid); - expect(state.list.c1.events[eventUid].title).toBe("New Event"); - }); - }); -}); + expect(Object.keys(state.list.c1.events)).toHaveLength(1) + expect(state.list.c1.events[eventUid]).toBeDefined() + expect(state.list.c1.events[eventUid].uid).toBe(eventUid) + expect(state.list.c1.events[eventUid].title).toBe('New Event') + }) + }) +}) diff --git a/__test__/features/Calendars/TimezoneInGrid.test.tsx b/__test__/features/Calendars/TimezoneInGrid.test.tsx index 5923f86..790a0a5 100644 --- a/__test__/features/Calendars/TimezoneInGrid.test.tsx +++ b/__test__/features/Calendars/TimezoneInGrid.test.tsx @@ -1,196 +1,196 @@ -import CalendarApp from "@/components/Calendar/Calendar"; -import * as calendarUtils from "@/components/Calendar/utils/calendarUtils"; -import { updateSlotLabelVisibility } from "@/components/Calendar/utils/calendarUtils"; -import EventPreviewModal from "@/features/Events/EventPreview"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import * as SettingsSlice from "@/features/Settings/SettingsSlice"; -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import CalendarApp from '@/components/Calendar/Calendar' +import * as calendarUtils from '@/components/Calendar/utils/calendarUtils' +import { updateSlotLabelVisibility } from '@/components/Calendar/utils/calendarUtils' +import EventPreviewModal from '@/features/Events/EventPreview' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import * as SettingsSlice from '@/features/Settings/SettingsSlice' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../../utils/Renderwithproviders' -describe("Calendar - Timezone Integration", () => { - const mockCalendarRef = { current: null }; +describe('Calendar - Timezone Integration', () => { + const mockCalendarRef = { current: null } const baseState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "testSid", - openpaasId: "user1", - }, + sub: 'test', + email: 'test@test.com', + sid: 'testSid', + openpaasId: 'user1' + } }, calendars: { list: { - "user1/cal1": { - id: "user1/cal1", - link: "/calendars/user1/cal1.json", - name: "Test Calendar", - description: "", - color: "#33B679", - owner: { firstname: "user1", emails: ["test@test.com"] }, - visibility: "public", - events: {}, - }, + 'user1/cal1': { + id: 'user1/cal1', + link: '/calendars/user1/cal1.json', + name: 'Test Calendar', + description: '', + color: '#33B679', + owner: { firstname: 'user1', emails: ['test@test.com'] }, + visibility: 'public', + events: {} + } }, - timeZone: "America/New_York", - pending: false, - }, - }; + timeZone: 'America/New_York', + pending: false + } + } beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - it("renders TimezoneSelector in week view", async () => { + it('renders TimezoneSelector in week view', async () => { renderWithProviders( , baseState - ); + ) // Look for timezone selector button (should show offset) await waitFor(() => { - expect(screen.getByText(/UTC/)).toBeInTheDocument(); - }); - }); + expect(screen.getByText(/UTC/)).toBeInTheDocument() + }) + }) - it("dispatches setTimeZone action when timezone is changed", async () => { - const setTimeZoneSpy = jest.spyOn(SettingsSlice, "setTimeZone"); + it('dispatches setTimeZone action when timezone is changed', async () => { + const setTimeZoneSpy = jest.spyOn(SettingsSlice, 'setTimeZone') renderWithProviders( , baseState - ); + ) // Find and click timezone selector await waitFor(() => { - const timezoneButton = screen.getByText(/UTC/i); - fireEvent.click(timezoneButton); + const timezoneButton = screen.getByText(/UTC/i) + fireEvent.click(timezoneButton) // Select a different timezone - const autocomplete = screen.getByRole("combobox"); - fireEvent.change(autocomplete, { target: { value: "Tokyo" } }); - }); - const option = await screen.findByText(/Tokyo/i); - fireEvent.click(option); + const autocomplete = screen.getByRole('combobox') + fireEvent.change(autocomplete, { target: { value: 'Tokyo' } }) + }) + const option = await screen.findByText(/Tokyo/i) + fireEvent.click(option) - expect(setTimeZoneSpy).toHaveBeenCalledWith("Asia/Tokyo"); - }); -}); + expect(setTimeZoneSpy).toHaveBeenCalledWith('Asia/Tokyo') + }) +}) -describe("Calendar - Timezone Slot Label Visibility", () => { - it("hides slot labels within 15 minutes of current time", () => { - const currentTime = new Date("2025-01-15T14:30:00Z"); - const timezone = "UTC"; +describe('Calendar - Timezone Slot Label Visibility', () => { + it('hides slot labels within 15 minutes of current time', () => { + const currentTime = new Date('2025-01-15T14:30:00Z') + const timezone = 'UTC' jest - .spyOn(calendarUtils, "checkIfCurrentWeekOrDay") - .mockImplementation(() => true); + .spyOn(calendarUtils, 'checkIfCurrentWeekOrDay') + .mockImplementation(() => true) // 14:25 - within 15 minutes - const slot1425 = { text: "14:25" }; + const slot1425 = { text: '14:25' } expect(updateSlotLabelVisibility(currentTime, slot1425, timezone)).toBe( - "timegrid-slot-label-hidden" - ); + 'timegrid-slot-label-hidden' + ) // 14:30 - exact match - const slot1430 = { text: "14:30" }; + const slot1430 = { text: '14:30' } expect(updateSlotLabelVisibility(currentTime, slot1430, timezone)).toBe( - "timegrid-slot-label-hidden" - ); + 'timegrid-slot-label-hidden' + ) // 14:35 - within 15 minutes - const slot1435 = { text: "14:35" }; + const slot1435 = { text: '14:35' } expect(updateSlotLabelVisibility(currentTime, slot1435, timezone)).toBe( - "timegrid-slot-label-hidden" - ); - }); + 'timegrid-slot-label-hidden' + ) + }) - it("shows slot labels more than 15 minutes from current time", () => { + it('shows slot labels more than 15 minutes from current time', () => { jest - .spyOn(calendarUtils, "checkIfCurrentWeekOrDay") - .mockImplementation(() => true); + .spyOn(calendarUtils, 'checkIfCurrentWeekOrDay') + .mockImplementation(() => true) - const currentTime = new Date("2025-01-15T14:30:00"); - const timezone = "UTC"; + const currentTime = new Date('2025-01-15T14:30:00') + const timezone = 'UTC' // 14:00 - 30 minutes before - const slot1400 = { text: "14:00" }; + const slot1400 = { text: '14:00' } expect(updateSlotLabelVisibility(currentTime, slot1400, timezone)).toBe( - "fc-timegrid-slot-label" - ); + 'fc-timegrid-slot-label' + ) // 15:00 - 30 minutes after - const slot1500 = { text: "15:00" }; + const slot1500 = { text: '15:00' } expect(updateSlotLabelVisibility(currentTime, slot1500, timezone)).toBe( - "fc-timegrid-slot-label" - ); - }); + 'fc-timegrid-slot-label' + ) + }) - it("returns visible class when not in current week/day", () => { + it('returns visible class when not in current week/day', () => { jest - .spyOn(calendarUtils, "checkIfCurrentWeekOrDay") - .mockImplementation(() => false); - const currentTime = new Date("2025-01-15T14:30:00"); - const timezone = "UTC"; - const slot = { text: "14:30" }; + .spyOn(calendarUtils, 'checkIfCurrentWeekOrDay') + .mockImplementation(() => false) + const currentTime = new Date('2025-01-15T14:30:00') + const timezone = 'UTC' + const slot = { text: '14:30' } // Should return visible class when not in current view - const result = updateSlotLabelVisibility(currentTime, slot, timezone); - expect(result).toBe("fc-timegrid-slot-label"); - }); -}); + const result = updateSlotLabelVisibility(currentTime, slot, timezone) + expect(result).toBe('fc-timegrid-slot-label') + }) +}) -describe("EventDisplayPreview - Timezone Display", () => { - const mockOnClose = jest.fn(); +describe('EventDisplayPreview - Timezone Display', () => { + const mockOnClose = jest.fn() const baseEvent: CalendarEvent = { - uid: "event1", - title: "Team Meeting", - start: new Date("2025-01-15T14:00:00Z"), - end: new Date("2025-01-15T15:00:00Z"), - calendarId: "user1/cal1", - allday: false, - }; + uid: 'event1', + title: 'Team Meeting', + start: new Date('2025-01-15T14:00:00Z'), + end: new Date('2025-01-15T15:00:00Z'), + calendarId: 'user1/cal1', + allday: false + } const allDayEvent = { ...baseEvent, allday: true, - start: new Date("2025-01-15"), - end: new Date("2025-01-16"), - }; + start: new Date('2025-01-15'), + end: new Date('2025-01-16') + } const baseState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "testSid", - openpaasId: "user1", - }, + sub: 'test', + email: 'test@test.com', + sid: 'testSid', + openpaasId: 'user1' + } }, calendars: { list: { - "user1/cal1": { - id: "user1/cal1", - name: "Test Calendar", - color: "#33B679", - owner: { firstname: "user1", emails: ["test@test.com"] }, - visibility: "public", + 'user1/cal1': { + id: 'user1/cal1', + name: 'Test Calendar', + color: '#33B679', + owner: { firstname: 'user1', emails: ['test@test.com'] }, + visibility: 'public', events: { event1: baseEvent, - allDayEvent, - }, - }, + allDayEvent + } + } }, - timeZone: "America/New_York", - pending: false, - }, - }; + timeZone: 'America/New_York', + pending: false + } + } beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - it("does not show timezone offset for all-day events", async () => { + it('does not show timezone offset for all-day events', async () => { renderWithProviders( { calId="user1/cal1" />, baseState - ); + ) await waitFor(() => { - const title = screen.getByText(/Team Meeting/i); - expect(title).toBeInTheDocument(); - }); + const title = screen.getByText(/Team Meeting/i) + expect(title).toBeInTheDocument() + }) // Should not show UTC offset - expect(screen.queryByText(/UTC[+-]\d+/i)).not.toBeInTheDocument(); - }); + expect(screen.queryByText(/UTC[+-]\d+/i)).not.toBeInTheDocument() + }) - it("displays correct timezone offset for different timezones", async () => { + it('displays correct timezone offset for different timezones', async () => { const timezones = [ - { tz: "America/New_York", expectedOffset: /UTC[-−][45]/ }, - { tz: "Europe/Paris", expectedOffset: /UTC\+[12]/ }, - { tz: "Asia/Tokyo", expectedOffset: /UTC\+9/ }, - { tz: "Australia/Sydney", expectedOffset: /UTC\+1[01]/ }, - { tz: "Asia/Kolkata", expectedOffset: /UTC\+5:30/ }, - ]; + { tz: 'America/New_York', expectedOffset: /UTC[-−][45]/ }, + { tz: 'Europe/Paris', expectedOffset: /UTC\+[12]/ }, + { tz: 'Asia/Tokyo', expectedOffset: /UTC\+9/ }, + { tz: 'Australia/Sydney', expectedOffset: /UTC\+1[01]/ }, + { tz: 'Asia/Kolkata', expectedOffset: /UTC\+5:30/ } + ] for (const { tz, expectedOffset } of timezones) { const state = { ...baseState, settings: { - timeZone: tz, - }, - }; + timeZone: tz + } + } renderWithProviders( { onClose={mockOnClose} />, state - ); + ) await waitFor(() => { - const content = document.body.textContent || ""; - expect(content).toMatch(expectedOffset); - }); + const content = document.body.textContent || '' + expect(content).toMatch(expectedOffset) + }) } - }); -}); + }) +}) diff --git a/__test__/features/Calendars/TimezoneSelector.test.tsx b/__test__/features/Calendars/TimezoneSelector.test.tsx index 5ef5ac0..b57dc0b 100644 --- a/__test__/features/Calendars/TimezoneSelector.test.tsx +++ b/__test__/features/Calendars/TimezoneSelector.test.tsx @@ -1,134 +1,134 @@ -import { TimezoneSelector } from "@/components/Calendar/TimezoneSelector"; -import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import { TimezoneSelector } from '@/components/Calendar/TimezoneSelector' +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../../utils/Renderwithproviders' -describe("TimezoneSelector", () => { - const mockOnChange = jest.fn(); +describe('TimezoneSelector', () => { + const mockOnChange = jest.fn() beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - it("renders with initial timezone value", () => { + it('renders with initial timezone value', () => { renderWithProviders( - ); + ) - const button = screen.getByRole("button"); - expect(button).toBeInTheDocument(); - expect(button).toHaveTextContent(/UTC[-−][45]/i); // New York offset - }); + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent(/UTC[-−][45]/i) // New York offset + }) - it("opens popover when button is clicked", async () => { + it('opens popover when button is clicked', async () => { renderWithProviders( - ); + ) - const button = screen.getByRole("button"); - fireEvent.click(button); + const button = screen.getByRole('button') + fireEvent.click(button) await waitFor(() => { - expect(screen.getByRole("combobox")).toBeInTheDocument(); - }); - }); + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + }) - it("calls onChange when a new timezone is selected", async () => { + it('calls onChange when a new timezone is selected', async () => { renderWithProviders( - ); + ) - const button = screen.getByRole("button"); - fireEvent.click(button); + const button = screen.getByRole('button') + fireEvent.click(button) await waitFor(() => { - expect(screen.getByRole("combobox")).toBeInTheDocument(); - }); + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) - const autocomplete = screen.getByRole("combobox"); - fireEvent.change(autocomplete, { target: { value: "Los Angeles" } }); + const autocomplete = screen.getByRole('combobox') + fireEvent.change(autocomplete, { target: { value: 'Los Angeles' } }) // Find and click the Los Angeles option - const option = await screen.findByText(/Los Angeles/i); - fireEvent.click(option); + const option = await screen.findByText(/Los Angeles/i) + fireEvent.click(option) - expect(mockOnChange).toHaveBeenCalledWith("America/Los_Angeles"); - }); + expect(mockOnChange).toHaveBeenCalledWith('America/Los_Angeles') + }) - it("closes popover after timezone selection", async () => { + it('closes popover after timezone selection', async () => { renderWithProviders( - ); + ) - const button = screen.getByRole("button"); - fireEvent.click(button); + const button = screen.getByRole('button') + fireEvent.click(button) await waitFor(() => { - expect(screen.getByRole("combobox")).toBeInTheDocument(); - }); + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) - const autocomplete = screen.getByRole("combobox"); - fireEvent.change(autocomplete, { target: { value: "Tokyo" } }); + const autocomplete = screen.getByRole('combobox') + fireEvent.change(autocomplete, { target: { value: 'Tokyo' } }) - const option = await screen.findByText(/Tokyo/i); - fireEvent.click(option); + const option = await screen.findByText(/Tokyo/i) + fireEvent.click(option) await waitFor(() => { - expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); - }); - }); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + }) + }) - it("displays timezones with half-hour offsets correctly", () => { + it('displays timezones with half-hour offsets correctly', () => { renderWithProviders( - ); + ) - const button = screen.getByRole("button"); - expect(button).toHaveTextContent("UTC+5:30"); // India offset - }); + const button = screen.getByRole('button') + expect(button).toHaveTextContent('UTC+5:30') // India offset + }) - it("shows correct offset for Europe/Paris depending on daylight saving time", () => { + it('shows correct offset for Europe/Paris depending on daylight saving time', () => { // Summer date (DST on) - const summerDate = new Date("2025-07-15T12:00:00Z"); + const summerDate = new Date('2025-07-15T12:00:00Z') renderWithProviders( - ); - let button = screen.getByRole("button"); - expect(button).toHaveTextContent(/UTC\+2\b/); - cleanup(); + ) + let button = screen.getByRole('button') + expect(button).toHaveTextContent(/UTC\+2\b/) + cleanup() // Rerender with a winter date (DST off) - const winterDate = new Date("2025-01-15T12:00:00Z"); + const winterDate = new Date('2025-01-15T12:00:00Z') renderWithProviders( - ); - button = screen.getByRole("button"); - expect(button).toHaveTextContent(/UTC\+1\b/); - }); -}); + ) + button = screen.getByRole('button') + expect(button).toHaveTextContent(/UTC\+1\b/) + }) +}) diff --git a/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx b/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx index 4104870..c33c318 100644 --- a/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx +++ b/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx @@ -1,152 +1,150 @@ -import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync"; -import { addSharedCalendar } from "@/features/Calendars/CalendarApi"; -import { fetchOwnerOfResource } from "@/features/Calendars/services/helpers"; -import { toRejectedError } from "@/utils/errorUtils"; -import { configureStore } from "@reduxjs/toolkit"; +import { addCalendarResourceAsync } from '@/features/Calendars/api/addCalendarResourceAsync' +import { addSharedCalendar } from '@/features/Calendars/CalendarApi' +import { fetchOwnerOfResource } from '@/features/Calendars/services/helpers' +import { toRejectedError } from '@/utils/errorUtils' +import { configureStore } from '@reduxjs/toolkit' -jest.mock("@/features/Calendars/CalendarApi"); -jest.mock("@/features/Calendars/services/helpers"); -jest.mock("@/utils/errorUtils"); +jest.mock('@/features/Calendars/CalendarApi') +jest.mock('@/features/Calendars/services/helpers') +jest.mock('@/utils/errorUtils') -const mockedAddSharedCalendar = addSharedCalendar as jest.Mock; -const mockedFetchOwnerOfResource = fetchOwnerOfResource as jest.Mock; -const mockedToRejectedError = toRejectedError as jest.Mock; +const mockedAddSharedCalendar = addSharedCalendar as jest.Mock +const mockedFetchOwnerOfResource = fetchOwnerOfResource as jest.Mock +const mockedToRejectedError = toRejectedError as jest.Mock -describe("addCalendarResourceAsync thunk", () => { - let store: ReturnType; - const dispatch = jest.fn(); +describe('addCalendarResourceAsync thunk', () => { + let store: ReturnType + const dispatch = jest.fn() beforeEach(() => { - jest.clearAllMocks(); + jest.clearAllMocks() store = configureStore({ - reducer: () => ({}), - }); - }); + reducer: () => ({}) + }) + }) const mockPayload = { - userId: "user-123", - calId: "cal-123", + userId: 'user-123', + calId: 'cal-123', cal: { color: { - background: "#000000", - foreground: "#FFFFFF", + background: '#000000', + foreground: '#FFFFFF' }, cal: { - "dav:name": "Resource Room A", - "caldav:description": "A meeting room", + 'dav:name': 'Resource Room A', + 'caldav:description': 'A meeting room', _links: { self: { - href: "/calendars/res-456/cal-123.json", - }, - }, - }, - }, - }; + 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", + _id: 'res-456', + id: 'res-456', + name: 'Resource Room A', + description: 'A meeting room', + creator: 'user-789', deleted: false, - _rev: "1", - }; + _rev: '1' + } - it("should add shared calendar, fetch resource details, map userData", async () => { + it('should add shared calendar, fetch resource details, map userData', async () => { mockedFetchOwnerOfResource.mockResolvedValueOnce({ - firstname: "Creator", - lastname: "User", - emails: ["creator@example.com"], - }); - mockedAddSharedCalendar.mockResolvedValueOnce({}); + firstname: 'Creator', + lastname: 'User', + emails: ['creator@example.com'] + }) + mockedAddSharedCalendar.mockResolvedValueOnce({}) const result = await addCalendarResourceAsync( mockPayload as unknown as Parameters[0] - )(dispatch, store.getState, undefined); + )(dispatch, store.getState, undefined) expect(mockedAddSharedCalendar).toHaveBeenCalledWith( mockPayload.userId, mockPayload.calId, mockPayload.cal - ); - expect(mockedFetchOwnerOfResource).toHaveBeenCalledWith("res-456"); + ) + expect(mockedFetchOwnerOfResource).toHaveBeenCalledWith('res-456') - expect(result.type).toBe("calendars/addCalendarResource/fulfilled"); + 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", + 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: { - firstname: "Creator", - lastname: "User", - emails: ["creator@example.com"], - resource: true, - }, - }); - }); + firstname: 'Creator', + lastname: 'User', + emails: ['creator@example.com'], + resource: true + } + }) + }) - it("should fallback to name if resource details fetch fails", async () => { - mockedAddSharedCalendar.mockResolvedValueOnce({}); - const errorDetails = new Error("Fetch failed"); - mockedFetchOwnerOfResource.mockRejectedValueOnce(errorDetails); + it('should fallback to name if resource details fetch fails', async () => { + mockedAddSharedCalendar.mockResolvedValueOnce({}) + const errorDetails = new Error('Fetch failed') + mockedFetchOwnerOfResource.mockRejectedValueOnce(errorDetails) // Silence expected console error in tests - const consoleSpy = jest - .spyOn(console, "error") - .mockImplementation(() => {}); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) const result = await addCalendarResourceAsync( mockPayload as unknown as Parameters[0] - )(dispatch, store.getState, undefined); + )(dispatch, store.getState, undefined) expect(mockedAddSharedCalendar).toHaveBeenCalledWith( mockPayload.userId, mockPayload.calId, mockPayload.cal - ); - expect(mockedFetchOwnerOfResource).toHaveBeenCalledWith("res-456"); + ) + expect(mockedFetchOwnerOfResource).toHaveBeenCalledWith('res-456') - consoleSpy.mockRestore(); + consoleSpy.mockRestore() - expect(result.type).toBe("calendars/addCalendarResource/fulfilled"); + 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", + 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: { - firstname: "", - lastname: "Resource Room A", + firstname: '', + lastname: 'Resource Room A', emails: [], - resource: true, - }, - }); - }); + resource: true + } + }) + }) - 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); + 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); + )(dispatch, store.getState, undefined) expect(mockedAddSharedCalendar).toHaveBeenCalledWith( mockPayload.userId, mockPayload.calId, mockPayload.cal - ); - expect(mockedFetchOwnerOfResource).not.toHaveBeenCalled(); + ) + expect(mockedFetchOwnerOfResource).not.toHaveBeenCalled() - expect(mockedToRejectedError).toHaveBeenCalledWith(errorAdd); + expect(mockedToRejectedError).toHaveBeenCalledWith(errorAdd) - expect(result.type).toBe("calendars/addCalendarResource/rejected"); - expect(result.payload).toEqual(mockRejectedErrorResult); - }); -}); + 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 7348095..5e37a0c 100644 --- a/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx +++ b/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx @@ -1,234 +1,234 @@ -import { getCalendarsListAsync } from "@/features/Calendars/services/getCalendarsListAsync"; -import { getOpenPaasUser } from "@/features/User/userAPI"; -import { fetchOwnerData } from "@/features/Calendars/services/helpers"; -import { getCalendars } from "@/features/Calendars/CalendarApi"; -import { formatReduxError } from "@/utils/errorUtils"; -import { normalizeCalendar } from "@/features/Calendars/utils/normalizeCalendar"; +import { getCalendarsListAsync } from '@/features/Calendars/services/getCalendarsListAsync' +import { getOpenPaasUser } from '@/features/User/userAPI' +import { fetchOwnerData } from '@/features/Calendars/services/helpers' +import { getCalendars } from '@/features/Calendars/CalendarApi' +import { formatReduxError } from '@/utils/errorUtils' +import { normalizeCalendar } from '@/features/Calendars/utils/normalizeCalendar' -jest.mock("@/features/User/userAPI"); -jest.mock("@/features/Calendars/services/helpers"); -jest.mock("@/features/Calendars/CalendarApi"); -jest.mock("@/utils/errorUtils"); -jest.mock("@/features/Calendars/utils/normalizeCalendar"); -jest.mock("@/utils/getAccessiblePair", () => ({ - getAccessiblePair: jest.fn().mockReturnValue("#FFF"), -})); +jest.mock('@/features/User/userAPI') +jest.mock('@/features/Calendars/services/helpers') +jest.mock('@/features/Calendars/CalendarApi') +jest.mock('@/utils/errorUtils') +jest.mock('@/features/Calendars/utils/normalizeCalendar') +jest.mock('@/utils/getAccessiblePair', () => ({ + getAccessiblePair: jest.fn().mockReturnValue('#FFF') +})) -jest.mock("@mui/material/styles", () => ({ - createTheme: jest.fn().mockReturnValue({}), -})); +jest.mock('@mui/material/styles', () => ({ + createTheme: jest.fn().mockReturnValue({}) +})) -const mockedGetOpenPaasUser = getOpenPaasUser as jest.Mock; -const mockedFetchOwnerData = fetchOwnerData as jest.Mock; -const mockedGetCalendars = getCalendars as jest.Mock; -const mockedFormatReduxError = formatReduxError as jest.Mock; -const mockedNormalizeCalendar = normalizeCalendar as jest.Mock; +const mockedGetOpenPaasUser = getOpenPaasUser as jest.Mock +const mockedFetchOwnerData = fetchOwnerData as jest.Mock +const mockedGetCalendars = getCalendars as jest.Mock +const mockedFormatReduxError = formatReduxError as jest.Mock +const mockedNormalizeCalendar = normalizeCalendar as jest.Mock -describe("getCalendarsListAsync", () => { - let dispatch: jest.Mock; - let getState: jest.Mock; +describe('getCalendarsListAsync', () => { + let dispatch: jest.Mock + let getState: jest.Mock beforeEach(() => { - dispatch = jest.fn(); - getState = jest.fn(); - jest.clearAllMocks(); + 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); - }); - }); + 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 () => { + 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": {} }, - }, - }, + 'cal-existing': { + id: 'cal-existing', + color: { light: 'red', dark: '#FFF' }, + events: { 'event-1': {} } + } + } }, user: { - userData: { openpaasId: "user-123" }, - }, - }); + userData: { openpaasId: 'user-123' } + } + }) const mockCalendarsResponse = { _embedded: { - "dav:calendar": [{ id: "cal-existing" }, { id: "cal-new" }], - }, - }; - mockedGetCalendars.mockResolvedValue(mockCalendarsResponse); + '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", + cal: { 'dav:name': 'Existing Cal', 'apple:color': 'blue' }, + id: 'cal-existing', + ownerId: 'user-123', + description: 'old cal', delegated: false, - link: "/link/1", + link: '/link/1', visibility: 1, - access: 3, + access: 3 }) .mockReturnValueOnce({ - cal: { "dav:name": "New Cal" }, - id: "cal-new", - ownerId: "user-456", - description: "new cal", + cal: { 'dav:name': 'New Cal' }, + id: 'cal-new', + ownerId: 'user-456', + description: 'new cal', delegated: true, - link: "/link/2", + link: '/link/2', visibility: 2, access: 2, - invite: [{ href: "", principal: "", access: 3, inviteStatus: 1 }], - }); + invite: [{ href: '', principal: '', access: 3, inviteStatus: 1 }] + }) mockedFetchOwnerData .mockResolvedValueOnce({ - firstname: "John", - lastname: "Doe", - emails: ["john@example.com"], + firstname: 'John', + lastname: 'Doe', + emails: ['john@example.com'] }) .mockResolvedValueOnce({ - firstname: "Jane", - lastname: "Smith", - emails: ["jane@example.com"], - }); + firstname: 'Jane', + lastname: 'Smith', + emails: ['jane@example.com'] + }) - const thunk = getCalendarsListAsync(); - const result = await thunk(dispatch, getState, undefined); + const thunk = getCalendarsListAsync() + const result = await thunk(dispatch, getState, undefined) - expect(result.type).toBe("calendars/getCalendars/fulfilled"); + expect(result.type).toBe('calendars/getCalendars/fulfilled') const payload = result.payload as { - importedCalendars: any; - errors: string; - }; + importedCalendars: any + errors: string + } - expect(payload.errors).toBe(""); - expect(Object.keys(payload.importedCalendars)).toHaveLength(2); + 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-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(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"); - }); + 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": [] } }); + 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); + const thunk = getCalendarsListAsync() + await thunk(dispatch, getState, undefined) - expect(mockedGetOpenPaasUser).toHaveBeenCalled(); - expect(mockedGetCalendars).toHaveBeenCalledWith("fetched-user-123"); - }); + 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" }); + 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", - }); + message: 'Server Error' + }) // toRejectedError is imported from a mocked module; provide the expected return - const { toRejectedError } = jest.requireMock("@/utils/errorUtils") as { - toRejectedError: jest.Mock; - }; + const { toRejectedError } = jest.requireMock('@/utils/errorUtils') as { + toRejectedError: jest.Mock + } toRejectedError.mockReturnValueOnce({ status: 500, - message: "Server Error", - }); + message: 'Server Error' + }) - const thunk = getCalendarsListAsync(); - const result = await thunk(dispatch, getState, undefined); + const thunk = getCalendarsListAsync() + const result = await thunk(dispatch, getState, undefined) - expect(result.type).toBe("calendars/getCalendars/rejected"); + expect(result.type).toBe('calendars/getCalendars/rejected') expect(result.payload).toEqual({ status: 500, - message: "Server Error", - }); - }); + message: 'Server Error' + }) + }) - it("should handle error when fetching owner data fails", async () => { + it('should handle error when fetching owner data fails', async () => { getState.mockReturnValue({ calendars: {}, - user: { userData: { openpaasId: "user-123" } }, - }); + user: { userData: { openpaasId: 'user-123' } } + }) mockedGetCalendars.mockResolvedValue({ - _embedded: { "dav:calendar": [{ id: "cal-1" }] }, - }); + _embedded: { 'dav:calendar': [{ id: 'cal-1' }] } + }) mockedNormalizeCalendar.mockReturnValue({ - cal: { "dav:name": "Error Cal" }, - id: "cal-1", - ownerId: "error-123", - }); + cal: { 'dav:name': 'Error Cal' }, + id: 'cal-1', + ownerId: 'error-123' + }) // fetchOwnerData fails - mockedFetchOwnerData.mockRejectedValueOnce(new Error("Network Error")); + mockedFetchOwnerData.mockRejectedValueOnce(new Error('Network Error')) - const thunk = getCalendarsListAsync(); - const result = await thunk(dispatch, getState, undefined); + const thunk = getCalendarsListAsync() + const result = await thunk(dispatch, getState, undefined) - const payload = result.payload as any; - expect(mockedFetchOwnerData).toHaveBeenCalledWith("error-123"); - expect(payload.importedCalendars["cal-1"].owner).toEqual({ - firstname: "", - lastname: "Unknown User", - emails: [], - }); + const payload = result.payload as any + expect(mockedFetchOwnerData).toHaveBeenCalledWith('error-123') + expect(payload.importedCalendars['cal-1'].owner).toEqual({ + firstname: '', + lastname: 'Unknown User', + emails: [] + }) // Errors array should contain the error - expect(payload.errors).toContain("Network Error"); - }); + expect(payload.errors).toContain('Network Error') + }) - it("should return owner data mapping properly (including resource: true)", async () => { + it('should return owner data mapping properly (including resource: true)', async () => { getState.mockReturnValue({ calendars: {}, - user: { userData: { openpaasId: "user-123" } }, - }); + user: { userData: { openpaasId: 'user-123' } } + }) mockedGetCalendars.mockResolvedValue({ - _embedded: { "dav:calendar": [{ id: "cal-1" }] }, - }); + _embedded: { 'dav:calendar': [{ id: 'cal-1' }] } + }) mockedNormalizeCalendar.mockReturnValue({ - cal: { "dav:name": "Resource Cal" }, - id: "cal-1", - ownerId: "resource-123", - }); + cal: { 'dav:name': 'Resource Cal' }, + id: 'cal-1', + ownerId: 'resource-123' + }) // fetchOwnerData succeeds and returns a resource config structure mockedFetchOwnerData.mockResolvedValueOnce({ - firstname: "Creator", - lastname: "User", + firstname: 'Creator', + lastname: 'User', emails: [], - resource: true, - }); + resource: true + }) - const thunk = getCalendarsListAsync(); - const result = await thunk(dispatch, getState, undefined); + const thunk = getCalendarsListAsync() + const result = await thunk(dispatch, getState, undefined) - const payload = result.payload as any; - expect(mockedFetchOwnerData).toHaveBeenCalledWith("resource-123"); - expect(payload.importedCalendars["cal-1"].owner).toEqual({ - firstname: "Creator", - lastname: "User", + const payload = result.payload as any + expect(mockedFetchOwnerData).toHaveBeenCalledWith('resource-123') + expect(payload.importedCalendars['cal-1'].owner).toEqual({ + firstname: 'Creator', + lastname: 'User', emails: [], - resource: true, - }); - }); -}); + resource: true + }) + }) +}) diff --git a/__test__/features/Calendars/services/helpers.test.tsx b/__test__/features/Calendars/services/helpers.test.tsx index 255a224..7780fd0 100644 --- a/__test__/features/Calendars/services/helpers.test.tsx +++ b/__test__/features/Calendars/services/helpers.test.tsx @@ -1,90 +1,90 @@ -import { fetchOwnerData } from "@/features/Calendars/services/helpers"; -import { getResourceDetails, getUserDetails } from "@/features/User/userAPI"; +import { fetchOwnerData } from '@/features/Calendars/services/helpers' +import { getResourceDetails, getUserDetails } from '@/features/User/userAPI' -jest.mock("@/features/User/userAPI"); +jest.mock('@/features/User/userAPI') -const mockedGetUserDetails = getUserDetails as jest.Mock; -const mockedGetResourceDetails = getResourceDetails as jest.Mock; +const mockedGetUserDetails = getUserDetails as jest.Mock +const mockedGetResourceDetails = getResourceDetails as jest.Mock -describe("helpers", () => { +describe('helpers', () => { beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - describe("fetchOwnerData", () => { - it("should return user details successfully", async () => { + describe('fetchOwnerData', () => { + it('should return user details successfully', async () => { const mockUser = { - firstname: "John", - lastname: "Doe", - emails: ["john@example.com"], - }; - mockedGetUserDetails.mockResolvedValueOnce(mockUser); + firstname: 'John', + lastname: 'Doe', + emails: ['john@example.com'] + } + mockedGetUserDetails.mockResolvedValueOnce(mockUser) - const result = await fetchOwnerData("user-123"); + const result = await fetchOwnerData('user-123') - expect(mockedGetUserDetails).toHaveBeenCalledWith("user-123"); - expect(mockedGetResourceDetails).not.toHaveBeenCalled(); - expect(result).toEqual(mockUser); - }); + expect(mockedGetUserDetails).toHaveBeenCalledWith('user-123') + expect(mockedGetResourceDetails).not.toHaveBeenCalled() + expect(result).toEqual(mockUser) + }) - it("should fetch resource details and its creator when user is not found", async () => { - const mockResource = { creator: "creator-456" }; + it('should fetch resource details and its creator when user is not found', async () => { + const mockResource = { creator: 'creator-456' } const mockCreator = { - firstname: "Creator", - lastname: "User", - emails: ["creator@example.com"], - }; + firstname: 'Creator', + lastname: 'User', + emails: ['creator@example.com'] + } // Mock getUserDetails to fail with 404 for the initial call mockedGetUserDetails.mockRejectedValueOnce({ - response: { status: 404 }, - }); + response: { status: 404 } + }) // Mock getResourceDetails to succeed and return a creator ID - mockedGetResourceDetails.mockResolvedValueOnce(mockResource); + mockedGetResourceDetails.mockResolvedValueOnce(mockResource) // Mock getUserDetails to succeed when called for the creator - mockedGetUserDetails.mockResolvedValueOnce(mockCreator); + mockedGetUserDetails.mockResolvedValueOnce(mockCreator) - const result = await fetchOwnerData("resource-123"); + const result = await fetchOwnerData('resource-123') - expect(mockedGetUserDetails).toHaveBeenNthCalledWith(1, "resource-123"); - expect(mockedGetResourceDetails).toHaveBeenCalledWith("resource-123"); - expect(mockedGetUserDetails).toHaveBeenNthCalledWith(2, "creator-456"); + expect(mockedGetUserDetails).toHaveBeenNthCalledWith(1, 'resource-123') + expect(mockedGetResourceDetails).toHaveBeenCalledWith('resource-123') + expect(mockedGetUserDetails).toHaveBeenNthCalledWith(2, 'creator-456') expect(result).toEqual({ ...mockCreator, resource: true, administrators: undefined, - resourceIcon: undefined, - }); - }); + resourceIcon: undefined + }) + }) - it("should throw error when getUserDetails fails with non-404 error", async () => { - const mockError = { response: { status: 500 } }; - mockedGetUserDetails.mockRejectedValueOnce(mockError); + it('should throw error when getUserDetails fails with non-404 error', async () => { + const mockError = { response: { status: 500 } } + mockedGetUserDetails.mockRejectedValueOnce(mockError) - await expect(fetchOwnerData("user-123")).rejects.toEqual(mockError); + await expect(fetchOwnerData('user-123')).rejects.toEqual(mockError) - expect(mockedGetUserDetails).toHaveBeenCalledWith("user-123"); - expect(mockedGetResourceDetails).not.toHaveBeenCalled(); - }); + expect(mockedGetUserDetails).toHaveBeenCalledWith('user-123') + expect(mockedGetResourceDetails).not.toHaveBeenCalled() + }) - it("should throw error when getResourceDetails fails", async () => { - const mockError = new Error("Resource not found"); + it('should throw error when getResourceDetails fails', async () => { + const mockError = new Error('Resource not found') // Mock getUserDetails to fail with 404 for the initial call mockedGetUserDetails.mockRejectedValueOnce({ - response: { status: 404 }, - }); + response: { status: 404 } + }) // Mock getResourceDetails to fail - mockedGetResourceDetails.mockRejectedValueOnce(mockError); + mockedGetResourceDetails.mockRejectedValueOnce(mockError) - await expect(fetchOwnerData("resource-123")).rejects.toEqual(mockError); + await expect(fetchOwnerData('resource-123')).rejects.toEqual(mockError) - expect(mockedGetUserDetails).toHaveBeenCalledWith("resource-123"); - expect(mockedGetResourceDetails).toHaveBeenCalledWith("resource-123"); - expect(mockedGetUserDetails).toHaveBeenCalledTimes(1); // Only called once - }); - }); -}); + expect(mockedGetUserDetails).toHaveBeenCalledWith('resource-123') + expect(mockedGetResourceDetails).toHaveBeenCalledWith('resource-123') + expect(mockedGetUserDetails).toHaveBeenCalledTimes(1) // Only called once + }) + }) +}) diff --git a/__test__/features/Calendars/services/refreshCalendar.test.tsx b/__test__/features/Calendars/services/refreshCalendar.test.tsx index ff41edd..ad71b2f 100644 --- a/__test__/features/Calendars/services/refreshCalendar.test.tsx +++ b/__test__/features/Calendars/services/refreshCalendar.test.tsx @@ -1,44 +1,44 @@ -import * as fetchSyncTokenChanges from "@/features/Calendars/api/fetchSyncTokenChanges"; -import reducer from "@/features/Calendars/CalendarSlice"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; +import * as fetchSyncTokenChanges from '@/features/Calendars/api/fetchSyncTokenChanges' +import reducer from '@/features/Calendars/CalendarSlice' +import { Calendar } from '@/features/Calendars/CalendarTypes' import { refreshCalendarWithSyncToken, - SyncTokenUpdates, -} from "@/features/Calendars/services/refreshCalendar"; -import * as EventApi from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { configureStore } from "@reduxjs/toolkit"; + SyncTokenUpdates +} from '@/features/Calendars/services/refreshCalendar' +import * as EventApi from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { configureStore } from '@reduxjs/toolkit' -jest.mock("@/features/Calendars/api/fetchSyncTokenChanges"); -jest.mock("@/features/Events/EventApi"); +jest.mock('@/features/Calendars/api/fetchSyncTokenChanges') +jest.mock('@/features/Events/EventApi') -describe("refreshCalendarWithSyncToken", () => { +describe('refreshCalendarWithSyncToken', () => { const mockCalendar: Calendar = { - id: "user1/cal1", - name: "Test Calendar", - link: "/calendars/user1/cal1.json", - owner: { firstname: "Test User", emails: ["test@example.com"] }, - description: "Test calendar description", + id: 'user1/cal1', + name: 'Test Calendar', + link: '/calendars/user1/cal1.json', + owner: { firstname: 'Test User', emails: ['test@example.com'] }, + description: 'Test calendar description', delegated: false, - color: { light: "#006BD8", dark: "#FFF" }, - visibility: "private", + color: { light: '#006BD8', dark: '#FFF' }, + visibility: 'private', events: { event1: { - uid: "event1", - title: "Existing Event", - start: "2024-01-01T10:00:00", - end: "2024-01-01T11:00:00", - calId: "user1/cal1", - URL: "/calendars/user1/cal1/event1.ics", - } as CalendarEvent, + uid: 'event1', + title: 'Existing Event', + start: '2024-01-01T10:00:00', + end: '2024-01-01T11:00:00', + calId: 'user1/cal1', + URL: '/calendars/user1/cal1/event1.ics' + } as CalendarEvent }, - syncToken: "sync-token-123", - }; + syncToken: 'sync-token-123' + } const calendarRange = { - start: new Date("2024-01-01"), - end: new Date("2024-01-31"), - }; + start: new Date('2024-01-01'), + end: new Date('2024-01-31') + } const storeFactory = () => configureStore({ @@ -48,87 +48,87 @@ describe("refreshCalendarWithSyncToken", () => { list: { [mockCalendar.id]: mockCalendar }, templist: {}, pending: false, - error: null, - }, - }, - }); + error: null + } + } + }) beforeEach(() => { - jest.resetAllMocks(); - }); + jest.resetAllMocks() + }) - it("should return early if calendar has no syncToken", async () => { - const calendarWithoutToken = { ...mockCalendar, syncToken: undefined }; - const store = storeFactory(); + it('should return early if calendar has no syncToken', async () => { + const calendarWithoutToken = { ...mockCalendar, syncToken: undefined } + const store = storeFactory() const result = await store.dispatch( refreshCalendarWithSyncToken({ calendar: calendarWithoutToken, - calendarRange, + calendarRange }) - ); + ) - expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); + expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled') expect(result.payload).toEqual({ calId: calendarWithoutToken.id, deletedEvents: [], createdOrUpdatedEvents: [], calType: undefined, - syncStatus: "NO_SYNC_TOKEN", - }); - expect(fetchSyncTokenChanges.fetchSyncTokenChanges).not.toHaveBeenCalled(); - }); + syncStatus: 'NO_SYNC_TOKEN' + }) + expect(fetchSyncTokenChanges.fetchSyncTokenChanges).not.toHaveBeenCalled() + }) - it("should handle deleted events (status 404)", async () => { + it('should handle deleted events (status 404)', async () => { const mockSyncResponse = { - "sync-token": "new-sync-token-456", + 'sync-token': 'new-sync-token-456', _embedded: { - "dav:item": [ + 'dav:item': [ { status: 404, _links: { - self: { href: "/calendars/user1/cal1/deleted-event.ics" }, - }, - }, - ], - }, - }; + self: { href: '/calendars/user1/cal1/deleted-event.ics' } + } + } + ] + } + } - ( + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockResolvedValue(mockSyncResponse); + ).mockResolvedValue(mockSyncResponse) - const store = storeFactory(); + const store = storeFactory() const result = await store.dispatch( refreshCalendarWithSyncToken({ calendar: mockCalendar, - calendarRange, + calendarRange }) - ); + ) - expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); + expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled') expect(result.payload).toMatchObject({ calId: mockCalendar.id, - deletedEvents: ["/calendars/user1/cal1/deleted-event.ics"], + deletedEvents: ['/calendars/user1/cal1/deleted-event.ics'], createdOrUpdatedEvents: [], - syncToken: "new-sync-token-456", - }); - }); + syncToken: 'new-sync-token-456' + }) + }) - it("should handle created/updated events (status 200)", async () => { + it('should handle created/updated events (status 200)', async () => { const mockSyncResponse = { - "sync-token": "new-sync-token-789", + 'sync-token': 'new-sync-token-789', _embedded: { - "dav:item": [ + 'dav:item': [ { status: 200, _links: { - self: { href: "/calendars/user1/cal1/new-event.ics" }, - }, - }, - ], - }, - }; + self: { href: '/calendars/user1/cal1/new-event.ics' } + } + } + ] + } + } const mockEventData = { data: [ @@ -136,65 +136,65 @@ describe("refreshCalendarWithSyncToken", () => { null, [ [ - "vevent", + 'vevent', [ - ["uid", {}, "text", "new-event"], - ["summary", {}, "text", "New Event Title"], - ["dtstart", {}, "date-time", "2024-01-15T10:00:00Z"], - ["dtend", {}, "date-time", "2024-01-15T11:00:00Z"], + ['uid', {}, 'text', 'new-event'], + ['summary', {}, 'text', 'New Event Title'], + ['dtstart', {}, 'date-time', '2024-01-15T10:00:00Z'], + ['dtend', {}, 'date-time', '2024-01-15T11:00:00Z'] ], - [], - ], - ], + [] + ] + ] ], _links: { - self: { href: "/calendars/user1/cal1/new-event.ics" }, - }, - }; + self: { href: '/calendars/user1/cal1/new-event.ics' } + } + } - ( + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockResolvedValue(mockSyncResponse); - (EventApi.reportEvent as jest.Mock).mockResolvedValue(mockEventData); + ).mockResolvedValue(mockSyncResponse) + ;(EventApi.reportEvent as jest.Mock).mockResolvedValue(mockEventData) - const store = storeFactory(); + const store = storeFactory() const result = await store.dispatch( refreshCalendarWithSyncToken({ calendar: mockCalendar, - calendarRange, + calendarRange }) - ); + ) - expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); - if (result.type === "calendars/refreshWithSyncToken/fulfilled") { - const payload = result.payload as SyncTokenUpdates; - expect(payload?.calId).toBe(mockCalendar.id); - expect(payload?.createdOrUpdatedEvents).toHaveLength(1); - expect(payload?.createdOrUpdatedEvents[0].uid).toBe("new-event"); - expect(payload?.syncToken).toBe("new-sync-token-789"); + expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled') + if (result.type === 'calendars/refreshWithSyncToken/fulfilled') { + const payload = result.payload as SyncTokenUpdates + expect(payload?.calId).toBe(mockCalendar.id) + expect(payload?.createdOrUpdatedEvents).toHaveLength(1) + expect(payload?.createdOrUpdatedEvents[0].uid).toBe('new-event') + expect(payload?.syncToken).toBe('new-sync-token-789') } - }); + }) - it("should handle both deletions and updates in same response", async () => { + it('should handle both deletions and updates in same response', async () => { const mockSyncResponse = { - "sync-token": "new-sync-token-mixed", + 'sync-token': 'new-sync-token-mixed', _embedded: { - "dav:item": [ + 'dav:item': [ { status: 404, _links: { - self: { href: "/calendars/user1/cal1/deleted-event.ics" }, - }, + self: { href: '/calendars/user1/cal1/deleted-event.ics' } + } }, { status: 200, _links: { - self: { href: "/calendars/user1/cal1/updated-event.ics" }, - }, - }, - ], - }, - }; + self: { href: '/calendars/user1/cal1/updated-event.ics' } + } + } + ] + } + } const mockEventData = { data: [ @@ -202,180 +202,180 @@ describe("refreshCalendarWithSyncToken", () => { null, [ [ - "vevent", + 'vevent', [ - ["uid", {}, "text", "updated-event"], - ["summary", {}, "text", "Updated Event"], - ["dtstart", {}, "date-time", "2024-01-20T10:00:00Z"], - ["dtend", {}, "date-time", "2024-01-20T11:00:00Z"], + ['uid', {}, 'text', 'updated-event'], + ['summary', {}, 'text', 'Updated Event'], + ['dtstart', {}, 'date-time', '2024-01-20T10:00:00Z'], + ['dtend', {}, 'date-time', '2024-01-20T11:00:00Z'] ], - [], - ], - ], + [] + ] + ] ], _links: { - self: { href: "/calendars/user1/cal1/updated-event.ics" }, - }, - }; + self: { href: '/calendars/user1/cal1/updated-event.ics' } + } + } - ( + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockResolvedValue(mockSyncResponse); - (EventApi.reportEvent as jest.Mock).mockResolvedValue(mockEventData); + ).mockResolvedValue(mockSyncResponse) + ;(EventApi.reportEvent as jest.Mock).mockResolvedValue(mockEventData) - const store = storeFactory(); + const store = storeFactory() const result = await store.dispatch( refreshCalendarWithSyncToken({ calendar: mockCalendar, - calendarRange, + calendarRange }) - ); + ) - expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); - if (result.type === "calendars/refreshWithSyncToken/fulfilled") { - const payload = result.payload as SyncTokenUpdates; + expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled') + if (result.type === 'calendars/refreshWithSyncToken/fulfilled') { + const payload = result.payload as SyncTokenUpdates expect(payload?.deletedEvents).toEqual([ - "/calendars/user1/cal1/deleted-event.ics", - "/calendars/user1/cal1/updated-event.ics", - ]); - expect(payload?.createdOrUpdatedEvents).toHaveLength(1); - expect(payload?.createdOrUpdatedEvents[0].uid).toBe("updated-event"); + '/calendars/user1/cal1/deleted-event.ics', + '/calendars/user1/cal1/updated-event.ics' + ]) + expect(payload?.createdOrUpdatedEvents).toHaveLength(1) + expect(payload?.createdOrUpdatedEvents[0].uid).toBe('updated-event') } - }); + }) - it("should reject with SYNC_TOKEN_INVALID error on 410 status", async () => { + it('should reject with SYNC_TOKEN_INVALID error on 410 status', async () => { const mockSyncResponse = { _embedded: { - "dav:item": [ + 'dav:item': [ { status: 410, _links: { - self: { href: "/calendars/user1/cal1/some-event.ics" }, - }, - }, - ], - }, - }; + self: { href: '/calendars/user1/cal1/some-event.ics' } + } + } + ] + } + } - ( + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockResolvedValue(mockSyncResponse); + ).mockResolvedValue(mockSyncResponse) - const store = storeFactory(); + const store = storeFactory() const result = await store.dispatch( refreshCalendarWithSyncToken({ calendar: mockCalendar, - calendarRange, + calendarRange }) - ); + ) - expect(result.type).toBe("calendars/refreshWithSyncToken/rejected"); + expect(result.type).toBe('calendars/refreshWithSyncToken/rejected') expect(result.payload).toMatchObject({ - message: expect.stringContaining("SYNC_TOKEN_INVALID"), - }); - }); + message: expect.stringContaining('SYNC_TOKEN_INVALID') + }) + }) - it("should handle API errors gracefully", async () => { - const mockError = new Error("Network error"); - ( + it('should handle API errors gracefully', async () => { + const mockError = new Error('Network error') + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockRejectedValue(mockError); + ).mockRejectedValue(mockError) - const store = storeFactory(); + const store = storeFactory() const result = await store.dispatch( refreshCalendarWithSyncToken({ calendar: mockCalendar, - calendarRange, + calendarRange }) - ); + ) - expect(result.type).toBe("calendars/refreshWithSyncToken/rejected"); + expect(result.type).toBe('calendars/refreshWithSyncToken/rejected') expect(result.payload).toMatchObject({ - message: expect.stringContaining("Network error"), - }); - }); + message: expect.stringContaining('Network error') + }) + }) - it("should handle malformed sync response", async () => { + it('should handle malformed sync response', async () => { const mockSyncResponse = { - "sync-token": "new-token", + 'sync-token': 'new-token' // Missing _embedded field - }; + } - ( + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockResolvedValue(mockSyncResponse); + ).mockResolvedValue(mockSyncResponse) - const store = storeFactory(); + const store = storeFactory() const result = await store.dispatch( refreshCalendarWithSyncToken({ calendar: mockCalendar, - calendarRange, + calendarRange }) - ); + ) - expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); + expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled') expect(result.payload).toMatchObject({ calId: mockCalendar.id, deletedEvents: [], createdOrUpdatedEvents: [], - syncToken: "new-token", - }); - }); + syncToken: 'new-token' + }) + }) - it("should handle events without href in _links", async () => { + it('should handle events without href in _links', async () => { const mockSyncResponse = { - "sync-token": "new-token", + 'sync-token': 'new-token', _embedded: { - "dav:item": [ + 'dav:item': [ { status: 200, _links: { - self: {}, // Missing href - }, - }, - ], - }, - }; + self: {} // Missing href + } + } + ] + } + } - ( + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockResolvedValue(mockSyncResponse); + ).mockResolvedValue(mockSyncResponse) - const store = storeFactory(); + const store = storeFactory() const result = await store.dispatch( refreshCalendarWithSyncToken({ calendar: mockCalendar, - calendarRange, + calendarRange }) - ); + ) - expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); - if (result.type === "calendars/refreshWithSyncToken/fulfilled") { - const payload = result.payload as SyncTokenUpdates; - expect(payload?.createdOrUpdatedEvents).toHaveLength(0); + expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled') + if (result.type === 'calendars/refreshWithSyncToken/fulfilled') { + const payload = result.payload as SyncTokenUpdates + expect(payload?.createdOrUpdatedEvents).toHaveLength(0) } - }); + }) - it("should continue processing other events if one fails to fetch", async () => { + it('should continue processing other events if one fails to fetch', async () => { const mockSyncResponse = { - "sync-token": "new-token", + 'sync-token': 'new-token', _embedded: { - "dav:item": [ + 'dav:item': [ { status: 200, _links: { - self: { href: "/calendars/user1/cal1/failing-event.ics" }, - }, + self: { href: '/calendars/user1/cal1/failing-event.ics' } + } }, { status: 200, _links: { - self: { href: "/calendars/user1/cal1/success-event.ics" }, - }, - }, - ], - }, - }; + self: { href: '/calendars/user1/cal1/success-event.ics' } + } + } + ] + } + } const mockEventData = { data: [ @@ -383,116 +383,116 @@ describe("refreshCalendarWithSyncToken", () => { null, [ [ - "vevent", + 'vevent', [ - ["uid", {}, "text", "success-event"], - ["summary", {}, "text", "Success Event"], - ["dtstart", {}, "date-time", "2024-01-20T10:00:00Z"], - ["dtend", {}, "date-time", "2024-01-20T11:00:00Z"], + ['uid', {}, 'text', 'success-event'], + ['summary', {}, 'text', 'Success Event'], + ['dtstart', {}, 'date-time', '2024-01-20T10:00:00Z'], + ['dtend', {}, 'date-time', '2024-01-20T11:00:00Z'] ], - [], - ], - ], + [] + ] + ] ], _links: { - self: { href: "/calendars/user1/cal1/success-event.ics" }, - }, - }; + self: { href: '/calendars/user1/cal1/success-event.ics' } + } + } - ( + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockResolvedValue(mockSyncResponse); - (EventApi.reportEvent as jest.Mock) - .mockRejectedValueOnce(new Error("Failed to fetch")) - .mockResolvedValueOnce(mockEventData); + ).mockResolvedValue(mockSyncResponse) + ;(EventApi.reportEvent as jest.Mock) + .mockRejectedValueOnce(new Error('Failed to fetch')) + .mockResolvedValueOnce(mockEventData) - const store = storeFactory(); + const store = storeFactory() const result = await store.dispatch( refreshCalendarWithSyncToken({ calendar: mockCalendar, - calendarRange, + calendarRange }) - ); + ) - expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); - if (result.type === "calendars/refreshWithSyncToken/fulfilled") { - const payload = result.payload as SyncTokenUpdates; - expect(payload?.createdOrUpdatedEvents).toHaveLength(1); - expect(payload?.createdOrUpdatedEvents[0].uid).toBe("success-event"); + expect(result.type).toBe('calendars/refreshWithSyncToken/fulfilled') + if (result.type === 'calendars/refreshWithSyncToken/fulfilled') { + const payload = result.payload as SyncTokenUpdates + expect(payload?.createdOrUpdatedEvents).toHaveLength(1) + expect(payload?.createdOrUpdatedEvents[0].uid).toBe('success-event') } - }); + }) - it("should update calendar state with new sync token", async () => { + it('should update calendar state with new sync token', async () => { const mockSyncResponse = { - "sync-token": "new-sync-token-state-test", + 'sync-token': 'new-sync-token-state-test', _embedded: { - "dav:item": [], - }, - }; + 'dav:item': [] + } + } - ( + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockResolvedValue(mockSyncResponse); + ).mockResolvedValue(mockSyncResponse) - const store = storeFactory(); + const store = storeFactory() await store.dispatch( refreshCalendarWithSyncToken({ calendar: mockCalendar, - calendarRange, + calendarRange }) - ); + ) - const state = store.getState().calendars; + const state = store.getState().calendars expect(state.list[mockCalendar.id].syncToken).toBe( - "new-sync-token-state-test" - ); - }); + 'new-sync-token-state-test' + ) + }) - it("should remove deleted events from calendar state", async () => { + it('should remove deleted events from calendar state', async () => { const mockSyncResponse = { - "sync-token": "new-token", + 'sync-token': 'new-token', _embedded: { - "dav:item": [ + 'dav:item': [ { status: 404, _links: { - self: { href: "/calendars/user1/cal1/event1.ics" }, - }, - }, - ], - }, - }; + self: { href: '/calendars/user1/cal1/event1.ics' } + } + } + ] + } + } - ( + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockResolvedValue(mockSyncResponse); + ).mockResolvedValue(mockSyncResponse) - const store = storeFactory(); + const store = storeFactory() await store.dispatch( refreshCalendarWithSyncToken({ calendar: mockCalendar, - calendarRange, + calendarRange }) - ); + ) - const state = store.getState().calendars; - expect(state.list[mockCalendar.id].events["event1"]).toBeUndefined(); - }); + const state = store.getState().calendars + expect(state.list[mockCalendar.id].events['event1']).toBeUndefined() + }) - it("should add new events to calendar state", async () => { + it('should add new events to calendar state', async () => { const mockSyncResponse = { - "sync-token": "new-token", + 'sync-token': 'new-token', _embedded: { - "dav:item": [ + 'dav:item': [ { status: 200, _links: { - self: { href: "/calendars/user1/cal1/new-event.ics" }, - }, - }, - ], - }, - }; + self: { href: '/calendars/user1/cal1/new-event.ics' } + } + } + ] + } + } const mockEventData = { data: [ @@ -500,82 +500,82 @@ describe("refreshCalendarWithSyncToken", () => { null, [ [ - "vevent", + 'vevent', [ - ["uid", {}, "text", "new-event"], - ["summary", {}, "text", "New Event"], - ["dtstart", {}, "date-time", "2024-01-25T10:00:00Z"], - ["dtend", {}, "date-time", "2024-01-25T11:00:00Z"], + ['uid', {}, 'text', 'new-event'], + ['summary', {}, 'text', 'New Event'], + ['dtstart', {}, 'date-time', '2024-01-25T10:00:00Z'], + ['dtend', {}, 'date-time', '2024-01-25T11:00:00Z'] ], - [], - ], - ], + [] + ] + ] ], _links: { - self: { href: "/calendars/user1/cal1/new-event.ics" }, - }, - }; + self: { href: '/calendars/user1/cal1/new-event.ics' } + } + } - ( + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockResolvedValue(mockSyncResponse); - (EventApi.reportEvent as jest.Mock).mockResolvedValue(mockEventData); + ).mockResolvedValue(mockSyncResponse) + ;(EventApi.reportEvent as jest.Mock).mockResolvedValue(mockEventData) - const store = storeFactory(); + const store = storeFactory() await store.dispatch( refreshCalendarWithSyncToken({ calendar: mockCalendar, - calendarRange, + calendarRange }) - ); + ) - const state = store.getState().calendars; - expect(state.list[mockCalendar.id].events["new-event"]).toBeDefined(); - expect(state.list[mockCalendar.id].events["new-event"].title).toBe( - "New Event" - ); - }); + const state = store.getState().calendars + expect(state.list[mockCalendar.id].events['new-event']).toBeDefined() + expect(state.list[mockCalendar.id].events['new-event'].title).toBe( + 'New Event' + ) + }) - it("should handle recurring events with base UID matching", async () => { + it('should handle recurring events with base UID matching', async () => { const calendarWithRecurringEvent = { ...mockCalendar, events: { - "recurring-base": { - uid: "recurring-base", - title: "Recurring Event", - start: "2024-01-01T10:00:00", - end: "2024-01-01T11:00:00", - calId: "user1/cal1", - URL: "/calendars/user1/cal1/recurring-base.ics", + 'recurring-base': { + uid: 'recurring-base', + title: 'Recurring Event', + start: '2024-01-01T10:00:00', + end: '2024-01-01T11:00:00', + calId: 'user1/cal1', + URL: '/calendars/user1/cal1/recurring-base.ics' } as CalendarEvent, - "recurring-base/20240108": { - uid: "recurring-base/20240108", - title: "Recurring Event Instance", - start: "2024-01-08T10:00:00", - end: "2024-01-08T11:00:00", - calId: "user1/cal1", - URL: "/calendars/user1/cal1/recurring-base.ics", - } as CalendarEvent, - }, - }; + 'recurring-base/20240108': { + uid: 'recurring-base/20240108', + title: 'Recurring Event Instance', + start: '2024-01-08T10:00:00', + end: '2024-01-08T11:00:00', + calId: 'user1/cal1', + URL: '/calendars/user1/cal1/recurring-base.ics' + } as CalendarEvent + } + } const mockSyncResponse = { - "sync-token": "new-token", + 'sync-token': 'new-token', _embedded: { - "dav:item": [ + 'dav:item': [ { status: 404, _links: { - self: { href: "/calendars/user1/cal1/recurring-base.ics" }, - }, - }, - ], - }, - }; + self: { href: '/calendars/user1/cal1/recurring-base.ics' } + } + } + ] + } + } - ( + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockResolvedValue(mockSyncResponse); + ).mockResolvedValue(mockSyncResponse) const store = configureStore({ reducer: { calendars: reducer }, @@ -584,42 +584,42 @@ describe("refreshCalendarWithSyncToken", () => { list: { [calendarWithRecurringEvent.id]: calendarWithRecurringEvent }, templist: {}, pending: false, - error: null, - }, - }, - }); + error: null + } + } + }) await store.dispatch( refreshCalendarWithSyncToken({ calendar: calendarWithRecurringEvent, - calendarRange, + calendarRange }) - ); + ) - const state = store.getState().calendars; + const state = store.getState().calendars // Both base event and instance should be removed expect( - state.list[calendarWithRecurringEvent.id].events["recurring-base"] - ).toBeUndefined(); + state.list[calendarWithRecurringEvent.id].events['recurring-base'] + ).toBeUndefined() expect( state.list[calendarWithRecurringEvent.id].events[ - "recurring-base/20240108" + 'recurring-base/20240108' ] - ).toBeUndefined(); - }); + ).toBeUndefined() + }) - it("should respect maxConcurrency parameter", async () => { + it('should respect maxConcurrency parameter', async () => { const mockSyncResponse = { - "sync-token": "new-token", + 'sync-token': 'new-token', _embedded: { - "dav:item": Array.from({ length: 20 }, (_, i) => ({ + 'dav:item': Array.from({ length: 20 }, (_, i) => ({ status: 200, _links: { - self: { href: `/calendars/user1/cal1/event${i}.ics` }, - }, - })), - }, - }; + self: { href: `/calendars/user1/cal1/event${i}.ics` } + } + })) + } + } const mockEventData = (uid: string) => ({ data: [ @@ -627,47 +627,47 @@ describe("refreshCalendarWithSyncToken", () => { null, [ [ - "vevent", + 'vevent', [ - ["uid", {}, "text", uid], - ["summary", {}, "text", `Event ${uid}`], - ["dtstart", {}, "date-time", "2024-01-20T10:00:00Z"], - ["dtend", {}, "date-time", "2024-01-20T11:00:00Z"], + ['uid', {}, 'text', uid], + ['summary', {}, 'text', `Event ${uid}`], + ['dtstart', {}, 'date-time', '2024-01-20T10:00:00Z'], + ['dtend', {}, 'date-time', '2024-01-20T11:00:00Z'] ], - [], - ], - ], + [] + ] + ] ], _links: { - self: { href: `/calendars/user1/cal1/${uid}.ics` }, - }, - }); + self: { href: `/calendars/user1/cal1/${uid}.ics` } + } + }) - ( + ;( fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock - ).mockResolvedValue(mockSyncResponse); + ).mockResolvedValue(mockSyncResponse) - let concurrentCalls = 0; - let maxConcurrent = 0; + let concurrentCalls = 0 + let maxConcurrent = 0 - (EventApi.reportEvent as jest.Mock).mockImplementation(async () => { - concurrentCalls++; - maxConcurrent = Math.max(maxConcurrent, concurrentCalls); - await new Promise((resolve) => setTimeout(resolve, 10)); - concurrentCalls--; - return mockEventData("test-event"); - }); + ;(EventApi.reportEvent as jest.Mock).mockImplementation(async () => { + concurrentCalls++ + maxConcurrent = Math.max(maxConcurrent, concurrentCalls) + await new Promise(resolve => setTimeout(resolve, 10)) + concurrentCalls-- + return mockEventData('test-event') + }) - const store = storeFactory(); + const store = storeFactory() await store.dispatch( refreshCalendarWithSyncToken({ calendar: mockCalendar, calendarRange, - maxConcurrency: 5, + maxConcurrency: 5 }) - ); + ) // Max concurrent should not exceed 5 - expect(maxConcurrent).toBeLessThanOrEqual(5); - }); -}); + expect(maxConcurrent).toBeLessThanOrEqual(5) + }) +}) diff --git a/__test__/features/Error/ErrorHandler.test.tsx b/__test__/features/Error/ErrorHandler.test.tsx index d6c19c3..2490f7c 100644 --- a/__test__/features/Error/ErrorHandler.test.tsx +++ b/__test__/features/Error/ErrorHandler.test.tsx @@ -1,35 +1,35 @@ -import { EventErrorHandler } from "@/components/Error/EventErrorHandler"; +import { EventErrorHandler } from '@/components/Error/EventErrorHandler' -describe("EventErrorHandler", () => { - it("calls the callback when a new error is reported", () => { - const handler = new EventErrorHandler(); - const callback = jest.fn(); +describe('EventErrorHandler', () => { + it('calls the callback when a new error is reported', () => { + const handler = new EventErrorHandler() + const callback = jest.fn() - handler.setErrorCallback(callback); - handler.reportError("123", "Something broke"); + handler.setErrorCallback(callback) + handler.reportError('123', 'Something broke') - expect(callback).toHaveBeenCalledWith(["Something broke"]); - }); + expect(callback).toHaveBeenCalledWith(['Something broke']) + }) - it("does not duplicate errors for same eventId", () => { - const handler = new EventErrorHandler(); - const callback = jest.fn(); + it('does not duplicate errors for same eventId', () => { + const handler = new EventErrorHandler() + const callback = jest.fn() - handler.setErrorCallback(callback); - handler.reportError("A", "Error 1"); - handler.reportError("A", "Error 1 again"); + handler.setErrorCallback(callback) + handler.reportError('A', 'Error 1') + handler.reportError('A', 'Error 1 again') - expect(callback).toHaveBeenCalledTimes(1); - }); + expect(callback).toHaveBeenCalledTimes(1) + }) - it("clears errors properly", () => { - const handler = new EventErrorHandler(); - const callback = jest.fn(); + it('clears errors properly', () => { + const handler = new EventErrorHandler() + const callback = jest.fn() - handler.setErrorCallback(callback); - handler.reportError("A", "Error 1"); - handler.clearAll(); + handler.setErrorCallback(callback) + handler.reportError('A', 'Error 1') + handler.clearAll() - expect(callback).toHaveBeenLastCalledWith([]); - }); -}); + expect(callback).toHaveBeenLastCalledWith([]) + }) +}) diff --git a/__test__/features/Events/AttendanceValidation.delegated.test.tsx b/__test__/features/Events/AttendanceValidation.delegated.test.tsx index cb44029..5602f58 100644 --- a/__test__/features/Events/AttendanceValidation.delegated.test.tsx +++ b/__test__/features/Events/AttendanceValidation.delegated.test.tsx @@ -1,183 +1,181 @@ -import { Calendar } from "@/features/Calendars/CalendarTypes"; -import { AttendanceValidation } from "@/features/Events/AttendanceValidation/AttendanceValidation"; -import { ContextualizedEvent } from "@/features/Events/EventsTypes"; -import { userAttendee } from "@/features/User/models/attendee"; -import { userData } from "@/features/User/userDataTypes"; -import { render } from "@testing-library/react"; +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { AttendanceValidation } from '@/features/Events/AttendanceValidation/AttendanceValidation' +import { ContextualizedEvent } from '@/features/Events/EventsTypes' +import { userAttendee } from '@/features/User/models/attendee' +import { userData } from '@/features/User/userDataTypes' +import { render } from '@testing-library/react' -jest.mock("twake-i18n", () => ({ - useI18n: () => ({ t: (key: string) => key }), -})); +jest.mock('twake-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }) +})) -jest.mock("@/app/hooks", () => ({ +jest.mock('@/app/hooks', () => ({ useAppDispatch: () => jest.fn(), - useAppSelector: jest.fn(), -})); + useAppSelector: jest.fn() +})) const makeUser = (email: string): userData => ({ email, - given_name: "Alice", - family_name: "User", - name: "Alice User", - sid: "user1", - sub: "user1", -}); + given_name: 'Alice', + family_name: 'User', + name: 'Alice User', + sid: 'user1', + sub: 'user1' +}) const makeContext = ( overrides: Partial = {} ): ContextualizedEvent => ({ event: { - uid: "event-1", - calId: "user1/cal1", - title: "Test", - start: "2024-01-15T10:00:00", - end: "2024-01-15T11:00:00", - organizer: { cal_address: "owner@example.com" }, - attendee: [ - { cal_address: "owner@example.com", partstat: "NEEDS-ACTION" }, - ], + uid: 'event-1', + calId: 'user1/cal1', + title: 'Test', + start: '2024-01-15T10:00:00', + end: '2024-01-15T11:00:00', + organizer: { cal_address: 'owner@example.com' }, + attendee: [{ cal_address: 'owner@example.com', partstat: 'NEEDS-ACTION' }] }, calendar: { - id: "user1/cal1", - name: "Test", + id: 'user1/cal1', + name: 'Test', delegated: false, - owner: { emails: ["alice@example.com"] }, - events: {}, + owner: { emails: ['alice@example.com'] }, + events: {} }, currentUserAttendee: { - cal_address: "alice@example.com", - partstat: "NEEDS-ACTION", + cal_address: 'alice@example.com', + partstat: 'NEEDS-ACTION' }, isOwn: true, isOrganizer: false, isRecurring: false, - ...overrides, - }) as unknown as ContextualizedEvent; + ...overrides + }) as unknown as ContextualizedEvent -const noopSetFunc = jest.fn(); +const noopSetFunc = jest.fn() -describe("AttendanceValidation - delegation", () => { - describe("non-delegated calendar", () => { - it("renders when isOwn and currentUserAttendee exists", () => { +describe('AttendanceValidation - delegation', () => { + describe('non-delegated calendar', () => { + it('renders when isOwn and currentUserAttendee exists', () => { const { container } = render( - ); - expect(container.firstChild).not.toBeNull(); - }); + ) + expect(container.firstChild).not.toBeNull() + }) - it("returns null when not isOwn and not delegated", () => { + it('returns null when not isOwn and not delegated', () => { const { container } = render( - ); - expect(container.firstChild).toBeNull(); - }); - }); + ) + expect(container.firstChild).toBeNull() + }) + }) - describe("delegated calendar", () => { + describe('delegated calendar', () => { const delegatedContext = makeContext({ isOwn: false, // logged-in user is not the owner calendar: { - id: "user2/cal1", - name: "Delegated Cal", + id: 'user2/cal1', + name: 'Delegated Cal', delegated: true, - owner: { emails: ["owner@example.com"] }, - events: {}, + owner: { emails: ['owner@example.com'] }, + events: {} } as Calendar, currentUserAttendee: { - cal_address: "owner@example.com", - partstat: "NEEDS-ACTION", + cal_address: 'owner@example.com', + partstat: 'NEEDS-ACTION' } as userAttendee, - event: { class: "PUBLIC" }, - }); + event: { class: 'PUBLIC' } + }) - it("renders even when isOwn is false", () => { + it('renders even when isOwn is false', () => { const { container } = render( - ); - expect(container.firstChild).not.toBeNull(); - }); + ) + expect(container.firstChild).not.toBeNull() + }) - it("renders regardless of currentUserAttendee presence", () => { + it('renders regardless of currentUserAttendee presence', () => { const { container } = render( - ); + ) // delegated flag alone should keep it visible - expect(container.firstChild).not.toBeNull(); - }); - }); + expect(container.firstChild).not.toBeNull() + }) + }) - describe("resource calendar", () => { + describe('resource calendar', () => { const resourceContext = makeContext({ isOwn: false, calendar: { - id: "res1/cal1", - name: "Resource Cal", + id: 'res1/cal1', + name: 'Resource Cal', delegated: false, owner: { - emails: ["resource@example.com"], + emails: ['resource@example.com'], resource: true, - administrators: [{ id: "admin1" }], + administrators: [{ id: 'admin1' }] }, - events: {}, - } as Calendar, - }); + events: {} + } as Calendar + }) - it("renders when user is an administrator of the resource", () => { + it('renders when user is an administrator of the resource', () => { const { getByText } = render( - ); - expect(getByText("eventPreview.authorizeQuestion")).toBeTruthy(); - }); + ) + expect(getByText('eventPreview.authorizeQuestion')).toBeTruthy() + }) - it("returns null when user is not an administrator of the resource", () => { + it('returns null when user is not an administrator of the resource', () => { const { container } = render( - ); - expect(container.firstChild).toBeNull(); - }); - }); -}); + ) + expect(container.firstChild).toBeNull() + }) + }) +}) diff --git a/__test__/features/Events/EventApi.test.tsx b/__test__/features/Events/EventApi.test.tsx index f3b907f..040bb16 100644 --- a/__test__/features/Events/EventApi.test.tsx +++ b/__test__/features/Events/EventApi.test.tsx @@ -1,279 +1,279 @@ -import { updateSeries } from "@/features/Events/api/updateSeries"; +import { updateSeries } from '@/features/Events/api/updateSeries' import { deleteEvent, importEventFromFile, moveEvent, putEvent, - searchEvent, -} from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { calendarEventToJCal } from "@/features/Events/utils"; -import { clientConfig } from "@/features/User/oidcAuth"; -import { api } from "@/utils/apiUtils"; + searchEvent +} from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { calendarEventToJCal } from '@/features/Events/utils' +import { clientConfig } from '@/features/User/oidcAuth' +import { api } from '@/utils/apiUtils' -clientConfig.url = "https://example.com"; +clientConfig.url = 'https://example.com' -jest.mock("@/utils/apiUtils"); +jest.mock('@/utils/apiUtils') -const day = new Date(); +const day = new Date() const mockEvent = { - uid: "event1", - title: "Test Event", - timezone: "UTC", - calId: "667037022b752d0026472254/cal1", - URL: "/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + uid: 'event1', + title: 'Test Event', + timezone: 'UTC', + calId: '667037022b752d0026472254/cal1', + URL: '/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics', start: day.toISOString(), end: day.toISOString(), - status: "PUBLIC", - organizer: { cn: "test", cal_address: "test@test.com" }, + status: 'PUBLIC', + organizer: { cn: 'test', cal_address: 'test@test.com' }, attendee: [ { - cn: "test", - cal_address: "test@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'test', + cal_address: 'test@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "John", - cal_address: "john@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ], -} as CalendarEvent; + cn: 'John', + cal_address: 'john@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] +} as CalendarEvent -describe("eventApi", () => { +describe('eventApi', () => { beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - it("putEvent sends PUT request with JCal body", async () => { - const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; - (api as unknown as jest.Mock).mockReturnValue(mockResponse); - const result = await putEvent(mockEvent); - const expectedResult = calendarEventToJCal(mockEvent); + it('putEvent sends PUT request with JCal body', async () => { + const mockResponse = { status: 201, url: '/dav/cals/test.ics' } + ;(api as unknown as jest.Mock).mockReturnValue(mockResponse) + const result = await putEvent(mockEvent) + const expectedResult = calendarEventToJCal(mockEvent) expect(api).toHaveBeenCalledWith( - "dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + 'dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics', expect.objectContaining({ - method: "PUT", - headers: { "content-type": "text/calendar; charset=utf-8" }, - body: JSON.stringify(expectedResult), + method: 'PUT', + headers: { 'content-type': 'text/calendar; charset=utf-8' }, + body: JSON.stringify(expectedResult) }) - ); - expect(result).toBe(mockResponse); - }); + ) + expect(result).toBe(mockResponse) + }) - it("putEvent logs when status is 201", async () => { - const consoleInfoSpy = jest.spyOn(console, "info").mockImplementation(); - const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; - (api as unknown as jest.Mock).mockReturnValue(mockResponse); + it('putEvent logs when status is 201', async () => { + const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation() + const mockResponse = { status: 201, url: '/dav/cals/test.ics' } + ;(api as unknown as jest.Mock).mockReturnValue(mockResponse) - await putEvent(mockEvent); + await putEvent(mockEvent) expect(consoleInfoSpy).toHaveBeenCalledWith( - "Event created successfully:", - "/dav/cals/test.ics" - ); + 'Event created successfully:', + '/dav/cals/test.ics' + ) - consoleInfoSpy.mockRestore(); - }); + consoleInfoSpy.mockRestore() + }) - test("moveEvent sends MOVE request with destination header", async () => { - const mockResponse = { status: 204 }; - (api as unknown as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue(mockResponse), - }); - const result = await moveEvent(mockEvent, "newurl.ics"); + test('moveEvent sends MOVE request with destination header', async () => { + const mockResponse = { status: 204 } + ;(api as unknown as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse) + }) + const result = await moveEvent(mockEvent, 'newurl.ics') expect(api).toHaveBeenCalledWith( - "dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + 'dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics', expect.objectContaining({ - method: "MOVE", + method: 'MOVE', headers: { - destination: "newurl.ics", - }, + destination: 'newurl.ics' + } }) - ); - }); + ) + }) - it("deleteEvent sends DELETE request and returns json response", async () => { - const mockResponse = { ok: true }; - (api as unknown as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue(mockResponse), - }); + it('deleteEvent sends DELETE request and returns json response', async () => { + const mockResponse = { ok: true } + ;(api as unknown as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse) + }) - const result = await deleteEvent("/calendars/test.ics"); + const result = await deleteEvent('/calendars/test.ics') - expect(api).toHaveBeenCalledWith("dav/calendars/test.ics", { - method: "DELETE", - }); - }); + expect(api).toHaveBeenCalledWith('dav/calendars/test.ics', { + method: 'DELETE' + }) + }) - it("import event file", async () => { - const mockResponse = { status: 202 }; - (api.post as unknown as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue(mockResponse), - }); + it('import event file', async () => { + const mockResponse = { status: 202 } + ;(api.post as unknown as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse) + }) - await importEventFromFile("123456789", "/calendar/calLink.json"); + await importEventFromFile('123456789', '/calendar/calLink.json') - expect(api.post).toHaveBeenCalledWith("api/import", { + expect(api.post).toHaveBeenCalledWith('api/import', { body: JSON.stringify({ - fileId: "123456789", - target: "/calendar/calLink.json", - }), - }); - }); + fileId: '123456789', + target: '/calendar/calLink.json' + }) + }) + }) - test("putEvent handles byday field correctly", async () => { - const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; - (api as unknown as jest.Mock).mockReturnValue(mockResponse); + test('putEvent handles byday field correctly', async () => { + const mockResponse = { status: 201, url: '/dav/cals/test.ics' } + ;(api as unknown as jest.Mock).mockReturnValue(mockResponse) const eventWithByday = { ...mockEvent, repetition: { - freq: "weekly", + freq: 'weekly', interval: 1, - byday: ["MO", "WE", "FR"], - }, - }; + byday: ['MO', 'WE', 'FR'] + } + } - await putEvent(eventWithByday); - const expectedResult = calendarEventToJCal(eventWithByday); + await putEvent(eventWithByday) + const expectedResult = calendarEventToJCal(eventWithByday) expect(api).toHaveBeenCalledWith( - "dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + 'dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics', expect.objectContaining({ - method: "PUT", - headers: { "content-type": "text/calendar; charset=utf-8" }, - body: JSON.stringify(expectedResult), + method: 'PUT', + headers: { 'content-type': 'text/calendar; charset=utf-8' }, + body: JSON.stringify(expectedResult) }) - ); - }); + ) + }) - test("putEvent handles null byday field correctly", async () => { - const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; - (api as unknown as jest.Mock).mockReturnValue(mockResponse); + test('putEvent handles null byday field correctly', async () => { + const mockResponse = { status: 201, url: '/dav/cals/test.ics' } + ;(api as unknown as jest.Mock).mockReturnValue(mockResponse) const eventWithNullByday = { ...mockEvent, repetition: { - freq: "daily", + freq: 'daily', interval: 1, - byday: null, - }, - }; + byday: null + } + } - await putEvent(eventWithNullByday); - const expectedResult = calendarEventToJCal(eventWithNullByday); + await putEvent(eventWithNullByday) + const expectedResult = calendarEventToJCal(eventWithNullByday) expect(api).toHaveBeenCalledWith( - "dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + 'dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics', expect.objectContaining({ - method: "PUT", - headers: { "content-type": "text/calendar; charset=utf-8" }, - body: JSON.stringify(expectedResult), + method: 'PUT', + headers: { 'content-type': 'text/calendar; charset=utf-8' }, + body: JSON.stringify(expectedResult) }) - ); - }); + ) + }) - describe("searchEvent", () => { + describe('searchEvent', () => { const mockFilters = { - searchIn: ["user1/calendar1", "user2/calendar2"], - keywords: "meeting", - organizers: ["org@example.com"], - attendees: ["part@example.com"], - }; + searchIn: ['user1/calendar1', 'user2/calendar2'], + keywords: 'meeting', + organizers: ['org@example.com'], + attendees: ['part@example.com'] + } - it("should call API with correct parameters", async () => { + it('should call API with correct parameters', async () => { const mockResponse = { _total_hits: 5, - _embedded: { events: [] }, - }; + _embedded: { events: [] } + } - (api.post as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue(mockResponse), - }); + ;(api.post as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse) + }) - await searchEvent("test", mockFilters); + await searchEvent('test', mockFilters) expect(api.post).toHaveBeenCalledWith( - "calendar/api/events/search?limit=30&offset=0", + 'calendar/api/events/search?limit=30&offset=0', { body: JSON.stringify({ - query: "meeting", + query: 'meeting', calendars: [ - { calendarId: "calendar1", userId: "user1" }, - { calendarId: "calendar2", userId: "user2" }, + { calendarId: 'calendar1', userId: 'user1' }, + { calendarId: 'calendar2', userId: 'user2' } ], - organizers: ["org@example.com"], - attendees: ["part@example.com"], - }), + organizers: ['org@example.com'], + attendees: ['part@example.com'] + }) } - ); - }); + ) + }) - it("should use query param when keywords is empty", async () => { - const mockResponse = { _total_hits: 0, _embedded: { events: [] } }; + it('should use query param when keywords is empty', async () => { + const mockResponse = { _total_hits: 0, _embedded: { events: [] } } - (api.post as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue(mockResponse), - }); + ;(api.post as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse) + }) - await searchEvent("fallback query", { + await searchEvent('fallback query', { ...mockFilters, - keywords: "", - }); + keywords: '' + }) expect(api.post).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - body: expect.stringContaining('"query":"fallback query"'), + body: expect.stringContaining('"query":"fallback query"') }) - ); - }); + ) + }) - it("should omit organizers when empty", async () => { - const mockResponse = { _total_hits: 0, _embedded: { events: [] } }; + it('should omit organizers when empty', async () => { + const mockResponse = { _total_hits: 0, _embedded: { events: [] } } - (api.post as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue(mockResponse), - }); + ;(api.post as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse) + }) - await searchEvent("test", { + await searchEvent('test', { ...mockFilters, - organizers: [], - }); + organizers: [] + }) - const callArgs = (api.post as jest.Mock).mock.calls[0][1]; - const body = JSON.parse(callArgs.body); - expect(body.organizers).toBeUndefined(); - }); + const callArgs = (api.post as jest.Mock).mock.calls[0][1] + const body = JSON.parse(callArgs.body) + expect(body.organizers).toBeUndefined() + }) - it("should omit participants when empty", async () => { - const mockResponse = { _total_hits: 0, _embedded: { events: [] } }; + it('should omit participants when empty', async () => { + const mockResponse = { _total_hits: 0, _embedded: { events: [] } } - (api.post as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue(mockResponse), - }); + ;(api.post as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse) + }) - await searchEvent("test", { + await searchEvent('test', { ...mockFilters, - attendees: [], - }); + attendees: [] + }) - const callArgs = (api.post as jest.Mock).mock.calls[0][1]; - const body = JSON.parse(callArgs.body); - expect(body.participants).toBeUndefined(); - }); - }); + const callArgs = (api.post as jest.Mock).mock.calls[0][1] + const body = JSON.parse(callArgs.body) + expect(body.participants).toBeUndefined() + }) + }) - describe("updateSeries", () => { + describe('updateSeries', () => { const recurringEventICS = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//EN @@ -294,68 +294,66 @@ SUMMARY:Old title SEQUENCE:1 END:VEVENT END:VCALENDAR -`; +` beforeEach(() => { - jest.clearAllMocks(); + jest.clearAllMocks() + ;(api.get as jest.Mock).mockResolvedValue({ + text: jest.fn().mockResolvedValue(recurringEventICS) + }) + ;(api as jest.Mock).mockResolvedValue({ ok: true }) + }) - (api.get as jest.Mock).mockResolvedValue({ - text: jest.fn().mockResolvedValue(recurringEventICS), - }); - - (api as jest.Mock).mockResolvedValue({ ok: true }); - }); - - it("propagates summary changes to overrides", async () => { + it('propagates summary changes to overrides', async () => { await updateSeries( - { ...mockEvent, title: "New title" } as any, + { ...mockEvent, title: 'New title' } as any, undefined, false - ); + ) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => - props.some(([k]: any) => k === "recurrence-id") - ); + props.some(([k]: any) => k === 'recurrence-id') + ) - const summary = override[1].find(([k]: any) => k === "summary"); - expect(summary[3]).toBe("New title"); - }); + const summary = override[1].find(([k]: any) => k === 'summary') + expect(summary[3]).toBe('New title') + }) - it("does not remove recurrence-id from overrides", async () => { - await updateSeries(mockEvent as any, undefined, false); + it('does not remove recurrence-id from overrides', async () => { + await updateSeries(mockEvent as any, undefined, false) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => - props.some(([k]: any) => k === "recurrence-id") - ); + props.some(([k]: any) => k === 'recurrence-id') + ) - expect(override).toBeDefined(); - }); + expect(override).toBeDefined() + }) - it("increments SEQUENCE on overrides when metadata changes", async () => { + it('increments SEQUENCE on overrides when metadata changes', async () => { await updateSeries( - { ...mockEvent, title: "Changed title" } as any, + { ...mockEvent, title: 'Changed title' } as any, undefined, false - ); + ) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => - props.some(([k]: any) => k === "recurrence-id") - ); + props.some(([k]: any) => k === 'recurrence-id') + ) - const sequence = override[1].find(([k]: any) => k === "sequence"); - expect(sequence[3]).toBe(2); - }); + const sequence = override[1].find(([k]: any) => k === 'sequence') + expect(sequence[3]).toBe(2) + }) - it("propagates description, location, class, and transp changes", async () => { + it('propagates description, location, class, and transp changes', async () => { const icsWithMultipleFields = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT @@ -383,42 +381,42 @@ TRANSP:OPAQUE SEQUENCE:1 END:VEVENT END:VCALENDAR -`; +` - (api.get as jest.Mock).mockResolvedValueOnce({ - text: jest.fn().mockResolvedValue(icsWithMultipleFields), - }); + ;(api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsWithMultipleFields) + }) await updateSeries( { ...mockEvent, - description: "New description", - location: "New location", - class: "PRIVATE", - transp: "TRANSPARENT", + description: 'New description', + location: 'New location', + class: 'PRIVATE', + transp: 'TRANSPARENT' } as any, undefined, false - ); + ) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => - props.some(([k]: any) => k === "recurrence-id") - ); + props.some(([k]: any) => k === 'recurrence-id') + ) - const description = override[1].find(([k]: any) => k === "description"); - const location = override[1].find(([k]: any) => k === "location"); - const classField = override[1].find(([k]: any) => k === "class"); - const transp = override[1].find(([k]: any) => k === "transp"); + const description = override[1].find(([k]: any) => k === 'description') + const location = override[1].find(([k]: any) => k === 'location') + const classField = override[1].find(([k]: any) => k === 'class') + const transp = override[1].find(([k]: any) => k === 'transp') - expect(description[3]).toBe("New description"); - expect(location[3]).toBe("New location"); - expect(classField[3]).toBe("PRIVATE"); - expect(transp[3]).toBe("TRANSPARENT"); - }); + expect(description[3]).toBe('New description') + expect(location[3]).toBe('New location') + expect(classField[3]).toBe('PRIVATE') + expect(transp[3]).toBe('TRANSPARENT') + }) - it("propagates organizer changes", async () => { + it('propagates organizer changes', async () => { const icsWithOrganizer = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT @@ -438,33 +436,33 @@ ORGANIZER;CN=Alice:mailto:alice@example.com SEQUENCE:1 END:VEVENT END:VCALENDAR -`; +` - (api.get as jest.Mock).mockResolvedValueOnce({ - text: jest.fn().mockResolvedValue(icsWithOrganizer), - }); + ;(api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsWithOrganizer) + }) await updateSeries( { ...mockEvent, - organizer: { cn: "Bob", cal_address: "bob@example.com" }, + organizer: { cn: 'Bob', cal_address: 'bob@example.com' } } as any, undefined, false - ); + ) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => - props.some(([k]: any) => k === "recurrence-id") - ); + props.some(([k]: any) => k === 'recurrence-id') + ) - const organizer = override[1].find(([k]: any) => k === "organizer"); - expect(organizer[3]).toBe("mailto:bob@example.com"); - expect(organizer[1].cn).toBe("Bob"); - }); + const organizer = override[1].find(([k]: any) => k === 'organizer') + expect(organizer[3]).toBe('mailto:bob@example.com') + expect(organizer[1].cn).toBe('Bob') + }) - it("propagates attendee changes", async () => { + it('propagates attendee changes', async () => { const icsWithAttendees = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT @@ -484,51 +482,51 @@ ATTENDEE;CN=Alice:mailto:alice@example.com SEQUENCE:1 END:VEVENT END:VCALENDAR -`; +` - (api.get as jest.Mock).mockResolvedValueOnce({ - text: jest.fn().mockResolvedValue(icsWithAttendees), - }); + ;(api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsWithAttendees) + }) await updateSeries( { ...mockEvent, attendee: [ { - cn: "Bob", - cal_address: "bob@example.com", - partstat: "NEEDS-ACTION", - cutype: "INDIVIDUAL", - role: "REQ-PARTICIPANT", - rsvp: "TRUE", + cn: 'Bob', + cal_address: 'bob@example.com', + partstat: 'NEEDS-ACTION', + cutype: 'INDIVIDUAL', + role: 'REQ-PARTICIPANT', + rsvp: 'TRUE' }, { - cn: "Charlie", - cal_address: "charlie@example.com", - partstat: "NEEDS-ACTION", - cutype: "INDIVIDUAL", - role: "REQ-PARTICIPANT", - rsvp: "TRUE", - }, - ], + cn: 'Charlie', + cal_address: 'charlie@example.com', + partstat: 'NEEDS-ACTION', + cutype: 'INDIVIDUAL', + role: 'REQ-PARTICIPANT', + rsvp: 'TRUE' + } + ] } as any, undefined, false - ); + ) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => - props.some(([k]: any) => k === "recurrence-id") - ); + props.some(([k]: any) => k === 'recurrence-id') + ) - const attendees = override[1].filter(([k]: any) => k === "attendee"); - expect(attendees).toHaveLength(2); - expect(attendees[0][3]).toBe("mailto:bob@example.com"); - expect(attendees[1][3]).toBe("mailto:charlie@example.com"); - }); + const attendees = override[1].filter(([k]: any) => k === 'attendee') + expect(attendees).toHaveLength(2) + expect(attendees[0][3]).toBe('mailto:bob@example.com') + expect(attendees[1][3]).toBe('mailto:charlie@example.com') + }) - it("propagates x-openpaas-videoconference changes", async () => { + it('propagates x-openpaas-videoconference changes', async () => { const icsWithVideo = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT @@ -548,34 +546,34 @@ X-OPENPAAS-VIDEOCONFERENCE:https://meet.old.com SEQUENCE:1 END:VEVENT END:VCALENDAR -`; +` - (api.get as jest.Mock).mockResolvedValueOnce({ - text: jest.fn().mockResolvedValue(icsWithVideo), - }); + ;(api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsWithVideo) + }) await updateSeries( { ...mockEvent, - x_openpass_videoconference: "https://meet.new.com", + x_openpass_videoconference: 'https://meet.new.com' } as any, undefined, false - ); + ) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => - props.some(([k]: any) => k === "recurrence-id") - ); + props.some(([k]: any) => k === 'recurrence-id') + ) const videoconf = override[1].find( - ([k]: any) => k === "x-openpaas-videoconference" - ); - expect(videoconf[3]).toBe("https://meet.new.com"); - }); + ([k]: any) => k === 'x-openpaas-videoconference' + ) + expect(videoconf[3]).toBe('https://meet.new.com') + }) - it("works with multiple override instances", async () => { + it('works with multiple override instances', async () => { const icsMultipleOverrides = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT @@ -603,34 +601,34 @@ SUMMARY:Old SEQUENCE:1 END:VEVENT END:VCALENDAR -`; +` - (api.get as jest.Mock).mockResolvedValueOnce({ - text: jest.fn().mockResolvedValue(icsMultipleOverrides), - }); + ;(api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsMultipleOverrides) + }) await updateSeries( - { ...mockEvent, title: "New title" } as any, + { ...mockEvent, title: 'New title' } as any, undefined, false - ); + ) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const overrides = vevents.filter(([, props]: any) => - props.some(([k]: any) => k === "recurrence-id") - ); + props.some(([k]: any) => k === 'recurrence-id') + ) - expect(overrides).toHaveLength(2); - overrides.forEach((override) => { - const summary = override[1].find(([k]: any) => k === "summary"); - const sequence = override[1].find(([k]: any) => k === "sequence"); - expect(summary[3]).toBe("New title"); - expect(sequence[3]).toBe(2); - }); - }); + expect(overrides).toHaveLength(2) + overrides.forEach(override => { + const summary = override[1].find(([k]: any) => k === 'summary') + const sequence = override[1].find(([k]: any) => k === 'sequence') + expect(summary[3]).toBe('New title') + expect(sequence[3]).toBe(2) + }) + }) - it("adds sequence field when missing and metadata changes", async () => { + it('adds sequence field when missing and metadata changes', async () => { const icsNoSequence = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT @@ -648,29 +646,29 @@ DTEND:20240202T110000Z SUMMARY:Old END:VEVENT END:VCALENDAR -`; +` - (api.get as jest.Mock).mockResolvedValueOnce({ - text: jest.fn().mockResolvedValue(icsNoSequence), - }); + ;(api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsNoSequence) + }) await updateSeries( - { ...mockEvent, title: "New title" } as any, + { ...mockEvent, title: 'New title' } as any, undefined, false - ); + ) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => - props.some(([k]: any) => k === "recurrence-id") - ); + props.some(([k]: any) => k === 'recurrence-id') + ) - const sequence = override[1].find(([k]: any) => k === "sequence"); - expect(sequence[3]).toBe(1); - }); + const sequence = override[1].find(([k]: any) => k === 'sequence') + expect(sequence[3]).toBe(1) + }) - it("preserves override-specific fields when propagating metadata", async () => { + it('preserves override-specific fields when propagating metadata', async () => { const icsCustomFields = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT @@ -691,41 +689,39 @@ DESCRIPTION:custom-value SEQUENCE:1 END:VEVENT END:VCALENDAR -`; +` - (api.get as jest.Mock).mockResolvedValueOnce({ - text: jest.fn().mockResolvedValue(icsCustomFields), - }); + ;(api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsCustomFields) + }) await updateSeries( - { ...mockEvent, title: "New title" } as any, + { ...mockEvent, title: 'New title' } as any, undefined, false - ); + ) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => - props.some(([k]: any) => k === "recurrence-id") - ); + props.some(([k]: any) => k === 'recurrence-id') + ) - const summary = override[1].find(([k]: any) => k === "summary"); - const dtstart = override[1].find(([k]: any) => k === "dtstart"); - const dtend = override[1].find(([k]: any) => k === "dtend"); - const description = override[1].find(([k]: any) => k === "description"); - const recurrenceId = override[1].find( - ([k]: any) => k === "recurrence-id" - ); + const summary = override[1].find(([k]: any) => k === 'summary') + const dtstart = override[1].find(([k]: any) => k === 'dtstart') + const dtend = override[1].find(([k]: any) => k === 'dtend') + const description = override[1].find(([k]: any) => k === 'description') + const recurrenceId = override[1].find(([k]: any) => k === 'recurrence-id') // Summary should be updated - expect(summary[3]).toBe("New title"); + expect(summary[3]).toBe('New title') // Override-specific time should be preserved - expect(dtstart[3]).toBe("2024-02-02T14:00:00Z"); - expect(dtend[3]).toBe("2024-02-02T15:00:00Z"); + expect(dtstart[3]).toBe('2024-02-02T14:00:00Z') + expect(dtend[3]).toBe('2024-02-02T15:00:00Z') // Custom field should be preserved - expect(description[3]).toBe("custom-value"); + expect(description[3]).toBe('custom-value') // Recurrence-id should be preserved - expect(recurrenceId[3]).toBe("2024-02-02T10:00:00Z"); - }); + expect(recurrenceId[3]).toBe('2024-02-02T10:00:00Z') + }) const recurringWithAlarmICS = `BEGIN:VCALENDAR VERSION:2.0 @@ -750,33 +746,33 @@ TRIGGER:-PT15M END:VALARM END:VEVENT END:VCALENDAR -`; +` - it("replaces VALARM on overrides when alarm changes", async () => { - (api.get as jest.Mock).mockResolvedValueOnce({ - text: jest.fn().mockResolvedValue(recurringWithAlarmICS), - }); + it('replaces VALARM on overrides when alarm changes', async () => { + ;(api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(recurringWithAlarmICS) + }) await updateSeries( - { ...mockEvent, alarm: { action: "EMAIL", trigger: "-PT30M" } } as any, + { ...mockEvent, alarm: { action: 'EMAIL', trigger: '-PT30M' } } as any, undefined, false - ); + ) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => - props.some(([k]: any) => k === "recurrence-id") - ); + props.some(([k]: any) => k === 'recurrence-id') + ) - const alarms = override[2].filter(([n]: any) => n === "valarm"); + const alarms = override[2].filter(([n]: any) => n === 'valarm') - expect(alarms).toHaveLength(1); - const action = alarms[0][1].find(([k]: any) => k === "action"); - const trigger = alarms[0][1].find(([k]: any) => k === "trigger"); - expect(action[3]).toBe("EMAIL"); - expect(trigger[3]).toBe("-PT30M"); - }); - }); -}); + expect(alarms).toHaveLength(1) + const action = alarms[0][1].find(([k]: any) => k === 'action') + const trigger = alarms[0][1].find(([k]: any) => k === 'trigger') + expect(action[3]).toBe('EMAIL') + expect(trigger[3]).toBe('-PT30M') + }) + }) +}) diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 829f0d6..8c17602 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -1,380 +1,380 @@ -import * as appHooks from "@/app/hooks"; -import { AppDispatch } from "@/app/store"; -import { InfoRow } from "@/components/Event/InfoRow"; +import * as appHooks from '@/app/hooks' +import { AppDispatch } from '@/app/store' +import { InfoRow } from '@/components/Event/InfoRow' import { stringAvatar, - stringToColor, -} from "@/components/Event/utils/eventUtils"; -import { DelegationAccess } from "@/features/Calendars/CalendarTypes"; -import * as eventThunks from "@/features/Calendars/services"; -import EventPreviewModal from "@/features/Events/EventPreview"; -import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; + stringToColor +} from '@/components/Event/utils/eventUtils' +import { DelegationAccess } from '@/features/Calendars/CalendarTypes' +import * as eventThunks from '@/features/Calendars/services' +import EventPreviewModal from '@/features/Events/EventPreview' +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../../utils/Renderwithproviders' -describe("Event Preview Display", () => { - const mockOnClose = jest.fn(); - const day = new Date("2025-01-15T10:00:00.000Z"); // Fixed date in UTC: Jan 15, 2025 10:00 AM UTC +describe('Event Preview Display', () => { + const mockOnClose = jest.fn() + const day = new Date('2025-01-15T10:00:00.000Z') // Fixed date in UTC: Jan 15, 2025 10:00 AM UTC beforeEach(() => { - jest.clearAllMocks(); - sessionStorage.clear(); - window.MAIL_SPA_URL = null; - }); + jest.clearAllMocks() + sessionStorage.clear() + window.MAIL_SPA_URL = null + }) const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro', + openpaasId: '667037022b752d0026472254' }, organiserData: { - cn: "test", - cal_address: "test@test.com", - }, + cn: 'test', + cal_address: 'test@test.com' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "Calendar", - color: "#FF0000", + '667037022b752d0026472254/cal1': { + id: '667037022b752d0026472254/cal1', + name: 'Calendar', + color: '#FF0000', events: { event1: { - uid: "event1", - title: "Test Event", - calId: "667037022b752d0026472254/cal1", + uid: 'event1', + title: 'Test Event', + calId: '667037022b752d0026472254/cal1', start: day.toISOString(), end: day.toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, + organizer: { cn: 'test', cal_address: 'test@test.com' }, attendee: [ { - cn: "test", - cal_address: "test@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'test', + cal_address: 'test@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "John", - cal_address: "john@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'John', + cal_address: 'john@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Projector Room", - cal_address: "room1@test.com", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "RESOURCE", - }, - ], + cn: 'Projector Room', + cal_address: 'room1@test.com', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'RESOURCE' + } + ] }, event2: { - uid: "event2", - title: "Test Event", - calId: "667037022b752d0026472254/cal1", + uid: 'event2', + title: 'Test Event', + calId: '667037022b752d0026472254/cal1', start: day.toISOString(), end: day.toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, + organizer: { cn: 'test', cal_address: 'test@test.com' } }, event3: { - uid: "event3", - calId: "667037022b752d0026472254/cal1", + uid: 'event3', + calId: '667037022b752d0026472254/cal1', start: day.toISOString(), end: day.toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, + organizer: { cn: 'test', cal_address: 'test@test.com' } }, event4: { - uid: "event4", - calId: "667037022b752d0026472254/cal1", + uid: 'event4', + calId: '667037022b752d0026472254/cal1', start: day.toISOString(), - end: day.toISOString(), - }, + end: day.toISOString() + } }, - owner: { emails: ["test@test.com"] }, + owner: { emails: ['test@test.com'] } }, - "otherCal/cal": { - id: "otherCal/cal", - name: "Calendar 1", - color: "#FF0000", - owner: { emails: ["other@test.com"] }, + 'otherCal/cal': { + id: 'otherCal/cal', + name: 'Calendar 1', + color: '#FF0000', + owner: { emails: ['other@test.com'] }, events: { event1: { - uid: "event1", - calId: "otherCal/cal", - title: "Test Event Other cal", + uid: 'event1', + calId: 'otherCal/cal', + title: 'Test Event Other cal', start: day.toISOString(), end: day.toISOString(), - organizer: { cn: "john", cal_address: "john@test.com" }, + organizer: { cn: 'john', cal_address: 'john@test.com' } }, event2: { - uid: "event2", - calId: "otherCal/cal", + uid: 'event2', + calId: 'otherCal/cal', start: day.toISOString(), - end: day.toISOString(), - }, - }, - }, + end: day.toISOString() + } + } + } }, - pending: false, - }, - }; + pending: false + } + } - it("renders correctly event data", () => { - const originalToLocaleString = Date.prototype.toLocaleString; + it('renders correctly event data', () => { + const originalToLocaleString = Date.prototype.toLocaleString - jest.spyOn(Date.prototype, "toLocaleString").mockImplementation(function ( + jest.spyOn(Date.prototype, 'toLocaleString').mockImplementation(function ( this: Date, locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions ) { - return originalToLocaleString.call(this, "en-US", options); - }); - const originalToLocaleTimeString = Date.prototype.toLocaleTimeString; + return originalToLocaleString.call(this, 'en-US', options) + }) + const originalToLocaleTimeString = Date.prototype.toLocaleTimeString jest - .spyOn(Date.prototype, "toLocaleTimeString") + .spyOn(Date.prototype, 'toLocaleTimeString') .mockImplementation(function ( this: Date, locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions ) { - return originalToLocaleTimeString.call(this, "en-US", options); - }); + return originalToLocaleTimeString.call(this, 'en-US', options) + }) renderWithProviders( , preloadedState - ); + ) // With UTC timezone, we can assert exact values // Date: January 15, 2025 10:00 AM UTC - expect(screen.getByText("Test Event")).toBeInTheDocument(); - expect(screen.getByText(/Wednesday/i)).toBeInTheDocument(); - expect(screen.getByText(/January/i)).toBeInTheDocument(); - expect(screen.getByText(/15/)).toBeInTheDocument(); + expect(screen.getByText('Test Event')).toBeInTheDocument() + expect(screen.getByText(/Wednesday/i)).toBeInTheDocument() + expect(screen.getByText(/January/i)).toBeInTheDocument() + expect(screen.getByText(/15/)).toBeInTheDocument() // Check time is displayed in 24h format (no AM/PM) // Format: "Wednesday, January 15, 2025 at 10:00" and " – 10:00" are in separate elements expect( screen.getByText(/Wednesday, January 15, 2025 at 10:00/) - ).toBeInTheDocument(); - expect(screen.getByText(/– 10:00/)).toBeInTheDocument(); + ).toBeInTheDocument() + expect(screen.getByText(/– 10:00/)).toBeInTheDocument() - expect(screen.getByText("Calendar")).toBeInTheDocument(); + expect(screen.getByText('Calendar')).toBeInTheDocument() - expect(screen.getByText("Projector Room")).toBeInTheDocument(); - }); - it("calls onClose when Cancel clicked", () => { + expect(screen.getByText('Projector Room')).toBeInTheDocument() + }) + it('calls onClose when Cancel clicked', () => { renderWithProviders( , preloadedState - ); - fireEvent.click(screen.getByTestId("CloseIcon")); + ) + fireEvent.click(screen.getByTestId('CloseIcon')) - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); - }); - it("Shows delete button only when calendar is own", () => { + expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick') + }) + it('Shows delete button only when calendar is own', () => { // Renders the other cal event renderWithProviders( , preloadedState - ); - const moreButton = screen.getByTestId("MoreVertIcon"); + ) + const moreButton = screen.getByTestId('MoreVertIcon') if (moreButton) { expect( - screen.queryByText("eventPreview.deleteEvent") - ).not.toBeInTheDocument(); + screen.queryByText('eventPreview.deleteEvent') + ).not.toBeInTheDocument() } - cleanup(); + cleanup() // Renders the personal cal event renderWithProviders( , preloadedState - ); - fireEvent.click(screen.getByTestId("MoreVertIcon")); - expect(screen.queryByText("eventPreview.deleteEvent")).toBeInTheDocument(); - }); - it("calls delete when Delete clicked", async () => { + ) + fireEvent.click(screen.getByTestId('MoreVertIcon')) + expect(screen.queryByText('eventPreview.deleteEvent')).toBeInTheDocument() + }) + it('calls delete when Delete clicked', async () => { renderWithProviders( , preloadedState - ); + ) const spy = jest - .spyOn(eventThunks, "deleteEventAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); - fireEvent.click(screen.getByTestId("MoreVertIcon")); + .spyOn(eventThunks, 'deleteEventAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) + fireEvent.click(screen.getByTestId('MoreVertIcon')) fireEvent.click( - screen.getByRole("menuitem", { name: "eventPreview.deleteEvent" }) - ); + screen.getByRole('menuitem', { name: 'eventPreview.deleteEvent' }) + ) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const receivedPayload = spy.mock.calls[0][0]; + const receivedPayload = spy.mock.calls[0][0] expect(receivedPayload).toEqual({ - calId: "667037022b752d0026472254/cal1", - eventId: "event1", - }); + calId: '667037022b752d0026472254/cal1', + eventId: 'event1' + }) - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); - }); - it("renders RSVP buttons when user is an attendee", () => { + expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick') + }) + it('renders RSVP buttons when user is an attendee', () => { const rsvpStateIsOrga = { ...preloadedState, calendars: { ...preloadedState.calendars, list: { ...preloadedState.calendars.list, - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + '667037022b752d0026472254/cal1': { + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'], events: { event1: { ...preloadedState.calendars.list[ - "667037022b752d0026472254/cal1" + '667037022b752d0026472254/cal1' ].events.event1, attendee: [ { - cal_address: "test@test.com", - cn: "Test User", - partstat: "NEEDS-ACTION", + cal_address: 'test@test.com', + cn: 'Test User', + partstat: 'NEEDS-ACTION' }, { - cal_address: "organizer@test.com", - cn: "Test Organizer", - partstat: "NEEDS-ACTION", - }, + cal_address: 'organizer@test.com', + cn: 'Test Organizer', + partstat: 'NEEDS-ACTION' + } ], organizer: { - cal_address: "organizer@test.com", - }, - }, - }, - }, - }, - }, - }; + cal_address: 'organizer@test.com' + } + } + } + } + } + } + } renderWithProviders( , rsvpStateIsOrga - ); + ) expect( - screen.getByText("eventPreview.attendingQuestion") - ).toBeInTheDocument(); + screen.getByText('eventPreview.attendingQuestion') + ).toBeInTheDocument() expect( - screen.getByRole("button", { name: "eventPreview.ACCEPTED" }) - ).toBeInTheDocument(); + screen.getByRole('button', { name: 'eventPreview.ACCEPTED' }) + ).toBeInTheDocument() expect( - screen.getByRole("button", { name: "eventPreview.TENTATIVE" }) - ).toBeInTheDocument(); + screen.getByRole('button', { name: 'eventPreview.TENTATIVE' }) + ).toBeInTheDocument() expect( - screen.getByRole("button", { name: "eventPreview.DECLINED" }) - ).toBeInTheDocument(); - }); - it("doesnt renders RSVP buttons when user isnt an attendee", () => { + screen.getByRole('button', { name: 'eventPreview.DECLINED' }) + ).toBeInTheDocument() + }) + it('doesnt renders RSVP buttons when user isnt an attendee', () => { const rsvpStateIsOrga = { ...preloadedState, calendars: { ...preloadedState.calendars, list: { ...preloadedState.calendars.list, - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + '667037022b752d0026472254/cal1': { + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'], events: { event1: { ...preloadedState.calendars.list[ - "667037022b752d0026472254/cal1" + '667037022b752d0026472254/cal1' ].events.event1, attendee: [ { - cal_address: "organizer@test.com", - cn: "Test Organizer", - partstat: "NEEDS-ACTION", - }, + cal_address: 'organizer@test.com', + cn: 'Test Organizer', + partstat: 'NEEDS-ACTION' + } ], organizer: { - cal_address: "organizer@test.com", - }, - }, - }, - }, - }, - }, - }; + cal_address: 'organizer@test.com' + } + } + } + } + } + } + } renderWithProviders( , rsvpStateIsOrga - ); + ) expect( - screen.queryByText("eventPreview.attendingQuestion") - ).not.toBeInTheDocument(); + screen.queryByText('eventPreview.attendingQuestion') + ).not.toBeInTheDocument() expect( - screen.queryByRole("button", { name: "eventPreview.ACCEPTED" }) - ).not.toBeInTheDocument(); + screen.queryByRole('button', { name: 'eventPreview.ACCEPTED' }) + ).not.toBeInTheDocument() expect( - screen.queryByRole("button", { name: "eventPreview.TENTATIVE" }) - ).not.toBeInTheDocument(); + screen.queryByRole('button', { name: 'eventPreview.TENTATIVE' }) + ).not.toBeInTheDocument() expect( - screen.queryByRole("button", { name: "eventPreview.DECLINED" }) - ).not.toBeInTheDocument(); - }); + screen.queryByRole('button', { name: 'eventPreview.DECLINED' }) + ).not.toBeInTheDocument() + }) - it("handles RSVP Accept click", async () => { + it('handles RSVP Accept click', async () => { const spy = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) const rsvpState = { ...preloadedState, @@ -382,60 +382,60 @@ describe("Event Preview Display", () => { ...preloadedState.calendars, list: { ...preloadedState.calendars.list, - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + '667037022b752d0026472254/cal1': { + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'], events: { event1: { ...preloadedState.calendars.list[ - "667037022b752d0026472254/cal1" + '667037022b752d0026472254/cal1' ].events.event1, attendee: [ { - cal_address: "test@test.com", - cn: "Test User", - partstat: "NEEDS-ACTION", - }, + cal_address: 'test@test.com', + cn: 'Test User', + partstat: 'NEEDS-ACTION' + } ], organizer: { - cal_address: "organizer@test.com", - }, - }, - }, - }, - }, - }, - }; + cal_address: 'organizer@test.com' + } + } + } + } + } + } + } renderWithProviders( , rsvpState - ); + ) fireEvent.click( - screen.getByRole("button", { name: "eventPreview.ACCEPTED" }) - ); + screen.getByRole('button', { name: 'eventPreview.ACCEPTED' }) + ) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const updatedEvent = spy.mock.calls[0][0].newEvent; - expect(updatedEvent.attendee[0].partstat).toBe("ACCEPTED"); - }); + const updatedEvent = spy.mock.calls[0][0].newEvent + expect(updatedEvent.attendee[0].partstat).toBe('ACCEPTED') + }) - it("handles RSVP Maybe click", async () => { + it('handles RSVP Maybe click', async () => { const spy = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) const rsvpState = { ...preloadedState, @@ -443,60 +443,60 @@ describe("Event Preview Display", () => { ...preloadedState.calendars, list: { ...preloadedState.calendars.list, - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + '667037022b752d0026472254/cal1': { + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'], events: { event1: { ...preloadedState.calendars.list[ - "667037022b752d0026472254/cal1" + '667037022b752d0026472254/cal1' ].events.event1, attendee: [ { - cal_address: "test@test.com", - cn: "Test User", - partstat: "NEEDS-ACTION", - }, + cal_address: 'test@test.com', + cn: 'Test User', + partstat: 'NEEDS-ACTION' + } ], organizer: { - cal_address: "organizer@test.com", - }, - }, - }, - }, - }, - }, - }; + cal_address: 'organizer@test.com' + } + } + } + } + } + } + } renderWithProviders( , rsvpState - ); + ) fireEvent.click( - screen.getByRole("button", { name: "eventPreview.TENTATIVE" }) - ); + screen.getByRole('button', { name: 'eventPreview.TENTATIVE' }) + ) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const updatedEvent = spy.mock.calls[0][0].newEvent; - expect(updatedEvent.attendee[0].partstat).toBe("TENTATIVE"); - }); + const updatedEvent = spy.mock.calls[0][0].newEvent + expect(updatedEvent.attendee[0].partstat).toBe('TENTATIVE') + }) - it("handles RSVP Decline click", async () => { + it('handles RSVP Decline click', async () => { const spy = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) const rsvpState = { ...preloadedState, @@ -504,176 +504,176 @@ describe("Event Preview Display", () => { ...preloadedState.calendars, list: { ...preloadedState.calendars.list, - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + '667037022b752d0026472254/cal1': { + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'], events: { event1: { ...preloadedState.calendars.list[ - "667037022b752d0026472254/cal1" + '667037022b752d0026472254/cal1' ].events.event1, attendee: [ { - cal_address: "test@test.com", - cn: "Test User", - partstat: "NEEDS-ACTION", - }, + cal_address: 'test@test.com', + cn: 'Test User', + partstat: 'NEEDS-ACTION' + } ], organizer: { - cal_address: "organizer@test.com", - }, - }, - }, - }, - }, - }, - }; + cal_address: 'organizer@test.com' + } + } + } + } + } + } + } renderWithProviders( , rsvpState - ); + ) fireEvent.click( - screen.getByRole("button", { name: "eventPreview.DECLINED" }) - ); + screen.getByRole('button', { name: 'eventPreview.DECLINED' }) + ) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const updatedEvent = spy.mock.calls[0][0].newEvent; - expect(updatedEvent.attendee[0].partstat).toBe("DECLINED"); - }); - it("handles Edit click when is own but with no organizer", async () => { + const updatedEvent = spy.mock.calls[0][0].newEvent + expect(updatedEvent.attendee[0].partstat).toBe('DECLINED') + }) + it('handles Edit click when is own but with no organizer', async () => { renderWithProviders( , preloadedState - ); + ) - fireEvent.click(screen.getByTestId("EditIcon")); + fireEvent.click(screen.getByTestId('EditIcon')) await waitFor(() => { - expect(screen.getByText("event.updateEvent")).toBeInTheDocument(); - }); - }); + expect(screen.getByText('event.updateEvent')).toBeInTheDocument() + }) + }) - it("doesnt show edit button when is not own and with no organizer", async () => { + it('doesnt show edit button when is not own and with no organizer', async () => { renderWithProviders( , preloadedState - ); + ) await waitFor(() => { - expect(screen.queryByTestId("EditIcon")).not.toBeInTheDocument(); - }); - }); - it("handles Edit click", async () => { + expect(screen.queryByTestId('EditIcon')).not.toBeInTheDocument() + }) + }) + it('handles Edit click', async () => { renderWithProviders( , preloadedState - ); + ) - fireEvent.click(screen.getByTestId("EditIcon")); + fireEvent.click(screen.getByTestId('EditIcon')) await waitFor(() => { - expect(screen.getByText("event.updateEvent")).toBeInTheDocument(); - }); - }); - it("properly render message button when MAIL_SPA_URL is not null and event has attendees", () => { - window.MAIL_SPA_URL = "test"; + expect(screen.getByText('event.updateEvent')).toBeInTheDocument() + }) + }) + it('properly render message button when MAIL_SPA_URL is not null and event has attendees', () => { + window.MAIL_SPA_URL = 'test' renderWithProviders( , preloadedState - ); - fireEvent.click(screen.getByTestId("MoreVertIcon")); - expect(screen.getByText("eventPreview.emailAttendees")).toBeInTheDocument(); - }); - it("doesnt render message button when MAIL_SPA_URL is not null and event has no attendees", () => { - window.MAIL_SPA_URL = "test"; + ) + fireEvent.click(screen.getByTestId('MoreVertIcon')) + expect(screen.getByText('eventPreview.emailAttendees')).toBeInTheDocument() + }) + it('doesnt render message button when MAIL_SPA_URL is not null and event has no attendees', () => { + window.MAIL_SPA_URL = 'test' renderWithProviders( , preloadedState - ); - expect(screen.queryByTestId("EmailIcon")).not.toBeInTheDocument(); - }); - it("doesnt render message button when MAIL_SPA_URL is null and event has attendees", () => { + ) + expect(screen.queryByTestId('EmailIcon')).not.toBeInTheDocument() + }) + it('doesnt render message button when MAIL_SPA_URL is null and event has attendees', () => { renderWithProviders( , preloadedState - ); - expect(screen.queryByTestId("EmailIcon")).not.toBeInTheDocument(); - }); - it("message button opens url with attendees as uri and title as subject", () => { - window.MAIL_SPA_URL = "test"; - const mockOpen = jest.fn(); - window.open = mockOpen; + ) + expect(screen.queryByTestId('EmailIcon')).not.toBeInTheDocument() + }) + it('message button opens url with attendees as uri and title as subject', () => { + window.MAIL_SPA_URL = 'test' + const mockOpen = jest.fn() + window.open = mockOpen renderWithProviders( , preloadedState - ); + ) - fireEvent.click(screen.getByTestId("MoreVertIcon")); - const emailButton = screen.getByRole("menuitem", { - name: "eventPreview.emailAttendees", - }); - expect(emailButton).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('MoreVertIcon')) + const emailButton = screen.getByRole('menuitem', { + name: 'eventPreview.emailAttendees' + }) + expect(emailButton).toBeInTheDocument() - fireEvent.click(emailButton); + fireEvent.click(emailButton) - const expectedUrl = `test/mailto/?uri=mailto%3Ajohn%40test.com&subject=Test%20Event`; + const expectedUrl = `test/mailto/?uri=mailto%3Ajohn%40test.com&subject=Test%20Event` expect(mockOpen).toHaveBeenCalledWith( expectedUrl, - "_blank", - "noopener,noreferrer" - ); - }); + '_blank', + 'noopener,noreferrer' + ) + }) - it("message button encodes special characters in event title correctly", () => { - window.MAIL_SPA_URL = "test"; - const mockOpen = jest.fn(); - window.open = mockOpen; + it('message button encodes special characters in event title correctly', () => { + window.MAIL_SPA_URL = 'test' + const mockOpen = jest.fn() + window.open = mockOpen const specialCharState = { ...preloadedState, @@ -681,82 +681,82 @@ describe("Event Preview Display", () => { ...preloadedState.calendars, list: { ...preloadedState.calendars.list, - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + '667037022b752d0026472254/cal1': { + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'], events: { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"] + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'] .events, eventWithSpecialChars: { - uid: "eventWithSpecialChars", - title: "Meeting & Discussion? #Important", - calId: "667037022b752d0026472254/cal1", + uid: 'eventWithSpecialChars', + title: 'Meeting & Discussion? #Important', + calId: '667037022b752d0026472254/cal1', start: day.toISOString(), end: day.toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, + organizer: { cn: 'test', cal_address: 'test@test.com' }, attendee: [ { - cn: "test", - cal_address: "test@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'test', + cal_address: 'test@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "John", - cal_address: "john@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ], - }, - }, - }, - }, - }, - }; + cn: 'John', + cal_address: 'john@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] + } + } + } + } + } + } renderWithProviders( , specialCharState - ); + ) - fireEvent.click(screen.getByTestId("MoreVertIcon")); - const emailButton = screen.getByRole("menuitem", { - name: "eventPreview.emailAttendees", - }); - expect(emailButton).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('MoreVertIcon')) + const emailButton = screen.getByRole('menuitem', { + name: 'eventPreview.emailAttendees' + }) + expect(emailButton).toBeInTheDocument() - fireEvent.click(emailButton); + fireEvent.click(emailButton) - const expectedUrl = `test/mailto/?uri=mailto%3Ajohn%40test.com&subject=Meeting%20%26%20Discussion%3F%20%23Important`; + const expectedUrl = `test/mailto/?uri=mailto%3Ajohn%40test.com&subject=Meeting%20%26%20Discussion%3F%20%23Important` expect(mockOpen).toHaveBeenCalledWith( expectedUrl, - "_blank", - "noopener,noreferrer" - ); - }); + '_blank', + 'noopener,noreferrer' + ) + }) - describe("Owner Email Permissions", () => { - const today = new Date(); - const start = new Date(today); - start.setHours(10, 0, 0, 0); - const end = new Date(today); - end.setHours(11, 0, 0, 0); + describe('Owner Email Permissions', () => { + const today = new Date() + const start = new Date(today) + start.setHours(10, 0, 0, 0) + const end = new Date(today) + end.setHours(11, 0, 0, 0) beforeEach(() => { - jest.clearAllMocks(); - const dispatch = jest.fn() as AppDispatch; - jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch); - }); + jest.clearAllMocks() + const dispatch = jest.fn() as AppDispatch + jest.spyOn(appHooks, 'useAppDispatch').mockReturnValue(dispatch) + }) const createPreloadedState = ( userEmail: string, @@ -765,60 +765,60 @@ describe("Event Preview Display", () => { ) => ({ user: { userData: { - sub: "test", + sub: 'test', email: userEmail, - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, tokens: { - accessToken: "token", - }, + accessToken: 'token' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - name: "Calendar 1", - id: "667037022b752d0026472254/cal1", - color: "#FF0000", + '667037022b752d0026472254/cal1': { + name: 'Calendar 1', + id: '667037022b752d0026472254/cal1', + color: '#FF0000', owner: { emails: ownerEmails }, events: { event1: { - uid: "event1", - calId: "667037022b752d0026472254/cal1", - title: "Test Event", + uid: 'event1', + calId: '667037022b752d0026472254/cal1', + title: 'Test Event', start: start.toISOString(), end: end.toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Organizer", - cal_address: organizerEmail, + cn: 'Organizer', + cal_address: organizerEmail }, attendee: [ { - cn: "Organizer", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: organizerEmail, - }, - ], - }, - }, - }, + cn: 'Organizer', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: organizerEmail + } + ] + } + } + } }, - pending: false, - }, - }); + pending: false + } + }) - it("should show edit button when user email matches organizer and is in ownerEmails", async () => { + it('should show edit button when user email matches organizer and is in ownerEmails', async () => { const preloadedState = createPreloadedState( - "owner@test.com", - ["owner@test.com"], - "owner@test.com" - ); + 'owner@test.com', + ['owner@test.com'], + 'owner@test.com' + ) - const mockOnClose = jest.fn(); + const mockOnClose = jest.fn() renderWithProviders( { onClose={mockOnClose} />, preloadedState - ); + ) await waitFor(() => { - const editButton = screen.queryByTestId("EditIcon"); - expect(editButton).toBeInTheDocument(); - }); - }); + const editButton = screen.queryByTestId('EditIcon') + expect(editButton).toBeInTheDocument() + }) + }) - it("should NOT show edit button when user is organizer but not in owner of calendar", async () => { + it('should NOT show edit button when user is organizer but not in owner of calendar', async () => { const preloadedState = createPreloadedState( - "organizer@test.com", - ["other@test.com"], // organizer not in list - "organizer@test.com" - ); + 'organizer@test.com', + ['other@test.com'], // organizer not in list + 'organizer@test.com' + ) - const mockOnClose = jest.fn(); + const mockOnClose = jest.fn() renderWithProviders( { onClose={mockOnClose} />, preloadedState - ); + ) await waitFor(() => { - const editButton = screen.queryByTestId("EditIcon"); - expect(editButton).not.toBeInTheDocument(); - }); - }); + const editButton = screen.queryByTestId('EditIcon') + expect(editButton).not.toBeInTheDocument() + }) + }) - it("should NOT show edit button when user is in ownerEmails but not organizer", async () => { + it('should NOT show edit button when user is in ownerEmails but not organizer', async () => { const preloadedState = createPreloadedState( - "owner@test.com", - ["owner@test.com"], - "other@test.com" // different organizer - ); + 'owner@test.com', + ['owner@test.com'], + 'other@test.com' // different organizer + ) - const mockOnClose = jest.fn(); + const mockOnClose = jest.fn() renderWithProviders( { onClose={mockOnClose} />, preloadedState - ); + ) await waitFor(() => { - const editButton = screen.queryByTestId("EditIcon"); - expect(editButton).not.toBeInTheDocument(); - }); - }); + const editButton = screen.queryByTestId('EditIcon') + expect(editButton).not.toBeInTheDocument() + }) + }) - it("should handle calendars without ownerEmails property gracefully", async () => { + it('should handle calendars without ownerEmails property gracefully', async () => { const preloadedStateWithoutOwnerEmails = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, tokens: { - accessToken: "token", - }, + accessToken: 'token' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - name: "Calendar 1", - id: "667037022b752d0026472254/cal1", - color: "#FF0000", + '667037022b752d0026472254/cal1': { + name: 'Calendar 1', + id: '667037022b752d0026472254/cal1', + color: '#FF0000', owner: {}, events: { event1: { - calId: "667037022b752d0026472254/cal1", - uid: "event1", - title: "Test Event", + calId: '667037022b752d0026472254/cal1', + uid: 'event1', + title: 'Test Event', start: start.toISOString(), end: end.toISOString(), organizer: { - cn: "Test", - cal_address: "test@test.com", - }, - }, - }, - }, + cn: 'Test', + cal_address: 'test@test.com' + } + } + } + } }, - pending: false, - }, - }; + pending: false + } + } - const mockOnClose = jest.fn(); + const mockOnClose = jest.fn() expect(() => { renderWithProviders( { onClose={mockOnClose} />, preloadedStateWithoutOwnerEmails - ); - }).not.toThrow(); - }); - }); - it("renders correctly event data event with empty title", () => { + ) + }).not.toThrow() + }) + }) + it('renders correctly event data event with empty title', () => { renderWithProviders( , preloadedState - ); + ) - expect(screen.getByText("event.untitled")).toBeInTheDocument(); + expect(screen.getByText('event.untitled')).toBeInTheDocument() - expect(screen.getByText("Calendar")).toBeInTheDocument(); - }); + expect(screen.getByText('Calendar')).toBeInTheDocument() + }) - describe("Attendee Preview Display", () => { - const mockOnClose = jest.fn(); - const day = new Date("2025-01-15T10:00:00.000Z"); + describe('Attendee Preview Display', () => { + const mockOnClose = jest.fn() + const day = new Date('2025-01-15T10:00:00.000Z') beforeEach(() => { - jest.clearAllMocks(); - window.MAIL_SPA_URL = null; - }); + jest.clearAllMocks() + window.MAIL_SPA_URL = null + }) const createStateWithAttendees = (attendees: any[]) => ({ user: { userData: { - sub: "test", - email: "test@test.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro', + openpaasId: '667037022b752d0026472254' }, organiserData: { - cn: "test", - cal_address: "test@test.com", - }, + cn: 'test', + cal_address: 'test@test.com' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "Calendar", - color: "#FF0000", + '667037022b752d0026472254/cal1': { + id: '667037022b752d0026472254/cal1', + name: 'Calendar', + color: '#FF0000', events: { event1: { - uid: "event1", - title: "Test Event", - calId: "667037022b752d0026472254/cal1", + uid: 'event1', + title: 'Test Event', + calId: '667037022b752d0026472254/cal1', start: day.toISOString(), end: day.toISOString(), organizer: { - cn: "organizer", - cal_address: "organizer@test.com", + cn: 'organizer', + cal_address: 'organizer@test.com' }, - attendee: attendees, - }, + attendee: attendees + } }, - owner: { emails: ["test@test.com"] }, - }, + owner: { emails: ['test@test.com'] } + } }, - pending: false, - }, - }); + pending: false + } + }) - describe("Guest count display", () => { - it("displays correct guest count including organizer", () => { + describe('Guest count display', () => { + it('displays correct guest count including organizer', () => { const attendees = [ { - cn: "organizer", - cal_address: "organizer@test.com", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'organizer', + cal_address: 'organizer@test.com', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 1", - cal_address: "guest1@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 1', + cal_address: 'guest1@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 2", - cal_address: "guest2@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ]; + cn: 'Guest 2', + cal_address: 'guest2@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] - const state = createStateWithAttendees(attendees); + const state = createStateWithAttendees(attendees) renderWithProviders( , state - ); + ) // Should display "3 guests" (organizer + 2 guests) - expect(screen.getByText(/3/)).toBeInTheDocument(); - }); - }); + expect(screen.getByText(/3/)).toBeInTheDocument() + }) + }) - describe("All attendees with single status", () => { - it("displays only yes count when all attendees accepted", () => { + describe('All attendees with single status', () => { + it('displays only yes count when all attendees accepted', () => { const attendees = [ { - cn: "organizer", - cal_address: "organizer@test.com", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'organizer', + cal_address: 'organizer@test.com', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 1", - cal_address: "guest1@test.com", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 1', + cal_address: 'guest1@test.com', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 2", - cal_address: "guest2@test.com", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ]; + cn: 'Guest 2', + cal_address: 'guest2@test.com', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] - const state = createStateWithAttendees(attendees); + const state = createStateWithAttendees(attendees) renderWithProviders( , state - ); + ) expect( - screen.getByText("eventPreview.yesCount(count=3)") - ).toBeInTheDocument(); + screen.getByText('eventPreview.yesCount(count=3)') + ).toBeInTheDocument() expect( screen.queryByText(/eventPreview.maybeCount/i) - ).not.toBeInTheDocument(); + ).not.toBeInTheDocument() expect( screen.queryByText(/eventPreview.needActionCount/i) - ).not.toBeInTheDocument(); + ).not.toBeInTheDocument() expect( screen.queryByText(/eventPreview.noCount\b/i) - ).not.toBeInTheDocument(); - }); + ).not.toBeInTheDocument() + }) - it("displays only TENTATIVE count when all attendees tentative", () => { + it('displays only TENTATIVE count when all attendees tentative', () => { const attendees = [ { - cn: "organizer", - cal_address: "organizer@test.com", - partstat: "TENTATIVE", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'organizer', + cal_address: 'organizer@test.com', + partstat: 'TENTATIVE', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 1", - cal_address: "guest1@test.com", - partstat: "TENTATIVE", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ]; + cn: 'Guest 1', + cal_address: 'guest1@test.com', + partstat: 'TENTATIVE', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] - const state = createStateWithAttendees(attendees); + const state = createStateWithAttendees(attendees) renderWithProviders( , state - ); + ) expect( - screen.getByText("eventPreview.maybeCount(count=2)") - ).toBeInTheDocument(); + screen.getByText('eventPreview.maybeCount(count=2)') + ).toBeInTheDocument() expect( screen.queryByText(/eventPreview.yesCount/i) - ).not.toBeInTheDocument(); + ).not.toBeInTheDocument() expect( screen.queryByText(/eventPreview.needActionCount/i) - ).not.toBeInTheDocument(); + ).not.toBeInTheDocument() expect( screen.queryByText(/eventPreview.noCount\b/i) - ).not.toBeInTheDocument(); - }); + ).not.toBeInTheDocument() + }) - it("displays only no count when all attendees declined", () => { + it('displays only no count when all attendees declined', () => { const attendees = [ { - cn: "organizer", - cal_address: "organizer@test.com", - partstat: "DECLINED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'organizer', + cal_address: 'organizer@test.com', + partstat: 'DECLINED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 1", - cal_address: "guest1@test.com", - partstat: "DECLINED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 1', + cal_address: 'guest1@test.com', + partstat: 'DECLINED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 2", - cal_address: "guest2@test.com", - partstat: "DECLINED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 2', + cal_address: 'guest2@test.com', + partstat: 'DECLINED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 3", - cal_address: "guest3@test.com", - partstat: "DECLINED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ]; + cn: 'Guest 3', + cal_address: 'guest3@test.com', + partstat: 'DECLINED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] - const state = createStateWithAttendees(attendees); + const state = createStateWithAttendees(attendees) renderWithProviders( , state - ); + ) expect( - screen.getByText("eventPreview.noCount(count=4)") - ).toBeInTheDocument(); + screen.getByText('eventPreview.noCount(count=4)') + ).toBeInTheDocument() expect( screen.queryByText(/eventPreview.yesCount/i) - ).not.toBeInTheDocument(); + ).not.toBeInTheDocument() expect( screen.queryByText(/eventPreview.maybeCount/i) - ).not.toBeInTheDocument(); + ).not.toBeInTheDocument() expect( screen.queryByText(/eventPreview.needActionCount/i) - ).not.toBeInTheDocument(); - }); + ).not.toBeInTheDocument() + }) - it("displays only waiting count when all attendees need action", () => { + it('displays only waiting count when all attendees need action', () => { const attendees = [ { - cn: "organizer", - cal_address: "organizer@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'organizer', + cal_address: 'organizer@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 1", - cal_address: "guest1@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ]; + cn: 'Guest 1', + cal_address: 'guest1@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] - const state = createStateWithAttendees(attendees); + const state = createStateWithAttendees(attendees) renderWithProviders( , state - ); + ) expect( - screen.getByText("eventPreview.needActionCount(count=2)") - ).toBeInTheDocument(); + screen.getByText('eventPreview.needActionCount(count=2)') + ).toBeInTheDocument() expect( screen.queryByText(/eventPreview.yesCount/i) - ).not.toBeInTheDocument(); + ).not.toBeInTheDocument() expect( screen.queryByText(/eventPreview.maybeCount/i) - ).not.toBeInTheDocument(); + ).not.toBeInTheDocument() expect( screen.queryByText(/eventPreview.noCount/i) - ).not.toBeInTheDocument(); - }); - }); + ).not.toBeInTheDocument() + }) + }) - describe("No display when count is 0", () => { - it("does not display attendee preview when no attendees", () => { - const state = createStateWithAttendees([]); + describe('No display when count is 0', () => { + it('does not display attendee preview when no attendees', () => { + const state = createStateWithAttendees([]) renderWithProviders( , state - ); + ) expect( - screen.queryByTestId("PeopleAltOutlinedIcon") - ).not.toBeInTheDocument(); - expect(screen.queryByText(/guests/i)).not.toBeInTheDocument(); - }); - }); + screen.queryByTestId('PeopleAltOutlinedIcon') + ).not.toBeInTheDocument() + expect(screen.queryByText(/guests/i)).not.toBeInTheDocument() + }) + }) - describe("Regression tests: mixed statuses with correct counts", () => { - it("displays all statuses when attendees have mixed responses", () => { + describe('Regression tests: mixed statuses with correct counts', () => { + it('displays all statuses when attendees have mixed responses', () => { const attendees = [ { - cn: "organizer", - cal_address: "organizer@test.com", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'organizer', + cal_address: 'organizer@test.com', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 1", - cal_address: "guest1@test.com", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 1', + cal_address: 'guest1@test.com', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 2", - cal_address: "guest2@test.com", - partstat: "TENTATIVE", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 2', + cal_address: 'guest2@test.com', + partstat: 'TENTATIVE', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 3", - cal_address: "guest3@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 3', + cal_address: 'guest3@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 4", - cal_address: "guest4@test.com", - partstat: "DECLINED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ]; + cn: 'Guest 4', + cal_address: 'guest4@test.com', + partstat: 'DECLINED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] - const state = createStateWithAttendees(attendees); + const state = createStateWithAttendees(attendees) renderWithProviders( , state - ); + ) expect( screen.getByText(/eventPreview.yesCount\(count\=2\)/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( screen.getByText(/eventPreview.maybeCount\(count\=1\)/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( screen.getByText(/eventPreview.needActionCount\(count\=1\)/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( screen.getByText(/eventPreview.noCount\(count\=1\)/i) - ).toBeInTheDocument(); - }); + ).toBeInTheDocument() + }) - it("displays correct counts with multiple yes and TENTATIVE", () => { + it('displays correct counts with multiple yes and TENTATIVE', () => { const attendees = [ { - cn: "Guest 1", - cal_address: "guest1@test.com", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 1', + cal_address: 'guest1@test.com', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 2", - cal_address: "guest2@test.com", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 2', + cal_address: 'guest2@test.com', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 3", - cal_address: "guest3@test.com", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 3', + cal_address: 'guest3@test.com', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 4", - cal_address: "guest4@test.com", - partstat: "TENTATIVE", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 4', + cal_address: 'guest4@test.com', + partstat: 'TENTATIVE', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 5", - cal_address: "guest5@test.com", - partstat: "TENTATIVE", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ]; + cn: 'Guest 5', + cal_address: 'guest5@test.com', + partstat: 'TENTATIVE', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] - const state = createStateWithAttendees(attendees); + const state = createStateWithAttendees(attendees) renderWithProviders( , state - ); + ) expect( screen.getByText(/eventPreview\.yesCount\(count\=3\)/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( screen.getByText(/eventPreview\.maybeCount\(count\=2\)/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( screen.queryByText(/eventPreview.noCount/i) - ).not.toBeInTheDocument(); + ).not.toBeInTheDocument() expect( screen.queryByText(/eventPreview.needActionCount/i) - ).not.toBeInTheDocument(); - }); + ).not.toBeInTheDocument() + }) - it("displays correct counts with waiting and declined", () => { + it('displays correct counts with waiting and declined', () => { const attendees = [ { - cn: "Guest 1", - cal_address: "guest1@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 1', + cal_address: 'guest1@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 2", - cal_address: "guest2@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 2', + cal_address: 'guest2@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 3", - cal_address: "guest3@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 3', + cal_address: 'guest3@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 4", - cal_address: "guest4@test.com", - partstat: "DECLINED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ]; + cn: 'Guest 4', + cal_address: 'guest4@test.com', + partstat: 'DECLINED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] - const state = createStateWithAttendees(attendees); + const state = createStateWithAttendees(attendees) renderWithProviders( , state - ); + ) expect( screen.getByText(/eventPreview.needActionCount\(count\=3\)/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( screen.getByText(/eventPreview.noCount\(count\=1\)/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( screen.queryByText(/eventPreview.yesCount/i) - ).not.toBeInTheDocument(); + ).not.toBeInTheDocument() expect( screen.queryByText(/eventPreview.maybeCount/i) - ).not.toBeInTheDocument(); - }); + ).not.toBeInTheDocument() + }) - it("does not display status categories with zero count", () => { + it('does not display status categories with zero count', () => { const attendees = [ { - cn: "Guest 1", - cal_address: "guest1@test.com", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Guest 1', + cal_address: 'guest1@test.com', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "Guest 2", - cal_address: "guest2@test.com", - partstat: "DECLINED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ]; + cn: 'Guest 2', + cal_address: 'guest2@test.com', + partstat: 'DECLINED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] - const state = createStateWithAttendees(attendees); + const state = createStateWithAttendees(attendees) renderWithProviders( , state - ); + ) expect( screen.getByText(/eventPreview.yesCount\(count\=1\)/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( screen.getByText(/eventPreview.noCount\(count\=1\)/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( screen.queryByText(/eventPreview.maybeCount/i) - ).not.toBeInTheDocument(); + ).not.toBeInTheDocument() expect( screen.queryByText(/eventPreview.needActionCount/i) - ).not.toBeInTheDocument(); - }); + ).not.toBeInTheDocument() + }) - it("handles large number of attendees correctly", () => { + it('handles large number of attendees correctly', () => { const attendees = Array.from({ length: 15 }, (_, i) => ({ cn: `Guest ${i}`, cal_address: `guest${i}@test.com`, partstat: i < 8 - ? "ACCEPTED" + ? 'ACCEPTED' : i < 11 - ? "TENTATIVE" + ? 'TENTATIVE' : i < 13 - ? "NEEDS-ACTION" - : "DECLINED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - })); + ? 'NEEDS-ACTION' + : 'DECLINED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + })) - const state = createStateWithAttendees(attendees); + const state = createStateWithAttendees(attendees) renderWithProviders( , state - ); + ) expect( screen.getByText(/eventPreview.yesCount\(count\=8\)/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( screen.getByText(/eventPreview.maybeCount\(count\=3\)/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( screen.getByText(/eventPreview.needActionCount\(count\=2\)/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( screen.getByText(/eventPreview.noCount\(count\=2\)/i) - ).toBeInTheDocument(); - }); - }); - }); + ).toBeInTheDocument() + }) + }) + }) - describe("EventDisplayPreview - delegation", () => { + describe('EventDisplayPreview - delegation', () => { const delegatedBaseEvent = { - uid: "event-1", - calId: "user2/cal1", - title: "Delegated Event", + uid: 'event-1', + calId: 'user2/cal1', + title: 'Delegated Event', start: day.toISOString(), end: day.toISOString(), - organizer: { cal_address: "owner@example.com" }, + organizer: { cal_address: 'owner@example.com' }, attendee: [ - { cal_address: "owner@example.com", partstat: "NEEDS-ACTION" }, + { cal_address: 'owner@example.com', partstat: 'NEEDS-ACTION' } ], - URL: "/calendars/user2/cal1/event-1.ics", - }; + URL: '/calendars/user2/cal1/event-1.ics' + } const makeDelegatedState = ( eventOverrides = {}, @@ -1581,45 +1581,45 @@ describe("Event Preview Display", () => { write: true, freebusy: false, read: true, - "write-properties": false, - all: false, + 'write-properties': false, + all: false } ) => ({ ...preloadedState, calendars: { list: { - "user2/cal1": { - id: "user2/cal1", - name: "Delegated Calendar", + 'user2/cal1': { + id: 'user2/cal1', + name: 'Delegated Calendar', delegated: true, access, owner: { - id: "user2", - firstname: "Bob", - lastname: "Owner", - emails: ["owner@example.com"], - preferredEmail: "owner@example.com", + id: 'user2', + firstname: 'Bob', + lastname: 'Owner', + emails: ['owner@example.com'], + preferredEmail: 'owner@example.com' }, - color: { light: "#FF0000", dark: "#000" }, + color: { light: '#FF0000', dark: '#000' }, events: { - "event-1": { ...delegatedBaseEvent, ...eventOverrides }, - }, - }, + 'event-1': { ...delegatedBaseEvent, ...eventOverrides } + } + } }, templist: {}, - pending: false, + pending: false }, user: { userData: { ...preloadedState.user.userData, - email: "alice@example.com", - openpaasId: "user1", - }, - }, - }); + email: 'alice@example.com', + openpaasId: 'user1' + } + } + }) - describe("edit button visibility", () => { - it("shows edit button when calendar is write-delegated and owner is organizer", () => { + describe('edit button visibility', () => { + it('shows edit button when calendar is write-delegated and owner is organizer', () => { renderWithProviders( { onClose={mockOnClose} />, makeDelegatedState() - ); + ) expect( - screen.getByTestId("EditIcon").closest("button") - ).toBeInTheDocument(); - }); + screen.getByTestId('EditIcon').closest('button') + ).toBeInTheDocument() + }) - it("does not show edit button when delegated but owner is not organizer", () => { + it('does not show edit button when delegated but owner is not organizer', () => { renderWithProviders( { onClose={mockOnClose} />, makeDelegatedState({ - organizer: { cal_address: "someone-else@example.com" }, + organizer: { cal_address: 'someone-else@example.com' } }) - ); - expect(screen.queryByTestId("EditIcon")).not.toBeInTheDocument(); - }); + ) + expect(screen.queryByTestId('EditIcon')).not.toBeInTheDocument() + }) - it("does not show edit button when delegated with read-only access", () => { + it('does not show edit button when delegated with read-only access', () => { renderWithProviders( { write: false, freebusy: false, read: true, - "write-properties": false, - all: false, + 'write-properties': false, + all: false } ) - ); - expect(screen.queryByTestId("EditIcon")).not.toBeInTheDocument(); - }); - }); + ) + expect(screen.queryByTestId('EditIcon')).not.toBeInTheDocument() + }) + }) - describe("delete menu item visibility", () => { - it("shows delete option when calendar is write-delegated", () => { + describe('delete menu item visibility', () => { + it('shows delete option when calendar is write-delegated', () => { renderWithProviders( { onClose={mockOnClose} />, makeDelegatedState() - ); - fireEvent.click(screen.getByTestId("MoreVertIcon")); - expect( - screen.getByText("eventPreview.deleteEvent") - ).toBeInTheDocument(); - }); + ) + fireEvent.click(screen.getByTestId('MoreVertIcon')) + expect(screen.getByText('eventPreview.deleteEvent')).toBeInTheDocument() + }) - it("does not show delete option when delegated with read-only access", () => { + it('does not show delete option when delegated with read-only access', () => { renderWithProviders( { write: false, freebusy: false, read: true, - "write-properties": false, - all: false, + 'write-properties': false, + all: false } ) - ); - fireEvent.click(screen.getByTestId("MoreVertIcon")); + ) + fireEvent.click(screen.getByTestId('MoreVertIcon')) expect( - screen.queryByText("eventPreview.deleteEvent") - ).not.toBeInTheDocument(); - }); - }); - }); + screen.queryByText('eventPreview.deleteEvent') + ).not.toBeInTheDocument() + }) + }) + }) - describe("BUGFIX", () => { - it("doesnt render anything next to date of all day preview", () => { + describe('BUGFIX', () => { + it('doesnt render anything next to date of all day preview', () => { const allDayState = { ...preloadedState, calendars: { ...preloadedState.calendars, list: { ...preloadedState.calendars.list, - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + '667037022b752d0026472254/cal1': { + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'], events: { event1: { ...preloadedState.calendars.list[ - "667037022b752d0026472254/cal1" + '667037022b752d0026472254/cal1' ].events.event1, - allday: true, // <-- the fix condition - }, - }, - }, - }, - }, - }; + allday: true // <-- the fix condition + } + } + } + } + } + } renderWithProviders( , allDayState - ); + ) const dateElement = - screen.getByText(/January/i).closest("p") ?? - screen.getByText(/January/i); + screen.getByText(/January/i).closest('p') ?? + screen.getByText(/January/i) - expect(dateElement).toBeInTheDocument(); - console.log(dateElement.textContent); + expect(dateElement).toBeInTheDocument() + console.log(dateElement.textContent) - const text = dateElement.textContent?.trim() ?? ""; + const text = dateElement.textContent?.trim() ?? '' - expect(text).toMatch(/\d{4}$/); - }); - }); -}); + expect(text).toMatch(/\d{4}$/) + }) + }) +}) -describe("Helper functions", () => { - it("stringToColor generates consistent color", () => { - expect(stringToColor("Alice")).toMatch(/^#[0-9a-f]{6}$/); - expect(stringToColor("Alice")).toBe(stringToColor("Alice")); - }); +describe('Helper functions', () => { + it('stringToColor generates consistent color', () => { + expect(stringToColor('Alice')).toMatch(/^#[0-9a-f]{6}$/) + expect(stringToColor('Alice')).toBe(stringToColor('Alice')) + }) - it("stringAvatar returns correct props", () => { - const result = stringAvatar("Alice"); - expect(result.children).toBe("A"); - expect(result.color).toBeDefined(); - expect(typeof result.color).toBe("string"); - }); + it('stringAvatar returns correct props', () => { + const result = stringAvatar('Alice') + expect(result.children).toBe('A') + expect(result.color).toBeDefined() + expect(typeof result.color).toBe('string') + }) - it("stringAvatar returns 2-letter initials for full name", () => { - const result = stringAvatar("John Doe"); - expect(result.children).toBe("JD"); - expect(result.color).toBeDefined(); - expect(typeof result.color).toBe("string"); - }); + it('stringAvatar returns 2-letter initials for full name', () => { + const result = stringAvatar('John Doe') + expect(result.children).toBe('JD') + expect(result.color).toBeDefined() + expect(typeof result.color).toBe('string') + }) - it("stringAvatar handles single word names", () => { - const result = stringAvatar("Alice"); - expect(result.children).toBe("A"); - expect(result.color).toBeDefined(); - }); + it('stringAvatar handles single word names', () => { + const result = stringAvatar('Alice') + expect(result.children).toBe('A') + expect(result.color).toBeDefined() + }) - it("stringAvatar handles email addresses", () => { - const result = stringAvatar("test@example.com"); - expect(result.children).toBe("T"); - expect(result.color).toBeDefined(); - }); + it('stringAvatar handles email addresses', () => { + const result = stringAvatar('test@example.com') + expect(result.children).toBe('T') + expect(result.color).toBeDefined() + }) - it("InfoRow renders text and link if url is valid", () => { + it('InfoRow renders text and link if url is valid', () => { renderWithProviders( ico} text="Meeting" data="https://example.com" /> - ); - expect(screen.getByText("Meeting").closest("a")).toHaveAttribute( - "href", - "https://example.com" - ); - }); -}); + ) + expect(screen.getByText('Meeting').closest('a')).toHaveAttribute( + 'href', + 'https://example.com' + ) + }) +}) diff --git a/__test__/features/Events/EventFullDisplay.delegated.test.tsx b/__test__/features/Events/EventFullDisplay.delegated.test.tsx index 5c61fcf..35c29ef 100644 --- a/__test__/features/Events/EventFullDisplay.delegated.test.tsx +++ b/__test__/features/Events/EventFullDisplay.delegated.test.tsx @@ -1,131 +1,131 @@ -import * as eventThunks from "@/features/Calendars/services"; -import EventUpdateModal from "@/features/Events/EventUpdateModal"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import * as eventThunks from '@/features/Calendars/services' +import EventUpdateModal from '@/features/Events/EventUpdateModal' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../../utils/Renderwithproviders' -describe("Event Full Display — delegated calendar move", () => { - const mockOnClose = jest.fn(); +describe('Event Full Display — delegated calendar move', () => { + const mockOnClose = jest.fn() beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - const testDate = new Date("2025-01-15T10:00:00.000Z"); - const testEndDate = new Date("2025-01-15T11:00:00.000Z"); + const testDate = new Date('2025-01-15T10:00:00.000Z') + const testEndDate = new Date('2025-01-15T11:00:00.000Z') const baseUserState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "user1", + sub: 'test', + email: 'test@test.com', + sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro', + openpaasId: 'user1' }, organiserData: { - cn: "test", - cal_address: "test@test.com", - }, - }, - }; + cn: 'test', + cal_address: 'test@test.com' + } + } + } const preloadedDelegatedCals = { ...baseUserState, calendars: { pending: false, list: { - "user1/cal1": { - id: "user1/cal1", - name: "Calendar One", - color: "#FF0000", + 'user1/cal1': { + id: 'user1/cal1', + name: 'Calendar One', + color: '#FF0000', events: { event1: { - uid: "event1", - title: "Test Event", - calId: "user1/cal1", + uid: 'event1', + title: 'Test Event', + calId: 'user1/cal1', start: testDate.toISOString(), end: testEndDate.toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, - attendee: [{ cn: "test", cal_address: "test@test.com" }], - URL: "/calendar/user1/cal1/event1.ics", - }, + organizer: { cn: 'test', cal_address: 'test@test.com' }, + attendee: [{ cn: 'test', cal_address: 'test@test.com' }], + URL: '/calendar/user1/cal1/event1.ics' + } }, - link: "/calendar/user1/cal1.json", + link: '/calendar/user1/cal1.json' }, - "user2/cal2": { - id: "user2/cal2", - name: "Delegated Calendar", - color: "#00FF00", + 'user2/cal2': { + id: 'user2/cal2', + name: 'Delegated Calendar', + color: '#00FF00', delegated: true, access: { write: true }, owner: { - emails: ["delegate@test.com"], - name: "Delegate User", + emails: ['delegate@test.com'], + name: 'Delegate User' }, events: {}, - link: "/calendar/user2/cal2.json", - }, - }, - }, - }; + link: '/calendar/user2/cal2.json' + } + } + } + } - it("calls deleteEventAsync and putEventAsync — and NOT moveEventAsync — when moving to a delegated calendar", async () => { + it('calls deleteEventAsync and putEventAsync — and NOT moveEventAsync — when moving to a delegated calendar', async () => { const spyPut = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) const spyDelete = jest - .spyOn(eventThunks, "deleteEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'deleteEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) const spyMove = jest - .spyOn(eventThunks, "moveEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'moveEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) await act(async () => renderWithProviders( , preloadedDelegatedCals ) - ); + ) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) - const calendarSelect = screen.getByLabelText("event.form.calendar"); - await act(async () => fireEvent.mouseDown(calendarSelect)); - const option = await screen.findByText("Delegated Calendar"); - fireEvent.click(option); + const calendarSelect = screen.getByLabelText('event.form.calendar') + await act(async () => fireEvent.mouseDown(calendarSelect)) + const option = await screen.findByText('Delegated Calendar') + fireEvent.click(option) await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "actions.save" })); - }); + fireEvent.click(screen.getByRole('button', { name: 'actions.save' })) + }) await waitFor( () => { - expect(spyPut).toHaveBeenCalled(); + expect(spyPut).toHaveBeenCalled() }, { timeout: 3000 } - ); + ) await waitFor( () => { - expect(spyDelete).toHaveBeenCalled(); + expect(spyDelete).toHaveBeenCalled() }, { timeout: 3000 } - ); + ) - expect(spyMove).not.toHaveBeenCalled(); - }); -}); + expect(spyMove).not.toHaveBeenCalled() + }) +}) diff --git a/__test__/features/Events/EventFullDisplay.test.tsx b/__test__/features/Events/EventFullDisplay.test.tsx index a27910b..6c5ddcb 100644 --- a/__test__/features/Events/EventFullDisplay.test.tsx +++ b/__test__/features/Events/EventFullDisplay.test.tsx @@ -1,422 +1,422 @@ -import { InfoRow } from "@/components/Event/InfoRow"; -import { LONG_DATE_FORMAT } from "@/components/Event/utils/dateTimeFormatters"; -import * as eventThunks from "@/features/Calendars/services"; -import EventUpdateModal from "@/features/Events/EventUpdateModal"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import dayjs from "dayjs"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import { InfoRow } from '@/components/Event/InfoRow' +import { LONG_DATE_FORMAT } from '@/components/Event/utils/dateTimeFormatters' +import * as eventThunks from '@/features/Calendars/services' +import EventUpdateModal from '@/features/Events/EventUpdateModal' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import dayjs from 'dayjs' +import { renderWithProviders } from '../../utils/Renderwithproviders' -describe("Event Full Display", () => { - const mockOnClose = jest.fn(); - const day = new Date("2025-01-15T10:00:00.000Z"); // Fixed date in UTC: Jan 15, 2025 10:00 AM UTC +describe('Event Full Display', () => { + const mockOnClose = jest.fn() + const day = new Date('2025-01-15T10:00:00.000Z') // Fixed date in UTC: Jan 15, 2025 10:00 AM UTC beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro', + openpaasId: '667037022b752d0026472254' }, organiserData: { - cn: "test", - cal_address: "test@test.com", - }, + cn: 'test', + cal_address: 'test@test.com' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "First Calendar", - color: "#FF0000", + '667037022b752d0026472254/cal1': { + id: '667037022b752d0026472254/cal1', + name: 'First Calendar', + color: '#FF0000', events: { event1: { - uid: "event1", - title: "Test Event", - calId: "667037022b752d0026472254/cal1", + uid: 'event1', + title: 'Test Event', + calId: '667037022b752d0026472254/cal1', start: day.toISOString(), end: day.toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, + organizer: { cn: 'test', cal_address: 'test@test.com' }, attendee: [ { - cn: "test", - cal_address: "test@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'test', + cal_address: 'test@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "John", - cal_address: "john@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ], - }, - }, + cn: 'John', + cal_address: 'john@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] + } + } }, - "otherCal/cal": { - id: "otherCal/cal", - name: "Calendar 1", - color: "#FF0000", + 'otherCal/cal': { + id: 'otherCal/cal', + name: 'Calendar 1', + color: '#FF0000', events: { event1: { - uid: "event1", - calId: "otherCal/cal", - title: "Test Event Other cal", + uid: 'event1', + calId: 'otherCal/cal', + title: 'Test Event Other cal', start: day.toISOString(), end: day.toISOString(), - organizer: { cn: "john", cal_address: "john@test.com" }, - }, - }, - }, - }, - }, - }; + organizer: { cn: 'john', cal_address: 'john@test.com' } + } + } + } + } + } + } - it("renders correctly event data with fixed timezone", () => { + it('renders correctly event data with fixed timezone', () => { // Use fixed timezone UTC for consistent test results across all environments - const fixedDate = new Date("2025-01-15T10:00:00.000Z"); // 10AM UTC - const endDate = new Date(fixedDate.getTime() + 3600000); // 11AM UTC + const fixedDate = new Date('2025-01-15T10:00:00.000Z') // 10AM UTC + const endDate = new Date(fixedDate.getTime() + 3600000) // 11AM UTC // With UTC timezone set, formatLocalDateTime produces predictable values - const expectedStart = "2025-01-15T10:00"; // 10:00 AM UTC - const expectedEnd = "2025-01-15T11:00"; // 11:00 AM UTC + const expectedStart = '2025-01-15T10:00' // 10:00 AM UTC + const expectedEnd = '2025-01-15T11:00' // 11:00 AM UTC const stateWithFixedDate = { ...preloadedState, calendars: { pending: false, list: { - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + '667037022b752d0026472254/cal1': { + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'], events: { event1: { ...preloadedState.calendars.list[ - "667037022b752d0026472254/cal1" + '667037022b752d0026472254/cal1' ].events.event1, start: fixedDate.toISOString(), end: endDate.toISOString(), - timezone: "UTC", - }, - }, - }, - }, - }, - }; + timezone: 'UTC' + } + } + } + } + } + } renderWithProviders( , stateWithFixedDate - ); + ) - expect(screen.getByDisplayValue("Test Event")).toBeInTheDocument(); + expect(screen.getByDisplayValue('Test Event')).toBeInTheDocument() // Expand to show date/time inputs (normal mode shows DateTimeSummary) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) - const startDateInput = screen.getByTestId("start-date-input"); - const startTimeInput = screen.getByTestId("start-time-input"); - const endTimeInput = screen.getByTestId("end-time-input"); + const startDateInput = screen.getByTestId('start-date-input') + const startTimeInput = screen.getByTestId('start-time-input') + const endTimeInput = screen.getByTestId('end-time-input') - expect(startDateInput).toBeInTheDocument(); - expect(startTimeInput).toBeInTheDocument(); - expect(endTimeInput).toBeInTheDocument(); + expect(startDateInput).toBeInTheDocument() + expect(startTimeInput).toBeInTheDocument() + expect(endTimeInput).toBeInTheDocument() expect(startDateInput).toHaveValue( dayjs(expectedStart).format(LONG_DATE_FORMAT) - ); - expect(startTimeInput).toHaveValue(dayjs(expectedStart).format("HH:mm")); + ) + expect(startTimeInput).toHaveValue(dayjs(expectedStart).format('HH:mm')) - expect(endTimeInput).toHaveValue(dayjs(expectedEnd).format("HH:mm")); + expect(endTimeInput).toHaveValue(dayjs(expectedEnd).format('HH:mm')) - expect(screen.getByText("First Calendar")).toBeInTheDocument(); - }); - it("calls onClose when Cancel clicked", () => { + expect(screen.getByText('First Calendar')).toBeInTheDocument() + }) + it('calls onClose when Cancel clicked', () => { renderWithProviders( , preloadedState - ); - fireEvent.click(screen.getAllByTestId("CloseIcon")[0]); + ) + fireEvent.click(screen.getAllByTestId('CloseIcon')[0]) - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); - }); - it("toggle Show More reveals extra fields", async () => { + expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick') + }) + it('toggle Show More reveals extra fields', async () => { renderWithProviders( , preloadedState - ); + ) act(() => { fireEvent.click( - screen.getByRole("button", { name: "common.moreOptions" }) - ); - }); + screen.getByRole('button', { name: 'common.moreOptions' }) + ) + }) await waitFor(() => { - expect(screen.getByText("event.form.notification")).toBeInTheDocument(); - }); + expect(screen.getByText('event.form.notification')).toBeInTheDocument() + }) // EventDisplay modal doesn't have Repeat checkbox, only RepeatEvent component // which shows repetition settings when repetition data exists // Since test event has no repetition data, RepeatEvent component won't show Repeat checkbox - fireEvent.click(screen.getByRole("button", { name: /Show Less/i })); - }); + fireEvent.click(screen.getByRole('button', { name: /Show Less/i })) + }) - it("can edit title when user is organizer", () => { + it('can edit title when user is organizer', () => { renderWithProviders( , preloadedState - ); - const titleField = screen.getByLabelText("event.form.title"); - fireEvent.change(titleField, { target: { value: "New Title" } }); - expect(screen.getByDisplayValue("New Title")).toBeInTheDocument(); - }); - it("toggle all-day updates end date correctly", () => { + ) + const titleField = screen.getByLabelText('event.form.title') + fireEvent.change(titleField, { target: { value: 'New Title' } }) + expect(screen.getByDisplayValue('New Title')).toBeInTheDocument() + }) + it('toggle all-day updates end date correctly', () => { renderWithProviders( , preloadedState - ); + ) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); - const allDayCheckbox = screen.getByLabelText("event.form.allDay"); - fireEvent.click(allDayCheckbox); - expect(allDayCheckbox).toBeChecked(); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) + const allDayCheckbox = screen.getByLabelText('event.form.allDay') + fireEvent.click(allDayCheckbox) + expect(allDayCheckbox).toBeChecked() - const expectedDate = dayjs(day).format(LONG_DATE_FORMAT); + const expectedDate = dayjs(day).format(LONG_DATE_FORMAT) - const startDateInput = screen.getByTestId("start-date-input"); - const endDateInput = screen.getByTestId("end-date-input"); + const startDateInput = screen.getByTestId('start-date-input') + const endDateInput = screen.getByTestId('end-date-input') - expect(startDateInput).toHaveValue(expectedDate); - expect(endDateInput).toHaveValue(expectedDate); - }); + expect(startDateInput).toHaveValue(expectedDate) + expect(endDateInput).toHaveValue(expectedDate) + }) - it("saves event and moves it when calendar is changed", async () => { + it('saves event and moves it when calendar is changed', async () => { const spyPut = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) const spyMove = jest - .spyOn(eventThunks, "moveEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'moveEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) - const testDate = new Date("2025-01-15T10:00:00.000Z"); - const testEndDate = new Date("2025-01-15T11:00:00.000Z"); + const testDate = new Date('2025-01-15T10:00:00.000Z') + const testEndDate = new Date('2025-01-15T11:00:00.000Z') const preloadedTwoCals = { ...preloadedState, calendars: { pending: false, list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "Calendar One", - color: "#FF0000", + '667037022b752d0026472254/cal1': { + id: '667037022b752d0026472254/cal1', + name: 'Calendar One', + color: '#FF0000', events: { event1: { - uid: "event1", - title: "Test Event", - calId: "667037022b752d0026472254/cal1", + uid: 'event1', + title: 'Test Event', + calId: '667037022b752d0026472254/cal1', start: testDate.toISOString(), end: testEndDate.toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, - attendee: [{ cn: "test", cal_address: "test@test.com" }], - }, - }, - }, - "667037022b752d0026472254/cal2": { - id: "667037022b752d0026472254/cal2", - name: "Calendar Two", - color: "#00FF00", - events: {}, + organizer: { cn: 'test', cal_address: 'test@test.com' }, + attendee: [{ cn: 'test', cal_address: 'test@test.com' }] + } + } }, + '667037022b752d0026472254/cal2': { + id: '667037022b752d0026472254/cal2', + name: 'Calendar Two', + color: '#00FF00', + events: {} + } }, - pending: false, - }, - }; + pending: false + } + } await act(async () => renderWithProviders( , preloadedTwoCals ) - ); + ) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); - const calendarSelect = screen.getByLabelText("event.form.calendar"); - await act(async () => fireEvent.mouseDown(calendarSelect)); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) + const calendarSelect = screen.getByLabelText('event.form.calendar') + await act(async () => fireEvent.mouseDown(calendarSelect)) - const option = await screen.findByText("Calendar Two"); - fireEvent.click(option); + const option = await screen.findByText('Calendar Two') + fireEvent.click(option) await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "actions.save" })); - }); + fireEvent.click(screen.getByRole('button', { name: 'actions.save' })) + }) await waitFor( () => { - expect(spyPut).toHaveBeenCalled(); + expect(spyPut).toHaveBeenCalled() }, { timeout: 3000 } - ); + ) await waitFor( () => { - expect(spyMove).toHaveBeenCalled(); + expect(spyMove).toHaveBeenCalled() }, { timeout: 3000 } - ); - }); + ) + }) - it("edit modal displays event time in original event timezone", () => { + it('edit modal displays event time in original event timezone', () => { // GIVEN user timezone is UTC+2 // WHEN the user edits an event at 2PM UTC+7 (Asia/Bangkok) // THEN the update modal displays the time as 2PM in Asia/Bangkok timezone - const eventDateUTC7 = new Date("2025-01-15T07:00:00.000Z"); // 7AM UTC = 2PM UTC+7 + const eventDateUTC7 = new Date('2025-01-15T07:00:00.000Z') // 7AM UTC = 2PM UTC+7 const stateWithTimezone = { ...preloadedState, calendars: { pending: false, list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "Test Calendar", - color: "#FF0000", + '667037022b752d0026472254/cal1': { + id: '667037022b752d0026472254/cal1', + name: 'Test Calendar', + color: '#FF0000', events: { event1: { - uid: "event1", - title: "Timezone Edit Test", - calId: "667037022b752d0026472254/cal1", + uid: 'event1', + title: 'Timezone Edit Test', + calId: '667037022b752d0026472254/cal1', start: eventDateUTC7.toISOString(), end: new Date(eventDateUTC7.getTime() + 3600000).toISOString(), - timezone: "Asia/Bangkok", + timezone: 'Asia/Bangkok', allday: false, - organizer: { cn: "test", cal_address: "test@test.com" }, - attendee: [{ cn: "test", cal_address: "test@test.com" }], - }, - }, - }, + organizer: { cn: 'test', cal_address: 'test@test.com' }, + attendee: [{ cn: 'test', cal_address: 'test@test.com' }] + } + } + } }, - pending: false, - }, - }; + pending: false + } + } renderWithProviders( , stateWithTimezone - ); + ) // Expand to show timezone selector (normal mode hides it) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) // The timezone select should have Asia/Bangkok selected - const timeZone = screen.getByDisplayValue(/Asia\/Bangkok/i); - expect(timeZone).toBeInTheDocument(); - }); + const timeZone = screen.getByDisplayValue(/Asia\/Bangkok/i) + expect(timeZone).toBeInTheDocument() + }) - it("InfoRow renders error style when error prop is true", () => { - renderWithProviders(i} text="Bad" error />); - expect(screen.getByText("Bad")).toBeInTheDocument(); - }); + it('InfoRow renders error style when error prop is true', () => { + renderWithProviders(i} text="Bad" error />) + expect(screen.getByText('Bad')).toBeInTheDocument() + }) - it("can remove an attendee with the close button", () => { + it('can remove an attendee with the close button', () => { renderWithProviders( , preloadedState - ); + ) - const removeBtn = screen.getAllByTestId("CloseIcon").pop()!; - fireEvent.click(removeBtn); + const removeBtn = screen.getAllByTestId('CloseIcon').pop()! + fireEvent.click(removeBtn) - expect(screen.queryByText(/John/)).not.toBeInTheDocument(); - }); + expect(screen.queryByText(/John/)).not.toBeInTheDocument() + }) - it("renders video conference info when x_openpass_videoconference exists", () => { + it('renders video conference info when x_openpass_videoconference exists', () => { const videoState = { ...preloadedState, calendars: { pending: false, list: { - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + '667037022b752d0026472254/cal1': { + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'], events: { event1: { ...preloadedState.calendars.list[ - "667037022b752d0026472254/cal1" + '667037022b752d0026472254/cal1' ].events.event1, - x_openpass_videoconference: "https://meet.test/video", - }, - }, - }, - }, - }, - }; + x_openpass_videoconference: 'https://meet.test/video' + } + } + } + } + } + } renderWithProviders( , videoState - ); + ) expect( - screen.getByText("event.form.joinVisioConference") - ).toBeInTheDocument(); - }); -}); + screen.getByText('event.form.joinVisioConference') + ).toBeInTheDocument() + }) +}) diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index 680b6b8..4983406 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -1,115 +1,115 @@ -import * as calendarsApi from "@/features/Calendars/CalendarApi"; -import * as eventThunks from "@/features/Calendars/services"; -import EventPopover from "@/features/Events/EventModal"; -import { api } from "@/utils/apiUtils"; -import { DateSelectArg } from "@fullcalendar/core"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import * as calendarsApi from '@/features/Calendars/CalendarApi' +import * as eventThunks from '@/features/Calendars/services' +import EventPopover from '@/features/Events/EventModal' +import { api } from '@/utils/apiUtils' +import { DateSelectArg } from '@fullcalendar/core' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../../utils/Renderwithproviders' -jest.mock("@/utils/apiUtils"); +jest.mock('@/utils/apiUtils') -describe("EventPopover", () => { +describe('EventPopover', () => { beforeEach(() => { - sessionStorage.clear(); - }); + sessionStorage.clear() + }) - const mockOnClose = jest.fn(); - const mockSetSelectedRange = jest.fn(); - const mockCalendarRef = { current: { select: jest.fn() } } as any; + const mockOnClose = jest.fn() + const mockSetSelectedRange = jest.fn() + const mockCalendarRef = { current: { select: jest.fn() } } as any const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro', + openpaasId: '667037022b752d0026472254' }, organiserData: { - cn: "test", - cal_address: "test@test.com", - }, + cn: 'test', + cal_address: 'test@test.com' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "Calendar 1", - color: "#FF0000", - }, - "667037022b752d0026472254/cal2": { - id: "667037022b752d0026472254/cal2", - name: "Calendar 2", - color: "#00FF00", + '667037022b752d0026472254/cal1': { + id: '667037022b752d0026472254/cal1', + name: 'Calendar 1', + color: '#FF0000' }, + '667037022b752d0026472254/cal2': { + id: '667037022b752d0026472254/cal2', + name: 'Calendar 2', + color: '#00FF00' + } }, - pending: false, - }, - }; + pending: false + } + } const mockUsers = [ { - id: "john@example.com", - objectType: "user", + id: 'john@example.com', + objectType: 'user', emailAddresses: [ { - value: "john@example.com", - type: "default", - }, + value: 'john@example.com', + type: 'default' + } ], names: [ { - displayName: "John Doe", - type: "default", - }, - ], + displayName: 'John Doe', + type: 'default' + } + ] }, { - id: "jane@example.com", - objectType: "user", + id: 'jane@example.com', + objectType: 'user', emailAddresses: [ { - value: "jane@example.com", - type: "default", - }, + value: 'jane@example.com', + type: 'default' + } ], names: [ { - displayName: "Jane Smith", - type: "default", - }, - ], + displayName: 'Jane Smith', + type: 'default' + } + ] }, { - id: "room1@example.com", - objectType: "resource", + id: 'room1@example.com', + objectType: 'resource', emailAddresses: [ { - value: "room1@example.com", - type: "default", - }, + value: 'room1@example.com', + type: 'default' + } ], names: [ { - displayName: "Room 1", - type: "default", - }, - ], - }, - ]; - (api.post as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue(mockUsers), - }); + displayName: 'Room 1', + type: 'default' + } + ] + } + ] + ;(api.post as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockUsers) + }) const defaultSelectedRange = { - startStr: "2025-07-18T09:00", - endStr: "2025-07-18T10:00", - start: new Date("2025-07-18T09:00"), - end: new Date("2025-07-18T10:00"), + startStr: '2025-07-18T09:00', + endStr: '2025-07-18T10:00', + start: new Date('2025-07-18T09:00'), + end: new Date('2025-07-18T10:00'), allDay: false, - resource: undefined, - } as unknown as DateSelectArg; + resource: undefined + } as unknown as DateSelectArg const renderPopover = (selectedRange = defaultSelectedRange) => { renderWithProviders( @@ -122,461 +122,459 @@ describe("EventPopover", () => { calendarRef={mockCalendarRef} />, preloadedState - ); - }; + ) + } - it("renders correctly with inputs and calendar options", () => { - renderPopover(); + it('renders correctly with inputs and calendar options', () => { + renderPopover() // Check dialog title - expect(screen.getByText("event.createEvent")).toBeInTheDocument(); + expect(screen.getByText('event.createEvent')).toBeInTheDocument() // Check inputs exist by their roles - const titleInput = screen.getByRole("textbox", { name: /title/i }); - expect(titleInput).toBeInTheDocument(); + const titleInput = screen.getByRole('textbox', { name: /title/i }) + expect(titleInput).toBeInTheDocument() // Description input is only visible after clicking "Add description" button - const addDescriptionButton = screen.getByRole("button", { - name: "event.form.addDescription", - }); - expect(addDescriptionButton).toBeInTheDocument(); + const addDescriptionButton = screen.getByRole('button', { + name: 'event.form.addDescription' + }) + expect(addDescriptionButton).toBeInTheDocument() // In normal mode calendar is a SectionPreviewRow (button), not combobox expect( - screen.getByRole("button", { name: "Calendar 1" }) - ).toBeInTheDocument(); + screen.getByRole('button', { name: 'Calendar 1' }) + ).toBeInTheDocument() // Check button expect( - screen.getByRole("button", { name: "common.moreOptions" }) - ).toBeInTheDocument(); + screen.getByRole('button', { name: 'common.moreOptions' }) + ).toBeInTheDocument() // Extended mode - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) // Back button appears - expect(screen.getByLabelText("show less")).toBeInTheDocument(); + expect(screen.getByLabelText('show less')).toBeInTheDocument() // Extended labels appear - expect(screen.getAllByText("event.form.repeat")).toHaveLength(1); - expect(screen.getAllByText("event.form.notification")).toHaveLength(1); - expect(screen.getAllByText("event.form.visibleTo")).toHaveLength(1); - expect(screen.getAllByText("event.form.showMeAs")).toHaveLength(1); - }); + expect(screen.getAllByText('event.form.repeat')).toHaveLength(1) + expect(screen.getAllByText('event.form.notification')).toHaveLength(1) + expect(screen.getAllByText('event.form.visibleTo')).toHaveLength(1) + expect(screen.getAllByText('event.form.showMeAs')).toHaveLength(1) + }) - it("fills start from selectedRange", () => { + it('fills start from selectedRange', () => { renderPopover({ - startStr: "2026-07-20T10:00", - endStr: "2026-07-20T12:00", - start: new Date("2026-07-20T10:00"), - end: new Date("2026-07-20T12:00"), - allDay: false, - } as unknown as DateSelectArg); + startStr: '2026-07-20T10:00', + endStr: '2026-07-20T12:00', + start: new Date('2026-07-20T10:00'), + end: new Date('2026-07-20T12:00'), + allDay: false + } as unknown as DateSelectArg) // Expand to show date/time inputs (normal mode shows DateTimeSummary) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); - expect(screen.getByTestId("start-date-input")).toBeInTheDocument(); - expect(screen.getByTestId("start-time-input")).toBeInTheDocument(); - }); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) + expect(screen.getByTestId('start-date-input')).toBeInTheDocument() + expect(screen.getByTestId('start-time-input')).toBeInTheDocument() + }) - it("updates inputs on change", () => { - renderPopover(); + it('updates inputs on change', () => { + renderPopover() - fireEvent.change(screen.getByLabelText("event.form.title"), { - target: { value: "My Event" }, - }); - expect(screen.getByLabelText("event.form.title")).toHaveValue("My Event"); + fireEvent.change(screen.getByLabelText('event.form.title'), { + target: { value: 'My Event' } + }) + expect(screen.getByLabelText('event.form.title')).toHaveValue('My Event') // Click "Add description" button first fireEvent.click( - screen.getByRole("button", { name: "event.form.addDescription" }) - ); + screen.getByRole('button', { name: 'event.form.addDescription' }) + ) - fireEvent.change(screen.getByLabelText("event.form.description"), { - target: { value: "Event Description" }, - }); - expect(screen.getByLabelText("event.form.description")).toHaveValue( - "Event Description" - ); + fireEvent.change(screen.getByLabelText('event.form.description'), { + target: { value: 'Event Description' } + }) + expect(screen.getByLabelText('event.form.description')).toHaveValue( + 'Event Description' + ) // Expand location section (normal mode shows SectionPreviewRow) fireEvent.click( - screen.getByRole("button", { name: "event.form.locationPlaceholder" }) - ); - fireEvent.change(screen.getByLabelText("event.form.location"), { - target: { value: "Conference Room" }, - }); - expect(screen.getByLabelText("event.form.location")).toHaveValue( - "Conference Room" - ); - }); + screen.getByRole('button', { name: 'event.form.locationPlaceholder' }) + ) + fireEvent.change(screen.getByLabelText('event.form.location'), { + target: { value: 'Conference Room' } + }) + expect(screen.getByLabelText('event.form.location')).toHaveValue( + 'Conference Room' + ) + }) - it("changes selected calendar", async () => { - renderPopover(); + it('changes selected calendar', async () => { + renderPopover() // Expand to show calendar combobox (normal mode shows SectionPreviewRow) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); - const select = screen.getByLabelText("event.form.calendar"); - fireEvent.mouseDown(select); // Open menu + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) + const select = screen.getByLabelText('event.form.calendar') + fireEvent.mouseDown(select) // Open menu - const option = await screen.findByText("Calendar 2"); - fireEvent.click(option); + const option = await screen.findByText('Calendar 2') + fireEvent.click(option) - const calendarSelect = screen.getByRole("combobox", { name: /Calendar/i }); - expect(calendarSelect).toHaveTextContent("Calendar 2"); - }); - it("adds a attendee", async () => { - jest.useFakeTimers(); + const calendarSelect = screen.getByRole('combobox', { name: /Calendar/i }) + expect(calendarSelect).toHaveTextContent('Calendar 2') + }) + it('adds a attendee', async () => { + jest.useFakeTimers() try { jest - .spyOn(calendarsApi, "getCalendars") - .mockReturnValue({ json: jest.fn() }); - renderPopover(); - fireEvent.change(screen.getByLabelText("event.form.title"), { - target: { value: "newEvent" }, - }); - const select = screen.getByLabelText("peopleSearch.label"); + .spyOn(calendarsApi, 'getCalendars') + .mockReturnValue({ json: jest.fn() }) + renderPopover() + fireEvent.change(screen.getByLabelText('event.form.title'), { + target: { value: 'newEvent' } + }) + const select = screen.getByLabelText('peopleSearch.label') act(() => { - select.focus(); - fireEvent.mouseDown(select); - userEvent.type(select, "john"); - }); + select.focus() + fireEvent.mouseDown(select) + userEvent.type(select, 'john') + }) await act(async () => { - jest.advanceTimersByTime(400); - }); - await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1)); + jest.advanceTimersByTime(400) + }) + await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1)) await waitFor(() => { - expect(screen.getByText("John Doe")).toBeInTheDocument(); - }); + expect(screen.getByText('John Doe')).toBeInTheDocument() + }) await act(async () => { - userEvent.click(screen.getByText("John Doe")); - }); + userEvent.click(screen.getByText('John Doe')) + }) const spy = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) - fireEvent.click(screen.getByRole("button", { name: "actions.save" })); + fireEvent.click(screen.getByRole('button', { name: 'actions.save' })) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const receivedPayload = spy.mock.calls[0][0]; + const receivedPayload = spy.mock.calls[0][0] expect(receivedPayload.cal).toEqual( - preloadedState.calendars.list["667037022b752d0026472254/cal1"] - ); + preloadedState.calendars.list['667037022b752d0026472254/cal1'] + ) - expect(receivedPayload.newEvent.attendee).toHaveLength(2); + expect(receivedPayload.newEvent.attendee).toHaveLength(2) expect(receivedPayload.newEvent.attendee).toStrictEqual([ { - cn: "test", - cal_address: "test@test.com", - partstat: "ACCEPTED", - rsvp: "FALSE", - role: "CHAIR", - cutype: "INDIVIDUAL", + cn: 'test', + cal_address: 'test@test.com', + partstat: 'ACCEPTED', + rsvp: 'FALSE', + role: 'CHAIR', + cutype: 'INDIVIDUAL' }, { - cn: "John Doe", - cal_address: "john@example.com", - partstat: "NEEDS-ACTION", - rsvp: "FALSE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ]); + cn: 'John Doe', + cal_address: 'john@example.com', + partstat: 'NEEDS-ACTION', + rsvp: 'FALSE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ]) } finally { - jest.useRealTimers(); + jest.useRealTimers() } - }); + }) - it("adds a resource", async () => { - jest.useFakeTimers(); + it('adds a resource', async () => { + jest.useFakeTimers() try { - renderPopover(); - fireEvent.change(screen.getByLabelText("event.form.title"), { - target: { value: "newEventWithResource" }, - }); + renderPopover() + fireEvent.change(screen.getByLabelText('event.form.title'), { + target: { value: 'newEventWithResource' } + }) fireEvent.click( - screen.getByRole("button", { name: "common.moreOptions" }) - ); + screen.getByRole('button', { name: 'common.moreOptions' }) + ) const resourceCombobox = screen.getByPlaceholderText( - "resourceSearch.placeholder" - ); + 'resourceSearch.placeholder' + ) act(() => { - resourceCombobox.focus(); - fireEvent.mouseDown(resourceCombobox); - }); - await userEvent.type(resourceCombobox, "room"); + resourceCombobox.focus() + fireEvent.mouseDown(resourceCombobox) + }) + await userEvent.type(resourceCombobox, 'room') await act(async () => { - jest.advanceTimersByTime(400); - }); + jest.advanceTimersByTime(400) + }) await waitFor(() => { - expect(screen.getByText("Room 1")).toBeInTheDocument(); - }); + expect(screen.getByText('Room 1')).toBeInTheDocument() + }) - await userEvent.click(screen.getByText("Room 1")); + await userEvent.click(screen.getByText('Room 1')) const spy = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) - fireEvent.click(screen.getByRole("button", { name: "actions.save" })); + fireEvent.click(screen.getByRole('button', { name: 'actions.save' })) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const receivedPayload = spy.mock.calls[0][0]; + const receivedPayload = spy.mock.calls[0][0] - expect(receivedPayload.newEvent.attendee).toHaveLength(2); // Organizer + 1 resource + expect(receivedPayload.newEvent.attendee).toHaveLength(2) // Organizer + 1 resource expect(receivedPayload.newEvent.attendee[1]).toStrictEqual({ - cn: "Room 1", - cal_address: "room1@example.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "RESOURCE", - }); + cn: 'Room 1', + cal_address: 'room1@example.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'RESOURCE' + }) } finally { - jest.useRealTimers(); + jest.useRealTimers() } - }); + }) - it("dispatches putEventAsync and calls onClose when Save is clicked", async () => { - renderPopover(); + it('dispatches putEventAsync and calls onClose when Save is clicked', async () => { + renderPopover() const newEvent = { - title: "Meeting", + title: 'Meeting', allday: false, - uid: "6045c603-11ab-43c5-bc30-0641420bb3a8", - description: "Discuss project", - location: "Zoom", - organizer: { cn: "test", cal_address: "test@test.com" }, - timezone: "Europe/Paris", - transp: "TRANSPARENT", - }; + uid: '6045c603-11ab-43c5-bc30-0641420bb3a8', + description: 'Discuss project', + location: 'Zoom', + organizer: { cn: 'test', cal_address: 'test@test.com' }, + timezone: 'Europe/Paris', + transp: 'TRANSPARENT' + } // Fill inputs - fireEvent.change(screen.getByLabelText("event.form.title"), { - target: { value: newEvent.title }, - }); + fireEvent.change(screen.getByLabelText('event.form.title'), { + target: { value: newEvent.title } + }) // Click "Add description" button first fireEvent.click( - screen.getByRole("button", { name: "event.form.addDescription" }) - ); - fireEvent.change(screen.getByLabelText("event.form.description"), { - target: { value: newEvent.description }, - }); + screen.getByRole('button', { name: 'event.form.addDescription' }) + ) + fireEvent.change(screen.getByLabelText('event.form.description'), { + target: { value: newEvent.description } + }) fireEvent.click( - screen.getByRole("button", { name: "event.form.locationPlaceholder" }) - ); - fireEvent.change(screen.getByLabelText("event.form.location"), { - target: { value: newEvent.location }, - }); + screen.getByRole('button', { name: 'event.form.locationPlaceholder' }) + ) + fireEvent.change(screen.getByLabelText('event.form.location'), { + target: { value: newEvent.location } + }) const spy = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'putEventAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) - fireEvent.click(screen.getByRole("button", { name: "actions.save" })); + fireEvent.click(screen.getByRole('button', { name: 'actions.save' })) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const receivedPayload = spy.mock.calls[0][0]; + const receivedPayload = spy.mock.calls[0][0] expect(receivedPayload.cal).toEqual( - preloadedState.calendars.list["667037022b752d0026472254/cal1"] - ); + preloadedState.calendars.list['667037022b752d0026472254/cal1'] + ) - expect(receivedPayload.newEvent.title).toBe(newEvent.title); - expect(receivedPayload.newEvent.description).toBe(newEvent.description); - expect(receivedPayload.newEvent.location).toBe(newEvent.location); - expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer); + expect(receivedPayload.newEvent.title).toBe(newEvent.title) + expect(receivedPayload.newEvent.description).toBe(newEvent.description) + expect(receivedPayload.newEvent.location).toBe(newEvent.location) + expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer) expect(receivedPayload.newEvent.color).toEqual( - preloadedState.calendars.list["667037022b752d0026472254/cal1"].color - ); + preloadedState.calendars.list['667037022b752d0026472254/cal1'].color + ) // onClose should be called - expect(mockOnClose).toHaveBeenCalledWith(true); - }); + expect(mockOnClose).toHaveBeenCalledWith(true) + }) - it("calls onClose with refresh = false when Cancel clicked", () => { - renderPopover(); + it('calls onClose with refresh = false when Cancel clicked', () => { + renderPopover() // Cancel button only appears in expanded mode - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) - fireEvent.click(screen.getByRole("button", { name: /Cancel/i })); + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })) - expect(mockOnClose).toHaveBeenCalledWith(false); - }); + expect(mockOnClose).toHaveBeenCalledWith(false) + }) - it("BUGFIX: Prefill Calendar field", async () => { - renderPopover(); + it('BUGFIX: Prefill Calendar field', async () => { + renderPopover() // In normal mode calendar is a SectionPreviewRow (button) with calendar name await waitFor(() => expect( - screen.getByRole("button", { name: "Calendar 1" }) - ).toHaveTextContent("Calendar 1") - ); - }); + screen.getByRole('button', { name: 'Calendar 1' }) + ).toHaveTextContent('Calendar 1') + ) + }) - describe("EventModal - Drag and Drop Scenarios", () => { + describe('EventModal - Drag and Drop Scenarios', () => { // Test 2.1: Drag from allday slot (single day) - it("sets allday=true when drag from allday slot (single day)", async () => { + it('sets allday=true when drag from allday slot (single day)', async () => { const selectedRange = { - startStr: "2025-07-18", - endStr: "2025-07-19", - start: new Date("2025-07-18"), - end: new Date("2025-07-19"), - allDay: true, - } as unknown as DateSelectArg; + startStr: '2025-07-18', + endStr: '2025-07-19', + start: new Date('2025-07-18'), + end: new Date('2025-07-19'), + allDay: true + } as unknown as DateSelectArg - renderPopover(selectedRange); + renderPopover(selectedRange) fireEvent.click( - screen.getByRole("button", { name: "common.moreOptions" }) - ); + screen.getByRole('button', { name: 'common.moreOptions' }) + ) - const allDayCheckbox = screen.getByLabelText("event.form.allDay"); + const allDayCheckbox = screen.getByLabelText('event.form.allDay') await waitFor(() => { - expect(allDayCheckbox).toBeChecked(); - }); - }); + expect(allDayCheckbox).toBeChecked() + }) + }) // Test 2.2: Drag from allday slot (multiple days) - it("sets allday=true and shows 2 date fields when drag from allday slot (multiple days)", async () => { + it('sets allday=true and shows 2 date fields when drag from allday slot (multiple days)', async () => { const selectedRange = { - startStr: "2025-07-18", - endStr: "2025-07-21", - start: new Date("2025-07-18"), - end: new Date("2025-07-21"), - allDay: true, - } as unknown as DateSelectArg; + startStr: '2025-07-18', + endStr: '2025-07-21', + start: new Date('2025-07-18'), + end: new Date('2025-07-21'), + allDay: true + } as unknown as DateSelectArg - renderPopover(selectedRange); + renderPopover(selectedRange) fireEvent.click( - screen.getByRole("button", { name: "common.moreOptions" }) - ); + screen.getByRole('button', { name: 'common.moreOptions' }) + ) - const allDayCheckbox = screen.getByLabelText("event.form.allDay"); + const allDayCheckbox = screen.getByLabelText('event.form.allDay') await waitFor(() => { - expect(allDayCheckbox).toBeChecked(); - }); + expect(allDayCheckbox).toBeChecked() + }) // Verify only date fields are shown await waitFor(() => { - expect(screen.getByTestId("start-date-input")).toBeInTheDocument(); - expect(screen.getByTestId("end-date-input")).toBeInTheDocument(); - expect( - screen.queryByTestId("start-time-input") - ).not.toBeInTheDocument(); - expect(screen.queryByTestId("end-time-input")).not.toBeInTheDocument(); - }); - }); + expect(screen.getByTestId('start-date-input')).toBeInTheDocument() + expect(screen.getByTestId('end-date-input')).toBeInTheDocument() + expect(screen.queryByTestId('start-time-input')).not.toBeInTheDocument() + expect(screen.queryByTestId('end-time-input')).not.toBeInTheDocument() + }) + }) // Test 2.3: Drag from week view (single day) - it("keeps allday=false when drag from week view (single day)", async () => { + it('keeps allday=false when drag from week view (single day)', async () => { const selectedRange = { - startStr: "2025-07-18T09:00", - endStr: "2025-07-18T10:00", - start: new Date("2025-07-18T09:00"), - end: new Date("2025-07-18T10:00"), - allDay: false, - } as unknown as DateSelectArg; + startStr: '2025-07-18T09:00', + endStr: '2025-07-18T10:00', + start: new Date('2025-07-18T09:00'), + end: new Date('2025-07-18T10:00'), + allDay: false + } as unknown as DateSelectArg - renderPopover(selectedRange); + renderPopover(selectedRange) fireEvent.click( - screen.getByRole("button", { name: "common.moreOptions" }) - ); + screen.getByRole('button', { name: 'common.moreOptions' }) + ) - const allDayCheckbox = screen.getByLabelText("event.form.allDay"); + const allDayCheckbox = screen.getByLabelText('event.form.allDay') await waitFor(() => { - expect(allDayCheckbox).not.toBeChecked(); - }); + expect(allDayCheckbox).not.toBeChecked() + }) // Verify time fields are shown await waitFor(() => { - expect(screen.getByTestId("start-time-input")).toBeInTheDocument(); - expect(screen.getByTestId("end-time-input")).toBeInTheDocument(); - }); - }); + expect(screen.getByTestId('start-time-input')).toBeInTheDocument() + expect(screen.getByTestId('end-time-input')).toBeInTheDocument() + }) + }) // Test 2.4: Drag from week view (multiple days) - CRITICAL TEST - it("keeps allday=false and shows 4 fields when drag from week view (multiple days)", async () => { + it('keeps allday=false and shows 4 fields when drag from week view (multiple days)', async () => { const selectedRange = { - startStr: "2025-07-18T09:00", - endStr: "2025-07-20T10:00", - start: new Date("2025-07-18T09:00"), - end: new Date("2025-07-20T10:00"), - allDay: false, - } as unknown as DateSelectArg; + startStr: '2025-07-18T09:00', + endStr: '2025-07-20T10:00', + start: new Date('2025-07-18T09:00'), + end: new Date('2025-07-20T10:00'), + allDay: false + } as unknown as DateSelectArg - renderPopover(selectedRange); + renderPopover(selectedRange) fireEvent.click( - screen.getByRole("button", { name: "common.moreOptions" }) - ); + screen.getByRole('button', { name: 'common.moreOptions' }) + ) - const allDayCheckbox = screen.getByLabelText("event.form.allDay"); + const allDayCheckbox = screen.getByLabelText('event.form.allDay') await waitFor(() => { - expect(allDayCheckbox).not.toBeChecked(); // CRITICAL: should NOT be checked - }); + expect(allDayCheckbox).not.toBeChecked() // CRITICAL: should NOT be checked + }) // Verify all 4 fields are shown await waitFor(() => { - expect(screen.getByTestId("start-date-input")).toBeInTheDocument(); - expect(screen.getByTestId("start-time-input")).toBeInTheDocument(); - expect(screen.getByTestId("end-date-input")).toBeInTheDocument(); - expect(screen.getByTestId("end-time-input")).toBeInTheDocument(); - }); - }); + expect(screen.getByTestId('start-date-input')).toBeInTheDocument() + expect(screen.getByTestId('start-time-input')).toBeInTheDocument() + expect(screen.getByTestId('end-date-input')).toBeInTheDocument() + expect(screen.getByTestId('end-time-input')).toBeInTheDocument() + }) + }) // Test 2.5: Drag from week view (multiple days) - fallback path - it("handles drag from week view (multiple days) with Date objects only", async () => { + it('handles drag from week view (multiple days) with Date objects only', async () => { const selectedRange = { - start: new Date("2025-07-18T09:00"), - end: new Date("2025-07-20T10:00"), - allDay: false, - } as unknown as DateSelectArg; + start: new Date('2025-07-18T09:00'), + end: new Date('2025-07-20T10:00'), + allDay: false + } as unknown as DateSelectArg - renderPopover(selectedRange); + renderPopover(selectedRange) fireEvent.click( - screen.getByRole("button", { name: "common.moreOptions" }) - ); + screen.getByRole('button', { name: 'common.moreOptions' }) + ) - const allDayCheckbox = screen.getByLabelText("event.form.allDay"); + const allDayCheckbox = screen.getByLabelText('event.form.allDay') await waitFor(() => { - expect(allDayCheckbox).not.toBeChecked(); - }); + expect(allDayCheckbox).not.toBeChecked() + }) // Verify all 4 fields are shown await waitFor(() => { - expect(screen.getByTestId("start-date-input")).toBeInTheDocument(); - expect(screen.getByTestId("start-time-input")).toBeInTheDocument(); - expect(screen.getByTestId("end-date-input")).toBeInTheDocument(); - expect(screen.getByTestId("end-time-input")).toBeInTheDocument(); - }); - }); - }); -}); + expect(screen.getByTestId('start-date-input')).toBeInTheDocument() + expect(screen.getByTestId('start-time-input')).toBeInTheDocument() + expect(screen.getByTestId('end-date-input')).toBeInTheDocument() + expect(screen.getByTestId('end-time-input')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/__test__/features/Events/EventPreview/utils/makeRecurrenceString.test.ts b/__test__/features/Events/EventPreview/utils/makeRecurrenceString.test.ts index 9be7256..8f07940 100644 --- a/__test__/features/Events/EventPreview/utils/makeRecurrenceString.test.ts +++ b/__test__/features/Events/EventPreview/utils/makeRecurrenceString.test.ts @@ -1,157 +1,157 @@ -import { makeRecurrenceString } from "@/features/Events/EventPreview/utils/makeRecurrenceString"; -import { RepetitionObject } from "@/features/Events/EventsTypes"; +import { makeRecurrenceString } from '@/features/Events/EventPreview/utils/makeRecurrenceString' +import { RepetitionObject } from '@/features/Events/EventsTypes' -describe("makeRecurrenceString", () => { +describe('makeRecurrenceString', () => { const mockT = jest.fn((key: string, params?: any) => { if (params) { - if (key === "eventPreview.everyInterval") { - return `Every ${params.interval} ${params.unit}`; + if (key === 'eventPreview.everyInterval') { + return `Every ${params.interval} ${params.unit}` } - if (key === "eventPreview.recurrenceOnDays") { - return `on ${params.days}`; + if (key === 'eventPreview.recurrenceOnDays') { + return `on ${params.days}` } - if (key === "eventPreview.forOccurrences") { - return `for ${params.count} times`; + if (key === 'eventPreview.forOccurrences') { + return `for ${params.count} times` } - if (key === "eventPreview.until") { - return `until ${params.date}`; + if (key === 'eventPreview.until') { + return `until ${params.date}` } } const translations: Record = { - "event.repeat.frequency.days": "days", - "event.repeat.frequency.weeks": "weeks", - "event.repeat.frequency.months": "months", - "event.repeat.frequency.years": "years", - "eventPreview.onDays.MO": "Monday", - "eventPreview.onDays.TU": "Tuesday", - "eventPreview.onDays.WE": "Wednesday", - "eventPreview.onDays.TH": "Thursday", - "eventPreview.onDays.FR": "Friday", - "eventPreview.onDays.SA": "Saturday", - "eventPreview.onDays.SU": "Sunday", - locale: "en-US", - }; + 'event.repeat.frequency.days': 'days', + 'event.repeat.frequency.weeks': 'weeks', + 'event.repeat.frequency.months': 'months', + 'event.repeat.frequency.years': 'years', + 'eventPreview.onDays.MO': 'Monday', + 'eventPreview.onDays.TU': 'Tuesday', + 'eventPreview.onDays.WE': 'Wednesday', + 'eventPreview.onDays.TH': 'Thursday', + 'eventPreview.onDays.FR': 'Friday', + 'eventPreview.onDays.SA': 'Saturday', + 'eventPreview.onDays.SU': 'Sunday', + locale: 'en-US' + } - return translations[key] || key; - }); + return translations[key] || key + }) - const startText = "Repeats"; + const startText = 'Repeats' beforeEach(() => { - mockT.mockClear(); - }); + mockT.mockClear() + }) - it("formats string for interval === 1 when enableStrForOneTimeInterval is true", () => { - const repetition: RepetitionObject = { freq: "daily", interval: 1 }; + it('formats string for interval === 1 when enableStrForOneTimeInterval is true', () => { + const repetition: RepetitionObject = { freq: 'daily', interval: 1 } const result = makeRecurrenceString({ repetition, t: mockT, startText, - enableStrForOneTimeInterval: true, - }); + enableStrForOneTimeInterval: true + }) - expect(result).toBe("Repeats, days"); - }); + expect(result).toBe('Repeats, days') + }) - it("formats string when interval is undefined", () => { - const repetition: RepetitionObject = { freq: "daily" }; + it('formats string when interval is undefined', () => { + const repetition: RepetitionObject = { freq: 'daily' } const result = makeRecurrenceString({ repetition, t: mockT, startText, - enableStrForOneTimeInterval: true, - }); + enableStrForOneTimeInterval: true + }) - expect(result).toBe("Repeats, days"); - }); + expect(result).toBe('Repeats, days') + }) - it("formats string for interval > 1", () => { - const repetition: RepetitionObject = { freq: "weekly", interval: 2 }; - const result = makeRecurrenceString({ repetition, t: mockT, startText }); + it('formats string for interval > 1', () => { + const repetition: RepetitionObject = { freq: 'weekly', interval: 2 } + const result = makeRecurrenceString({ repetition, t: mockT, startText }) - expect(result).toBe("Repeats, Every 2 weeks"); - expect(mockT).toHaveBeenCalledWith("eventPreview.everyInterval", { + expect(result).toBe('Repeats, Every 2 weeks') + expect(mockT).toHaveBeenCalledWith('eventPreview.everyInterval', { interval: 2, - unit: "weeks", - }); - }); + unit: 'weeks' + }) + }) - it("formats string with specific days and sorts them according to WEEK_DAYS order", () => { + it('formats string with specific days and sorts them according to WEEK_DAYS order', () => { const repetition: RepetitionObject = { - freq: "weekly", + freq: 'weekly', interval: 1, - byday: ["WE", "MO", "FR"], - }; - const result = makeRecurrenceString({ repetition, t: mockT, startText }); + byday: ['WE', 'MO', 'FR'] + } + const result = makeRecurrenceString({ repetition, t: mockT, startText }) // Expected order: MO, WE, FR - expect(result).toBe("Repeats, on Monday, Wednesday, Friday"); - }); + expect(result).toBe('Repeats, on Monday, Wednesday, Friday') + }) - it("formats string with occurrences count", () => { + it('formats string with occurrences count', () => { const repetition: RepetitionObject = { - freq: "daily", + freq: 'daily', interval: 1, - occurrences: 5, - }; - const result = makeRecurrenceString({ repetition, t: mockT, startText }); + occurrences: 5 + } + const result = makeRecurrenceString({ repetition, t: mockT, startText }) - expect(result).toBe("Repeats, for 5 times"); - }); + expect(result).toBe('Repeats, for 5 times') + }) - it("formats string with end date", () => { - const endDate = "2025-12-31T00:00:00.000Z"; + it('formats string with end date', () => { + const endDate = '2025-12-31T00:00:00.000Z' const repetition: RepetitionObject = { - freq: "daily", + freq: 'daily', interval: 1, - endDate, - }; - const result = makeRecurrenceString({ repetition, t: mockT, startText }); + endDate + } + const result = makeRecurrenceString({ repetition, t: mockT, startText }) - const formattedDate = new Date(endDate).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); + const formattedDate = new Date(endDate).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) - expect(result).toBe(`Repeats, until ${formattedDate}`); - }); + expect(result).toBe(`Repeats, until ${formattedDate}`) + }) - it("combines multiple properties", () => { - const endDate = "2025-12-31T00:00:00.000Z"; + it('combines multiple properties', () => { + const endDate = '2025-12-31T00:00:00.000Z' const repetition: RepetitionObject = { - freq: "monthly", + freq: 'monthly', interval: 3, - byday: ["TU", "TH"], - endDate, - }; - const result = makeRecurrenceString({ repetition, t: mockT, startText }); + byday: ['TU', 'TH'], + endDate + } + const result = makeRecurrenceString({ repetition, t: mockT, startText }) - const formattedDate = new Date(endDate).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); + const formattedDate = new Date(endDate).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) expect(result).toBe( `Repeats, Every 3 months, on Tuesday, Thursday, until ${formattedDate}` - ); - }); + ) + }) - it("uses custom joinChar when provided", () => { + it('uses custom joinChar when provided', () => { const repetition: RepetitionObject = { - freq: "weekly", + freq: 'weekly', interval: 2, - occurrences: 10, - }; + occurrences: 10 + } const result = makeRecurrenceString({ repetition, t: mockT, startText, - joinChar: ";", - }); + joinChar: ';' + }) - expect(result).toBe("Repeats; Every 2 weeks; for 10 times"); - }); -}); + expect(result).toBe('Repeats; Every 2 weeks; for 10 times') + }) +}) diff --git a/__test__/features/Events/EventRepetition.test.tsx b/__test__/features/Events/EventRepetition.test.tsx index 4eb80af..5243209 100644 --- a/__test__/features/Events/EventRepetition.test.tsx +++ b/__test__/features/Events/EventRepetition.test.tsx @@ -1,163 +1,163 @@ -import { RootState } from "@/app/store"; +import { RootState } from '@/app/store' import { createEventHandlers, - EventHandlersProps, -} from "@/components/Calendar/handlers/eventHandlers"; -import { EditModeDialog } from "@/components/Event/EditModeDialog"; -import * as eventThunks from "@/features/Calendars/services"; -import * as EventApi from "@/features/Events/EventApi"; -import EventPreviewModal from "@/features/Events/EventPreview"; -import EventUpdateModal from "@/features/Events/EventUpdateModal"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import preview from "jest-preview"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; + EventHandlersProps +} from '@/components/Calendar/handlers/eventHandlers' +import { EditModeDialog } from '@/components/Event/EditModeDialog' +import * as eventThunks from '@/features/Calendars/services' +import * as EventApi from '@/features/Events/EventApi' +import EventPreviewModal from '@/features/Events/EventPreview' +import EventUpdateModal from '@/features/Events/EventUpdateModal' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import preview from 'jest-preview' +import { renderWithProviders } from '../../utils/Renderwithproviders' -jest.mock("@/components/Event/utils/eventUtils", () => { - const actual = jest.requireActual("@/components/Event/utils/eventUtils"); +jest.mock('@/components/Event/utils/eventUtils', () => { + const actual = jest.requireActual('@/components/Event/utils/eventUtils') return { ...actual, refreshCalendars: jest.fn(() => Promise.resolve()), - refreshSingularCalendar: jest.fn(() => Promise.resolve()), - }; -}); -const mockOnClose = jest.fn(); -const day = new Date("2025-03-15T10:00:00Z"); + refreshSingularCalendar: jest.fn(() => Promise.resolve()) + } +}) +const mockOnClose = jest.fn() +const day = new Date('2025-03-15T10:00:00Z') const basePreloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro', + openpaasId: '667037022b752d0026472254' }, organiserData: { - cn: "test", - cal_address: "test@test.com", - }, + cn: 'test', + cal_address: 'test@test.com' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "Calendar", - color: "#FF0000", + '667037022b752d0026472254/cal1': { + id: '667037022b752d0026472254/cal1', + name: 'Calendar', + color: '#FF0000', events: { - "recurring-base/20250315T100000": { - uid: "recurring-base/20250315T100000", - title: "Recurring Event Instance", - calId: "667037022b752d0026472254/cal1", + 'recurring-base/20250315T100000': { + uid: 'recurring-base/20250315T100000', + title: 'Recurring Event Instance', + calId: '667037022b752d0026472254/cal1', start: day.toISOString(), - end: new Date("2025-03-15T11:00:00Z").toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, - recurrenceId: "20250315T100000", - repetition: { freq: "weekly", occurrences: 4, interval: 1 }, - timezone: "UTC", - URL: "/calendars/667037022b752d0026472254/cal1/recurring-base.ics", + end: new Date('2025-03-15T11:00:00Z').toISOString(), + organizer: { cn: 'test', cal_address: 'test@test.com' }, + recurrenceId: '20250315T100000', + repetition: { freq: 'weekly', occurrences: 4, interval: 1 }, + timezone: 'UTC', + URL: '/calendars/667037022b752d0026472254/cal1/recurring-base.ics', attendee: [ { - cn: "test", - cal_address: "test@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'test', + cal_address: 'test@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "John", - cal_address: "john@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ], + cn: 'John', + cal_address: 'john@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] }, - "recurring-base/20250322T100000": { - uid: "recurring-base/20250322T100000", - title: "Recurring Event Instance", - calId: "667037022b752d0026472254/cal1", - start: new Date("2025-03-22T10:00:00Z").toISOString(), - end: new Date("2025-03-22T11:00:00Z").toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, - recurrenceId: "20250322T100000", - timezone: "UTC", - URL: "/calendars/667037022b752d0026472254/cal1/recurring-base.ics", + 'recurring-base/20250322T100000': { + uid: 'recurring-base/20250322T100000', + title: 'Recurring Event Instance', + calId: '667037022b752d0026472254/cal1', + start: new Date('2025-03-22T10:00:00Z').toISOString(), + end: new Date('2025-03-22T11:00:00Z').toISOString(), + organizer: { cn: 'test', cal_address: 'test@test.com' }, + recurrenceId: '20250322T100000', + timezone: 'UTC', + URL: '/calendars/667037022b752d0026472254/cal1/recurring-base.ics', attendee: [ { - cn: "test", - cal_address: "test@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ], - }, + cn: 'test', + cal_address: 'test@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] + } }, - owner: { emails: ["test@test.com"] }, - }, + owner: { emails: ['test@test.com'] } + } }, pending: false, - templist: {}, - }, -} as unknown as RootState; + templist: {} + } +} as unknown as RootState -describe("EditModeDialog Component", () => { +describe('EditModeDialog Component', () => { beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) it("renders dialog when type is 'edit'", async () => { - const mockSetOpen = jest.fn(); - const mockEventAction = jest.fn(); + const mockSetOpen = jest.fn() + const mockEventAction = jest.fn() await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) expect( - screen.getByText("editModeDialog.updateRecurrentEvent") - ).toBeInTheDocument(); - expect(screen.getByText("editModeDialog.thisEvent")).toBeInTheDocument(); - expect(screen.getByText("editModeDialog.allEvents")).toBeInTheDocument(); - }); + screen.getByText('editModeDialog.updateRecurrentEvent') + ).toBeInTheDocument() + expect(screen.getByText('editModeDialog.thisEvent')).toBeInTheDocument() + expect(screen.getByText('editModeDialog.allEvents')).toBeInTheDocument() + }) it("renders dialog when type is 'attendance'", async () => { - const mockSetOpen = jest.fn(); - const mockEventAction = jest.fn(); + const mockSetOpen = jest.fn() + const mockEventAction = jest.fn() await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) expect( - screen.getByText("editModeDialog.updateParticipationStatus") - ).toBeInTheDocument(); - }); + screen.getByText('editModeDialog.updateParticipationStatus') + ).toBeInTheDocument() + }) it("calls eventAction with 'solo' when solo option is selected and Ok clicked", async () => { - const mockSetOpen = jest.fn(); - const mockEventAction = jest.fn(); + const mockSetOpen = jest.fn() + const mockEventAction = jest.fn() await act(async () => renderWithProviders( @@ -165,28 +165,28 @@ describe("EditModeDialog Component", () => { type="edit" setOpen={mockSetOpen} event={ - basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] - .events["recurring-base/20250315T100000"] + basePreloadedState.calendars.list['667037022b752d0026472254/cal1'] + .events['recurring-base/20250315T100000'] } eventAction={mockEventAction} />, basePreloadedState ) - ); + ) await act(async () => { - fireEvent.click(screen.getByLabelText("editModeDialog.thisEvent")); - fireEvent.click(screen.getByRole("button", { name: "common.ok" })); - }); + fireEvent.click(screen.getByLabelText('editModeDialog.thisEvent')) + fireEvent.click(screen.getByRole('button', { name: 'common.ok' })) + }) await waitFor(() => { - expect(mockEventAction).toHaveBeenCalledWith("solo"); - expect(mockSetOpen).toHaveBeenCalledWith(null); - }); - }); + expect(mockEventAction).toHaveBeenCalledWith('solo') + expect(mockSetOpen).toHaveBeenCalledWith(null) + }) + }) it("calls eventAction with 'all' when all option is selected and Ok clicked", async () => { - const mockSetOpen = jest.fn(); - const mockEventAction = jest.fn(); + const mockSetOpen = jest.fn() + const mockEventAction = jest.fn() await act(async () => renderWithProviders( @@ -194,27 +194,27 @@ describe("EditModeDialog Component", () => { type="edit" setOpen={mockSetOpen} event={ - basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] - .events["recurring-base/20250315T100000"] + basePreloadedState.calendars.list['667037022b752d0026472254/cal1'] + .events['recurring-base/20250315T100000'] } eventAction={mockEventAction} />, basePreloadedState ) - ); + ) - fireEvent.click(screen.getByLabelText("editModeDialog.allEvents")); - fireEvent.click(screen.getByRole("button", { name: /Ok/i })); + fireEvent.click(screen.getByLabelText('editModeDialog.allEvents')) + fireEvent.click(screen.getByRole('button', { name: /Ok/i })) await waitFor(() => { - expect(mockEventAction).toHaveBeenCalledWith("all"); - expect(mockSetOpen).toHaveBeenCalledWith(null); - }); - }); + expect(mockEventAction).toHaveBeenCalledWith('all') + expect(mockSetOpen).toHaveBeenCalledWith(null) + }) + }) - it("calls setOpen with null when Cancel is clicked", async () => { - const mockSetOpen = jest.fn(); - const mockEventAction = jest.fn(); + it('calls setOpen with null when Cancel is clicked', async () => { + const mockSetOpen = jest.fn() + const mockEventAction = jest.fn() await act(async () => renderWithProviders( @@ -222,463 +222,463 @@ describe("EditModeDialog Component", () => { type="edit" setOpen={mockSetOpen} event={ - basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] - .events["recurring-base/20250315T100000"] + basePreloadedState.calendars.list['667037022b752d0026472254/cal1'] + .events['recurring-base/20250315T100000'] } eventAction={mockEventAction} />, basePreloadedState ) - ); + ) - fireEvent.click(screen.getByText("common.cancel")); + fireEvent.click(screen.getByText('common.cancel')) await waitFor(() => { - expect(mockSetOpen).toHaveBeenCalledWith(null); - expect(mockEventAction).not.toHaveBeenCalled(); - }); - }); -}); + expect(mockSetOpen).toHaveBeenCalledWith(null) + expect(mockEventAction).not.toHaveBeenCalled() + }) + }) +}) -describe("EventPreviewModal - Recurring Event Interactions", () => { +describe('EventPreviewModal - Recurring Event Interactions', () => { beforeEach(() => { - jest.clearAllMocks(); - }); - it("shows EditModeDialog when editing a recurring event", async () => { - jest.spyOn(eventThunks, "getEventAsync").mockImplementation((payload) => { + jest.clearAllMocks() + }) + it('shows EditModeDialog when editing a recurring event', async () => { + jest.spyOn(eventThunks, 'getEventAsync').mockImplementation(payload => { return () => Promise.resolve({ calId: payload.calId, event: - basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] - .events["recurring-base/20250315T100000"], - }) as any; - }); + basePreloadedState.calendars.list['667037022b752d0026472254/cal1'] + .events['recurring-base/20250315T100000'] + }) as any + }) await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) - fireEvent.click(screen.getByTestId("EditIcon")); + fireEvent.click(screen.getByTestId('EditIcon')) await waitFor(() => { expect( - screen.getByText("editModeDialog.updateRecurrentEvent") - ).toBeInTheDocument(); - }); - }); + screen.getByText('editModeDialog.updateRecurrentEvent') + ).toBeInTheDocument() + }) + }) - it("shows EditModeDialog when deleting a recurring event", async () => { + it('shows EditModeDialog when deleting a recurring event', async () => { await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) - fireEvent.click(screen.getByTestId("MoreVertIcon")); + fireEvent.click(screen.getByTestId('MoreVertIcon')) fireEvent.click( - screen.getByRole("menuitem", { name: "eventPreview.deleteEvent" }) - ); + screen.getByRole('menuitem', { name: 'eventPreview.deleteEvent' }) + ) await waitFor(() => { expect( - screen.getByText("editModeDialog.deleteRecurrentEvent") - ).toBeInTheDocument(); - }); - }); + screen.getByText('editModeDialog.deleteRecurrentEvent') + ).toBeInTheDocument() + }) + }) - it("shows EditModeDialog when RSVP to a recurring event", async () => { + it('shows EditModeDialog when RSVP to a recurring event', async () => { await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) fireEvent.click( - screen.getByRole("button", { name: "eventPreview.ACCEPTED" }) - ); + screen.getByRole('button', { name: 'eventPreview.ACCEPTED' }) + ) await waitFor(() => { expect( - screen.getByText("editModeDialog.updateParticipationStatus") - ).toBeInTheDocument(); - }); - }); + screen.getByText('editModeDialog.updateParticipationStatus') + ).toBeInTheDocument() + }) + }) - it("does not show EditModeDialog for non-recurring events", async () => { + it('does not show EditModeDialog for non-recurring events', async () => { const nonRecurringState = { ...basePreloadedState, calendars: { ...basePreloadedState.calendars, list: { - "667037022b752d0026472254/cal1": { + '667037022b752d0026472254/cal1': { ...basePreloadedState.calendars.list[ - "667037022b752d0026472254/cal1" + '667037022b752d0026472254/cal1' ], events: { - "single-event": { - uid: "single-event", - title: "Single Event", - calId: "667037022b752d0026472254/cal1", + 'single-event': { + uid: 'single-event', + title: 'Single Event', + calId: '667037022b752d0026472254/cal1', start: day.toISOString(), - end: new Date("2025-03-15T11:00:00Z").toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, - timezone: "UTC", - URL: "/calendars/667037022b752d0026472254/cal1/single-event.ics", - attendee: [], - }, - }, - }, - }, - }, - }; + end: new Date('2025-03-15T11:00:00Z').toISOString(), + organizer: { cn: 'test', cal_address: 'test@test.com' }, + timezone: 'UTC', + URL: '/calendars/667037022b752d0026472254/cal1/single-event.ics', + attendee: [] + } + } + } + } + } + } const spy = jest - .spyOn(eventThunks, "deleteEventAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(eventThunks, 'deleteEventAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) await act(async () => renderWithProviders( , nonRecurringState ) - ); + ) - fireEvent.click(screen.getByTestId("MoreVertIcon")); + fireEvent.click(screen.getByTestId('MoreVertIcon')) fireEvent.click( - screen.getByRole("menuitem", { name: "eventPreview.deleteEvent" }) - ); + screen.getByRole('menuitem', { name: 'eventPreview.deleteEvent' }) + ) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - expect(mockOnClose).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + expect(mockOnClose).toHaveBeenCalled() + }) expect( - screen.queryByText("editModeDialog.deleteRecurrentEvent") - ).not.toBeInTheDocument(); - }); -}); + screen.queryByText('editModeDialog.deleteRecurrentEvent') + ).not.toBeInTheDocument() + }) +}) -describe("Delete Recurring Event Instance", () => { +describe('Delete Recurring Event Instance', () => { beforeEach(() => { - jest.clearAllMocks(); - }); - it("calls deleteEventInstanceAsync when deleting single instance", async () => { + jest.clearAllMocks() + }) + it('calls deleteEventInstanceAsync when deleting single instance', async () => { const spy = jest - .spyOn(eventThunks, "deleteEventInstanceAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(eventThunks, 'deleteEventInstanceAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) - fireEvent.click(screen.getByTestId("MoreVertIcon")); + fireEvent.click(screen.getByTestId('MoreVertIcon')) fireEvent.click( - screen.getByRole("menuitem", { name: "eventPreview.deleteEvent" }) - ); + screen.getByRole('menuitem', { name: 'eventPreview.deleteEvent' }) + ) await waitFor(() => { expect( - screen.getByText("editModeDialog.deleteRecurrentEvent") - ).toBeInTheDocument(); - }); + screen.getByText('editModeDialog.deleteRecurrentEvent') + ).toBeInTheDocument() + }) - fireEvent.click(screen.getByLabelText("editModeDialog.thisEvent")); - fireEvent.click(screen.getByRole("button", { name: /Ok/i })); + fireEvent.click(screen.getByLabelText('editModeDialog.thisEvent')) + fireEvent.click(screen.getByRole('button', { name: /Ok/i })) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const receivedPayload = spy.mock.calls[0][0]; - expect(receivedPayload.event.uid).toBe("recurring-base/20250315T100000"); - }); + const receivedPayload = spy.mock.calls[0][0] + expect(receivedPayload.event.uid).toBe('recurring-base/20250315T100000') + }) - it("calls deleteEventAsync when deleting all instances", async () => { - jest.spyOn(EventApi, "getEvent").mockResolvedValue({ - ...basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] - .events["recurring-base/20250315T100000"], - uid: "recurring-base", - } as any); + it('calls deleteEventAsync when deleting all instances', async () => { + jest.spyOn(EventApi, 'getEvent').mockResolvedValue({ + ...basePreloadedState.calendars.list['667037022b752d0026472254/cal1'] + .events['recurring-base/20250315T100000'], + uid: 'recurring-base' + } as any) const spy = jest - .spyOn(eventThunks, "deleteEventAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(eventThunks, 'deleteEventAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) - fireEvent.click(screen.getByTestId("MoreVertIcon")); + fireEvent.click(screen.getByTestId('MoreVertIcon')) fireEvent.click( - screen.getByRole("menuitem", { name: "eventPreview.deleteEvent" }) - ); + screen.getByRole('menuitem', { name: 'eventPreview.deleteEvent' }) + ) await waitFor(() => { expect( - screen.getByText("editModeDialog.deleteRecurrentEvent") - ).toBeInTheDocument(); - }); + screen.getByText('editModeDialog.deleteRecurrentEvent') + ).toBeInTheDocument() + }) - fireEvent.click(screen.getByLabelText("editModeDialog.allEvents")); - fireEvent.click(screen.getByRole("button", { name: /Ok/i })); + fireEvent.click(screen.getByLabelText('editModeDialog.allEvents')) + fireEvent.click(screen.getByRole('button', { name: /Ok/i })) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const receivedPayload = spy.mock.calls[0][0]; - expect(receivedPayload.eventId).toBe("recurring-base/20250315T100000"); - }); -}); + const receivedPayload = spy.mock.calls[0][0] + expect(receivedPayload.eventId).toBe('recurring-base/20250315T100000') + }) +}) -describe("RSVP to Recurring Event", () => { +describe('RSVP to Recurring Event', () => { beforeEach(() => { - jest.clearAllMocks(); - }); - it("calls updateEventInstanceAsync when accepting single instance", async () => { + jest.clearAllMocks() + }) + it('calls updateEventInstanceAsync when accepting single instance', async () => { const spy = jest - .spyOn(eventThunks, "updateEventInstanceAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'updateEventInstanceAsync') + .mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) fireEvent.click( - screen.getByRole("button", { name: "eventPreview.ACCEPTED" }) - ); + screen.getByRole('button', { name: 'eventPreview.ACCEPTED' }) + ) await waitFor(() => { expect( - screen.getByText("editModeDialog.updateParticipationStatus") - ).toBeInTheDocument(); - }); + screen.getByText('editModeDialog.updateParticipationStatus') + ).toBeInTheDocument() + }) - fireEvent.click(screen.getByLabelText("editModeDialog.thisEvent")); - fireEvent.click(screen.getByRole("button", { name: /Ok/i })); + fireEvent.click(screen.getByLabelText('editModeDialog.thisEvent')) + fireEvent.click(screen.getByRole('button', { name: /Ok/i })) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const updatedEvent = spy.mock.calls[0][0].event; - expect(updatedEvent.attendee[0].partstat).toBe("ACCEPTED"); - }); + const updatedEvent = spy.mock.calls[0][0].event + expect(updatedEvent.attendee[0].partstat).toBe('ACCEPTED') + }) - it("calls updateSeriesPartstat when accepting all instances", async () => { + it('calls updateSeriesPartstat when accepting all instances', async () => { const spy = jest - .spyOn(EventApi, "updateSeriesPartstat") - .mockResolvedValue({} as any); + .spyOn(EventApi, 'updateSeriesPartstat') + .mockResolvedValue({} as any) await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) fireEvent.click( - screen.getByRole("button", { name: "eventPreview.ACCEPTED" }) - ); + screen.getByRole('button', { name: 'eventPreview.ACCEPTED' }) + ) await waitFor(() => { expect( - screen.getByText("editModeDialog.updateParticipationStatus") - ).toBeInTheDocument(); - }); + screen.getByText('editModeDialog.updateParticipationStatus') + ).toBeInTheDocument() + }) - fireEvent.click(screen.getByLabelText("editModeDialog.allEvents")); - fireEvent.click(screen.getByRole("button", { name: "common.ok" })); + fireEvent.click(screen.getByLabelText('editModeDialog.allEvents')) + fireEvent.click(screen.getByRole('button', { name: 'common.ok' })) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const callArgs = spy.mock.calls[0]; - expect(callArgs[0].uid).toBe("recurring-base/20250315T100000"); - expect(callArgs[1]).toBe("test@test.com"); - expect(callArgs[2]).toBe("ACCEPTED"); - }); -}); + const callArgs = spy.mock.calls[0] + expect(callArgs[0].uid).toBe('recurring-base/20250315T100000') + expect(callArgs[1]).toBe('test@test.com') + expect(callArgs[2]).toBe('ACCEPTED') + }) +}) -describe("Edit Recurring Event in Full Display", () => { +describe('Edit Recurring Event in Full Display', () => { beforeEach(() => { - jest.clearAllMocks(); - }); - it("renders event with recurrenceId in URL", async () => { + jest.clearAllMocks() + }) + it('renders event with recurrenceId in URL', async () => { await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) expect( - screen.getByDisplayValue("Recurring Event Instance") - ).toBeInTheDocument(); - }); + screen.getByDisplayValue('Recurring Event Instance') + ).toBeInTheDocument() + }) it("calls updateEventInstanceAsync when saving single instance with typeOfAction='solo'", async () => { - jest.spyOn(EventApi, "getEvent").mockResolvedValue({ - ...basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] - .events["recurring-base/20250315T100000"], - } as any); + jest.spyOn(EventApi, 'getEvent').mockResolvedValue({ + ...basePreloadedState.calendars.list['667037022b752d0026472254/cal1'] + .events['recurring-base/20250315T100000'] + } as any) const spy = jest - .spyOn(eventThunks, "updateEventInstanceAsync") - .mockImplementation((payload) => { + .spyOn(eventThunks, 'updateEventInstanceAsync') + .mockImplementation(payload => { return () => { - const promise = Promise.resolve(); - (promise as any).unwrap = () => promise; - return promise as any; - }; - }); + const promise = Promise.resolve() + ;(promise as any).unwrap = () => promise + return promise as any + } + }) await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) - const titleField = screen.getByLabelText("event.form.title"); + const titleField = screen.getByLabelText('event.form.title') fireEvent.change(titleField, { - target: { value: "Updated Single Instance" }, - }); + target: { value: 'Updated Single Instance' } + }) // Click Save - fireEvent.click(screen.getByText("actions.save")); + fireEvent.click(screen.getByText('actions.save')) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const updatedEvent = spy.mock.calls[0][0].event; - expect(updatedEvent.title).toBe("Updated Single Instance"); - expect(updatedEvent.recurrenceId).toBe("20250315T100000"); - }); + const updatedEvent = spy.mock.calls[0][0].event + expect(updatedEvent.title).toBe('Updated Single Instance') + expect(updatedEvent.recurrenceId).toBe('20250315T100000') + }) it("calls updateSeriesAsync when saving all instances with typeOfAction='all'", async () => { - const getEventSpy = jest.spyOn(EventApi, "getEvent").mockResolvedValue({ - ...basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] - .events["recurring-base/20250315T100000"], - } as any); + const getEventSpy = jest.spyOn(EventApi, 'getEvent').mockResolvedValue({ + ...basePreloadedState.calendars.list['667037022b752d0026472254/cal1'] + .events['recurring-base/20250315T100000'] + } as any) const spy = jest - .spyOn(eventThunks, "updateSeriesAsync") - .mockImplementation((payload) => { + .spyOn(eventThunks, 'updateSeriesAsync') + .mockImplementation(payload => { return () => { - const promise = Promise.resolve(); - (promise as any).unwrap = () => promise; - return promise as any; - }; - }); + const promise = Promise.resolve() + ;(promise as any).unwrap = () => promise + return promise as any + } + }) await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) await waitFor(() => { - expect(getEventSpy).toHaveBeenCalled(); - }); + expect(getEventSpy).toHaveBeenCalled() + }) - const titleField = screen.getByDisplayValue("Recurring Event Instance"); + const titleField = screen.getByDisplayValue('Recurring Event Instance') fireEvent.change(titleField, { - target: { value: "Updated All Instances" }, - }); + target: { value: 'Updated All Instances' } + }) - fireEvent.click(screen.getByRole("button", { name: "actions.save" })); + fireEvent.click(screen.getByRole('button', { name: 'actions.save' })) await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalled() + }) - const updatedEvent = spy.mock.calls[0][0].event; - expect(updatedEvent.title).toBe("Updated All Instances"); - }); + const updatedEvent = spy.mock.calls[0][0].event + expect(updatedEvent.title).toBe('Updated All Instances') + }) it("disables repetition editing when typeOfAction='solo'", async () => { await act(async () => @@ -686,68 +686,66 @@ describe("Edit Recurring Event in Full Display", () => { , basePreloadedState ) - ); + ) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); - expect(screen.getByText("event.repeat.every")).toBeInTheDocument(); - expect(screen.getByText("event.repeat.end.label")).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) + expect(screen.getByText('event.repeat.every')).toBeInTheDocument() + expect(screen.getByText('event.repeat.end.label')).toBeInTheDocument() - const frequencySelect = screen.getByRole("radio", { - name: /event.repeat.end.after \d event.repeat.end.occurrences/i, - }); - expect(frequencySelect).toBeDisabled(); - }); + const frequencySelect = screen.getByRole('radio', { + name: /event.repeat.end.after \d event.repeat.end.occurrences/i + }) + expect(frequencySelect).toBeDisabled() + }) it("fetches master event data when typeOfAction='all'", async () => { - const getEventSpy = jest.spyOn(EventApi, "getEvent").mockResolvedValue({ - ...basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] - .events["recurring-base/20250315T100000"], - uid: "recurring-base", - title: "Master Event Title", - description: "Master Description", - } as any); + const getEventSpy = jest.spyOn(EventApi, 'getEvent').mockResolvedValue({ + ...basePreloadedState.calendars.list['667037022b752d0026472254/cal1'] + .events['recurring-base/20250315T100000'], + uid: 'recurring-base', + title: 'Master Event Title', + description: 'Master Description' + } as any) await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) await waitFor(() => { - expect(getEventSpy).toHaveBeenCalled(); - expect( - screen.getByDisplayValue("Master Event Title") - ).toBeInTheDocument(); - }); - }); -}); + expect(getEventSpy).toHaveBeenCalled() + expect(screen.getByDisplayValue('Master Event Title')).toBeInTheDocument() + }) + }) +}) -describe("Event Drag and Drop - Recurring Events", () => { +describe('Event Drag and Drop - Recurring Events', () => { beforeEach(() => { - jest.clearAllMocks(); - }); - it("shows EditModeDialog when dragging recurring event", () => { - const mockDispatch = jest.fn(); - const mockSetSelectedEvent = jest.fn(); - const mockSetOpenEditModePopup = jest.fn(); - const mockSetAfterChoiceFunc = jest.fn(); + jest.clearAllMocks() + }) + it('shows EditModeDialog when dragging recurring event', () => { + const mockDispatch = jest.fn() + const mockSetSelectedEvent = jest.fn() + const mockSetOpenEditModePopup = jest.fn() + const mockSetAfterChoiceFunc = jest.fn() const eventHandlers = createEventHandlers({ setSelectedRange: jest.fn(), @@ -762,38 +760,38 @@ describe("Event Drag and Drop - Recurring Events", () => { calendars: basePreloadedState.calendars.list, setSelectedEvent: mockSetSelectedEvent, setAfterChoiceFunc: mockSetAfterChoiceFunc, - setOpenEditModePopup: mockSetOpenEditModePopup, - } as unknown as EventHandlersProps); + setOpenEditModePopup: mockSetOpenEditModePopup + } as unknown as EventHandlersProps) const mockArg = { event: { _def: { extendedProps: { - uid: "recurring-base/20250315T100000", - calId: "667037022b752d0026472254/cal1", - }, - }, + uid: 'recurring-base/20250315T100000', + calId: '667037022b752d0026472254/cal1' + } + } }, - delta: { years: 0, months: 0, days: 1, milliseconds: 0 }, - }; + delta: { years: 0, months: 0, days: 1, milliseconds: 0 } + } - eventHandlers.handleEventDrop(mockArg); + eventHandlers.handleEventDrop(mockArg) - expect(mockSetSelectedEvent).toHaveBeenCalled(); - expect(mockSetOpenEditModePopup).toHaveBeenCalledWith("edit"); - expect(mockSetAfterChoiceFunc).toHaveBeenCalled(); - }); -}); + expect(mockSetSelectedEvent).toHaveBeenCalled() + expect(mockSetOpenEditModePopup).toHaveBeenCalledWith('edit') + expect(mockSetAfterChoiceFunc).toHaveBeenCalled() + }) +}) -describe("Event Resize - Recurring Events", () => { +describe('Event Resize - Recurring Events', () => { beforeEach(() => { - jest.clearAllMocks(); - }); - it("shows EditModeDialog when resizing recurring event", () => { - const mockDispatch = jest.fn(); - const mockSetSelectedEvent = jest.fn(); - const mockSetOpenEditModePopup = jest.fn(); - const mockSetAfterChoiceFunc = jest.fn(); + jest.clearAllMocks() + }) + it('shows EditModeDialog when resizing recurring event', () => { + const mockDispatch = jest.fn() + const mockSetSelectedEvent = jest.fn() + const mockSetOpenEditModePopup = jest.fn() + const mockSetAfterChoiceFunc = jest.fn() const eventHandlers = createEventHandlers({ setSelectedRange: jest.fn(), @@ -808,396 +806,392 @@ describe("Event Resize - Recurring Events", () => { calendars: basePreloadedState.calendars.list, setSelectedEvent: mockSetSelectedEvent, setAfterChoiceFunc: mockSetAfterChoiceFunc, - setOpenEditModePopup: mockSetOpenEditModePopup, - } as unknown as EventHandlersProps); + setOpenEditModePopup: mockSetOpenEditModePopup + } as unknown as EventHandlersProps) const mockArg = { event: { _def: { extendedProps: { - uid: "recurring-base/20250315T100000", - calId: "667037022b752d0026472254/cal1", - }, - }, + uid: 'recurring-base/20250315T100000', + calId: '667037022b752d0026472254/cal1' + } + } }, startDelta: { years: 0, months: 0, days: 0, milliseconds: 0 }, - endDelta: { years: 0, months: 0, days: 0, milliseconds: 3600000 }, // 1 hour - }; + endDelta: { years: 0, months: 0, days: 0, milliseconds: 3600000 } // 1 hour + } - eventHandlers.handleEventResize(mockArg); + eventHandlers.handleEventResize(mockArg) - expect(mockSetSelectedEvent).toHaveBeenCalled(); - expect(mockSetOpenEditModePopup).toHaveBeenCalledWith("edit"); - expect(mockSetAfterChoiceFunc).toHaveBeenCalled(); - }); -}); + expect(mockSetSelectedEvent).toHaveBeenCalled() + expect(mockSetOpenEditModePopup).toHaveBeenCalledWith('edit') + expect(mockSetAfterChoiceFunc).toHaveBeenCalled() + }) +}) -describe("RepeatEvent Component - Recurrence Editing", () => { +describe('RepeatEvent Component - Recurrence Editing', () => { beforeEach(() => { - jest.clearAllMocks(); - }); - it("disables repetition fields when isOwn is false", async () => { + jest.clearAllMocks() + }) + it('disables repetition fields when isOwn is false', async () => { await act(async () => renderWithProviders( , { ...basePreloadedState, calendars: { ...basePreloadedState.calendars, list: { - "otherCal/cal": { - id: "otherCal/cal", - name: "Other Calendar", - color: "#00FF00", + 'otherCal/cal': { + id: 'otherCal/cal', + name: 'Other Calendar', + color: '#00FF00', events: { - "recurring-base/20250315T100000": { + 'recurring-base/20250315T100000': { ...basePreloadedState.calendars.list[ - "667037022b752d0026472254/cal1" - ].events["recurring-base/20250315T100000"], - calId: "otherCal/cal", - organizer: { cn: "other", cal_address: "other@test.com" }, - }, - }, - }, - }, - }, + '667037022b752d0026472254/cal1' + ].events['recurring-base/20250315T100000'], + calId: 'otherCal/cal', + organizer: { cn: 'other', cal_address: 'other@test.com' } + } + } + } + } + } } ) - ); + ) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) // Repetition section should not be visible for non-owner expect( - screen.queryByLabelText("event.form.repetition") - ).not.toBeInTheDocument(); - }); -}); + screen.queryByLabelText('event.form.repetition') + ).not.toBeInTheDocument() + }) +}) -describe("handleRSVP function", () => { +describe('handleRSVP function', () => { beforeEach(() => { - jest.clearAllMocks(); - }); - it("calls putEventAsync for non-recurring events", async () => { - const mockDispatch = jest.fn(); - const mockOnClose = jest.fn(); + jest.clearAllMocks() + }) + it('calls putEventAsync for non-recurring events', async () => { + const mockDispatch = jest.fn() + const mockOnClose = jest.fn() const { - handleRSVP, - } = require("@/components/Event/eventHandlers/eventHandlers"); + handleRSVP + } = require('@/components/Event/eventHandlers/eventHandlers') - jest.spyOn(eventThunks, "putEventAsync").mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + jest.spyOn(eventThunks, 'putEventAsync').mockImplementation(payload => { + const promise = Promise.resolve(payload) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) const nonRecurringEvent = { - uid: "single-event", - title: "Single Event", - calId: "667037022b752d0026472254/cal1", + uid: 'single-event', + title: 'Single Event', + calId: '667037022b752d0026472254/cal1', start: day.toISOString(), - end: new Date("2025-03-15T11:00:00Z").toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, + end: new Date('2025-03-15T11:00:00Z').toISOString(), + organizer: { cn: 'test', cal_address: 'test@test.com' }, attendee: [ { - cn: "test", - cal_address: "test@test.com", - partstat: "NEEDS-ACTION", - }, - ], - }; + cn: 'test', + cal_address: 'test@test.com', + partstat: 'NEEDS-ACTION' + } + ] + } await handleRSVP( mockDispatch, - basePreloadedState.calendars.list["667037022b752d0026472254/cal1"], + basePreloadedState.calendars.list['667037022b752d0026472254/cal1'], basePreloadedState.user, nonRecurringEvent, - "ACCEPTED" - ); + 'ACCEPTED' + ) - expect(mockDispatch).toHaveBeenCalled(); - }); -}); + expect(mockDispatch).toHaveBeenCalled() + }) +}) -describe("handleDelete function", () => { +describe('handleDelete function', () => { beforeEach(() => { - jest.clearAllMocks(); - }); - it("calls deleteEventAsync for non-recurring events", () => { - const mockDispatch = jest.fn(); - const mockOnClose = jest.fn(); + jest.clearAllMocks() + }) + it('calls deleteEventAsync for non-recurring events', () => { + const mockDispatch = jest.fn() + const mockOnClose = jest.fn() const { - handleDelete, - } = require("@/components/Event/eventHandlers/eventHandlers"); + handleDelete + } = require('@/components/Event/eventHandlers/eventHandlers') - jest - .spyOn(eventThunks, "deleteEventAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + jest.spyOn(eventThunks, 'deleteEventAsync').mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) const nonRecurringEvent = { - uid: "single-event", - title: "Single Event", - calId: "667037022b752d0026472254/cal1", - URL: "/calendars/667037022b752d0026472254/cal1/single-event.ics", - }; + uid: 'single-event', + title: 'Single Event', + calId: '667037022b752d0026472254/cal1', + URL: '/calendars/667037022b752d0026472254/cal1/single-event.ics' + } handleDelete( false, // isRecurring undefined, mockOnClose, mockDispatch, - basePreloadedState.calendars.list["667037022b752d0026472254/cal1"], + basePreloadedState.calendars.list['667037022b752d0026472254/cal1'], nonRecurringEvent, - "667037022b752d0026472254/cal1", - "single-event" - ); + '667037022b752d0026472254/cal1', + 'single-event' + ) - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); - expect(mockDispatch).toHaveBeenCalled(); - }); + expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick') + expect(mockDispatch).toHaveBeenCalled() + }) - it("calls deleteEventInstanceAsync when deleting solo recurring event", () => { - const mockDispatch = jest.fn(); - const mockOnClose = jest.fn(); + it('calls deleteEventInstanceAsync when deleting solo recurring event', () => { + const mockDispatch = jest.fn() + const mockOnClose = jest.fn() const { - handleDelete, - } = require("@/components/Event/eventHandlers/eventHandlers"); + handleDelete + } = require('@/components/Event/eventHandlers/eventHandlers') jest - .spyOn(eventThunks, "deleteEventInstanceAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .spyOn(eventThunks, 'deleteEventInstanceAsync') + .mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) handleDelete( true, // isRecurring - "solo", + 'solo', mockOnClose, mockDispatch, - basePreloadedState.calendars.list["667037022b752d0026472254/cal1"], - basePreloadedState.calendars.list["667037022b752d0026472254/cal1"].events[ - "recurring-base/20250315T100000" + basePreloadedState.calendars.list['667037022b752d0026472254/cal1'], + basePreloadedState.calendars.list['667037022b752d0026472254/cal1'].events[ + 'recurring-base/20250315T100000' ], - "667037022b752d0026472254/cal1", - "recurring-base/20250315T100000" - ); + '667037022b752d0026472254/cal1', + 'recurring-base/20250315T100000' + ) - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); - expect(mockDispatch).toHaveBeenCalled(); - }); + expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick') + expect(mockDispatch).toHaveBeenCalled() + }) - it("calls deleteEventAsync when deleting all recurring events", () => { - const mockDispatch = jest.fn(); - const mockOnClose = jest.fn(); + it('calls deleteEventAsync when deleting all recurring events', () => { + const mockDispatch = jest.fn() + const mockOnClose = jest.fn() const { - handleDelete, - } = require("@/components/Event/eventHandlers/eventHandlers"); + handleDelete + } = require('@/components/Event/eventHandlers/eventHandlers') - jest - .spyOn(eventThunks, "deleteEventAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + jest.spyOn(eventThunks, 'deleteEventAsync').mockImplementation(payload => { + return () => Promise.resolve(payload) as any + }) handleDelete( true, // isRecurring - "all", + 'all', mockOnClose, mockDispatch, - basePreloadedState.calendars.list["667037022b752d0026472254/cal1"], - basePreloadedState.calendars.list["667037022b752d0026472254/cal1"].events[ - "recurring-base/20250315T100000" + basePreloadedState.calendars.list['667037022b752d0026472254/cal1'], + basePreloadedState.calendars.list['667037022b752d0026472254/cal1'].events[ + 'recurring-base/20250315T100000' ], - "667037022b752d0026472254/cal1", - "recurring-base/20250315T100000" - ); + '667037022b752d0026472254/cal1', + 'recurring-base/20250315T100000' + ) - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); - expect(mockDispatch).toHaveBeenCalled(); - }); -}); + expect(mockOnClose).toHaveBeenCalledWith({}, 'backdropClick') + expect(mockDispatch).toHaveBeenCalled() + }) +}) -describe("Calendar Integration - EditModeDialog Flow", () => { +describe('Calendar Integration - EditModeDialog Flow', () => { beforeEach(() => { - jest.clearAllMocks(); - }); - it("passes correct eventId when editing all instances from preview", async () => { + jest.clearAllMocks() + }) + it('passes correct eventId when editing all instances from preview', async () => { await act(async () => renderWithProviders( , basePreloadedState ) - ); + ) // Click edit - fireEvent.click(screen.getByTestId("EditIcon")); + fireEvent.click(screen.getByTestId('EditIcon')) await waitFor(() => { expect( - screen.getByText("editModeDialog.updateRecurrentEvent") - ).toBeInTheDocument(); - }); + screen.getByText('editModeDialog.updateRecurrentEvent') + ).toBeInTheDocument() + }) // Select "All the events" - fireEvent.click(screen.getByLabelText("editModeDialog.allEvents")); - fireEvent.click(screen.getByRole("button", { name: /Ok/i })); + fireEvent.click(screen.getByLabelText('editModeDialog.allEvents')) + fireEvent.click(screen.getByRole('button', { name: /Ok/i })) await waitFor(() => { // Update modal should open - expect(screen.getByText("event.updateEvent")).toBeInTheDocument(); - }); - }); -}); + expect(screen.getByText('event.updateEvent')).toBeInTheDocument() + }) + }) +}) -describe("Event URL handling for recurring events", () => { +describe('Event URL handling for recurring events', () => { beforeEach(() => { - jest.clearAllMocks(); - }); - it("uses base ID for event URL when moving recurring event", async () => { + jest.clearAllMocks() + }) + it('uses base ID for event URL when moving recurring event', async () => { const moveEventSpy = jest - .spyOn(eventThunks, "moveEventAsync") - .mockImplementation((payload) => { - const result = { calId: payload.cal.id, events: [] }; - const promise = Promise.resolve(result); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); + .spyOn(eventThunks, 'moveEventAsync') + .mockImplementation(payload => { + const result = { calId: payload.cal.id, events: [] } + const promise = Promise.resolve(result) + ;(promise as any).unwrap = () => promise + return () => promise as any + }) const twoCalState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro', + openpaasId: '667037022b752d0026472254' }, organiserData: { - cn: "test", - cal_address: "test@test.com", - }, + cn: 'test', + cal_address: 'test@test.com' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "Calendar", - color: "#FF0000", + '667037022b752d0026472254/cal1': { + id: '667037022b752d0026472254/cal1', + name: 'Calendar', + color: '#FF0000', events: { - "recurring-base/20250315T100000": { - uid: "recurring-base/20250315T100000", - title: "Recurring Event Instance", - calId: "667037022b752d0026472254/cal1", + 'recurring-base/20250315T100000': { + uid: 'recurring-base/20250315T100000', + title: 'Recurring Event Instance', + calId: '667037022b752d0026472254/cal1', start: day.toISOString(), - end: new Date("2025-03-15T11:00:00Z").toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, - recurrenceId: "20250315T100000", - repetition: { freq: "weekly", occurrences: 4, interval: 1 }, - timezone: "UTC", - URL: "/calendars/667037022b752d0026472254/cal1/recurring-base.ics", + end: new Date('2025-03-15T11:00:00Z').toISOString(), + organizer: { cn: 'test', cal_address: 'test@test.com' }, + recurrenceId: '20250315T100000', + repetition: { freq: 'weekly', occurrences: 4, interval: 1 }, + timezone: 'UTC', + URL: '/calendars/667037022b752d0026472254/cal1/recurring-base.ics', attendee: [ { - cn: "test", - cal_address: "test@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'test', + cal_address: 'test@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, { - cn: "John", - cal_address: "john@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ], + cn: 'John', + cal_address: 'john@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] }, - "recurring-base/20250322T100000": { - uid: "recurring-base/20250322T100000", - title: "Recurring Event Instance", - calId: "667037022b752d0026472254/cal1", - start: new Date("2025-03-22T10:00:00Z").toISOString(), - end: new Date("2025-03-22T11:00:00Z").toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, - recurrenceId: "20250322T100000", - timezone: "UTC", - URL: "/calendars/667037022b752d0026472254/cal1/recurring-base.ics", + 'recurring-base/20250322T100000': { + uid: 'recurring-base/20250322T100000', + title: 'Recurring Event Instance', + calId: '667037022b752d0026472254/cal1', + start: new Date('2025-03-22T10:00:00Z').toISOString(), + end: new Date('2025-03-22T11:00:00Z').toISOString(), + organizer: { cn: 'test', cal_address: 'test@test.com' }, + recurrenceId: '20250322T100000', + timezone: 'UTC', + URL: '/calendars/667037022b752d0026472254/cal1/recurring-base.ics', attendee: [ { - cn: "test", - cal_address: "test@test.com", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, - ], - }, + cn: 'test', + cal_address: 'test@test.com', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } + ] + } }, - owner: { emails: ["test@test.com"] }, - }, - "667037022b752d0026472254/cal2": { - id: "667037022b752d0026472254/cal2", - name: "Calendar 2", - color: "#00FF00", - events: {}, + owner: { emails: ['test@test.com'] } }, + '667037022b752d0026472254/cal2': { + id: '667037022b752d0026472254/cal2', + name: 'Calendar 2', + color: '#00FF00', + events: {} + } }, pending: false, - templist: {}, - }, - }; + templist: {} + } + } await act(async () => renderWithProviders( , twoCalState ) - ); + ) // Expand to show calendar combobox (normal mode shows SectionPreviewRow) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); - fireEvent.mouseDown(screen.getByLabelText("event.form.calendar")); - const option = await screen.findByText("Calendar 2"); - fireEvent.click(option); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) + fireEvent.mouseDown(screen.getByLabelText('event.form.calendar')) + const option = await screen.findByText('Calendar 2') + fireEvent.click(option) // Click Save button - const saveButton = screen.getByRole("button", { name: "actions.save" }); + const saveButton = screen.getByRole('button', { name: 'actions.save' }) await act(async () => { - fireEvent.click(saveButton); + fireEvent.click(saveButton) // Wait for the 500ms delay in the code - await new Promise((resolve) => setTimeout(resolve, 600)); - }); + await new Promise(resolve => setTimeout(resolve, 600)) + }) await waitFor(() => { - expect(moveEventSpy).toHaveBeenCalled(); - }); + expect(moveEventSpy).toHaveBeenCalled() + }) - const movePayload = moveEventSpy.mock.calls[0][0]; + const movePayload = moveEventSpy.mock.calls[0][0] // Should use base ID, not full uid with recurrence ID - expect(movePayload.newURL).toContain("recurring-base.ics"); - expect(movePayload.newURL).not.toContain("/20250315T100000"); - }); -}); + expect(movePayload.newURL).toContain('recurring-base.ics') + expect(movePayload.newURL).not.toContain('/20250315T100000') + }) +}) diff --git a/__test__/features/Events/EventUpdateModal.recurring.test.tsx b/__test__/features/Events/EventUpdateModal.recurring.test.tsx index 35893e3..02e8ce1 100644 --- a/__test__/features/Events/EventUpdateModal.recurring.test.tsx +++ b/__test__/features/Events/EventUpdateModal.recurring.test.tsx @@ -1,81 +1,81 @@ -import * as CalendarApi from "@/features/Calendars/CalendarApi"; -import * as eventThunks from "@/features/Calendars/services"; -import * as EventApi from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import EventUpdateModal from "@/features/Events/EventUpdateModal"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import * as CalendarApi from '@/features/Calendars/CalendarApi' +import * as eventThunks from '@/features/Calendars/services' +import * as EventApi from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import EventUpdateModal from '@/features/Events/EventUpdateModal' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../../utils/Renderwithproviders' -jest.mock("@/features/Events/EventApi"); -jest.mock("@/features/Calendars/CalendarApi"); -jest.mock("@/features/Events/api/updateSeries"); +jest.mock('@/features/Events/EventApi') +jest.mock('@/features/Calendars/CalendarApi') +jest.mock('@/features/Events/api/updateSeries') describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => { - const mockOnClose = jest.fn(); + const mockOnClose = jest.fn() beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - sessionStorage.clear(); - }); + jest.clearAllMocks() + jest.restoreAllMocks() + sessionStorage.clear() + }) - const baseUID = "recurring-event-base"; - const calId = "667037022b752d0026472254/cal1"; + const baseUID = 'recurring-event-base' + const calId = '667037022b752d0026472254/cal1' const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "test-sid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'test-sid', + openpaasId: '667037022b752d0026472254' }, organiserData: { - cn: "test", - cal_address: "test@test.com", - }, + cn: 'test', + cal_address: 'test@test.com' + } }, calendars: { list: { [calId]: { id: calId, - name: "Test Calendar", - color: "#FF0000", + name: 'Test Calendar', + color: '#FF0000', events: {}, - owner: { emails: ["test@test.com"] }, - }, + owner: { emails: ['test@test.com'] } + } }, - pending: false, - }, - }; + pending: false + } + } - describe("Master Event Display", () => { + describe('Master Event Display', () => { it("should fetch and display master event when editing 'all events' of a recurring series", async () => { // Given a recurring series with modified instances const masterEvent = { uid: baseUID, - title: "Master Event Title", - description: "Master Description", + title: 'Master Event Title', + description: 'Master Description', calId, - start: "2025-01-15T10:00:00.000Z", - end: "2025-01-15T11:00:00.000Z", - repetition: { freq: "daily", interval: 1 }, + start: '2025-01-15T10:00:00.000Z', + end: '2025-01-15T11:00:00.000Z', + repetition: { freq: 'daily', interval: 1 }, allday: false, - timezone: "America/New_York", - organizer: { cn: "test", cal_address: "test@test.com" }, - attendee: [{ cn: "test", cal_address: "test@test.com" }], - URL: `/calendars/${calId}/${baseUID}.ics`, - } as CalendarEvent; + timezone: 'America/New_York', + organizer: { cn: 'test', cal_address: 'test@test.com' }, + attendee: [{ cn: 'test', cal_address: 'test@test.com' }], + URL: `/calendars/${calId}/${baseUID}.ics` + } as CalendarEvent // Instance that was moved to different time const modifiedInstance = { ...masterEvent, uid: `${baseUID}/20250116`, - title: "Modified Instance Title", // Different title - start: "2025-01-16T14:00:00.000Z", // Different time (2PM instead of 10AM) - end: "2025-01-16T15:00:00.000Z", - }; + title: 'Modified Instance Title', // Different title + start: '2025-01-16T14:00:00.000Z', // Different time (2PM instead of 10AM) + end: '2025-01-16T15:00:00.000Z' + } const stateWithRecurringEvent = { ...preloadedState, @@ -86,17 +86,17 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => { ...preloadedState.calendars.list[calId], events: { [baseUID]: masterEvent, - [`${baseUID}/20250116`]: modifiedInstance, - }, - }, - }, - }, - }; + [`${baseUID}/20250116`]: modifiedInstance + } + } + } + } + } // Mock getEvent to return master event const mockGetEvent = jest - .spyOn(EventApi, "getEvent") - .mockResolvedValue(masterEvent); + .spyOn(EventApi, 'getEvent') + .mockResolvedValue(masterEvent) renderWithProviders( { typeOfAction="all" />, stateWithRecurringEvent - ); + ) // Wait for master event to be fetched await waitFor(() => { expect(mockGetEvent).toHaveBeenCalledWith( expect.objectContaining({ - uid: baseUID, // Should fetch base UID, not instance + uid: baseUID // Should fetch base UID, not instance }), true - ); - }); + ) + }) // Should display master event data, NOT modified instance data await waitFor(() => { - const titleInput = screen.getByDisplayValue("Master Event Title"); - expect(titleInput).toBeInTheDocument(); - }); + const titleInput = screen.getByDisplayValue('Master Event Title') + expect(titleInput).toBeInTheDocument() + }) // Should NOT show modified instance title expect( - screen.queryByDisplayValue("Modified Instance Title") - ).not.toBeInTheDocument(); + screen.queryByDisplayValue('Modified Instance Title') + ).not.toBeInTheDocument() // Expand more options to show timezone (normal mode shows summary first) fireEvent.click( - screen.getByRole("button", { name: "common.moreOptions" }) - ); + screen.getByRole('button', { name: 'common.moreOptions' }) + ) // Verify timezone dropdown shows master timezone await waitFor(() => { - expect(screen.getByDisplayValue(/New York/i)).toBeDefined(); - }); - }); + expect(screen.getByDisplayValue(/New York/i)).toBeDefined() + }) + }) - it("should use master event directly if clicked event is already the master", async () => { + it('should use master event directly if clicked event is already the master', async () => { const masterEvent = { uid: baseUID, // No recurrence-id - title: "Master Event", + title: 'Master Event', calId, - start: "2025-01-15T10:00:00.000Z", - end: "2025-01-15T11:00:00.000Z", - repetition: { freq: "weekly", interval: 1 }, + start: '2025-01-15T10:00:00.000Z', + end: '2025-01-15T11:00:00.000Z', + repetition: { freq: 'weekly', interval: 1 }, allday: false, - organizer: { cn: "test", cal_address: "test@test.com" }, - URL: `/calendars/${calId}/${baseUID}.ics`, - }; + organizer: { cn: 'test', cal_address: 'test@test.com' }, + URL: `/calendars/${calId}/${baseUID}.ics` + } const stateWithMaster = { ...preloadedState, @@ -161,14 +161,14 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => { [calId]: { ...preloadedState.calendars.list[calId], events: { - [baseUID]: masterEvent, - }, - }, - }, - }, - }; + [baseUID]: masterEvent + } + } + } + } + } - const mockGetEvent = jest.spyOn(EventApi, "getEvent"); + const mockGetEvent = jest.spyOn(EventApi, 'getEvent') renderWithProviders( { typeOfAction="all" />, stateWithMaster - ); + ) await waitFor(() => { - expect(screen.getByDisplayValue("Master Event")).toBeInTheDocument(); - }); + expect(screen.getByDisplayValue('Master Event')).toBeInTheDocument() + }) // Should NOT fetch from API since we already have master - expect(mockGetEvent).not.toHaveBeenCalled(); - }); + expect(mockGetEvent).not.toHaveBeenCalled() + }) - it("should fallback to instance data if master event fetch fails", async () => { + it('should fallback to instance data if master event fetch fails', async () => { const instance = { uid: `${baseUID}/20250115`, - title: "Instance Event", + title: 'Instance Event', calId, - start: "2025-01-15T10:00:00.000Z", - end: "2025-01-15T11:00:00.000Z", - repetition: { freq: "daily", interval: 1 }, + start: '2025-01-15T10:00:00.000Z', + end: '2025-01-15T11:00:00.000Z', + repetition: { freq: 'daily', interval: 1 }, allday: false, - organizer: { cn: "test", cal_address: "test@test.com" }, - URL: `/calendars/${calId}/${baseUID}.ics`, - } as CalendarEvent; + organizer: { cn: 'test', cal_address: 'test@test.com' }, + URL: `/calendars/${calId}/${baseUID}.ics` + } as CalendarEvent const stateWithInstance = { ...preloadedState, @@ -210,19 +210,19 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => { [calId]: { ...preloadedState.calendars.list[calId], events: { - [`${baseUID}/20250115`]: instance, - }, - }, - }, - }, - }; + [`${baseUID}/20250115`]: instance + } + } + } + } + } // Mock getEvent to fail jest - .spyOn(EventApi, "getEvent") - .mockRejectedValue(new Error("Network error")); + .spyOn(EventApi, 'getEvent') + .mockRejectedValue(new Error('Network error')) - const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() renderWithProviders( { typeOfAction="all" />, stateWithInstance - ); + ) // Should still display the instance data as fallback await waitFor(() => { - expect(screen.getByDisplayValue("Instance Event")).toBeInTheDocument(); - }); + expect(screen.getByDisplayValue('Instance Event')).toBeInTheDocument() + }) expect(consoleErrorSpy).toHaveBeenCalledWith( - "Failed to fetch master event:", + 'Failed to fetch master event:', expect.any(Error) - ); + ) - consoleErrorSpy.mockRestore(); - }); - }); + consoleErrorSpy.mockRestore() + }) + }) - describe("Update All Events - Using Base UID", () => { - it("should use base UID (not instance UID) when updating series with property changes", async () => { + describe('Update All Events - Using Base UID', () => { + it('should use base UID (not instance UID) when updating series with property changes', async () => { const masterEvent = { uid: baseUID, - title: "Weekly Meeting", - description: "Original description", + title: 'Weekly Meeting', + description: 'Original description', calId, - start: "2025-01-15T10:00:00.000Z", - end: "2025-01-15T11:00:00.000Z", - repetition: { freq: "weekly", interval: 1 }, + start: '2025-01-15T10:00:00.000Z', + end: '2025-01-15T11:00:00.000Z', + repetition: { freq: 'weekly', interval: 1 }, allday: false, - organizer: { cn: "test", cal_address: "test@test.com" }, - URL: `/calendars/${calId}/${baseUID}.ics`, - } as CalendarEvent; + organizer: { cn: 'test', cal_address: 'test@test.com' }, + URL: `/calendars/${calId}/${baseUID}.ics` + } as CalendarEvent const instance1 = { ...masterEvent, - uid: `${baseUID}/20250115`, - }; + uid: `${baseUID}/20250115` + } const instance2 = { ...masterEvent, uid: `${baseUID}/20250122`, - start: "2025-01-22T10:00:00.000Z", - end: "2025-01-22T11:00:00.000Z", - }; + start: '2025-01-22T10:00:00.000Z', + end: '2025-01-22T11:00:00.000Z' + } const stateWithSeries = { ...preloadedState, @@ -286,16 +286,16 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => { events: { [baseUID]: masterEvent, [`${baseUID}/20250115`]: instance1, - [`${baseUID}/20250122`]: instance2, - }, - }, - }, - }, - }; + [`${baseUID}/20250122`]: instance2 + } + } + } + } + } // Mock getEvent to return master - jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent); - const updateSeriesAsyncSpy = jest.spyOn(eventThunks, "updateSeriesAsync"); + jest.spyOn(EventApi, 'getEvent').mockResolvedValue(masterEvent) + const updateSeriesAsyncSpy = jest.spyOn(eventThunks, 'updateSeriesAsync') renderWithProviders( { typeOfAction="all" />, stateWithSeries - ); + ) await waitFor(() => { - expect(screen.getByDisplayValue("Weekly Meeting")).toBeInTheDocument(); - }); + expect(screen.getByDisplayValue('Weekly Meeting')).toBeInTheDocument() + }) // Change only the title (property change, not recurrence rules) - const titleInput = screen.getByDisplayValue("Weekly Meeting"); - fireEvent.change(titleInput, { target: { value: "Updated Meeting" } }); + const titleInput = screen.getByDisplayValue('Weekly Meeting') + fireEvent.change(titleInput, { target: { value: 'Updated Meeting' } }) - const saveButton = screen.getByRole("button", { name: "actions.save" }); + const saveButton = screen.getByRole('button', { name: 'actions.save' }) await act(async () => { - fireEvent.click(saveButton); - }); + fireEvent.click(saveButton) + }) // Verify updateSeriesAsync was dispatched with base UID await waitFor(() => { expect(updateSeriesAsyncSpy).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ - uid: "recurring-event-base", - recurrenceId: undefined, + uid: 'recurring-event-base', + recurrenceId: undefined }), - removeOverrides: false, + removeOverrides: false }) - ); - }); - }); + ) + }) + }) - it("should use base UID when updating series with recurrence rule changes", async () => { + it('should use base UID when updating series with recurrence rule changes', async () => { const masterEvent = { uid: baseUID, - title: "Daily Standup", + title: 'Daily Standup', calId, - start: "2025-01-15T09:00:00.000Z", - end: "2025-01-15T09:30:00.000Z", - repetition: { freq: "daily", interval: 1 }, // Daily + start: '2025-01-15T09:00:00.000Z', + end: '2025-01-15T09:30:00.000Z', + repetition: { freq: 'daily', interval: 1 }, // Daily allday: false, - timezone: "America/New_York", - organizer: { cn: "test", cal_address: "test@test.com" }, - URL: `/calendars/${calId}/${baseUID}.ics`, - } as CalendarEvent; + timezone: 'America/New_York', + organizer: { cn: 'test', cal_address: 'test@test.com' }, + URL: `/calendars/${calId}/${baseUID}.ics` + } as CalendarEvent const instance1 = { ...masterEvent, - uid: `${baseUID}/20250115`, - }; + uid: `${baseUID}/20250115` + } const instance2 = { ...masterEvent, uid: `${baseUID}/20250116`, - start: "2025-01-16T09:00:00.000Z", - end: "2025-01-16T09:30:00.000Z", - }; + start: '2025-01-16T09:00:00.000Z', + end: '2025-01-16T09:30:00.000Z' + } const stateWithSeries = { ...preloadedState, @@ -372,17 +372,17 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => { events: { [baseUID]: masterEvent, [`${baseUID}/20250115`]: instance1, - [`${baseUID}/20250116`]: instance2, - }, - }, - }, - }, - }; + [`${baseUID}/20250116`]: instance2 + } + } + } + } + } - jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent); - jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({ - _embedded: { "dav:item": [] }, - } as any); + jest.spyOn(EventApi, 'getEvent').mockResolvedValue(masterEvent) + jest.spyOn(CalendarApi, 'getCalendar').mockResolvedValue({ + _embedded: { 'dav:item': [] } + } as any) const { store } = renderWithProviders( { typeOfAction="all" />, stateWithSeries - ); + ) await waitFor(() => { - expect(screen.getByDisplayValue("Daily Standup")).toBeInTheDocument(); - }); + expect(screen.getByDisplayValue('Daily Standup')).toBeInTheDocument() + }) // Click More Options to access repeat settings - const moreOptionsButton = screen.getByText("common.moreOptions"); - fireEvent.click(moreOptionsButton); + const moreOptionsButton = screen.getByText('common.moreOptions') + fireEvent.click(moreOptionsButton) await waitFor(() => { - const repeatCheckbox = screen.getByLabelText("event.form.repeat"); - expect(repeatCheckbox).toBeInTheDocument(); - expect(repeatCheckbox).toBeChecked(); - }); + const repeatCheckbox = screen.getByLabelText('event.form.repeat') + expect(repeatCheckbox).toBeInTheDocument() + expect(repeatCheckbox).toBeChecked() + }) // Change from daily to weekly - const frequencySelect = screen.getByText("event.repeat.frequency.days"); - fireEvent.mouseDown(frequencySelect); - const weeklyOption = screen.getByRole("option", { - name: "event.repeat.frequency.weeks", - }); - fireEvent.click(weeklyOption); + const frequencySelect = screen.getByText('event.repeat.frequency.days') + fireEvent.mouseDown(frequencySelect) + const weeklyOption = screen.getByRole('option', { + name: 'event.repeat.frequency.weeks' + }) + fireEvent.click(weeklyOption) - const saveButton = screen.getByRole("button", { name: "actions.save" }); + const saveButton = screen.getByRole('button', { name: 'actions.save' }) await act(async () => { - fireEvent.click(saveButton); - await new Promise((resolve) => setTimeout(resolve, 100)); - }); + fireEvent.click(saveButton) + await new Promise(resolve => setTimeout(resolve, 100)) + }) // Verify all old instances were removed from store await waitFor(() => { - const state = store.getState(); - const calendar = state.calendars.list[calId]; + const state = store.getState() + const calendar = state.calendars.list[calId] - expect(calendar.events[baseUID]).toBeUndefined(); - expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined(); - expect(calendar.events[`${baseUID}/20250116`]).toBeUndefined(); - }); - }); - }); + expect(calendar.events[baseUID]).toBeUndefined() + expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined() + expect(calendar.events[`${baseUID}/20250116`]).toBeUndefined() + }) + }) + }) - describe("Master Event Date Preservation", () => { + describe('Master Event Date Preservation', () => { it("should preserve master event date when updating time in 'all events' mode", async () => { // Master event starts on Jan 15 at 10:00 AM const masterEvent = { uid: baseUID, - title: "Daily Standup", + title: 'Daily Standup', calId, - start: "2025-01-15T10:00:00.000Z", - end: "2025-01-15T10:30:00.000Z", - repetition: { freq: "daily", interval: 1 }, + start: '2025-01-15T10:00:00.000Z', + end: '2025-01-15T10:30:00.000Z', + repetition: { freq: 'daily', interval: 1 }, allday: false, - timezone: "UTC", - organizer: { cn: "test", cal_address: "test@test.com" }, - URL: `/calendars/${calId}/${baseUID}.ics`, - } as CalendarEvent; + timezone: 'UTC', + organizer: { cn: 'test', cal_address: 'test@test.com' }, + URL: `/calendars/${calId}/${baseUID}.ics` + } as CalendarEvent // Instance on Jan 17 (different date) const instance = { ...masterEvent, uid: `${baseUID}/20250117`, - start: "2025-01-17T10:00:00.000Z", - end: "2025-01-17T10:30:00.000Z", - }; + start: '2025-01-17T10:00:00.000Z', + end: '2025-01-17T10:30:00.000Z' + } const stateWithSeries = { ...preloadedState, @@ -469,19 +469,17 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => { ...preloadedState.calendars.list[calId], events: { [baseUID]: masterEvent, - [`${baseUID}/20250117`]: instance, - }, - }, - }, - }, - }; + [`${baseUID}/20250117`]: instance + } + } + } + } + } - jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent); - jest - .spyOn(EventApi, "putEvent") - .mockResolvedValue({ status: 201 } as any); + jest.spyOn(EventApi, 'getEvent').mockResolvedValue(masterEvent) + jest.spyOn(EventApi, 'putEvent').mockResolvedValue({ status: 201 } as any) - const updateSeriesAsyncSpy = jest.spyOn(eventThunks, "updateSeriesAsync"); + const updateSeriesAsyncSpy = jest.spyOn(eventThunks, 'updateSeriesAsync') renderWithProviders( { typeOfAction="all" />, stateWithSeries - ); + ) await waitFor(() => { - expect(screen.getByDisplayValue("Daily Standup")).toBeInTheDocument(); - }); + expect(screen.getByDisplayValue('Daily Standup')).toBeInTheDocument() + }) // Expand more options to show date/time inputs (normal mode shows DateTimeSummary first) fireEvent.click( - screen.getByRole("button", { name: "common.moreOptions" }) - ); - const input = await waitFor(() => screen.getByTestId("start-time-input")); + screen.getByRole('button', { name: 'common.moreOptions' }) + ) + const input = await waitFor(() => screen.getByTestId('start-time-input')) // Change time to 2:00 PM (14:00) - await userEvent.click(input); - await userEvent.clear(input); - await userEvent.type(input, "14:00{enter}"); + await userEvent.click(input) + await userEvent.clear(input) + await userEvent.type(input, '14:00{enter}') - const saveButton = screen.getByRole("button", { name: "actions.save" }); + const saveButton = screen.getByRole('button', { name: 'actions.save' }) await act(async () => { - fireEvent.click(saveButton); - await new Promise((resolve) => setTimeout(resolve, 100)); - }); + fireEvent.click(saveButton) + await new Promise(resolve => setTimeout(resolve, 100)) + }) // Verify the API was called with master date (Jan 15) + new time (14:00) await waitFor(() => { - expect(updateSeriesAsyncSpy).toHaveBeenCalled(); - const callArgs = updateSeriesAsyncSpy.mock.calls[0][0]; - const updatedEvent = callArgs.event; + expect(updateSeriesAsyncSpy).toHaveBeenCalled() + const callArgs = updateSeriesAsyncSpy.mock.calls[0][0] + const updatedEvent = callArgs.event // Should preserve Jan 15 date (master), not Jan 17 (clicked instance) - expect(updatedEvent.start).toContain("2025-01-15"); + expect(updatedEvent.start).toContain('2025-01-15') // But with new time 14:00 (in UTC, this is 14:00) - expect(updatedEvent.start).toContain("14:00"); - }); - }); - }); -}); + expect(updatedEvent.start).toContain('14:00') + }) + }) + }) +}) diff --git a/__test__/features/Events/EventUpdateModal.test.tsx b/__test__/features/Events/EventUpdateModal.test.tsx index 5a1a725..dc9a756 100644 --- a/__test__/features/Events/EventUpdateModal.test.tsx +++ b/__test__/features/Events/EventUpdateModal.test.tsx @@ -1,329 +1,329 @@ -import * as CalendarApi from "@/features/Calendars/CalendarApi"; -import * as EventApi from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import EventUpdateModal from "@/features/Events/EventUpdateModal"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import * as CalendarApi from '@/features/Calendars/CalendarApi' +import * as EventApi from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import EventUpdateModal from '@/features/Events/EventUpdateModal' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../../utils/Renderwithproviders' -jest.mock("@/features/Events/EventApi"); -jest.mock("@/features/Calendars/CalendarApi"); +jest.mock('@/features/Events/EventApi') +jest.mock('@/features/Calendars/CalendarApi') -describe("EventUpdateModal Timezone Handling", () => { - const mockOnClose = jest.fn(); +describe('EventUpdateModal Timezone Handling', () => { + const mockOnClose = jest.fn() beforeEach(() => { - jest.clearAllMocks(); - sessionStorage.clear(); - }); + jest.clearAllMocks() + sessionStorage.clear() + }) const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "test-sid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'test-sid', + openpaasId: '667037022b752d0026472254' }, organiserData: { - cn: "test", - cal_address: "test@test.com", - }, + cn: 'test', + cal_address: 'test@test.com' + } }, calendars: { list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "Test Calendar", - color: "#FF0000", - events: {}, - }, + '667037022b752d0026472254/cal1': { + id: '667037022b752d0026472254/cal1', + name: 'Test Calendar', + color: '#FF0000', + events: {} + } }, - pending: false, - }, - }; + pending: false + } + } - it("displays event time in original timezone when editing event", async () => { + it('displays event time in original timezone when editing event', async () => { // GIVEN user timezone is UTC+2 // WHEN the user edits an event at 2PM UTC+7 (Asia/Bangkok) // THEN the update modal prompts him date with 2PM UTC+7 // Create event at 2PM UTC+7 (Asia/Bangkok) // 2PM UTC+7 = 14:00 in Bangkok = 07:00 UTC - const eventDateUTC = new Date("2025-01-15T07:00:00.000Z"); + const eventDateUTC = new Date('2025-01-15T07:00:00.000Z') const eventData = { - uid: "test-event-1", - title: "Timezone Event", - calId: "667037022b752d0026472254/cal1", + uid: 'test-event-1', + title: 'Timezone Event', + calId: '667037022b752d0026472254/cal1', start: eventDateUTC.toISOString(), end: new Date(eventDateUTC.getTime() + 3600000).toISOString(), - timezone: "Asia/Bangkok", + timezone: 'Asia/Bangkok', allday: false, - organizer: { cn: "test", cal_address: "test@test.com" }, - attendee: [{ cn: "test", cal_address: "test@test.com" }], - URL: "/calendars/667037022b752d0026472254/cal1/test-event-1.ics", - } as CalendarEvent; + organizer: { cn: 'test', cal_address: 'test@test.com' }, + attendee: [{ cn: 'test', cal_address: 'test@test.com' }], + URL: '/calendars/667037022b752d0026472254/cal1/test-event-1.ics' + } as CalendarEvent const stateWithEvent = { ...preloadedState, calendars: { ...preloadedState.calendars, list: { - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + '667037022b752d0026472254/cal1': { + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'], events: { - "test-event-1": eventData, - }, - }, - }, - }, - }; + 'test-event-1': eventData + } + } + } + } + } renderWithProviders( , stateWithEvent - ); + ) // Verify title is displayed - const titleInput = screen.getByDisplayValue("Timezone Event"); - expect(titleInput).toBeInTheDocument(); + const titleInput = screen.getByDisplayValue('Timezone Event') + expect(titleInput).toBeInTheDocument() // Expand to show date/time inputs (normal mode shows DateTimeSummary) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) // Verify the start date and time inputs exist await waitFor(() => { - const startDateInput = screen.getByTestId("start-date-input"); - const startTimeInput = screen.getByTestId("start-time-input"); + const startDateInput = screen.getByTestId('start-date-input') + const startTimeInput = screen.getByTestId('start-time-input') - expect(startDateInput).toBeInTheDocument(); - expect(startTimeInput).toBeInTheDocument(); + expect(startDateInput).toBeInTheDocument() + expect(startTimeInput).toBeInTheDocument() // MUI DatePicker/TimePicker values are stored differently - just check elements exist // The actual values are verified through the form submission - }); - }); + }) + }) - it("preserves original timezone when editing event fields", async () => { - const eventDateUTC = new Date("2025-01-15T07:00:00.000Z"); + it('preserves original timezone when editing event fields', async () => { + const eventDateUTC = new Date('2025-01-15T07:00:00.000Z') const eventData = { - uid: "test-event-2", - title: "Original Event", - calId: "667037022b752d0026472254/cal1", + uid: 'test-event-2', + title: 'Original Event', + calId: '667037022b752d0026472254/cal1', start: eventDateUTC.toISOString(), end: new Date(eventDateUTC.getTime() + 3600000).toISOString(), - timezone: "Asia/Bangkok", + timezone: 'Asia/Bangkok', allday: false, - organizer: { cn: "test", cal_address: "test@test.com" }, - attendee: [{ cn: "test", cal_address: "test@test.com" }], - URL: "/calendars/667037022b752d0026472254/cal1/test-event-2.ics", - } as CalendarEvent; + organizer: { cn: 'test', cal_address: 'test@test.com' }, + attendee: [{ cn: 'test', cal_address: 'test@test.com' }], + URL: '/calendars/667037022b752d0026472254/cal1/test-event-2.ics' + } as CalendarEvent const stateWithEvent = { ...preloadedState, calendars: { ...preloadedState.calendars, list: { - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + '667037022b752d0026472254/cal1': { + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'], events: { - "test-event-2": eventData, - }, - }, - }, - }, - }; + 'test-event-2': eventData + } + } + } + } + } renderWithProviders( , stateWithEvent - ); + ) // Edit the title - const titleInput = screen.getByDisplayValue("Original Event"); - fireEvent.change(titleInput, { target: { value: "Updated Event" } }); + const titleInput = screen.getByDisplayValue('Original Event') + fireEvent.change(titleInput, { target: { value: 'Updated Event' } }) // Verify the timezone is still preserved (should be Asia/Bangkok) - expect(titleInput).toHaveValue("Updated Event"); - }); + expect(titleInput).toHaveValue('Updated Event') + }) - it("preserves resources when editing an event", async () => { - const eventDateUTC = new Date("2025-01-15T07:00:00.000Z"); + it('preserves resources when editing an event', async () => { + const eventDateUTC = new Date('2025-01-15T07:00:00.000Z') const eventData = { - uid: "test-event-resource", - title: "Resource Event", - calId: "667037022b752d0026472254/cal1", + uid: 'test-event-resource', + title: 'Resource Event', + calId: '667037022b752d0026472254/cal1', start: eventDateUTC.toISOString(), end: new Date(eventDateUTC.getTime() + 3600000).toISOString(), - timezone: "Asia/Bangkok", + timezone: 'Asia/Bangkok', allday: false, - organizer: { cn: "test", cal_address: "test@test.com" }, + organizer: { cn: 'test', cal_address: 'test@test.com' }, attendee: [ - { cn: "test", cal_address: "test@test.com" }, + { cn: 'test', cal_address: 'test@test.com' }, { - cn: "Conference Room", - cal_address: "room@test.com", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "RESOURCE", - }, + cn: 'Conference Room', + cal_address: 'room@test.com', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'RESOURCE' + } ], - URL: "/calendars/667037022b752d0026472254/cal1/test-event-resource.ics", - } as CalendarEvent; + URL: '/calendars/667037022b752d0026472254/cal1/test-event-resource.ics' + } as CalendarEvent const stateWithEvent = { ...preloadedState, calendars: { ...preloadedState.calendars, list: { - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + '667037022b752d0026472254/cal1': { + ...preloadedState.calendars.list['667037022b752d0026472254/cal1'], events: { - "test-event-resource": eventData, - }, - }, - }, - }, - }; + 'test-event-resource': eventData + } + } + } + } + } - const mockPutEvent = jest.spyOn(EventApi, "putEvent").mockResolvedValue({ + const mockPutEvent = jest.spyOn(EventApi, 'putEvent').mockResolvedValue({ status: 201, - url: `/calendars/667037022b752d0026472254/cal1/test-event-resource.ics`, - } as any); + url: `/calendars/667037022b752d0026472254/cal1/test-event-resource.ics` + } as any) renderWithProviders( , stateWithEvent - ); + ) // Edit the title - const titleInput = screen.getByDisplayValue("Resource Event"); + const titleInput = screen.getByDisplayValue('Resource Event') fireEvent.change(titleInput, { - target: { value: "Updated Resource Event" }, - }); + target: { value: 'Updated Resource Event' } + }) // Click Save - const saveButton = screen.getByRole("button", { name: "actions.save" }); + const saveButton = screen.getByRole('button', { name: 'actions.save' }) await act(async () => { - fireEvent.click(saveButton); - }); + fireEvent.click(saveButton) + }) await waitFor(() => { - expect(mockPutEvent).toHaveBeenCalled(); - }); + expect(mockPutEvent).toHaveBeenCalled() + }) - const putEventCall = mockPutEvent.mock.calls[0][0]; - expect(putEventCall.title).toBe("Updated Resource Event"); + const putEventCall = mockPutEvent.mock.calls[0][0] + expect(putEventCall.title).toBe('Updated Resource Event') // Check that the resource is still in the attendee list! - const attendees = putEventCall.attendee; - const resource = attendees.find((a: any) => a.cutype === "RESOURCE"); - expect(resource).toBeDefined(); - expect(resource!.cn).toBe("Conference Room"); - expect(resource!.cal_address).toBe("room@test.com"); - expect(resource!.partstat).toBe("ACCEPTED"); - expect(resource!.rsvp).toBe("TRUE"); - expect(resource!.role).toBe("REQ-PARTICIPANT"); - expect(resource!.cutype).toBe("RESOURCE"); - }); -}); + const attendees = putEventCall.attendee + const resource = attendees.find((a: any) => a.cutype === 'RESOURCE') + expect(resource).toBeDefined() + expect(resource!.cn).toBe('Conference Room') + expect(resource!.cal_address).toBe('room@test.com') + expect(resource!.partstat).toBe('ACCEPTED') + expect(resource!.rsvp).toBe('TRUE') + expect(resource!.role).toBe('REQ-PARTICIPANT') + expect(resource!.cutype).toBe('RESOURCE') + }) +}) -describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => { - const mockOnClose = jest.fn(); +describe('EventUpdateModal Recurring to Non-Recurring Conversion', () => { + const mockOnClose = jest.fn() beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); + jest.clearAllMocks() + jest.restoreAllMocks() + }) - const baseUID = "recurring-event-base"; - const calId = "667037022b752d0026472254/cal1"; + const baseUID = 'recurring-event-base' + const calId = '667037022b752d0026472254/cal1' const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "test-sid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'test-sid', + openpaasId: '667037022b752d0026472254' }, organiserData: { - cn: "test", - cal_address: "test@test.com", - }, + cn: 'test', + cal_address: 'test@test.com' + } }, calendars: { list: { [calId]: { id: calId, - name: "Test Calendar", - color: "#FF0000", - events: {}, - }, + name: 'Test Calendar', + color: '#FF0000', + events: {} + } }, - pending: false, - }, - }; + pending: false + } + } - it("converts recurring event to non-recurring when repeat is disabled in edit all mode", async () => { + it('converts recurring event to non-recurring when repeat is disabled in edit all mode', async () => { // Create recurring event with multiple instances - const eventDate = new Date("2025-01-15T10:00:00.000Z"); + const eventDate = new Date('2025-01-15T10:00:00.000Z') const masterEvent = { uid: baseUID, - title: "Recurring Meeting", + title: 'Recurring Meeting', calId, start: eventDate.toISOString(), end: new Date(eventDate.getTime() + 3600000).toISOString(), - repetition: { freq: "daily", interval: 1 }, + repetition: { freq: 'daily', interval: 1 }, allday: false, - organizer: { cn: "test", cal_address: "test@test.com" }, - attendee: [{ cn: "test", cal_address: "test@test.com" }], + organizer: { cn: 'test', cal_address: 'test@test.com' }, + attendee: [{ cn: 'test', cal_address: 'test@test.com' }], URL: `/calendars/${calId}/${baseUID}.ics`, - timezone: "UTC", - } as CalendarEvent; + timezone: 'UTC' + } as CalendarEvent const instance1 = { ...masterEvent, uid: `${baseUID}/20250115`, - start: "2025-01-15T10:00:00.000Z", - end: "2025-01-15T11:00:00.000Z", - }; + start: '2025-01-15T10:00:00.000Z', + end: '2025-01-15T11:00:00.000Z' + } const instance2 = { ...masterEvent, uid: `${baseUID}/20250116`, - start: "2025-01-16T10:00:00.000Z", - end: "2025-01-16T11:00:00.000Z", - }; + start: '2025-01-16T10:00:00.000Z', + end: '2025-01-16T11:00:00.000Z' + } const instance3 = { ...masterEvent, uid: `${baseUID}/20250117`, - start: "2025-01-17T10:00:00.000Z", - end: "2025-01-17T11:00:00.000Z", - }; + start: '2025-01-17T10:00:00.000Z', + end: '2025-01-17T11:00:00.000Z' + } const stateWithRecurringEvent = { ...preloadedState, @@ -336,29 +336,29 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => { [baseUID]: masterEvent, [`${baseUID}/20250115`]: instance1, [`${baseUID}/20250116`]: instance2, - [`${baseUID}/20250117`]: instance3, - }, - }, - }, - }, - }; + [`${baseUID}/20250117`]: instance3 + } + } + } + } + } // Mock API calls const mockDeleteEvent = jest - .spyOn(EventApi, "deleteEvent") - .mockResolvedValue({} as any); - const mockPutEvent = jest.spyOn(EventApi, "putEvent").mockResolvedValue({ + .spyOn(EventApi, 'deleteEvent') + .mockResolvedValue({} as any) + const mockPutEvent = jest.spyOn(EventApi, 'putEvent').mockResolvedValue({ status: 201, - url: `/calendars/${calId}/new-event.ics`, - } as any); + url: `/calendars/${calId}/new-event.ics` + } as any) const mockGetMasterEvent = jest - .spyOn(EventApi, "getEvent") - .mockResolvedValue(masterEvent); - jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({ + .spyOn(EventApi, 'getEvent') + .mockResolvedValue(masterEvent) + jest.spyOn(CalendarApi, 'getCalendar').mockResolvedValue({ _embedded: { - "dav:item": [], - }, - } as any); + 'dav:item': [] + } + } as any) const { store } = renderWithProviders( { typeOfAction="all" />, stateWithRecurringEvent - ); + ) // Wait for master event to be fetched AND form to be initialized await waitFor(() => { - expect(mockGetMasterEvent).toHaveBeenCalled(); - }); + expect(mockGetMasterEvent).toHaveBeenCalled() + }) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) // Wait for the repeat checkbox to be checked (indicating form is initialized) const repeatCheckbox = await waitFor(() => { - const checkbox = screen.getByLabelText("event.form.repeat"); - expect(checkbox).toBeChecked(); - return checkbox; - }); + const checkbox = screen.getByLabelText('event.form.repeat') + expect(checkbox).toBeChecked() + return checkbox + }) // Now uncheck repeat checkbox await act(async () => { - fireEvent.click(repeatCheckbox); - }); + fireEvent.click(repeatCheckbox) + }) // Wait for checkbox to be unchecked await waitFor(() => { - expect(repeatCheckbox).not.toBeChecked(); - }); + expect(repeatCheckbox).not.toBeChecked() + }) // Click Save button - const saveButton = screen.getByRole("button", { name: "actions.save" }); + const saveButton = screen.getByRole('button', { name: 'actions.save' }) await act(async () => { - fireEvent.click(saveButton); - }); + fireEvent.click(saveButton) + }) await waitFor(() => { - expect(mockPutEvent).toHaveBeenCalled(); - }); + expect(mockPutEvent).toHaveBeenCalled() + }) // Verify API calls - should delete all instances await waitFor( () => { // Should have called deleteEvent for each instance (4 total: base + 3 recurrences) - expect(mockDeleteEvent).toHaveBeenCalled(); + expect(mockDeleteEvent).toHaveBeenCalled() // At least one instance should be deleted - expect(mockDeleteEvent.mock.calls.length).toBeGreaterThan(0); + expect(mockDeleteEvent.mock.calls.length).toBeGreaterThan(0) }, { timeout: 3000 } - ); + ) // Verify new event was created via putEvent - expect(mockPutEvent).toHaveBeenCalled(); - const putEventCall = mockPutEvent.mock.calls[0][0]; - expect(putEventCall.title).toBe("Recurring Meeting"); - expect(putEventCall.repetition?.freq).toBeFalsy(); - expect(putEventCall.uid).not.toContain(baseUID); + expect(mockPutEvent).toHaveBeenCalled() + const putEventCall = mockPutEvent.mock.calls[0][0] + expect(putEventCall.title).toBe('Recurring Meeting') + expect(putEventCall.repetition?.freq).toBeFalsy() + expect(putEventCall.uid).not.toContain(baseUID) // Verify Redux store state changes - const finalState = store.getState(); - const calendar = finalState.calendars.list[calId]; + const finalState = store.getState() + const calendar = finalState.calendars.list[calId] // Old recurring instances should be removed from store - expect(calendar.events[baseUID]).toBeUndefined(); - expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined(); - expect(calendar.events[`${baseUID}/20250116`]).toBeUndefined(); - expect(calendar.events[`${baseUID}/20250117`]).toBeUndefined(); + expect(calendar.events[baseUID]).toBeUndefined() + expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined() + expect(calendar.events[`${baseUID}/20250116`]).toBeUndefined() + expect(calendar.events[`${baseUID}/20250117`]).toBeUndefined() // Verify modal was closed after completion await waitFor(() => { - expect(mockOnClose).toHaveBeenCalled(); - }); - }); + expect(mockOnClose).toHaveBeenCalled() + }) + }) - it("gracefully handles deletion errors and continues to create new event", async () => { - const eventDate = new Date("2025-01-15T10:00:00.000Z"); + it('gracefully handles deletion errors and continues to create new event', async () => { + const eventDate = new Date('2025-01-15T10:00:00.000Z') const masterEvent = { uid: baseUID, - title: "Recurring Meeting", + title: 'Recurring Meeting', calId, start: eventDate.toISOString(), end: new Date(eventDate.getTime() + 3600000).toISOString(), - repetition: { freq: "weekly", interval: 1 }, + repetition: { freq: 'weekly', interval: 1 }, allday: false, - organizer: { cn: "test", cal_address: "test@test.com" }, - attendee: [{ cn: "test", cal_address: "test@test.com" }], + organizer: { cn: 'test', cal_address: 'test@test.com' }, + attendee: [{ cn: 'test', cal_address: 'test@test.com' }], URL: `/calendars/${calId}/${baseUID}.ics`, - timezone: "UTC", - } as CalendarEvent; + timezone: 'UTC' + } as CalendarEvent const instance1 = { ...masterEvent, - uid: `${baseUID}/20250115`, - }; + uid: `${baseUID}/20250115` + } const stateWithRecurringEvent = { ...preloadedState, @@ -470,24 +470,24 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => { ...preloadedState.calendars.list[calId], events: { [baseUID]: masterEvent, - [`${baseUID}/20250115`]: instance1, - }, - }, - }, - }, - }; + [`${baseUID}/20250115`]: instance1 + } + } + } + } + } // Mock deleteEvent to fail with a non-404 error const mockDeleteEvent = jest - .spyOn(EventApi, "deleteEvent") - .mockRejectedValue(new Error("Network error")); + .spyOn(EventApi, 'deleteEvent') + .mockRejectedValue(new Error('Network error')) const mockPutEvent = jest - .spyOn(EventApi, "putEvent") - .mockResolvedValue({ status: 201 } as any); - const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + .spyOn(EventApi, 'putEvent') + .mockResolvedValue({ status: 201 } as any) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() const mockGetMasterEvent = jest - .spyOn(EventApi, "getEvent") - .mockResolvedValue(masterEvent); + .spyOn(EventApi, 'getEvent') + .mockResolvedValue(masterEvent) const { store } = renderWithProviders( { typeOfAction="all" />, stateWithRecurringEvent - ); + ) // Wait for master event to be fetched await waitFor(() => { - expect(mockGetMasterEvent).toHaveBeenCalled(); - }); + expect(mockGetMasterEvent).toHaveBeenCalled() + }) // Wait for component to load await waitFor(() => { - expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument(); - }); + expect(screen.getByDisplayValue('Recurring Meeting')).toBeInTheDocument() + }) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) // Wait for repeat checkbox to be checked const repeatCheckbox = await waitFor(() => { - const checkbox = screen.getByLabelText("event.form.repeat"); - expect(checkbox).toBeChecked(); - return checkbox; - }); + const checkbox = screen.getByLabelText('event.form.repeat') + expect(checkbox).toBeChecked() + return checkbox + }) // Uncheck repeat checkbox and save await act(async () => { - fireEvent.click(repeatCheckbox); - }); + fireEvent.click(repeatCheckbox) + }) // Wait for checkbox to be unchecked await waitFor(() => { - expect(repeatCheckbox).not.toBeChecked(); - }); + expect(repeatCheckbox).not.toBeChecked() + }) - const saveButton = screen.getByRole("button", { name: "actions.save" }); + const saveButton = screen.getByRole('button', { name: 'actions.save' }) await act(async () => { - fireEvent.click(saveButton); - }); + fireEvent.click(saveButton) + }) // Wait for delete to be attempted await waitFor( () => { - expect(mockDeleteEvent).toHaveBeenCalled(); + expect(mockDeleteEvent).toHaveBeenCalled() }, { timeout: 3000 } - ); + ) // Verify error was logged for failed deletion await waitFor( () => { expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining("Failed to delete event file") - ); + expect.stringContaining('Failed to delete event file') + ) }, { timeout: 3000 } - ); + ) // New implementation gracefully handles deletion errors: // Even if some instances fail to delete, we still create the new event // This ensures user gets their non-recurring event await waitFor( () => { - expect(mockPutEvent).toHaveBeenCalled(); + expect(mockPutEvent).toHaveBeenCalled() }, { timeout: 3000 } - ); + ) - const putEventCall = mockPutEvent.mock.calls[0][0]; - expect(putEventCall.title).toBe("Recurring Meeting"); - expect(putEventCall.repetition?.freq).toBeFalsy(); + const putEventCall = mockPutEvent.mock.calls[0][0] + expect(putEventCall.title).toBe('Recurring Meeting') + expect(putEventCall.repetition?.freq).toBeFalsy() // Modal should close after operation completes await waitFor( () => { - expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalled() }, { timeout: 3000 } - ); + ) - consoleErrorSpy.mockRestore(); - }); + consoleErrorSpy.mockRestore() + }) - it("closes all modals after converting recurring to non-recurring", async () => { - const eventDate = new Date("2025-01-15T10:00:00.000Z"); + it('closes all modals after converting recurring to non-recurring', async () => { + const eventDate = new Date('2025-01-15T10:00:00.000Z') const masterEvent = { uid: baseUID, - title: "Recurring Meeting", + title: 'Recurring Meeting', calId, start: eventDate.toISOString(), end: new Date(eventDate.getTime() + 3600000).toISOString(), - repetition: { freq: "monthly", interval: 1 }, + repetition: { freq: 'monthly', interval: 1 }, allday: false, - organizer: { cn: "test", cal_address: "test@test.com" }, - attendee: [{ cn: "test", cal_address: "test@test.com" }], + organizer: { cn: 'test', cal_address: 'test@test.com' }, + attendee: [{ cn: 'test', cal_address: 'test@test.com' }], URL: `/calendars/${calId}/${baseUID}.ics`, - timezone: "UTC", - } as CalendarEvent; + timezone: 'UTC' + } as CalendarEvent const instance1 = { ...masterEvent, - uid: `${baseUID}/20250115`, - }; + uid: `${baseUID}/20250115` + } const stateWithRecurringEvent = { ...preloadedState, @@ -608,24 +608,24 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => { ...preloadedState.calendars.list[calId], events: { [baseUID]: masterEvent, - [`${baseUID}/20250115`]: instance1, - }, - }, - }, - }, - }; + [`${baseUID}/20250115`]: instance1 + } + } + } + } + } // Mock API calls - jest.spyOn(EventApi, "deleteEvent").mockResolvedValue({} as any); - jest.spyOn(EventApi, "putEvent").mockResolvedValue({ status: 201 } as any); - jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({ + jest.spyOn(EventApi, 'deleteEvent').mockResolvedValue({} as any) + jest.spyOn(EventApi, 'putEvent').mockResolvedValue({ status: 201 } as any) + jest.spyOn(CalendarApi, 'getCalendar').mockResolvedValue({ _embedded: { - "dav:item": [], - }, - } as any); + 'dav:item': [] + } + } as any) // Mock onCloseAll to test closing both modals - const mockOnCloseAll = jest.fn(); + const mockOnCloseAll = jest.fn() renderWithProviders( { typeOfAction="all" />, stateWithRecurringEvent - ); + ) // Wait for component to load await waitFor(() => { - expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument(); - }); + expect(screen.getByDisplayValue('Recurring Meeting')).toBeInTheDocument() + }) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + fireEvent.click(screen.getByRole('button', { name: 'common.moreOptions' })) // Uncheck repeat and save - const repeatCheckbox = screen.getByLabelText("event.form.repeat"); + const repeatCheckbox = screen.getByLabelText('event.form.repeat') await act(async () => { - fireEvent.click(repeatCheckbox); - }); + fireEvent.click(repeatCheckbox) + }) - const saveButton = screen.getByRole("button", { name: "actions.save" }); + const saveButton = screen.getByRole('button', { name: 'actions.save' }) await act(async () => { - fireEvent.click(saveButton); - }); + fireEvent.click(saveButton) + }) // Verify onCloseAll was called to close both preview and update modals await waitFor( () => { - expect(mockOnCloseAll).toHaveBeenCalled(); + expect(mockOnCloseAll).toHaveBeenCalled() }, { timeout: 3000 } - ); + ) // Verify onClose was NOT called (onCloseAll is used instead) - expect(mockOnClose).not.toHaveBeenCalled(); - }); -}); + expect(mockOnClose).not.toHaveBeenCalled() + }) +}) diff --git a/__test__/features/Events/FreeBusy.test.tsx b/__test__/features/Events/FreeBusy.test.tsx index c4931f2..0463587 100644 --- a/__test__/features/Events/FreeBusy.test.tsx +++ b/__test__/features/Events/FreeBusy.test.tsx @@ -1,95 +1,93 @@ import { hasFreeBusyConflict, - useAttendeesFreeBusy, -} from "@/components/Attendees/useFreeBusy"; -import * as getFreeBusyREPORT from "@/features/Events/api/getFreeBusyForAddedAttendeesREPORT"; -import * as getFreeBusyPOST from "@/features/Events/api/getFreeBusyForEventAttendeesPOST"; -import * as getUserData from "@/features/Events/api/getUserDataFromEmail"; -import { renderHook, waitFor } from "@testing-library/react"; + useAttendeesFreeBusy +} from '@/components/Attendees/useFreeBusy' +import * as getFreeBusyREPORT from '@/features/Events/api/getFreeBusyForAddedAttendeesREPORT' +import * as getFreeBusyPOST from '@/features/Events/api/getFreeBusyForEventAttendeesPOST' +import * as getUserData from '@/features/Events/api/getUserDataFromEmail' +import { renderHook, waitFor } from '@testing-library/react' -jest.mock("moment-timezone", () => { - const actual = jest.requireActual("moment-timezone"); - return actual; -}); +jest.mock('moment-timezone', () => { + const actual = jest.requireActual('moment-timezone') + return actual +}) -jest.mock("@/features/Events/api/getUserDataFromEmail"); -jest.mock("@/features/Events/api/getFreeBusyForAddedAttendeesREPORT"); -jest.mock("@/features/Events/api/getFreeBusyForEventAttendeesPOST"); +jest.mock('@/features/Events/api/getUserDataFromEmail') +jest.mock('@/features/Events/api/getFreeBusyForAddedAttendeesREPORT') +jest.mock('@/features/Events/api/getFreeBusyForEventAttendeesPOST') const mockGetUserData = getUserData.getUserDataFromEmail as jest.MockedFunction< typeof getUserData.getUserDataFromEmail ->; +> const mockREPORT = getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT as jest.MockedFunction< typeof getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT - >; + > const mockPOST = getFreeBusyPOST.getFreeBusyForEventAttendeesPOST as jest.MockedFunction< typeof getFreeBusyPOST.getFreeBusyForEventAttendeesPOST - >; + > -const START = "2026-03-14T14:00:00"; -const END = "2026-03-14T15:00:00"; -const TZ = "Europe/Paris"; +const START = '2026-03-14T14:00:00' +const END = '2026-03-14T15:00:00' +const TZ = 'Europe/Paris' -describe("hasFreeBusyConflict", () => { - it("returns false for non-vcalendar data", () => { - expect(hasFreeBusyConflict({ data: ["not-vcalendar", [], []] })).toBe( - false - ); - }); +describe('hasFreeBusyConflict', () => { + it('returns false for non-vcalendar data', () => { + expect(hasFreeBusyConflict({ data: ['not-vcalendar', [], []] })).toBe(false) + }) - it("returns false when vfreebusy has no freebusy property", () => { + it('returns false when vfreebusy has no freebusy property', () => { const data = { data: [ - "vcalendar", + 'vcalendar', [], [ [ - "vfreebusy", - [["dtstart", {}, "date-time", "2026-03-14T14:00:00Z"]], - [], - ], - ], - ], - }; - expect(hasFreeBusyConflict(data)).toBe(false); - }); + 'vfreebusy', + [['dtstart', {}, 'date-time', '2026-03-14T14:00:00Z']], + [] + ] + ] + ] + } + expect(hasFreeBusyConflict(data)).toBe(false) + }) - it("returns true when vfreebusy contains a freebusy property", () => { + it('returns true when vfreebusy contains a freebusy property', () => { const data = { data: [ - "vcalendar", + 'vcalendar', [], [ [ - "vfreebusy", + 'vfreebusy', [ - ["dtstart", {}, "date-time", "2026-03-14T14:00:00Z"], - ["freebusy", {}, "period", "2026-03-14T14:00:00Z/PT1H"], + ['dtstart', {}, 'date-time', '2026-03-14T14:00:00Z'], + ['freebusy', {}, 'period', '2026-03-14T14:00:00Z/PT1H'] ], - [], - ], - ], - ], - }; - expect(hasFreeBusyConflict(data)).toBe(true); - }); + [] + ] + ] + ] + } + expect(hasFreeBusyConflict(data)).toBe(true) + }) - it("returns false for malformed data without throwing", () => { - expect(hasFreeBusyConflict(null)).toBe(false); - expect(hasFreeBusyConflict("garbage")).toBe(false); - expect(hasFreeBusyConflict({ data: null })).toBe(false); - }); -}); + it('returns false for malformed data without throwing', () => { + expect(hasFreeBusyConflict(null)).toBe(false) + expect(hasFreeBusyConflict('garbage')).toBe(false) + expect(hasFreeBusyConflict({ data: null })).toBe(false) + }) +}) -describe("useAttendeesFreeBusy — Flow B (new attendees)", () => { - beforeEach(() => jest.clearAllMocks()); +describe('useAttendeesFreeBusy — Flow B (new attendees)', () => { + beforeEach(() => jest.clearAllMocks()) - const newAttendee = { email: "alice@example.com", userId: "user-alice" }; + const newAttendee = { email: 'alice@example.com', userId: 'user-alice' } - it("shows loading then free for a new attendee", async () => { - mockREPORT.mockResolvedValue(false); + it('shows loading then free for a new attendee', async () => { + mockREPORT.mockResolvedValue(false) const { result } = renderHook(() => useAttendeesFreeBusy({ @@ -97,17 +95,17 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => { newAttendees: [newAttendee], start: START, end: END, - timezone: TZ, + timezone: TZ }) - ); + ) - expect(result.current[newAttendee.email]).toBe("loading"); + expect(result.current[newAttendee.email]).toBe('loading') - await waitFor(() => expect(result.current[newAttendee.email]).toBe("free")); - }); + await waitFor(() => expect(result.current[newAttendee.email]).toBe('free')) + }) - it("shows busy when REPORT returns true", async () => { - mockREPORT.mockResolvedValue(true); + it('shows busy when REPORT returns true', async () => { + mockREPORT.mockResolvedValue(true) const { result } = renderHook(() => useAttendeesFreeBusy({ @@ -115,15 +113,15 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => { newAttendees: [newAttendee], start: START, end: END, - timezone: TZ, + timezone: TZ }) - ); + ) - await waitFor(() => expect(result.current[newAttendee.email]).toBe("busy")); - }); + await waitFor(() => expect(result.current[newAttendee.email]).toBe('busy')) + }) - it("shows unknown when REPORT throws", async () => { - mockREPORT.mockRejectedValue(new Error("network error")); + it('shows unknown when REPORT throws', async () => { + mockREPORT.mockRejectedValue(new Error('network error')) const { result } = renderHook(() => useAttendeesFreeBusy({ @@ -131,20 +129,20 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => { newAttendees: [newAttendee], start: START, end: END, - timezone: TZ, + timezone: TZ }) - ); + ) await waitFor(() => - expect(result.current[newAttendee.email]).toBe("unknown") - ); - }); + expect(result.current[newAttendee.email]).toBe('unknown') + ) + }) - it("resolves userId via getUserDataFromEmail when not provided", async () => { - mockGetUserData.mockResolvedValue([{ _id: "resolved-id" }] as never); - mockREPORT.mockResolvedValue(false); + it('resolves userId via getUserDataFromEmail when not provided', async () => { + mockGetUserData.mockResolvedValue([{ _id: 'resolved-id' }] as never) + mockREPORT.mockResolvedValue(false) - const attendeeWithoutId = { email: "bob@example.com" }; + const attendeeWithoutId = { email: 'bob@example.com' } const { result } = renderHook(() => useAttendeesFreeBusy({ @@ -152,42 +150,42 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => { newAttendees: [attendeeWithoutId], start: START, end: END, - timezone: TZ, + timezone: TZ }) - ); + ) await waitFor(() => - expect(result.current[attendeeWithoutId.email]).toBe("free") - ); - expect(mockGetUserData).toHaveBeenCalledWith("bob@example.com"); + expect(result.current[attendeeWithoutId.email]).toBe('free') + ) + expect(mockGetUserData).toHaveBeenCalledWith('bob@example.com') expect(mockREPORT).toHaveBeenCalledWith( - "resolved-id", + 'resolved-id', expect.any(String), expect.any(String) - ); - }); + ) + }) - it("shows unknown when userId cannot be resolved", async () => { - mockGetUserData.mockResolvedValue([]); + it('shows unknown when userId cannot be resolved', async () => { + mockGetUserData.mockResolvedValue([]) const { result } = renderHook(() => useAttendeesFreeBusy({ existingAttendees: [], - newAttendees: [{ email: "ghost@example.com" }], + newAttendees: [{ email: 'ghost@example.com' }], start: START, end: END, - timezone: TZ, + timezone: TZ }) - ); + ) await waitFor(() => - expect(result.current["ghost@example.com"]).toBe("unknown") - ); - expect(mockREPORT).not.toHaveBeenCalled(); - }); + expect(result.current['ghost@example.com']).toBe('unknown') + ) + expect(mockREPORT).not.toHaveBeenCalled() + }) - it("does not re-fetch an attendee already fetched", async () => { - mockREPORT.mockResolvedValue(false); + it('does not re-fetch an attendee already fetched', async () => { + mockREPORT.mockResolvedValue(false) const { result, rerender } = renderHook( ({ attendees }) => @@ -196,19 +194,19 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => { newAttendees: attendees, start: START, end: END, - timezone: TZ, + timezone: TZ }), { initialProps: { attendees: [newAttendee] } } - ); + ) - await waitFor(() => expect(result.current[newAttendee.email]).toBe("free")); + await waitFor(() => expect(result.current[newAttendee.email]).toBe('free')) - rerender({ attendees: [newAttendee] }); - expect(mockREPORT).toHaveBeenCalledTimes(1); - }); + rerender({ attendees: [newAttendee] }) + expect(mockREPORT).toHaveBeenCalledTimes(1) + }) - it("removes departed attendee from the map", async () => { - mockREPORT.mockResolvedValue(false); + it('removes departed attendee from the map', async () => { + mockREPORT.mockResolvedValue(false) const { result, rerender } = renderHook( ({ attendees }) => @@ -217,22 +215,22 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => { newAttendees: attendees, start: START, end: END, - timezone: TZ, + timezone: TZ }), { initialProps: { attendees: [newAttendee] } } - ); + ) - await waitFor(() => expect(result.current[newAttendee.email]).toBe("free")); + await waitFor(() => expect(result.current[newAttendee.email]).toBe('free')) - rerender({ attendees: [] }); + rerender({ attendees: [] }) await waitFor(() => expect(result.current[newAttendee.email]).toBeUndefined() - ); - }); + ) + }) - it("invalidates cache and re-fetches when time window changes", async () => { - mockREPORT.mockResolvedValue(false); + it('invalidates cache and re-fetches when time window changes', async () => { + mockREPORT.mockResolvedValue(false) const { result, rerender } = renderHook( ({ start, end }) => @@ -241,65 +239,65 @@ describe("useAttendeesFreeBusy — Flow B (new attendees)", () => { newAttendees: [newAttendee], start, end, - timezone: TZ, + timezone: TZ }), { initialProps: { start: START, end: END } } - ); + ) - await waitFor(() => expect(result.current[newAttendee.email]).toBe("free")); + await waitFor(() => expect(result.current[newAttendee.email]).toBe('free')) - rerender({ start: "2026-03-15T14:00:00", end: "2026-03-15T15:00:00" }); + rerender({ start: '2026-03-15T14:00:00', end: '2026-03-15T15:00:00' }) - await waitFor(() => expect(result.current[newAttendee.email]).toBe("free")); + await waitFor(() => expect(result.current[newAttendee.email]).toBe('free')) - expect(mockREPORT).toHaveBeenCalledTimes(2); - }); + expect(mockREPORT).toHaveBeenCalledTimes(2) + }) - it("passes UTC-converted iCal times to the API", async () => { - mockREPORT.mockResolvedValue(false); + it('passes UTC-converted iCal times to the API', async () => { + mockREPORT.mockResolvedValue(false) renderHook(() => useAttendeesFreeBusy({ existingAttendees: [], newAttendees: [newAttendee], - start: "2026-03-14T16:00:00", // 16:00 Paris = 15:00 UTC in winter - end: "2026-03-14T17:00:00", - timezone: "Europe/Paris", + start: '2026-03-14T16:00:00', // 16:00 Paris = 15:00 UTC in winter + end: '2026-03-14T17:00:00', + timezone: 'Europe/Paris' }) - ); + ) - await waitFor(() => expect(mockREPORT).toHaveBeenCalled()); + await waitFor(() => expect(mockREPORT).toHaveBeenCalled()) // Paris is UTC+1 in March (before DST), so 16:00 → 15:00 UTC expect(mockREPORT).toHaveBeenCalledWith( - "user-alice", - "20260314T150000", - "20260314T160000" - ); - }); -}); + 'user-alice', + '20260314T150000', + '20260314T160000' + ) + }) +}) -describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => { - beforeEach(() => jest.clearAllMocks()); +describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => { + beforeEach(() => jest.clearAllMocks()) - const existingAttendee = { email: "carol@example.com", userId: "user-carol" }; + const existingAttendee = { email: 'carol@example.com', userId: 'user-carol' } - it("does not fetch when eventUid is missing", () => { + it('does not fetch when eventUid is missing', () => { renderHook(() => useAttendeesFreeBusy({ existingAttendees: [existingAttendee], newAttendees: [], start: START, end: END, - timezone: TZ, + timezone: TZ }) - ); + ) - expect(mockPOST).not.toHaveBeenCalled(); - }); + expect(mockPOST).not.toHaveBeenCalled() + }) - it("shows loading then free for existing attendees", async () => { - mockPOST.mockResolvedValue({ "user-carol": false }); + it('shows loading then free for existing attendees', async () => { + mockPOST.mockResolvedValue({ 'user-carol': false }) const { result } = renderHook(() => useAttendeesFreeBusy({ @@ -308,19 +306,19 @@ describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => { start: START, end: END, timezone: TZ, - eventUid: "event-123", + eventUid: 'event-123' }) - ); + ) - expect(result.current[existingAttendee.email]).toBe("loading"); + expect(result.current[existingAttendee.email]).toBe('loading') await waitFor(() => - expect(result.current[existingAttendee.email]).toBe("free") - ); - }); + expect(result.current[existingAttendee.email]).toBe('free') + ) + }) - it("shows busy when POST returns busy for userId", async () => { - mockPOST.mockResolvedValue({ "user-carol": true }); + it('shows busy when POST returns busy for userId', async () => { + mockPOST.mockResolvedValue({ 'user-carol': true }) const { result } = renderHook(() => useAttendeesFreeBusy({ @@ -329,17 +327,17 @@ describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => { start: START, end: END, timezone: TZ, - eventUid: "event-123", + eventUid: 'event-123' }) - ); + ) await waitFor(() => - expect(result.current[existingAttendee.email]).toBe("busy") - ); - }); + expect(result.current[existingAttendee.email]).toBe('busy') + ) + }) - it("shows unknown when POST throws", async () => { - mockPOST.mockRejectedValue(new Error("server error")); + it('shows unknown when POST throws', async () => { + mockPOST.mockRejectedValue(new Error('server error')) const { result } = renderHook(() => useAttendeesFreeBusy({ @@ -348,17 +346,17 @@ describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => { start: START, end: END, timezone: TZ, - eventUid: "event-123", + eventUid: 'event-123' }) - ); + ) await waitFor(() => - expect(result.current[existingAttendee.email]).toBe("unknown") - ); - }); + expect(result.current[existingAttendee.email]).toBe('unknown') + ) + }) - it("passes the eventUid to the POST API", async () => { - mockPOST.mockResolvedValue({ "user-carol": false }); + it('passes the eventUid to the POST API', async () => { + mockPOST.mockResolvedValue({ 'user-carol': false }) renderHook(() => useAttendeesFreeBusy({ @@ -367,35 +365,35 @@ describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => { start: START, end: END, timezone: TZ, - eventUid: "event-abc", + eventUid: 'event-abc' }) - ); + ) - await waitFor(() => expect(mockPOST).toHaveBeenCalled()); + await waitFor(() => expect(mockPOST).toHaveBeenCalled()) expect(mockPOST).toHaveBeenCalledWith( - ["user-carol"], + ['user-carol'], expect.any(String), expect.any(String), - "event-abc" - ); - }); -}); + 'event-abc' + ) + }) +}) -describe("useAttendeesFreeBusy — enabled flag", () => { - it("does not fetch when disabled", () => { +describe('useAttendeesFreeBusy — enabled flag', () => { + it('does not fetch when disabled', () => { renderHook(() => useAttendeesFreeBusy({ - existingAttendees: [{ email: "x@x.com", userId: "uid" }], - newAttendees: [{ email: "y@y.com", userId: "uid2" }], + existingAttendees: [{ email: 'x@x.com', userId: 'uid' }], + newAttendees: [{ email: 'y@y.com', userId: 'uid2' }], start: START, end: END, timezone: TZ, - eventUid: "event-123", - enabled: false, + eventUid: 'event-123', + enabled: false }) - ); + ) - expect(mockPOST).not.toHaveBeenCalled(); - expect(mockREPORT).not.toHaveBeenCalled(); - }); -}); + expect(mockPOST).not.toHaveBeenCalled() + expect(mockREPORT).not.toHaveBeenCalled() + }) +}) diff --git a/__test__/features/Events/ICSPropertiesPreservation.test.tsx b/__test__/features/Events/ICSPropertiesPreservation.test.tsx index f338312..d541962 100644 --- a/__test__/features/Events/ICSPropertiesPreservation.test.tsx +++ b/__test__/features/Events/ICSPropertiesPreservation.test.tsx @@ -1,552 +1,550 @@ -import { Calendar } from "@/features/Calendars/CalendarTypes"; -import { VObjectProperty } from "@/features/Calendars/types/CalendarData"; -import { putEvent, putEventWithOverrides } from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { VObjectProperty } from '@/features/Calendars/types/CalendarData' +import { putEvent, putEventWithOverrides } from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' import { calendarEventToJCal, makeVevent, - parseCalendarEvent, -} from "@/features/Events/utils"; -import { api } from "@/utils/apiUtils"; + parseCalendarEvent +} from '@/features/Events/utils' +import { api } from '@/utils/apiUtils' -jest.mock("@/utils/apiUtils"); +jest.mock('@/utils/apiUtils') const MOCK_CALENDAR: Calendar = { - id: "cal-1", - link: "/dav/calendars/user/cal-1.json", -} as Calendar; -const EVENT_URL = "/dav/calendars/user/cal-1/event-abc.ics"; + id: 'cal-1', + link: '/dav/calendars/user/cal-1.json' +} as Calendar +const EVENT_URL = '/dav/calendars/user/cal-1/event-abc.ics' const mockEvent = { - uid: "event-123", - title: "Test Event", - start: "2025-07-23T08:00:00.000Z", - end: "2025-07-23T09:00:00.000Z", - timezone: "Europe/Paris", + uid: 'event-123', + title: 'Test Event', + start: '2025-07-23T08:00:00.000Z', + end: '2025-07-23T09:00:00.000Z', + timezone: 'Europe/Paris', allday: false, attendee: [], passthroughProps: [ - ["x-attachment", {}, "text", "data:application/pdf;base64,abc123=="], - ["x-custom-prop", {}, "text", "custom-value"], - ] as VObjectProperty[], -} as unknown as CalendarEvent; + ['x-attachment', {}, 'text', 'data:application/pdf;base64,abc123=='], + ['x-custom-prop', {}, 'text', 'custom-value'] + ] as VObjectProperty[] +} as unknown as CalendarEvent /** Minimal valid vevent props (dtstart is a timed event in Europe/Paris) */ function baseVeventProps(overrides: VObjectProperty[] = []): VObjectProperty[] { return [ - ["uid", {}, "text", "event-abc-uid"], - ["summary", {}, "text", "Team standup"], - ["dtstart", { tzid: "Europe/Paris" }, "date-time", "2024-03-15T10:00:00"], - ["dtend", { tzid: "Europe/Paris" }, "date-time", "2024-03-15T10:30:00"], - ["dtstamp", {}, "date-time", "2024-03-01T00:00:00Z"], - ["sequence", {}, "integer", 0], - ...overrides, - ]; + ['uid', {}, 'text', 'event-abc-uid'], + ['summary', {}, 'text', 'Team standup'], + ['dtstart', { tzid: 'Europe/Paris' }, 'date-time', '2024-03-15T10:00:00'], + ['dtend', { tzid: 'Europe/Paris' }, 'date-time', '2024-03-15T10:30:00'], + ['dtstamp', {}, 'date-time', '2024-03-01T00:00:00Z'], + ['sequence', {}, 'integer', 0], + ...overrides + ] } /** X- / custom props that must survive round-trips */ const CUSTOM_PROPS: VObjectProperty[] = [ - ["x-attachment", {}, "text", "base64encodeddata=="], - ["x-apple-travel-title", {}, "text", "Drive to office"], - ["x-custom-field", {}, "unknown", "some-value"], -]; + ['x-attachment', {}, 'text', 'base64encodeddata=='], + ['x-apple-travel-title', {}, 'text', 'Drive to office'], + ['x-custom-field', {}, 'unknown', 'some-value'] +] /** Build a CalendarEvent directly (skips parseCalendarEvent) for makeVevent tests */ function baseCalendarEvent( overrides: Partial = {} ): CalendarEvent { return { - uid: "event-abc-uid", - title: "Team standup", - start: "2024-03-15T09:00:00.000Z", - end: "2024-03-15T09:30:00.000Z", - timezone: "Europe/Paris", + uid: 'event-abc-uid', + title: 'Team standup', + start: '2024-03-15T09:00:00.000Z', + end: '2024-03-15T09:30:00.000Z', + timezone: 'Europe/Paris', allday: false, attendee: [], - calId: "cal-1", + calId: 'cal-1', URL: EVENT_URL, - transp: "OPAQUE", - class: "PUBLIC", + transp: 'OPAQUE', + class: 'PUBLIC', sequence: 1, exdates: [], - ...overrides, - } as CalendarEvent; + ...overrides + } as CalendarEvent } -describe("parseCalendarEvent — passthroughProps", () => { - it("captures unknown X- properties into passthroughProps", () => { - const props = baseVeventProps(CUSTOM_PROPS); - const event = parseCalendarEvent(props, {}, MOCK_CALENDAR, EVENT_URL); +describe('parseCalendarEvent — passthroughProps', () => { + it('captures unknown X- properties into passthroughProps', () => { + const props = baseVeventProps(CUSTOM_PROPS) + const event = parseCalendarEvent(props, {}, MOCK_CALENDAR, EVENT_URL) - expect(event.passthroughProps).toBeDefined(); - const keys = event.passthroughProps!.map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - expect(keys).toContain("x-apple-travel-title"); - expect(keys).toContain("x-custom-field"); - }); + expect(event.passthroughProps).toBeDefined() + const keys = event.passthroughProps!.map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + expect(keys).toContain('x-apple-travel-title') + expect(keys).toContain('x-custom-field') + }) - it("preserves the full tuple structure of passthrough props", () => { - const props = baseVeventProps(CUSTOM_PROPS); - const event = parseCalendarEvent(props, {}, MOCK_CALENDAR, EVENT_URL); + it('preserves the full tuple structure of passthrough props', () => { + const props = baseVeventProps(CUSTOM_PROPS) + const event = parseCalendarEvent(props, {}, MOCK_CALENDAR, EVENT_URL) const attachment = event.passthroughProps!.find( - ([k]) => k === "x-attachment" - ); + ([k]) => k === 'x-attachment' + ) expect(attachment).toEqual([ - "x-attachment", + 'x-attachment', {}, - "text", - "base64encodeddata==", - ]); - }); + 'text', + 'base64encodeddata==' + ]) + }) - it("does NOT include known props in passthroughProps", () => { - const props = baseVeventProps(CUSTOM_PROPS); - const event = parseCalendarEvent(props, {}, MOCK_CALENDAR, EVENT_URL); + it('does NOT include known props in passthroughProps', () => { + const props = baseVeventProps(CUSTOM_PROPS) + const event = parseCalendarEvent(props, {}, MOCK_CALENDAR, EVENT_URL) - const known = ["uid", "summary", "dtstart", "dtend", "dtstamp", "sequence"]; + const known = ['uid', 'summary', 'dtstart', 'dtend', 'dtstamp', 'sequence'] const passthroughKeys = event.passthroughProps!.map(([k]) => k.toLowerCase() - ); + ) for (const k of known) { - expect(passthroughKeys).not.toContain(k); + expect(passthroughKeys).not.toContain(k) } - }); + }) - it("sets passthroughProps to empty array when no unknown props exist", () => { + it('sets passthroughProps to empty array when no unknown props exist', () => { const event = parseCalendarEvent( baseVeventProps(), {}, MOCK_CALENDAR, EVENT_URL - ); - expect(event.passthroughProps).toEqual([]); - }); + ) + expect(event.passthroughProps).toEqual([]) + }) - it("parses known fields correctly alongside custom props", () => { - const props = baseVeventProps(CUSTOM_PROPS); - const event = parseCalendarEvent(props, {}, MOCK_CALENDAR, EVENT_URL); + it('parses known fields correctly alongside custom props', () => { + const props = baseVeventProps(CUSTOM_PROPS) + const event = parseCalendarEvent(props, {}, MOCK_CALENDAR, EVENT_URL) - expect(event.title).toBe("Team standup"); - expect(event.uid).toBe("event-abc-uid"); - expect(event.allday).toBe(false); - }); -}); + expect(event.title).toBe('Team standup') + expect(event.uid).toBe('event-abc-uid') + expect(event.allday).toBe(false) + }) +}) -describe("makeVevent — passthroughProps round-trip", () => { - it("injects passthroughProps into the vevent output", () => { - const event = baseCalendarEvent({ passthroughProps: CUSTOM_PROPS }); - const [, props] = makeVevent(event, "Europe/Paris", undefined); +describe('makeVevent — passthroughProps round-trip', () => { + it('injects passthroughProps into the vevent output', () => { + const event = baseCalendarEvent({ passthroughProps: CUSTOM_PROPS }) + const [, props] = makeVevent(event, 'Europe/Paris', undefined) - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - expect(keys).toContain("x-apple-travel-title"); - expect(keys).toContain("x-custom-field"); - }); + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + expect(keys).toContain('x-apple-travel-title') + expect(keys).toContain('x-custom-field') + }) - it("preserves the exact value of passthrough props", () => { - const event = baseCalendarEvent({ passthroughProps: CUSTOM_PROPS }); - const [, props] = makeVevent(event, "Europe/Paris", undefined); + it('preserves the exact value of passthrough props', () => { + const event = baseCalendarEvent({ passthroughProps: CUSTOM_PROPS }) + const [, props] = makeVevent(event, 'Europe/Paris', undefined) const attachment = (props as VObjectProperty[]).find( - ([k]) => k === "x-attachment" - ); - expect(attachment?.[3]).toBe("base64encodeddata=="); - }); + ([k]) => k === 'x-attachment' + ) + expect(attachment?.[3]).toBe('base64encodeddata==') + }) - it("does NOT duplicate a prop that is already written by makeVevent", () => { + it('does NOT duplicate a prop that is already written by makeVevent', () => { // If somehow a passthrough prop overlaps a known key, it must not be doubled const duplicateSummary: VObjectProperty = [ - "summary", + 'summary', {}, - "text", - "duplicate", - ]; - const event = baseCalendarEvent({ passthroughProps: [duplicateSummary] }); - const [, props] = makeVevent(event, "Europe/Paris", undefined); + 'text', + 'duplicate' + ] + const event = baseCalendarEvent({ passthroughProps: [duplicateSummary] }) + const [, props] = makeVevent(event, 'Europe/Paris', undefined) const summaries = (props as VObjectProperty[]).filter( - ([k]) => k === "summary" - ); - expect(summaries.length).toBe(1); - }); + ([k]) => k === 'summary' + ) + expect(summaries.length).toBe(1) + }) - it("produces a vevent with no passthroughProps when field is undefined", () => { - const event = baseCalendarEvent({ passthroughProps: undefined }); - const [name, props] = makeVevent(event, "Europe/Paris", undefined); + it('produces a vevent with no passthroughProps when field is undefined', () => { + const event = baseCalendarEvent({ passthroughProps: undefined }) + const [name, props] = makeVevent(event, 'Europe/Paris', undefined) - expect(name).toBe("vevent"); - expect(Array.isArray(props)).toBe(true); - }); + expect(name).toBe('vevent') + expect(Array.isArray(props)).toBe(true) + }) - it("produces a vevent with no passthroughProps when field is empty array", () => { - const event = baseCalendarEvent({ passthroughProps: [] }); - const [, props] = makeVevent(event, "Europe/Paris", undefined); + it('produces a vevent with no passthroughProps when field is empty array', () => { + const event = baseCalendarEvent({ passthroughProps: [] }) + const [, props] = makeVevent(event, 'Europe/Paris', undefined) // Just check it doesn't throw and still has standard props - const keys = (props as VObjectProperty[]).map(([k]) => k); - expect(keys).toContain("uid"); - expect(keys).toContain("summary"); - }); -}); + const keys = (props as VObjectProperty[]).map(([k]) => k) + expect(keys).toContain('uid') + expect(keys).toContain('summary') + }) +}) -describe("calendarEventToJCal", () => { - it("preserves passthroughProps inside the vcalendar output", () => { - const event = baseCalendarEvent({ passthroughProps: CUSTOM_PROPS }); - const [, , children] = calendarEventToJCal(event); - const vevent = (children as unknown[][]).find((c) => c[0] === "vevent"); - const veventProps = vevent![1] as VObjectProperty[]; - const keys = veventProps.map(([k]) => k.toLowerCase()); +describe('calendarEventToJCal', () => { + it('preserves passthroughProps inside the vcalendar output', () => { + const event = baseCalendarEvent({ passthroughProps: CUSTOM_PROPS }) + const [, , children] = calendarEventToJCal(event) + const vevent = (children as unknown[][]).find(c => c[0] === 'vevent') + const veventProps = vevent![1] as VObjectProperty[] + const keys = veventProps.map(([k]) => k.toLowerCase()) - expect(keys).toContain("x-attachment"); + expect(keys).toContain('x-attachment') - expect(keys).toContain("x-custom-field"); - }); -}); + expect(keys).toContain('x-custom-field') + }) +}) -describe("Standard event modification", () => { - describe("title update", () => { - it("updates title while preserving custom props", () => { +describe('Standard event modification', () => { + describe('title update', () => { + it('updates title while preserving custom props', () => { const event = baseCalendarEvent({ - title: "Old title", - passthroughProps: CUSTOM_PROPS, - }); - const updated = { ...event, title: "New title" }; - const [, props] = makeVevent(updated, updated.timezone, undefined); + title: 'Old title', + passthroughProps: CUSTOM_PROPS + }) + const updated = { ...event, title: 'New title' } + const [, props] = makeVevent(updated, updated.timezone, undefined) const summary = (props as VObjectProperty[]).find( - ([k]) => k === "summary" - ); - expect(summary?.[3]).toBe("New title"); + ([k]) => k === 'summary' + ) + expect(summary?.[3]).toBe('New title') - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - }); - }); + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + }) + }) - describe("time update", () => { - it("updates dtstart/dtend while preserving custom props", () => { + describe('time update', () => { + it('updates dtstart/dtend while preserving custom props', () => { const event = baseCalendarEvent({ - start: "2024-03-16T08:00:00.000Z", - end: "2024-03-16T09:00:00.000Z", - passthroughProps: CUSTOM_PROPS, - }); - const [, props] = makeVevent(event, event.timezone, undefined); + start: '2024-03-16T08:00:00.000Z', + end: '2024-03-16T09:00:00.000Z', + passthroughProps: CUSTOM_PROPS + }) + const [, props] = makeVevent(event, event.timezone, undefined) const dtstart = (props as VObjectProperty[]).find( - ([k]) => k === "dtstart" - ); - expect(dtstart).toBeDefined(); + ([k]) => k === 'dtstart' + ) + expect(dtstart).toBeDefined() - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - }); - }); + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + }) + }) - describe("description update", () => { - it("adds description while preserving custom props", () => { + describe('description update', () => { + it('adds description while preserving custom props', () => { const event = baseCalendarEvent({ - description: "Updated description", - passthroughProps: CUSTOM_PROPS, - }); - const [, props] = makeVevent(event, event.timezone, undefined); + description: 'Updated description', + passthroughProps: CUSTOM_PROPS + }) + const [, props] = makeVevent(event, event.timezone, undefined) const desc = (props as VObjectProperty[]).find( - ([k]) => k === "description" - ); - expect(desc?.[3]).toBe("Updated description"); + ([k]) => k === 'description' + ) + expect(desc?.[3]).toBe('Updated description') - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - }); - }); + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + }) + }) - describe("location update", () => { - it("adds location while preserving custom props", () => { + describe('location update', () => { + it('adds location while preserving custom props', () => { const event = baseCalendarEvent({ - location: "Conference Room B", - passthroughProps: CUSTOM_PROPS, - }); - const [, props] = makeVevent(event, event.timezone, undefined); + location: 'Conference Room B', + passthroughProps: CUSTOM_PROPS + }) + const [, props] = makeVevent(event, event.timezone, undefined) - const loc = (props as VObjectProperty[]).find(([k]) => k === "location"); - expect(loc?.[3]).toBe("Conference Room B"); + const loc = (props as VObjectProperty[]).find(([k]) => k === 'location') + expect(loc?.[3]).toBe('Conference Room B') - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - }); - }); + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + }) + }) - describe("attendee update", () => { - it("adds attendee while preserving custom props", () => { + describe('attendee update', () => { + it('adds attendee while preserving custom props', () => { const event = baseCalendarEvent({ attendee: [ { - cal_address: "alice@example.com", - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - }, + cal_address: 'alice@example.com', + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' + } ], - passthroughProps: CUSTOM_PROPS, - }); + passthroughProps: CUSTOM_PROPS + }) const [, props] = makeVevent( event, event.timezone, - "organizer@example.com" - ); + 'organizer@example.com' + ) const attendees = (props as VObjectProperty[]).filter( - ([k]) => k === "attendee" - ); - expect(attendees.length).toBe(1); - expect(String(attendees[0][3])).toContain("alice@example.com"); + ([k]) => k === 'attendee' + ) + expect(attendees.length).toBe(1) + expect(String(attendees[0][3])).toContain('alice@example.com') - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - }); - }); + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + }) + }) - describe("all-day event", () => { - it("formats dates without time component and preserves custom props", () => { + describe('all-day event', () => { + it('formats dates without time component and preserves custom props', () => { const event = baseCalendarEvent({ allday: true, - start: "2024-03-15T00:00:00.000Z", - end: "2024-03-16T00:00:00.000Z", - passthroughProps: CUSTOM_PROPS, - }); - const [, props] = makeVevent(event, event.timezone, undefined); + start: '2024-03-15T00:00:00.000Z', + end: '2024-03-16T00:00:00.000Z', + passthroughProps: CUSTOM_PROPS + }) + const [, props] = makeVevent(event, event.timezone, undefined) const dtstart = (props as VObjectProperty[]).find( - ([k]) => k === "dtstart" - ); + ([k]) => k === 'dtstart' + ) // All-day values should be date strings (no T separator) - expect(String(dtstart?.[3])).not.toContain("T"); + expect(String(dtstart?.[3])).not.toContain('T') - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - }); - }); + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + }) + }) - describe("timezone change", () => { - it("switches tzid param while preserving custom props", () => { + describe('timezone change', () => { + it('switches tzid param while preserving custom props', () => { const event = baseCalendarEvent({ - timezone: "America/New_York", - passthroughProps: CUSTOM_PROPS, - }); - const [, props] = makeVevent(event, "America/New_York", undefined); + timezone: 'America/New_York', + passthroughProps: CUSTOM_PROPS + }) + const [, props] = makeVevent(event, 'America/New_York', undefined) const dtstart = (props as VObjectProperty[]).find( - ([k]) => k === "dtstart" - ); + ([k]) => k === 'dtstart' + ) expect((dtstart?.[1] as Record)?.tzid).toBe( - "America/New_York" - ); + 'America/New_York' + ) - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - }); - }); -}); + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + }) + }) +}) -describe("Recurring event — makeVevent as master", () => { +describe('Recurring event — makeVevent as master', () => { const recurringBase = () => baseCalendarEvent({ - repetition: { freq: "weekly", byday: ["MO", "WE", "FR"], interval: 1 }, - passthroughProps: CUSTOM_PROPS, - }); + repetition: { freq: 'weekly', byday: ['MO', 'WE', 'FR'], interval: 1 }, + passthroughProps: CUSTOM_PROPS + }) - it("includes rrule in the output", () => { + it('includes rrule in the output', () => { const [, props] = makeVevent( recurringBase(), - "Europe/Paris", + 'Europe/Paris', undefined, true - ); - const rrule = (props as VObjectProperty[]).find(([k]) => k === "rrule"); - expect(rrule).toBeDefined(); - expect((rrule?.[3] as Record)?.freq).toBe("weekly"); - }); + ) + const rrule = (props as VObjectProperty[]).find(([k]) => k === 'rrule') + expect(rrule).toBeDefined() + expect((rrule?.[3] as Record)?.freq).toBe('weekly') + }) - it("preserves custom props on master vevent", () => { + it('preserves custom props on master vevent', () => { const [, props] = makeVevent( recurringBase(), - "Europe/Paris", + 'Europe/Paris', undefined, true - ); - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - expect(keys).toContain("x-custom-field"); - }); + ) + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + expect(keys).toContain('x-custom-field') + }) - it("does NOT include recurrence-id on master event", () => { + it('does NOT include recurrence-id on master event', () => { const [, props] = makeVevent( recurringBase(), - "Europe/Paris", + 'Europe/Paris', undefined, true - ); - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).not.toContain("recurrence-id"); - }); -}); + ) + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).not.toContain('recurrence-id') + }) +}) -describe("Recurring event — makeVevent as override (single instance)", () => { +describe('Recurring event — makeVevent as override (single instance)', () => { const overrideBase = () => baseCalendarEvent({ - recurrenceId: "2024-03-20T09:00:00", - uid: "event-abc-uid/2024-03-20T09:00:00", - passthroughProps: CUSTOM_PROPS, - }); + recurrenceId: '2024-03-20T09:00:00', + uid: 'event-abc-uid/2024-03-20T09:00:00', + passthroughProps: CUSTOM_PROPS + }) - it("preserves custom props on override vevent", () => { + it('preserves custom props on override vevent', () => { const [, props] = makeVevent( overrideBase(), - "Europe/Paris", + 'Europe/Paris', undefined, false - ); - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - }); -}); + ) + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + }) +}) -describe("Recurring event — exdate (delete instance)", () => { - it("adds exdate entries and preserves custom props", () => { +describe('Recurring event — exdate (delete instance)', () => { + it('adds exdate entries and preserves custom props', () => { const event = baseCalendarEvent({ - exdates: ["2024-03-20T09:00:00.000Z", "2024-03-27T09:00:00.000Z"], - passthroughProps: CUSTOM_PROPS, - }); - const [, props] = makeVevent(event, "Europe/Paris", undefined, true); + exdates: ['2024-03-20T09:00:00.000Z', '2024-03-27T09:00:00.000Z'], + passthroughProps: CUSTOM_PROPS + }) + const [, props] = makeVevent(event, 'Europe/Paris', undefined, true) - const exdates = (props as VObjectProperty[]).filter( - ([k]) => k === "exdate" - ); - expect(exdates.length).toBe(2); + const exdates = (props as VObjectProperty[]).filter(([k]) => k === 'exdate') + expect(exdates.length).toBe(2) - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - }); -}); + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + }) +}) -describe("Edge cases", () => { - it("handles multiple custom props of the same name", () => { +describe('Edge cases', () => { + it('handles multiple custom props of the same name', () => { const multiAttach: VObjectProperty[] = [ - ["x-attachment", {}, "text", "file1=="], - ["x-attachment", {}, "text", "file2=="], - ]; + ['x-attachment', {}, 'text', 'file1=='], + ['x-attachment', {}, 'text', 'file2=='] + ] const event = parseCalendarEvent( baseVeventProps(multiAttach), {}, MOCK_CALENDAR, EVENT_URL - ); + ) expect( - event.passthroughProps?.filter(([k]) => k === "x-attachment").length - ).toBe(2); + event.passthroughProps?.filter(([k]) => k === 'x-attachment').length + ).toBe(2) const updated = baseCalendarEvent({ - passthroughProps: event.passthroughProps, - }); - const [, props] = makeVevent(updated, updated.timezone, undefined); + passthroughProps: event.passthroughProps + }) + const [, props] = makeVevent(updated, updated.timezone, undefined) const attachments = (props as VObjectProperty[]).filter( - ([k]) => k === "x-attachment" - ); - expect(attachments.length).toBe(2); - }); + ([k]) => k === 'x-attachment' + ) + expect(attachments.length).toBe(2) + }) - it("handles passthrough prop with complex params object", () => { + it('handles passthrough prop with complex params object', () => { const complexProp: VObjectProperty = [ - "x-complex", - { encoding: "BASE64", fmttype: "application/pdf" }, - "binary", - "binarydata==", - ]; - const event = baseCalendarEvent({ passthroughProps: [complexProp] }); - const [, props] = makeVevent(event, event.timezone, undefined); + 'x-complex', + { encoding: 'BASE64', fmttype: 'application/pdf' }, + 'binary', + 'binarydata==' + ] + const event = baseCalendarEvent({ passthroughProps: [complexProp] }) + const [, props] = makeVevent(event, event.timezone, undefined) - const found = (props as VObjectProperty[]).find(([k]) => k === "x-complex"); + const found = (props as VObjectProperty[]).find(([k]) => k === 'x-complex') expect(found?.[1]).toEqual({ - encoding: "BASE64", - fmttype: "application/pdf", - }); - expect(found?.[3]).toBe("binarydata=="); - }); + encoding: 'BASE64', + fmttype: 'application/pdf' + }) + expect(found?.[3]).toBe('binarydata==') + }) - it("round-trips without mutation: passthroughProps array is not modified", () => { - const originalProps = [...CUSTOM_PROPS]; - const event = baseCalendarEvent({ passthroughProps: CUSTOM_PROPS }); - makeVevent(event, event.timezone, undefined); + it('round-trips without mutation: passthroughProps array is not modified', () => { + const originalProps = [...CUSTOM_PROPS] + const event = baseCalendarEvent({ passthroughProps: CUSTOM_PROPS }) + makeVevent(event, event.timezone, undefined) // Original array must be untouched - expect(CUSTOM_PROPS).toEqual(originalProps); - }); + expect(CUSTOM_PROPS).toEqual(originalProps) + }) - it("handles event with alarm alongside passthroughProps", () => { + it('handles event with alarm alongside passthroughProps', () => { const event = baseCalendarEvent({ - alarm: { trigger: "-PT15M", action: "EMAIL" }, - passthroughProps: CUSTOM_PROPS, - }); + alarm: { trigger: '-PT15M', action: 'EMAIL' }, + passthroughProps: CUSTOM_PROPS + }) const [, props, subComponents] = makeVevent( event, event.timezone, - "owner@example.com" - ); - expect(Array.isArray(subComponents)).toBe(true); + 'owner@example.com' + ) + expect(Array.isArray(subComponents)).toBe(true) - const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()); - expect(keys).toContain("x-attachment"); - }); -}); + const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) + expect(keys).toContain('x-attachment') + }) +}) -describe("putEvent - passthroughProps preservation", () => { +describe('putEvent - passthroughProps preservation', () => { beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - it("includes passthroughProps in the PUT body", async () => { - const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; - (api as unknown as jest.Mock).mockReturnValue(mockResponse); + it('includes passthroughProps in the PUT body', async () => { + const mockResponse = { status: 201, url: '/dav/cals/test.ics' } + ;(api as unknown as jest.Mock).mockReturnValue(mockResponse) const eventWithPassthrough = { ...mockEvent, passthroughProps: [ - ["x-attachment", {}, "text", "data:application/pdf;base64,abc123=="], - ], - } as CalendarEvent; + ['x-attachment', {}, 'text', 'data:application/pdf;base64,abc123=='] + ] + } as CalendarEvent - await putEvent(eventWithPassthrough); + await putEvent(eventWithPassthrough) - const callArgs = (api as unknown as jest.Mock).mock.calls[0][1]; - const body = JSON.parse(callArgs.body); - const veventProps = body[2][0][1]; + const callArgs = (api as unknown as jest.Mock).mock.calls[0][1] + const body = JSON.parse(callArgs.body) + const veventProps = body[2][0][1] expect(veventProps).toContainEqual([ - "x-attachment", + 'x-attachment', {}, - "text", - "data:application/pdf;base64,abc123==", - ]); - }); + 'text', + 'data:application/pdf;base64,abc123==' + ]) + }) - it("sends clean body without passthroughProps when none exist", async () => { - const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; - (api as unknown as jest.Mock).mockReturnValue(mockResponse); + it('sends clean body without passthroughProps when none exist', async () => { + const mockResponse = { status: 201, url: '/dav/cals/test.ics' } + ;(api as unknown as jest.Mock).mockReturnValue(mockResponse) - await putEvent({ ...mockEvent, passthroughProps: [] } as CalendarEvent); + await putEvent({ ...mockEvent, passthroughProps: [] } as CalendarEvent) - const callArgs = (api as unknown as jest.Mock).mock.calls[0][1]; - const body = JSON.parse(callArgs.body); - const veventProps = body[2][0][1] as [string, ...unknown[]][]; + const callArgs = (api as unknown as jest.Mock).mock.calls[0][1] + const body = JSON.parse(callArgs.body) + const veventProps = body[2][0][1] as [string, ...unknown[]][] - const xProps = veventProps.filter(([k]) => k.startsWith("x-attachment")); - expect(xProps).toHaveLength(0); - }); -}); + const xProps = veventProps.filter(([k]) => k.startsWith('x-attachment')) + expect(xProps).toHaveLength(0) + }) +}) -describe("putEventWithOverrides - passthroughProps isolation", () => { +describe('putEventWithOverrides - passthroughProps isolation', () => { const icsWithPassthrough = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//EN @@ -568,66 +566,66 @@ X-ATTACHMENT:data:application/pdf;base64,abc123== SEQUENCE:1 END:VEVENT END:VCALENDAR -`; +` beforeEach(() => { - jest.clearAllMocks(); - (api.get as jest.Mock).mockResolvedValue({ - text: jest.fn().mockResolvedValue(icsWithPassthrough), - }); - (api as jest.Mock).mockResolvedValue({ ok: true }); - }); + jest.clearAllMocks() + ;(api.get as jest.Mock).mockResolvedValue({ + text: jest.fn().mockResolvedValue(icsWithPassthrough) + }) + ;(api as jest.Mock).mockResolvedValue({ ok: true }) + }) - it("preserves X-ATTACHMENT on the updated instance in the PUT body", async () => { + it('preserves X-ATTACHMENT on the updated instance in the PUT body', async () => { const updatedInstance = { ...mockEvent, - title: "Override updated", - recurrenceId: "20240202T100000Z", - start: "2024-02-02T10:00:00Z", - end: "2024-02-02T11:00:00Z", + title: 'Override updated', + recurrenceId: '20240202T100000Z', + start: '2024-02-02T10:00:00Z', + end: '2024-02-02T11:00:00Z', passthroughProps: [ - ["x-attachment", {}, "text", "data:application/pdf;base64,abc123=="], - ] as VObjectProperty[], - } as CalendarEvent; + ['x-attachment', {}, 'text', 'data:application/pdf;base64,abc123=='] + ] as VObjectProperty[] + } as CalendarEvent - await putEventWithOverrides(updatedInstance); + await putEventWithOverrides(updatedInstance) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => - props.some(([k]: any) => k === "recurrence-id") - ); + props.some(([k]: any) => k === 'recurrence-id') + ) - const attachment = override[1].find(([k]: any) => k === "x-attachment"); - expect(attachment).toBeDefined(); - expect(attachment[3]).toBe("data:application/pdf;base64,abc123=="); - }); + const attachment = override[1].find(([k]: any) => k === 'x-attachment') + expect(attachment).toBeDefined() + expect(attachment[3]).toBe('data:application/pdf;base64,abc123==') + }) - it("does not inject instance passthroughProps into the master vevent", async () => { + it('does not inject instance passthroughProps into the master vevent', async () => { const updatedInstance = { ...mockEvent, - title: "Override updated", - recurrenceId: "20240202T100000Z", - start: "2024-02-02T10:00:00Z", - end: "2024-02-02T11:00:00Z", + title: 'Override updated', + recurrenceId: '20240202T100000Z', + start: '2024-02-02T10:00:00Z', + end: '2024-02-02T11:00:00Z', passthroughProps: [ - ["x-attachment", {}, "text", "data:application/pdf;base64,abc123=="], - ] as VObjectProperty[], - } as CalendarEvent; + ['x-attachment', {}, 'text', 'data:application/pdf;base64,abc123=='] + ] as VObjectProperty[] + } as CalendarEvent - await putEventWithOverrides(updatedInstance); + await putEventWithOverrides(updatedInstance) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const master = vevents.find(([, props]: any) => - props.every(([k]: any) => k !== "recurrence-id") - ); + props.every(([k]: any) => k !== 'recurrence-id') + ) - const attachment = master[1].find(([k]: any) => k === "x-attachment"); - expect(attachment).toBeUndefined(); - }); + const attachment = master[1].find(([k]: any) => k === 'x-attachment') + expect(attachment).toBeUndefined() + }) - it("preserves raw X-ATTACHMENT on untouched instances from the ICS", async () => { + it('preserves raw X-ATTACHMENT on untouched instances from the ICS', async () => { const icsWithTwoOverrides = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT @@ -657,43 +655,43 @@ X-ATTACHMENT:data:application/pdf;base64,bbb== SEQUENCE:1 END:VEVENT END:VCALENDAR -`; +` - (api.get as jest.Mock).mockResolvedValueOnce({ - text: jest.fn().mockResolvedValue(icsWithTwoOverrides), - }); + ;(api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsWithTwoOverrides) + }) // Only updating Override A — Override B should be untouched const updatedInstance = { ...mockEvent, - title: "Override A updated", - recurrenceId: "20240202T100000Z", - start: "2024-02-02T10:00:00Z", - end: "2024-02-02T11:00:00Z", + title: 'Override A updated', + recurrenceId: '20240202T100000Z', + start: '2024-02-02T10:00:00Z', + end: '2024-02-02T11:00:00Z', passthroughProps: [ - ["x-attachment", {}, "text", "data:application/pdf;base64,aaa=="], - ] as VObjectProperty[], - } as CalendarEvent; + ['x-attachment', {}, 'text', 'data:application/pdf;base64,aaa=='] + ] as VObjectProperty[] + } as CalendarEvent - await putEventWithOverrides(updatedInstance); + await putEventWithOverrides(updatedInstance) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const overrideB = vevents.find(([, props]: any) => props.some( ([k, , , v]: any) => - k === "recurrence-id" && v === "2024-02-03T10:00:00Z" + k === 'recurrence-id' && v === '2024-02-03T10:00:00Z' ) - ); + ) // Override B was never touched — its raw jCal props come straight from the ICS - const attachmentB = overrideB[1].find(([k]: any) => k === "x-attachment"); - expect(attachmentB).toBeDefined(); - expect(attachmentB[3]).toBe("data:application/pdf;base64,bbb=="); - }); + const attachmentB = overrideB[1].find(([k]: any) => k === 'x-attachment') + expect(attachmentB).toBeDefined() + expect(attachmentB[3]).toBe('data:application/pdf;base64,bbb==') + }) - it("does not bleed X-ATTACHMENT from one instance to another in the same PUT", async () => { + it('does not bleed X-ATTACHMENT from one instance to another in the same PUT', async () => { const icsWithTwoOverrides = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT @@ -722,36 +720,36 @@ SUMMARY:Override B - no attachment SEQUENCE:1 END:VEVENT END:VCALENDAR -`; +` - (api.get as jest.Mock).mockResolvedValueOnce({ - text: jest.fn().mockResolvedValue(icsWithTwoOverrides), - }); + ;(api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsWithTwoOverrides) + }) const updatedInstance = { ...mockEvent, - title: "Override A updated", - recurrenceId: "20240202T100000Z", - start: "2024-02-02T10:00:00Z", - end: "2024-02-02T11:00:00Z", + title: 'Override A updated', + recurrenceId: '20240202T100000Z', + start: '2024-02-02T10:00:00Z', + end: '2024-02-02T11:00:00Z', passthroughProps: [ - ["x-attachment", {}, "text", "data:application/pdf;base64,aaa=="], - ] as VObjectProperty[], - } as CalendarEvent; + ['x-attachment', {}, 'text', 'data:application/pdf;base64,aaa=='] + ] as VObjectProperty[] + } as CalendarEvent - await putEventWithOverrides(updatedInstance); + await putEventWithOverrides(updatedInstance) - const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); - const vevents = body[2].filter(([n]: any) => n === "vevent"); + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) + const vevents = body[2].filter(([n]: any) => n === 'vevent') const overrideB = vevents.find(([, props]: any) => props.some( ([k, , , v]: any) => - k === "recurrence-id" && v === "2024-02-03T10:00:00Z" + k === 'recurrence-id' && v === '2024-02-03T10:00:00Z' ) - ); + ) - const attachmentB = overrideB[1].find(([k]: any) => k === "x-attachment"); - expect(attachmentB).toBeUndefined(); - }); -}); + const attachmentB = overrideB[1].find(([k]: any) => k === 'x-attachment') + expect(attachmentB).toBeUndefined() + }) +}) diff --git a/__test__/features/Events/ImportAlert.test.tsx b/__test__/features/Events/ImportAlert.test.tsx index b7f3fca..76af448 100644 --- a/__test__/features/Events/ImportAlert.test.tsx +++ b/__test__/features/Events/ImportAlert.test.tsx @@ -1,67 +1,67 @@ -import ImportAlert from "@/features/Events/ImportAlert"; -import { fireEvent, screen } from "@testing-library/react"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import ImportAlert from '@/features/Events/ImportAlert' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../utils/Renderwithproviders' // Mock the ErrorSnackbar component since we want to test ImportAlert logic, // but we can also test integration if we don't mock it. // Given ImportAlert uses EventErrorSnackbar which uses MUI Snackbar, // it's better to render it and check for text presence. -describe("ImportAlert", () => { +describe('ImportAlert', () => { const preloadedState = { calendars: { list: { cal1: { - id: "cal1", - name: "Calendar 1", + id: 'cal1', + name: 'Calendar 1', events: { event1: { - uid: "event1", - title: "Event 1", - error: "Error message 1", + uid: 'event1', + title: 'Event 1', + error: 'Error message 1' }, event2: { - uid: "event2", - title: "Event 2", + uid: 'event2', + title: 'Event 2' // No error - }, - }, + } + } }, cal2: { - id: "cal2", - name: "Calendar 2", + id: 'cal2', + name: 'Calendar 2', events: { event3: { - uid: "event3", - title: "Event 3", - error: "Error message 2", - }, - }, - }, - }, - }, - }; + uid: 'event3', + title: 'Event 3', + error: 'Error message 2' + } + } + } + } + } + } - it("renders nothing when there are no errors", () => { + it('renders nothing when there are no errors', () => { const state = { calendars: { list: { cal1: { - id: "cal1", + id: 'cal1', events: { - event1: { uid: "event1" }, - }, - }, - }, - }, - }; + event1: { uid: 'event1' } + } + } + } + } + } - const { container } = renderWithProviders(, state); - expect(container).toBeEmptyDOMElement(); - }); + const { container } = renderWithProviders(, state) + expect(container).toBeEmptyDOMElement() + }) - it("renders snackbar with error message when there is one error", () => { - renderWithProviders(, preloadedState); + it('renders snackbar with error message when there is one error', () => { + renderWithProviders(, preloadedState) // EventErrorSnackbar shows "multipleEvents" if > 1, or the message if 1. // Here we have 2 errors in preloadedState. @@ -70,24 +70,24 @@ describe("ImportAlert", () => { calendars: { list: { cal1: { - id: "cal1", + id: 'cal1', events: { event1: { - uid: "event1", - error: "Single Error", - }, - }, - }, - }, - }, - }; + uid: 'event1', + error: 'Single Error' + } + } + } + } + } + } - renderWithProviders(, singleErrorState); - expect(screen.getByText("Single Error")).toBeInTheDocument(); - }); + renderWithProviders(, singleErrorState) + expect(screen.getByText('Single Error')).toBeInTheDocument() + }) - it("renders summary message when there are multiple errors", () => { - renderWithProviders(, preloadedState); + it('renders summary message when there are multiple errors', () => { + renderWithProviders(, preloadedState) // 2 errors in preloadedState // "error.multipleEvents" key is used. // In test environment, t function usually returns the key or formatted string. @@ -108,34 +108,34 @@ describe("ImportAlert", () => { // const summary = messages.length === 1 ? messages[0] : t("error.multipleEvents", { count: messages.length }); // We can check if the Alert is present. - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); + expect(screen.getByRole('alert')).toBeInTheDocument() + }) - it("dismisses error when close button is clicked", () => { + it('dismisses error when close button is clicked', () => { const singleErrorState = { calendars: { list: { cal1: { - id: "cal1", + id: 'cal1', events: { event1: { - uid: "event1", - error: "Dismiss Me", - }, - }, - }, - }, - }, - }; + uid: 'event1', + error: 'Dismiss Me' + } + } + } + } + } + } - renderWithProviders(, singleErrorState); - expect(screen.getByText("Dismiss Me")).toBeInTheDocument(); + renderWithProviders(, singleErrorState) + expect(screen.getByText('Dismiss Me')).toBeInTheDocument() - const closeButton = screen.getByRole("button", { name: /ok/i }); // EventErrorSnackbar has an "OK" button - fireEvent.click(closeButton); + const closeButton = screen.getByRole('button', { name: /ok/i }) // EventErrorSnackbar has an "OK" button + fireEvent.click(closeButton) // After clicking, it should disappear (or at least be removed from DOM or hidden) // Since we use local state to filter dismissed errors, it should re-render with null. - expect(screen.queryByText("Dismiss Me")).not.toBeInTheDocument(); - }); -}); + expect(screen.queryByText('Dismiss Me')).not.toBeInTheDocument() + }) +}) diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index b6cda3c..b397826 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -1,1496 +1,1488 @@ -import { Calendar } from "@/features/Calendars/CalendarTypes"; -import { VObjectProperty } from "@/features/Calendars/types/CalendarData"; -import { CalendarEvent, RepetitionObject } from "@/features/Events/EventsTypes"; +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { VObjectProperty } from '@/features/Calendars/types/CalendarData' +import { CalendarEvent, RepetitionObject } from '@/features/Events/EventsTypes' import { calendarEventToJCal, combineMasterDateWithFormTime, detectRecurringEventChanges, normalizeRepetition, normalizeTimezone, - parseCalendarEvent, -} from "@/features/Events/utils"; + parseCalendarEvent +} from '@/features/Events/utils' -describe("parseCalendarEvent", () => { - const baseColor = { light: "#00FF00" }; - const calendar = { id: "calendar-123" } as Calendar; +describe('parseCalendarEvent', () => { + const baseColor = { light: '#00FF00' } + const calendar = { id: 'calendar-123' } as Calendar - it("parses a full event with MAILTO in caps correctly", () => { + it('parses a full event with MAILTO in caps correctly', () => { const rawData = [ - ["UID", {}, "text", "event-1"], - ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], - ["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"], - ["SUMMARY", {}, "text", "Team Meeting"], - ["DESCRIPTION", {}, "text", "Discuss roadmap"], - ["LOCATION", {}, "text", "Zoom"], - ["ORGANIZER", { cn: "Alice" }, "cal-address", "alice@example.com"], + ['UID', {}, 'text', 'event-1'], + ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'], + ['DTEND', {}, 'date-time', '2025-07-18T10:00:00Z'], + ['SUMMARY', {}, 'text', 'Team Meeting'], + ['DESCRIPTION', {}, 'text', 'Discuss roadmap'], + ['LOCATION', {}, 'text', 'Zoom'], + ['ORGANIZER', { cn: 'Alice' }, 'cal-address', 'alice@example.com'], [ - "ATTENDEE", - { cn: "Bob", partstat: "ACCEPTED" }, - "cal-address", - "bob@example.com", + 'ATTENDEE', + { cn: 'Bob', partstat: 'ACCEPTED' }, + 'cal-address', + 'bob@example.com' ], - ["X-OPENPAAS-VIDEOCONFERENCE", {}, "text", "https://meet.link"], - ["STATUS", {}, "text", "CONFIRMED"], - ["SEQUENCE", {}, "integer", "2"], - ["TRANSP", {}, "text", "OPAQUE"], - ["CLASS", {}, "text", "PUBLIC"], - ["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"], - ] as VObjectProperty[]; + ['X-OPENPAAS-VIDEOCONFERENCE', {}, 'text', 'https://meet.link'], + ['STATUS', {}, 'text', 'CONFIRMED'], + ['SEQUENCE', {}, 'integer', '2'], + ['TRANSP', {}, 'text', 'OPAQUE'], + ['CLASS', {}, 'text', 'PUBLIC'], + ['DTSTAMP', {}, 'date-time', '2025-07-18T08:00:00Z'] + ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, - "/calendars/test.ics" - ); + '/calendars/test.ics' + ) - expect(result.uid).toBe("event-1"); - expect(result.title).toBe("Team Meeting"); - expect(result.description).toBe("Discuss roadmap"); - expect(result.location).toBe("Zoom"); - expect(result.start).toBe("2025-07-18T09:00:00Z"); - expect(result.end).toBe("2025-07-18T10:00:00Z"); - expect(result.stamp).toBe("2025-07-18T08:00:00Z"); - expect(result.sequence).toBe(2); - expect(result.color).toBe(baseColor); - expect(result.status).toBe("CONFIRMED"); - expect(result.transp).toBe("OPAQUE"); - expect(result.class).toBe("PUBLIC"); - expect(result.x_openpass_videoconference).toBe("https://meet.link"); - expect(result.timezone).not.toBeDefined(); + expect(result.uid).toBe('event-1') + expect(result.title).toBe('Team Meeting') + expect(result.description).toBe('Discuss roadmap') + expect(result.location).toBe('Zoom') + expect(result.start).toBe('2025-07-18T09:00:00Z') + expect(result.end).toBe('2025-07-18T10:00:00Z') + expect(result.stamp).toBe('2025-07-18T08:00:00Z') + expect(result.sequence).toBe(2) + expect(result.color).toBe(baseColor) + expect(result.status).toBe('CONFIRMED') + expect(result.transp).toBe('OPAQUE') + expect(result.class).toBe('PUBLIC') + expect(result.x_openpass_videoconference).toBe('https://meet.link') + expect(result.timezone).not.toBeDefined() expect(result.organizer).toEqual({ - cn: "Alice", - cal_address: "alice@example.com", - }); + cn: 'Alice', + cal_address: 'alice@example.com' + }) expect(result.attendee).toEqual([ { - cn: "Bob", - cal_address: "bob@example.com", - partstat: "ACCEPTED", - cutype: "INDIVIDUAL", - role: "REQ-PARTICIPANT", - rsvp: "FALSE", - }, - ]); - }); + cn: 'Bob', + cal_address: 'bob@example.com', + partstat: 'ACCEPTED', + cutype: 'INDIVIDUAL', + role: 'REQ-PARTICIPANT', + rsvp: 'FALSE' + } + ]) + }) - it("dedupes attendees addresses correctly", () => { + it('dedupes attendees addresses correctly', () => { const rawData = [ - ["UID", {}, "text", "event-1"], - ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], - ["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"], - ["SUMMARY", {}, "text", "Team Meeting"], - ["DESCRIPTION", {}, "text", "Discuss roadmap"], - ["LOCATION", {}, "text", "Zoom"], - ["ORGANIZER", { cn: "Alice" }, "cal-address", "alice@example.com"], + ['UID', {}, 'text', 'event-1'], + ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'], + ['DTEND', {}, 'date-time', '2025-07-18T10:00:00Z'], + ['SUMMARY', {}, 'text', 'Team Meeting'], + ['DESCRIPTION', {}, 'text', 'Discuss roadmap'], + ['LOCATION', {}, 'text', 'Zoom'], + ['ORGANIZER', { cn: 'Alice' }, 'cal-address', 'alice@example.com'], [ - "ATTENDEE", - { cn: "Bob", partstat: "ACCEPTED" }, - "cal-address", - "bob@example.com", + 'ATTENDEE', + { cn: 'Bob', partstat: 'ACCEPTED' }, + 'cal-address', + 'bob@example.com' ], [ - "ATTENDEE", - { cn: "Bob", partstat: "ACCEPTED" }, - "cal-address", - "bob@example.com", + 'ATTENDEE', + { cn: 'Bob', partstat: 'ACCEPTED' }, + 'cal-address', + 'bob@example.com' ], [ - "ATTENDEE", - { cn: "Bob", partstat: "ACCEPTED" }, - "cal-address", - "bob@example.com", + 'ATTENDEE', + { cn: 'Bob', partstat: 'ACCEPTED' }, + 'cal-address', + 'bob@example.com' ], - ["X-OPENPAAS-VIDEOCONFERENCE", {}, "text", "https://meet.link"], - ["STATUS", {}, "text", "CONFIRMED"], - ["SEQUENCE", {}, "integer", "2"], - ["TRANSP", {}, "text", "OPAQUE"], - ["CLASS", {}, "text", "PUBLIC"], - ["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"], - ] as VObjectProperty[]; + ['X-OPENPAAS-VIDEOCONFERENCE', {}, 'text', 'https://meet.link'], + ['STATUS', {}, 'text', 'CONFIRMED'], + ['SEQUENCE', {}, 'integer', '2'], + ['TRANSP', {}, 'text', 'OPAQUE'], + ['CLASS', {}, 'text', 'PUBLIC'], + ['DTSTAMP', {}, 'date-time', '2025-07-18T08:00:00Z'] + ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, - "/calendars/test.ics" - ); + '/calendars/test.ics' + ) - expect(result.uid).toBe("event-1"); - expect(result.title).toBe("Team Meeting"); - expect(result.description).toBe("Discuss roadmap"); - expect(result.location).toBe("Zoom"); - expect(result.start).toBe("2025-07-18T09:00:00Z"); - expect(result.end).toBe("2025-07-18T10:00:00Z"); - expect(result.stamp).toBe("2025-07-18T08:00:00Z"); - expect(result.sequence).toBe(2); - expect(result.color).toBe(baseColor); - expect(result.status).toBe("CONFIRMED"); - expect(result.transp).toBe("OPAQUE"); - expect(result.class).toBe("PUBLIC"); - expect(result.x_openpass_videoconference).toBe("https://meet.link"); - expect(result.timezone).not.toBeDefined(); + expect(result.uid).toBe('event-1') + expect(result.title).toBe('Team Meeting') + expect(result.description).toBe('Discuss roadmap') + expect(result.location).toBe('Zoom') + expect(result.start).toBe('2025-07-18T09:00:00Z') + expect(result.end).toBe('2025-07-18T10:00:00Z') + expect(result.stamp).toBe('2025-07-18T08:00:00Z') + expect(result.sequence).toBe(2) + expect(result.color).toBe(baseColor) + expect(result.status).toBe('CONFIRMED') + expect(result.transp).toBe('OPAQUE') + expect(result.class).toBe('PUBLIC') + expect(result.x_openpass_videoconference).toBe('https://meet.link') + expect(result.timezone).not.toBeDefined() expect(result.organizer).toEqual({ - cn: "Alice", - cal_address: "alice@example.com", - }); + cn: 'Alice', + cal_address: 'alice@example.com' + }) expect(result.attendee).toEqual([ { - cn: "Bob", - cal_address: "bob@example.com", - partstat: "ACCEPTED", - cutype: "INDIVIDUAL", - role: "REQ-PARTICIPANT", - rsvp: "FALSE", - }, - ]); - }); + cn: 'Bob', + cal_address: 'bob@example.com', + partstat: 'ACCEPTED', + cutype: 'INDIVIDUAL', + role: 'REQ-PARTICIPANT', + rsvp: 'FALSE' + } + ]) + }) - it("parses a full event correctly", () => { + it('parses a full event correctly', () => { const rawData = [ - ["UID", {}, "text", "event-1"], - ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], - ["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"], - ["SUMMARY", {}, "text", "Team Meeting"], - ["DESCRIPTION", {}, "text", "Discuss roadmap"], - ["LOCATION", {}, "text", "Zoom"], - ["ORGANIZER", { cn: "Alice" }, "cal-address", "MAILTO:alice@example.com"], + ['UID', {}, 'text', 'event-1'], + ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'], + ['DTEND', {}, 'date-time', '2025-07-18T10:00:00Z'], + ['SUMMARY', {}, 'text', 'Team Meeting'], + ['DESCRIPTION', {}, 'text', 'Discuss roadmap'], + ['LOCATION', {}, 'text', 'Zoom'], + ['ORGANIZER', { cn: 'Alice' }, 'cal-address', 'MAILTO:alice@example.com'], [ - "ATTENDEE", - { cn: "Bob", partstat: "ACCEPTED" }, - "cal-address", - "MAILTO:bob@example.com", + 'ATTENDEE', + { cn: 'Bob', partstat: 'ACCEPTED' }, + 'cal-address', + 'MAILTO:bob@example.com' ], - ["X-OPENPAAS-VIDEOCONFERENCE", {}, "text", "https://meet.link"], - ["STATUS", {}, "text", "CONFIRMED"], - ["SEQUENCE", {}, "integer", "2"], - ["TRANSP", {}, "text", "OPAQUE"], - ["CLASS", {}, "text", "PUBLIC"], - ["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"], - ] as VObjectProperty[]; + ['X-OPENPAAS-VIDEOCONFERENCE', {}, 'text', 'https://meet.link'], + ['STATUS', {}, 'text', 'CONFIRMED'], + ['SEQUENCE', {}, 'integer', '2'], + ['TRANSP', {}, 'text', 'OPAQUE'], + ['CLASS', {}, 'text', 'PUBLIC'], + ['DTSTAMP', {}, 'date-time', '2025-07-18T08:00:00Z'] + ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, - "/calendars/test.ics" - ); + '/calendars/test.ics' + ) - expect(result.uid).toBe("event-1"); - expect(result.title).toBe("Team Meeting"); - expect(result.description).toBe("Discuss roadmap"); - expect(result.location).toBe("Zoom"); - expect(result.start).toBe("2025-07-18T09:00:00Z"); - expect(result.end).toBe("2025-07-18T10:00:00Z"); - expect(result.stamp).toBe("2025-07-18T08:00:00Z"); - expect(result.sequence).toBe(2); - expect(result.color).toBe(baseColor); - expect(result.status).toBe("CONFIRMED"); - expect(result.transp).toBe("OPAQUE"); - expect(result.class).toBe("PUBLIC"); - expect(result.x_openpass_videoconference).toBe("https://meet.link"); - expect(result.timezone).not.toBeDefined(); + expect(result.uid).toBe('event-1') + expect(result.title).toBe('Team Meeting') + expect(result.description).toBe('Discuss roadmap') + expect(result.location).toBe('Zoom') + expect(result.start).toBe('2025-07-18T09:00:00Z') + expect(result.end).toBe('2025-07-18T10:00:00Z') + expect(result.stamp).toBe('2025-07-18T08:00:00Z') + expect(result.sequence).toBe(2) + expect(result.color).toBe(baseColor) + expect(result.status).toBe('CONFIRMED') + expect(result.transp).toBe('OPAQUE') + expect(result.class).toBe('PUBLIC') + expect(result.x_openpass_videoconference).toBe('https://meet.link') + expect(result.timezone).not.toBeDefined() expect(result.organizer).toEqual({ - cn: "Alice", - cal_address: "alice@example.com", - }); + cn: 'Alice', + cal_address: 'alice@example.com' + }) expect(result.attendee).toEqual([ { - cn: "Bob", - cal_address: "bob@example.com", - partstat: "ACCEPTED", - cutype: "INDIVIDUAL", - role: "REQ-PARTICIPANT", - rsvp: "FALSE", - }, - ]); - }); + cn: 'Bob', + cal_address: 'bob@example.com', + partstat: 'ACCEPTED', + cutype: 'INDIVIDUAL', + role: 'REQ-PARTICIPANT', + rsvp: 'FALSE' + } + ]) + }) - it("marks allday true for DATE format", () => { + it('marks allday true for DATE format', () => { const rawData: VObjectProperty[] = [ - ["UID", {}, "text", "event-2"], - ["DTSTART", {}, "date", "2025-07-20"], - ["DTEND", {}, "date", "2025-07-21"], - ]; + ['UID', {}, 'text', 'event-2'], + ['DTSTART', {}, 'date', '2025-07-20'], + ['DTEND', {}, 'date', '2025-07-21'] + ] const result = parseCalendarEvent( rawData, baseColor, calendar, - "/calendars/test.ics" - ); + '/calendars/test.ics' + ) - expect(result.allday).toBe(true); - }); + expect(result.allday).toBe(true) + }) - it("appends recurrence-id to UID if present", () => { + it('appends recurrence-id to UID if present', () => { const rawData: VObjectProperty[] = [ - ["UID", {}, "text", "event-2"], - ["RECURRENCE-ID", {}, "date-time", "2025-07-18T09:00:00Z"], - ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], - ]; + ['UID', {}, 'text', 'event-2'], + ['RECURRENCE-ID', {}, 'date-time', '2025-07-18T09:00:00Z'], + ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'] + ] const result = parseCalendarEvent( rawData, baseColor, calendar, - "/calendars/test.ics" - ); + '/calendars/test.ics' + ) - expect(result.uid).toBe("event-2/2025-07-18T09:00:00Z"); - }); + expect(result.uid).toBe('event-2/2025-07-18T09:00:00Z') + }) - it("returns error if UID or start is missing", () => { + it('returns error if UID or start is missing', () => { const rawDataMissingUid: VObjectProperty[] = [ - ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], - ]; + ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'] + ] const result = parseCalendarEvent( rawDataMissingUid, baseColor, calendar, - "/calendars/test.ics" - ); - expect(result.error).toMatch(/missing crucial event param/); + '/calendars/test.ics' + ) + expect(result.error).toMatch(/missing crucial event param/) const rawDataMissingStart: VObjectProperty[] = [ - ["UID", {}, "text", "event-3"], - ]; + ['UID', {}, 'text', 'event-3'] + ] const result2 = parseCalendarEvent( rawDataMissingStart, baseColor, calendar, - "/calendars/test.ics" - ); - expect(result2.error).toMatch(/missing crucial event param/); - }); + '/calendars/test.ics' + ) + expect(result2.error).toMatch(/missing crucial event param/) + }) - it("returns computed end when there is no end but a duration", () => { - jest.useFakeTimers().setSystemTime(new Date("2025-07-18T00:00:00Z")); + it('returns computed end when there is no end but a duration', () => { + jest.useFakeTimers().setSystemTime(new Date('2025-07-18T00:00:00Z')) const rawDataMissing: VObjectProperty[] = [ - ["UID", {}, "text", "event-1"], - ["DTSTART", {}, "date-time", "2025-07-18T09:00:00"], - ["duration", {}, "duration", "PT60M"], - ]; + ['UID', {}, 'text', 'event-1'], + ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00'], + ['duration', {}, 'duration', 'PT60M'] + ] const result = parseCalendarEvent( rawDataMissing, baseColor, calendar, - "/calendars/test.ics" - ); - expect(result.end).toBeDefined(); - const endDate = new Date(result.end ?? ""); - const startDate = new Date(result.start); - expect(endDate.getTime() - startDate.getTime()).toBe(60 * 60 * 1000); - }); + '/calendars/test.ics' + ) + expect(result.end).toBeDefined() + const endDate = new Date(result.end ?? '') + const startDate = new Date(result.start) + expect(endDate.getTime() - startDate.getTime()).toBe(60 * 60 * 1000) + }) - it("returns 30 minutes event if end and duration are missing", () => { + it('returns 30 minutes event if end and duration are missing', () => { const rawDataMissing: VObjectProperty[] = [ - ["UID", {}, "text", "event-1"], - ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], - ]; + ['UID', {}, 'text', 'event-1'], + ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'] + ] const result = parseCalendarEvent( rawDataMissing, baseColor, calendar, - "/calendars/test.ics" - ); - expect(result.end).toBeDefined(); - const endDate = new Date(result.end ?? ""); - const startDate = new Date(result.start); - expect(endDate.getTime() - startDate.getTime()).toBe(30 * 60 * 1000); - }); + '/calendars/test.ics' + ) + expect(result.end).toBeDefined() + const endDate = new Date(result.end ?? '') + const startDate = new Date(result.start) + expect(endDate.getTime() - startDate.getTime()).toBe(30 * 60 * 1000) + }) - it("parses alarm block correctly", () => { + it('parses alarm block correctly', () => { const rawData: VObjectProperty[] = [ - ["UID", {}, "text", "event-5"], - ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], - ]; + ['UID', {}, 'text', 'event-5'], + ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'] + ] const valarm = [ - "VALARM", + 'VALARM', [ - ["ACTION", {}, "text", "DISPLAY"], - ["TRIGGER", {}, "duration", "-PT15M"], - ], - ] as unknown as VObjectProperty[]; + ['ACTION', {}, 'text', 'DISPLAY'], + ['TRIGGER', {}, 'duration', '-PT15M'] + ] + ] as unknown as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, - "/calendars/test.ics", + '/calendars/test.ics', valarm - ); + ) - expect(result.alarm?.action).toBe("DISPLAY"); - expect(result.alarm?.trigger).toBe("-PT15M"); - }); + expect(result.alarm?.action).toBe('DISPLAY') + expect(result.alarm?.trigger).toBe('-PT15M') + }) - it("handles optional organizer and attendee fields gracefully", () => { + it('handles optional organizer and attendee fields gracefully', () => { const rawData = [ - ["UID", {}, "text", "event-4"], - ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], - ["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"], - ["ATTENDEE", {}, "cal-address", "john@example.com"], - ["ORGANIZER", {}, "cal-address", "jane@example.com"], - ] as VObjectProperty[]; + ['UID', {}, 'text', 'event-4'], + ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'], + ['DTEND', {}, 'date-time', '2025-07-18T10:00:00Z'], + ['ATTENDEE', {}, 'cal-address', 'john@example.com'], + ['ORGANIZER', {}, 'cal-address', 'jane@example.com'] + ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, - "/calendars/test.ics" - ); + '/calendars/test.ics' + ) expect(result.attendee).toEqual([ { - cn: "", - cal_address: "john@example.com", - partstat: "NEEDS-ACTION", - cutype: "INDIVIDUAL", - role: "REQ-PARTICIPANT", - rsvp: "FALSE", - }, - ]); + cn: '', + cal_address: 'john@example.com', + partstat: 'NEEDS-ACTION', + cutype: 'INDIVIDUAL', + role: 'REQ-PARTICIPANT', + rsvp: 'FALSE' + } + ]) expect(result.organizer).toEqual({ - cn: "", - cal_address: "jane@example.com", - }); - }); + cn: '', + cal_address: 'jane@example.com' + }) + }) - it("converts datetime without timezone to ISO UTC when timezone is detected", () => { + it('converts datetime without timezone to ISO UTC when timezone is detected', () => { const rawData = [ - ["UID", {}, "text", "event-tz"], - ["DTSTART", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T09:00:00"], - ["DTEND", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T10:00:00"], - ] as VObjectProperty[]; + ['UID', {}, 'text', 'event-tz'], + ['DTSTART', { tzid: 'Asia/Bangkok' }, 'date-time', '2025-07-18T09:00:00'], + ['DTEND', { tzid: 'Asia/Bangkok' }, 'date-time', '2025-07-18T10:00:00'] + ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, - "/calendars/test.ics" - ); + '/calendars/test.ics' + ) - expect(result.timezone).toBeDefined(); + expect(result.timezone).toBeDefined() expect(result.start).toMatch( /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ - ); - expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); - expect(result.allday).toBe(false); - }); + ) + expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + expect(result.allday).toBe(false) + }) - it("does not convert datetime with Z suffix", () => { + it('does not convert datetime with Z suffix', () => { const rawData = [ - ["UID", {}, "text", "event-utc"], - ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], - ["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"], - ] as VObjectProperty[]; + ['UID', {}, 'text', 'event-utc'], + ['DTSTART', {}, 'date-time', '2025-07-18T09:00:00Z'], + ['DTEND', {}, 'date-time', '2025-07-18T10:00:00Z'] + ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, - "/calendars/test.ics" - ); + '/calendars/test.ics' + ) - expect(result.start).toBe("2025-07-18T09:00:00Z"); - expect(result.end).toBe("2025-07-18T10:00:00Z"); - }); + expect(result.start).toBe('2025-07-18T09:00:00Z') + expect(result.end).toBe('2025-07-18T10:00:00Z') + }) - it("preserves all-day event dates without conversion", () => { + it('preserves all-day event dates without conversion', () => { const rawData = [ - ["UID", {}, "text", "event-allday"], - ["DTSTART", {}, "date", "2025-07-18"], - ["DTEND", {}, "date", "2025-07-19"], - ] as VObjectProperty[]; + ['UID', {}, 'text', 'event-allday'], + ['DTSTART', {}, 'date', '2025-07-18'], + ['DTEND', {}, 'date', '2025-07-19'] + ] as VObjectProperty[] const result = parseCalendarEvent( rawData, baseColor, calendar, - "/calendars/test.ics" - ); + '/calendars/test.ics' + ) - expect(result.allday).toBe(true); - expect(result.start).toBe("2025-07-18"); - expect(result.end).toBe("2025-07-19"); - }); -}); + expect(result.allday).toBe(true) + expect(result.start).toBe('2025-07-18') + expect(result.end).toBe('2025-07-19') + }) +}) -describe("calendarEventToJCal", () => { - it("should convert a CalendarEvent to JCal format", () => { +describe('calendarEventToJCal', () => { + it('should convert a CalendarEvent to JCal format', () => { const mockEvent = { - uid: "event-123", - URL: "/calendars/test.ics", - calId: "test/test", - title: "Team Meeting", - start: "2025-07-23T08:00:00.000Z", - end: "2025-07-23T09:00:00.000Z", - timezone: "Europe/Paris", - transp: "OPAQUE", - class: "PUBLIC", + uid: 'event-123', + URL: '/calendars/test.ics', + calId: 'test/test', + title: 'Team Meeting', + start: '2025-07-23T08:00:00.000Z', + end: '2025-07-23T09:00:00.000Z', + timezone: 'Europe/Paris', + transp: 'OPAQUE', + class: 'PUBLIC', allday: false, - location: "Room 101", - description: "Discuss project roadmap.", - repetition: { freq: "WEEKLY" }, + location: 'Room 101', + description: 'Discuss project roadmap.', + repetition: { freq: 'WEEKLY' }, organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Bob", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "bob@example.com", - }, - ], - }; + cn: 'Bob', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'bob@example.com' + } + ] + } - const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent); + const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) - expect(result[0]).toBe("vcalendar"); - const [vevent, vtimezone] = result[2]; - expect(vevent[0]).toBe("vevent"); + expect(result[0]).toBe('vcalendar') + const [vevent, vtimezone] = result[2] + expect(vevent[0]).toBe('vevent') - const props = vevent[1]; - const dtstart = props.find((p: VObjectProperty) => p[0] === "dtstart"); - const dtend = props.find((p: VObjectProperty) => p[0] === "dtend"); + const props = vevent[1] + const dtstart = props.find((p: VObjectProperty) => p[0] === 'dtstart') + const dtend = props.find((p: VObjectProperty) => p[0] === 'dtend') - expect(dtstart).toBeDefined(); - expect(dtstart[1]).toEqual({ tzid: "Europe/Paris" }); - expect(dtstart[2]).toBe("date-time"); - expect(dtstart[3]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + expect(dtstart).toBeDefined() + expect(dtstart[1]).toEqual({ tzid: 'Europe/Paris' }) + expect(dtstart[2]).toBe('date-time') + expect(dtstart[3]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/) - expect(dtend).toBeDefined(); - expect(dtend[1]).toEqual({ tzid: "Europe/Paris" }); - expect(dtend[2]).toBe("date-time"); - expect(dtend[3]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + expect(dtend).toBeDefined() + expect(dtend[1]).toEqual({ tzid: 'Europe/Paris' }) + expect(dtend[2]).toBe('date-time') + expect(dtend[3]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/) expect(props).toEqual( expect.arrayContaining([ - ["uid", {}, "text", "event-123"], - ["summary", {}, "text", "Team Meeting"], - ["transp", {}, "text", "OPAQUE"], - ["class", {}, "text", "PUBLIC"], - ["location", {}, "text", "Room 101"], - ["description", {}, "text", "Discuss project roadmap."], - ["x-openpaas-videoconference", {}, "unknown", null], - ["rrule", {}, "recur", { freq: "WEEKLY" }], + ['uid', {}, 'text', 'event-123'], + ['summary', {}, 'text', 'Team Meeting'], + ['transp', {}, 'text', 'OPAQUE'], + ['class', {}, 'text', 'PUBLIC'], + ['location', {}, 'text', 'Room 101'], + ['description', {}, 'text', 'Discuss project roadmap.'], + ['x-openpaas-videoconference', {}, 'unknown', null], + ['rrule', {}, 'recur', { freq: 'WEEKLY' }], [ - "organizer", - { cn: "Alice" }, - "cal-address", - "mailto:alice@example.com", + 'organizer', + { cn: 'Alice' }, + 'cal-address', + 'mailto:alice@example.com' ], [ - "attendee", + 'attendee', { - cn: "Bob", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Bob', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, - "cal-address", - "mailto:bob@example.com", - ], + 'cal-address', + 'mailto:bob@example.com' + ] ]) - ); - expect(vtimezone[0]).toBe("vtimezone"); + ) + expect(vtimezone[0]).toBe('vtimezone') expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ - "daylight", + 'daylight', [ - ["tzoffsetfrom", {}, "utc-offset", "+01:00"], - ["tzoffsetto", {}, "utc-offset", "+02:00"], - ["tzname", {}, "text", "CEST"], - ["dtstart", {}, "date-time", "1970-03-29T02:00:00"], + ['tzoffsetfrom', {}, 'utc-offset', '+01:00'], + ['tzoffsetto', {}, 'utc-offset', '+02:00'], + ['tzname', {}, 'text', 'CEST'], + ['dtstart', {}, 'date-time', '1970-03-29T02:00:00'], [ - "rrule", + 'rrule', {}, - "recur", - { byday: "-1SU", bymonth: 3, freq: "YEARLY" }, - ], + 'recur', + { byday: '-1SU', bymonth: 3, freq: 'YEARLY' } + ] ], - [], + [] ], [ - "standard", + 'standard', [ - ["tzoffsetfrom", {}, "utc-offset", "+02:00"], - ["tzoffsetto", {}, "utc-offset", "+01:00"], - ["tzname", {}, "text", "CET"], - ["dtstart", {}, "date-time", "1970-10-25T03:00:00"], + ['tzoffsetfrom', {}, 'utc-offset', '+02:00'], + ['tzoffsetto', {}, 'utc-offset', '+01:00'], + ['tzname', {}, 'text', 'CET'], + ['dtstart', {}, 'date-time', '1970-10-25T03:00:00'], [ - "rrule", + 'rrule', {}, - "recur", - { byday: "-1SU", bymonth: 10, freq: "YEARLY" }, - ], + 'recur', + { byday: '-1SU', bymonth: 10, freq: 'YEARLY' } + ] ], - [], - ], + [] + ] ]) - ); - }); + ) + }) - it("converts with alarm included", () => { + it('converts with alarm included', () => { const mockEvent = { - uid: "event-10", - title: "Alarm Event", - start: "2025-07-20T07:00:00.000Z", - end: "2025-07-20T08:00:00.000Z", - timezone: "Europe/Paris", + uid: 'event-10', + title: 'Alarm Event', + start: '2025-07-20T07:00:00.000Z', + end: '2025-07-20T08:00:00.000Z', + timezone: 'Europe/Paris', allday: false, - alarm: { trigger: "-PT10M", action: "DISPLAY" }, - attendee: [], - } as unknown as CalendarEvent; + alarm: { trigger: '-PT10M', action: 'DISPLAY' }, + attendee: [] + } as unknown as CalendarEvent - const result = calendarEventToJCal(mockEvent, "owner@example.com"); - const vevent = result[2][0]; + const result = calendarEventToJCal(mockEvent, 'owner@example.com') + const vevent = result[2][0] - expect(vevent[2][0][0]).toBe("valarm"); + expect(vevent[2][0][0]).toBe('valarm') expect(vevent[2][0][1]).toEqual( expect.arrayContaining([ - ["trigger", {}, "duration", "-PT10M"], - ["action", {}, "text", "DISPLAY"], + ['trigger', {}, 'duration', '-PT10M'], + ['action', {}, 'text', 'DISPLAY'] ]) - ); - }); + ) + }) - it("converts all-day events", () => { + it('converts all-day events', () => { const mockEvent = { - uid: "event-11", - title: "All Day", - start: "2025-07-21T00:00:00.000Z", - end: "2025-07-22T00:00:00.000Z", - timezone: "Europe/Paris", + uid: 'event-11', + title: 'All Day', + start: '2025-07-21T00:00:00.000Z', + end: '2025-07-22T00:00:00.000Z', + timezone: 'Europe/Paris', allday: true, - attendee: [], - } as unknown as CalendarEvent; + attendee: [] + } as unknown as CalendarEvent - const result = calendarEventToJCal(mockEvent); - const veventProps = result[2][0][1]; + const result = calendarEventToJCal(mockEvent) + const veventProps = result[2][0][1] expect(veventProps).toEqual( expect.arrayContaining([ - ["dtstart", { tzid: "Europe/Paris" }, "date", "2025-07-21"], - ["dtend", { tzid: "Europe/Paris" }, "date", "2025-07-22"], + ['dtstart', { tzid: 'Europe/Paris' }, 'date', '2025-07-21'], + ['dtend', { tzid: 'Europe/Paris' }, 'date', '2025-07-22'] ]) - ); - }); + ) + }) - it("should convert a CalendarEvent to JCal format, with all day activated", () => { + it('should convert a CalendarEvent to JCal format, with all day activated', () => { const mockEvent = { - uid: "event-123", - URL: "/calendars/test.ics", - calId: "test/test", - title: "Team Meeting", - start: "2025-07-23T00:00:00.000Z", - end: "2025-07-24T00:00:00.000Z", - timezone: "Europe/Paris", - transp: "OPAQUE", - class: "PUBLIC", + uid: 'event-123', + URL: '/calendars/test.ics', + calId: 'test/test', + title: 'Team Meeting', + start: '2025-07-23T00:00:00.000Z', + end: '2025-07-24T00:00:00.000Z', + timezone: 'Europe/Paris', + transp: 'OPAQUE', + class: 'PUBLIC', allday: true, - location: "Room 101", - description: "Discuss project roadmap.", - repetition: { freq: "WEEKLY", interval: 2 }, + location: 'Room 101', + description: 'Discuss project roadmap.', + repetition: { freq: 'WEEKLY', interval: 2 }, organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Bob", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "bob@example.com", - }, - ], - }; + cn: 'Bob', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'bob@example.com' + } + ] + } - const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent); + const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) - expect(result[0]).toBe("vcalendar"); - const [vevent, vtimezone] = result[2]; - expect(vevent[0]).toBe("vevent"); + expect(result[0]).toBe('vcalendar') + const [vevent, vtimezone] = result[2] + expect(vevent[0]).toBe('vevent') - const props = vevent[1]; + const props = vevent[1] expect(props).toEqual( expect.arrayContaining([ - ["uid", {}, "text", "event-123"], - ["summary", {}, "text", "Team Meeting"], - ["transp", {}, "text", "OPAQUE"], - ["dtstart", { tzid: "Europe/Paris" }, "date", "2025-07-23"], - ["dtend", { tzid: "Europe/Paris" }, "date", "2025-07-24"], - ["class", {}, "text", "PUBLIC"], - ["location", {}, "text", "Room 101"], - ["description", {}, "text", "Discuss project roadmap."], - ["x-openpaas-videoconference", {}, "unknown", null], - ["rrule", {}, "recur", { freq: "WEEKLY", interval: 2 }], + ['uid', {}, 'text', 'event-123'], + ['summary', {}, 'text', 'Team Meeting'], + ['transp', {}, 'text', 'OPAQUE'], + ['dtstart', { tzid: 'Europe/Paris' }, 'date', '2025-07-23'], + ['dtend', { tzid: 'Europe/Paris' }, 'date', '2025-07-24'], + ['class', {}, 'text', 'PUBLIC'], + ['location', {}, 'text', 'Room 101'], + ['description', {}, 'text', 'Discuss project roadmap.'], + ['x-openpaas-videoconference', {}, 'unknown', null], + ['rrule', {}, 'recur', { freq: 'WEEKLY', interval: 2 }], [ - "organizer", - { cn: "Alice" }, - "cal-address", - "mailto:alice@example.com", + 'organizer', + { cn: 'Alice' }, + 'cal-address', + 'mailto:alice@example.com' ], [ - "attendee", + 'attendee', { - cn: "Bob", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", + cn: 'Bob', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL' }, - "cal-address", - "mailto:bob@example.com", - ], + 'cal-address', + 'mailto:bob@example.com' + ] ]) - ); - expect(vtimezone[0]).toBe("vtimezone"); + ) + expect(vtimezone[0]).toBe('vtimezone') expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ - "daylight", + 'daylight', [ - ["tzoffsetfrom", {}, "utc-offset", "+01:00"], - ["tzoffsetto", {}, "utc-offset", "+02:00"], - ["tzname", {}, "text", "CEST"], - ["dtstart", {}, "date-time", "1970-03-29T02:00:00"], + ['tzoffsetfrom', {}, 'utc-offset', '+01:00'], + ['tzoffsetto', {}, 'utc-offset', '+02:00'], + ['tzname', {}, 'text', 'CEST'], + ['dtstart', {}, 'date-time', '1970-03-29T02:00:00'], [ - "rrule", + 'rrule', {}, - "recur", - { byday: "-1SU", bymonth: 3, freq: "YEARLY" }, - ], + 'recur', + { byday: '-1SU', bymonth: 3, freq: 'YEARLY' } + ] ], - [], + [] ], [ - "standard", + 'standard', [ - ["tzoffsetfrom", {}, "utc-offset", "+02:00"], - ["tzoffsetto", {}, "utc-offset", "+01:00"], - ["tzname", {}, "text", "CET"], - ["dtstart", {}, "date-time", "1970-10-25T03:00:00"], + ['tzoffsetfrom', {}, 'utc-offset', '+02:00'], + ['tzoffsetto', {}, 'utc-offset', '+01:00'], + ['tzname', {}, 'text', 'CET'], + ['dtstart', {}, 'date-time', '1970-10-25T03:00:00'], [ - "rrule", + 'rrule', {}, - "recur", - { byday: "-1SU", bymonth: 10, freq: "YEARLY" }, - ], + 'recur', + { byday: '-1SU', bymonth: 10, freq: 'YEARLY' } + ] ], - [], - ], + [] + ] ]) - ); - }); + ) + }) // Edge cases for timezone fallback - it("handles invalid timezone with UTC fallback", () => { + it('handles invalid timezone with UTC fallback', () => { const mockEvent = { - uid: "event-invalid-tz", - title: "Invalid Timezone Event", - start: "2025-07-23T10:00:00.000Z", - end: "2025-07-23T11:00:00.000Z", - timezone: "Invalid/Timezone", + uid: 'event-invalid-tz', + title: 'Invalid Timezone Event', + start: '2025-07-23T10:00:00.000Z', + end: '2025-07-23T11:00:00.000Z', + timezone: 'Invalid/Timezone', allday: false, - attendee: [], - }; + attendee: [] + } - const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent); + const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) - expect(result[0]).toBe("vcalendar"); - const [vevent, vtimezone] = result[2]; - expect(vevent[0]).toBe("vevent"); - expect(vtimezone[0]).toBe("vtimezone"); + expect(result[0]).toBe('vcalendar') + const [vevent, vtimezone] = result[2] + expect(vevent[0]).toBe('vevent') + expect(vtimezone[0]).toBe('vtimezone') // Should use UTC timezone as fallback - check for UTC standard timezone expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ - "standard", + 'standard', [ - ["tzoffsetfrom", {}, "utc-offset", "+00:00"], - ["tzoffsetto", {}, "utc-offset", "+00:00"], - ["tzname", {}, "text", "UTC"], - ["dtstart", {}, "date-time", "1970-01-01T00:00:00"], + ['tzoffsetfrom', {}, 'utc-offset', '+00:00'], + ['tzoffsetto', {}, 'utc-offset', '+00:00'], + ['tzname', {}, 'text', 'UTC'], + ['dtstart', {}, 'date-time', '1970-01-01T00:00:00'] ], - [], - ], + [] + ] ]) - ); - }); + ) + }) - it("handles null timezone with UTC fallback", () => { + it('handles null timezone with UTC fallback', () => { const mockEvent = { - uid: "event-null-tz", - title: "Null Timezone Event", - start: "2025-07-23T10:00:00.000Z", - end: "2025-07-23T11:00:00.000Z", + uid: 'event-null-tz', + title: 'Null Timezone Event', + start: '2025-07-23T10:00:00.000Z', + end: '2025-07-23T11:00:00.000Z', timezone: null, allday: false, - attendee: [], - }; + attendee: [] + } - const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent); + const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) - expect(result[0]).toBe("vcalendar"); - const [vevent, vtimezone] = result[2]; - expect(vevent[0]).toBe("vevent"); - expect(vtimezone[0]).toBe("vtimezone"); + expect(result[0]).toBe('vcalendar') + const [vevent, vtimezone] = result[2] + expect(vevent[0]).toBe('vevent') + expect(vtimezone[0]).toBe('vtimezone') // Should use UTC timezone as fallback expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ - "standard", + 'standard', [ - ["tzoffsetfrom", {}, "utc-offset", "+00:00"], - ["tzoffsetto", {}, "utc-offset", "+00:00"], - ["tzname", {}, "text", "UTC"], - ["dtstart", {}, "date-time", "1970-01-01T00:00:00"], + ['tzoffsetfrom', {}, 'utc-offset', '+00:00'], + ['tzoffsetto', {}, 'utc-offset', '+00:00'], + ['tzname', {}, 'text', 'UTC'], + ['dtstart', {}, 'date-time', '1970-01-01T00:00:00'] ], - [], - ], + [] + ] ]) - ); - }); + ) + }) - it("handles undefined timezone with UTC fallback", () => { + it('handles undefined timezone with UTC fallback', () => { const mockEvent = { - uid: "event-undefined-tz", - title: "Undefined Timezone Event", - start: "2025-07-23T10:00:00.000Z", - end: "2025-07-23T11:00:00.000Z", + uid: 'event-undefined-tz', + title: 'Undefined Timezone Event', + start: '2025-07-23T10:00:00.000Z', + end: '2025-07-23T11:00:00.000Z', timezone: undefined, allday: false, - attendee: [], - }; + attendee: [] + } - const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent); + const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) - expect(result[0]).toBe("vcalendar"); - const [vevent, vtimezone] = result[2]; - expect(vevent[0]).toBe("vevent"); - expect(vtimezone[0]).toBe("vtimezone"); + expect(result[0]).toBe('vcalendar') + const [vevent, vtimezone] = result[2] + expect(vevent[0]).toBe('vevent') + expect(vtimezone[0]).toBe('vtimezone') // Should use UTC timezone as fallback expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ - "standard", + 'standard', [ - ["tzoffsetfrom", {}, "utc-offset", "+00:00"], - ["tzoffsetto", {}, "utc-offset", "+00:00"], - ["tzname", {}, "text", "UTC"], - ["dtstart", {}, "date-time", "1970-01-01T00:00:00"], + ['tzoffsetfrom', {}, 'utc-offset', '+00:00'], + ['tzoffsetto', {}, 'utc-offset', '+00:00'], + ['tzname', {}, 'text', 'UTC'], + ['dtstart', {}, 'date-time', '1970-01-01T00:00:00'] ], - [], - ], + [] + ] ]) - ); - }); + ) + }) - it("handles empty string timezone with UTC fallback", () => { + it('handles empty string timezone with UTC fallback', () => { const mockEvent = { - uid: "event-empty-tz", - title: "Empty Timezone Event", - start: "2025-07-23T10:00:00.000Z", - end: "2025-07-23T11:00:00.000Z", - timezone: "", + uid: 'event-empty-tz', + title: 'Empty Timezone Event', + start: '2025-07-23T10:00:00.000Z', + end: '2025-07-23T11:00:00.000Z', + timezone: '', allday: false, - attendee: [], - }; + attendee: [] + } - const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent); + const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) - expect(result[0]).toBe("vcalendar"); - const [vevent, vtimezone] = result[2]; - expect(vevent[0]).toBe("vevent"); - expect(vtimezone[0]).toBe("vtimezone"); + expect(result[0]).toBe('vcalendar') + const [vevent, vtimezone] = result[2] + expect(vevent[0]).toBe('vevent') + expect(vtimezone[0]).toBe('vtimezone') // Should use UTC timezone as fallback expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ - "standard", + 'standard', [ - ["tzoffsetfrom", {}, "utc-offset", "+00:00"], - ["tzoffsetto", {}, "utc-offset", "+00:00"], - ["tzname", {}, "text", "UTC"], - ["dtstart", {}, "date-time", "1970-01-01T00:00:00"], + ['tzoffsetfrom', {}, 'utc-offset', '+00:00'], + ['tzoffsetto', {}, 'utc-offset', '+00:00'], + ['tzname', {}, 'text', 'UTC'], + ['dtstart', {}, 'date-time', '1970-01-01T00:00:00'] ], - [], - ], + [] + ] ]) - ); - }); + ) + }) - it("handles valid timezone correctly", () => { + it('handles valid timezone correctly', () => { const mockEvent = { - uid: "event-valid-tz", - title: "Valid Timezone Event", - start: "2025-07-23T08:00:00.000Z", - end: "2025-07-23T09:00:00.000Z", - timezone: "Europe/Paris", + uid: 'event-valid-tz', + title: 'Valid Timezone Event', + start: '2025-07-23T08:00:00.000Z', + end: '2025-07-23T09:00:00.000Z', + timezone: 'Europe/Paris', allday: false, - attendee: [], - }; + attendee: [] + } - const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent); + const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) - expect(result[0]).toBe("vcalendar"); - const [vevent, vtimezone] = result[2]; - expect(vevent[0]).toBe("vevent"); - expect(vtimezone[0]).toBe("vtimezone"); + expect(result[0]).toBe('vcalendar') + const [vevent, vtimezone] = result[2] + expect(vevent[0]).toBe('vevent') + expect(vtimezone[0]).toBe('vtimezone') // Should use the specified timezone - check for Europe/Paris timezone components expect(vtimezone[2]).toEqual( expect.arrayContaining([ [ - "daylight", + 'daylight', [ - ["tzoffsetfrom", {}, "utc-offset", "+01:00"], - ["tzoffsetto", {}, "utc-offset", "+02:00"], - ["tzname", {}, "text", "CEST"], - ["dtstart", {}, "date-time", "1970-03-29T02:00:00"], + ['tzoffsetfrom', {}, 'utc-offset', '+01:00'], + ['tzoffsetto', {}, 'utc-offset', '+02:00'], + ['tzname', {}, 'text', 'CEST'], + ['dtstart', {}, 'date-time', '1970-03-29T02:00:00'], [ - "rrule", + 'rrule', {}, - "recur", - { byday: "-1SU", bymonth: 3, freq: "YEARLY" }, - ], + 'recur', + { byday: '-1SU', bymonth: 3, freq: 'YEARLY' } + ] ], - [], + [] ], [ - "standard", + 'standard', [ - ["tzoffsetfrom", {}, "utc-offset", "+02:00"], - ["tzoffsetto", {}, "utc-offset", "+01:00"], - ["tzname", {}, "text", "CET"], - ["dtstart", {}, "date-time", "1970-10-25T03:00:00"], + ['tzoffsetfrom', {}, 'utc-offset', '+02:00'], + ['tzoffsetto', {}, 'utc-offset', '+01:00'], + ['tzname', {}, 'text', 'CET'], + ['dtstart', {}, 'date-time', '1970-10-25T03:00:00'], [ - "rrule", + 'rrule', {}, - "recur", - { byday: "-1SU", bymonth: 10, freq: "YEARLY" }, - ], + 'recur', + { byday: '-1SU', bymonth: 10, freq: 'YEARLY' } + ] ], - [], - ], + [] + ] ]) - ); - }); + ) + }) - it("includes SEQUENCE field when present in event", () => { + it('includes SEQUENCE field when present in event', () => { const mockEvent = { - uid: "event-with-sequence", - title: "Event with Sequence", - start: "2025-07-23T08:00:00.000Z", - end: "2025-07-23T09:00:00.000Z", - timezone: "Europe/Paris", + uid: 'event-with-sequence', + title: 'Event with Sequence', + start: '2025-07-23T08:00:00.000Z', + end: '2025-07-23T09:00:00.000Z', + timezone: 'Europe/Paris', allday: false, sequence: 3, - attendee: [], - }; + attendee: [] + } - const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent); + const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) - const [vevent] = result[2]; - const props = vevent[1]; + const [vevent] = result[2] + const props = vevent[1] - const sequenceProp = props.find( - (p: VObjectProperty) => p[0] === "sequence" - ); - expect(sequenceProp).toBeDefined(); - expect(sequenceProp).toEqual(["sequence", {}, "integer", 3]); - }); + const sequenceProp = props.find((p: VObjectProperty) => p[0] === 'sequence') + expect(sequenceProp).toBeDefined() + expect(sequenceProp).toEqual(['sequence', {}, 'integer', 3]) + }) - it("defaults SEQUENCE to 1 when not present in event", () => { + it('defaults SEQUENCE to 1 when not present in event', () => { const mockEvent = { - uid: "event-without-sequence", - title: "Event without Sequence", - start: "2025-07-23T08:00:00.000Z", - end: "2025-07-23T09:00:00.000Z", - timezone: "Europe/Paris", + uid: 'event-without-sequence', + title: 'Event without Sequence', + start: '2025-07-23T08:00:00.000Z', + end: '2025-07-23T09:00:00.000Z', + timezone: 'Europe/Paris', allday: false, - attendee: [], - }; + attendee: [] + } - const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent); + const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) - const [vevent] = result[2]; - const props = vevent[1]; + const [vevent] = result[2] + const props = vevent[1] - const sequenceProp = props.find( - (p: VObjectProperty) => p[0] === "sequence" - ); - expect(sequenceProp).toBeDefined(); - expect(sequenceProp).toEqual(["sequence", {}, "integer", 1]); - }); + const sequenceProp = props.find((p: VObjectProperty) => p[0] === 'sequence') + expect(sequenceProp).toBeDefined() + expect(sequenceProp).toEqual(['sequence', {}, 'integer', 1]) + }) - it("includes SEQUENCE in recurring event", () => { + it('includes SEQUENCE in recurring event', () => { const mockEvent = { - uid: "recurring-event-sequence", - title: "Recurring Event with Sequence", - start: "2025-07-23T08:00:00.000Z", - end: "2025-07-23T09:00:00.000Z", - timezone: "Europe/Paris", + uid: 'recurring-event-sequence', + title: 'Recurring Event with Sequence', + start: '2025-07-23T08:00:00.000Z', + end: '2025-07-23T09:00:00.000Z', + timezone: 'Europe/Paris', allday: false, sequence: 2, - repetition: { freq: "DAILY", interval: 1 }, - attendee: [], - }; + repetition: { freq: 'DAILY', interval: 1 }, + attendee: [] + } - const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent); + const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) - const [vevent] = result[2]; - const props = vevent[1]; + const [vevent] = result[2] + const props = vevent[1] - const sequenceProp = props.find( - (p: VObjectProperty) => p[0] === "sequence" - ); - expect(sequenceProp).toBeDefined(); - expect(sequenceProp).toEqual(["sequence", {}, "integer", 2]); - }); + const sequenceProp = props.find((p: VObjectProperty) => p[0] === 'sequence') + expect(sequenceProp).toBeDefined() + expect(sequenceProp).toEqual(['sequence', {}, 'integer', 2]) + }) - it("includes SEQUENCE in event exception (with recurrence-id)", () => { + it('includes SEQUENCE in event exception (with recurrence-id)', () => { const mockEvent = { - uid: "event-exception-sequence", - title: "Event Exception with Sequence", - start: "2025-07-23T08:00:00.000Z", - end: "2025-07-23T09:00:00.000Z", - timezone: "Europe/Paris", + uid: 'event-exception-sequence', + title: 'Event Exception with Sequence', + start: '2025-07-23T08:00:00.000Z', + end: '2025-07-23T09:00:00.000Z', + timezone: 'Europe/Paris', allday: false, sequence: 5, - recurrenceId: "2025-07-23T08:00:00Z", - attendee: [], - }; + recurrenceId: '2025-07-23T08:00:00Z', + attendee: [] + } - const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent); + const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent) - const [vevent] = result[2]; - const props = vevent[1]; + const [vevent] = result[2] + const props = vevent[1] - const sequenceProp = props.find( - (p: VObjectProperty) => p[0] === "sequence" - ); - expect(sequenceProp).toBeDefined(); - expect(sequenceProp).toEqual(["sequence", {}, "integer", 5]); + const sequenceProp = props.find((p: VObjectProperty) => p[0] === 'sequence') + expect(sequenceProp).toBeDefined() + expect(sequenceProp).toEqual(['sequence', {}, 'integer', 5]) // Also verify recurrence-id is present const recurrenceIdProp = props.find( - (p: VObjectProperty) => p[0] === "recurrence-id" - ); - expect(recurrenceIdProp).toBeDefined(); - }); -}); + (p: VObjectProperty) => p[0] === 'recurrence-id' + ) + expect(recurrenceIdProp).toBeDefined() + }) +}) -describe("combineMasterDateWithFormTime", () => { +describe('combineMasterDateWithFormTime', () => { const mockFormatDateTime = (iso: string) => { - const date = new Date(iso); - const year = date.getUTCFullYear(); - const month = String(date.getUTCMonth() + 1).padStart(2, "0"); - const day = String(date.getUTCDate()).padStart(2, "0"); - const hour = String(date.getUTCHours()).padStart(2, "0"); - const minute = String(date.getUTCMinutes()).padStart(2, "0"); - return `${year}-${month}-${day}T${hour}:${minute}`; - }; + const date = new Date(iso) + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') + const day = String(date.getUTCDate()).padStart(2, '0') + const hour = String(date.getUTCHours()).padStart(2, '0') + const minute = String(date.getUTCMinutes()).padStart(2, '0') + return `${year}-${month}-${day}T${hour}:${minute}` + } - it("should use master event dates for all-day events", () => { + it('should use master event dates for all-day events', () => { const masterEvent = { - start: "2025-10-14T07:00:00.000Z", - end: "2025-10-14T08:00:00.000Z", - } as CalendarEvent; + start: '2025-10-14T07:00:00.000Z', + end: '2025-10-14T08:00:00.000Z' + } as CalendarEvent const result = combineMasterDateWithFormTime( masterEvent, - "2025-10-15T09:00", - "2025-10-15T10:00", - "UTC", + '2025-10-15T09:00', + '2025-10-15T10:00', + 'UTC', true, mockFormatDateTime - ); + ) // Function now returns ISO UTC strings for all-day events to avoid timezone offset issues - expect(result.startDate).toBe("2025-10-14T00:00:00.000Z"); - expect(result.endDate).toBe("2025-10-14T00:00:00.000Z"); - }); + expect(result.startDate).toBe('2025-10-14T00:00:00.000Z') + expect(result.endDate).toBe('2025-10-14T00:00:00.000Z') + }) - it("should handle all-day events with missing end date", () => { + it('should handle all-day events with missing end date', () => { const masterEvent = { - start: "2025-10-14T07:00:00.000Z", - end: undefined, - } as CalendarEvent; + start: '2025-10-14T07:00:00.000Z', + end: undefined + } as CalendarEvent const result = combineMasterDateWithFormTime( masterEvent, - "2025-10-15T09:00", - "2025-10-15T10:00", - "UTC", + '2025-10-15T09:00', + '2025-10-15T10:00', + 'UTC', true, mockFormatDateTime - ); + ) // Function now returns ISO UTC strings for all-day events to avoid timezone offset issues // When end is missing, it uses start date for end date - expect(result.startDate).toBe("2025-10-14T00:00:00.000Z"); - expect(result.endDate).toBe("2025-10-14T00:00:00.000Z"); - }); + expect(result.startDate).toBe('2025-10-14T00:00:00.000Z') + expect(result.endDate).toBe('2025-10-14T00:00:00.000Z') + }) - it("should combine master date with form time for timed events", () => { + it('should combine master date with form time for timed events', () => { const masterEvent = { - start: "2025-10-14T07:00:00.000Z", - end: "2025-10-14T08:00:00.000Z", - } as CalendarEvent; + start: '2025-10-14T07:00:00.000Z', + end: '2025-10-14T08:00:00.000Z' + } as CalendarEvent const result = combineMasterDateWithFormTime( masterEvent, - "2025-10-15T09:00", - "2025-10-15T10:00", - "UTC", + '2025-10-15T09:00', + '2025-10-15T10:00', + 'UTC', false, mockFormatDateTime - ); + ) - const resultStartDate = new Date(result.startDate); - expect(resultStartDate.getUTCDate()).toBe(14); // Monday (master's date) - expect(resultStartDate.getUTCHours()).toBe(9); // 9am (form's time) - }); + const resultStartDate = new Date(result.startDate) + expect(resultStartDate.getUTCDate()).toBe(14) // Monday (master's date) + expect(resultStartDate.getUTCHours()).toBe(9) // 9am (form's time) + }) - it("should preserve timezone when combining date and time", () => { + it('should preserve timezone when combining date and time', () => { const masterEvent = { - start: "2025-10-14T00:00:00.000Z", - end: "2025-10-14T01:00:00.000Z", - } as CalendarEvent; + start: '2025-10-14T00:00:00.000Z', + end: '2025-10-14T01:00:00.000Z' + } as CalendarEvent const result = combineMasterDateWithFormTime( masterEvent, - "2025-10-15T14:30", - "2025-10-15T15:30", - "Asia/Ho_Chi_Minh", + '2025-10-15T14:30', + '2025-10-15T15:30', + 'Asia/Ho_Chi_Minh', false, mockFormatDateTime - ); + ) - expect(result.startDate).toBeDefined(); - expect(result.endDate).toBeDefined(); - }); + expect(result.startDate).toBeDefined() + expect(result.endDate).toBeDefined() + }) - it("should handle timed events with missing end date", () => { + it('should handle timed events with missing end date', () => { const masterEvent = { - start: "2025-10-14T07:00:00.000Z", - end: undefined, - } as CalendarEvent; + start: '2025-10-14T07:00:00.000Z', + end: undefined + } as CalendarEvent const result = combineMasterDateWithFormTime( masterEvent, - "2025-10-15T09:00", - "2025-10-15T10:00", - "UTC", + '2025-10-15T09:00', + '2025-10-15T10:00', + 'UTC', false, mockFormatDateTime - ); + ) - expect(result.startDate).toBeDefined(); - expect(result.endDate).toBeDefined(); - }); + expect(result.startDate).toBeDefined() + expect(result.endDate).toBeDefined() + }) - it("should handle different date formats from form input", () => { + it('should handle different date formats from form input', () => { const masterEvent = { - start: "2025-10-14T07:00:00.000Z", - end: "2025-10-14T08:00:00.000Z", - } as CalendarEvent; + start: '2025-10-14T07:00:00.000Z', + end: '2025-10-14T08:00:00.000Z' + } as CalendarEvent // Test with different time format const result = combineMasterDateWithFormTime( masterEvent, - "2025-10-15 09:00:00", - "2025-10-15 10:00:00", - "UTC", + '2025-10-15 09:00:00', + '2025-10-15 10:00:00', + 'UTC', false, mockFormatDateTime - ); + ) - expect(result.startDate).toBeDefined(); - expect(result.endDate).toBeDefined(); - }); -}); + expect(result.startDate).toBeDefined() + expect(result.endDate).toBeDefined() + }) +}) -describe("normalizeRepetition", () => { - it("should normalize repetition with all fields", () => { +describe('normalizeRepetition', () => { + it('should normalize repetition with all fields', () => { const repetition: RepetitionObject = { - freq: "weekly", + freq: 'weekly', interval: 2, - byday: ["MO", "WE", "FR"], + byday: ['MO', 'WE', 'FR'], occurrences: 10, - endDate: "2025-12-31", - }; + endDate: '2025-12-31' + } - const result = normalizeRepetition(repetition); + const result = normalizeRepetition(repetition) expect(result).toEqual({ - freq: "weekly", + freq: 'weekly', interval: 2, - byday: ["FR", "MO", "WE"], // Sorted + byday: ['FR', 'MO', 'WE'], // Sorted occurrences: 10, - endDate: "2025-12-31", - }); - }); + endDate: '2025-12-31' + }) + }) - it("should return null for empty repetition", () => { - expect(normalizeRepetition(undefined)).toBeNull(); - expect(normalizeRepetition({} as RepetitionObject)).toBeNull(); - expect(normalizeRepetition({ freq: "" } as RepetitionObject)).toBeNull(); - }); + it('should return null for empty repetition', () => { + expect(normalizeRepetition(undefined)).toBeNull() + expect(normalizeRepetition({} as RepetitionObject)).toBeNull() + expect(normalizeRepetition({ freq: '' } as RepetitionObject)).toBeNull() + }) - it("should handle repetition with default interval", () => { + it('should handle repetition with default interval', () => { const repetition: RepetitionObject = { - freq: "daily", - occurrences: 5, - } as RepetitionObject; + freq: 'daily', + occurrences: 5 + } as RepetitionObject - const result = normalizeRepetition(repetition); + const result = normalizeRepetition(repetition) - expect(result?.interval).toBe(1); - }); + expect(result?.interval).toBe(1) + }) - it("should normalize empty byday to null", () => { + it('should normalize empty byday to null', () => { const repetition: RepetitionObject = { - freq: "weekly", + freq: 'weekly', interval: 1, - byday: [], - } as RepetitionObject; + byday: [] + } as RepetitionObject - const result = normalizeRepetition(repetition); + const result = normalizeRepetition(repetition) - expect(result?.byday).toBeNull(); - }); -}); + expect(result?.byday).toBeNull() + }) +}) -describe("normalizeTimezone", () => { +describe('normalizeTimezone', () => { const mockResolveTimezone = (tz: string) => { - if (tz === "Asia/Saigon") return "Asia/Ho_Chi_Minh"; - return tz; - }; + if (tz === 'Asia/Saigon') return 'Asia/Ho_Chi_Minh' + return tz + } - it("should resolve timezone aliases", () => { - const result = normalizeTimezone("Asia/Saigon", mockResolveTimezone); - expect(result).toBe("Asia/Ho_Chi_Minh"); - }); + it('should resolve timezone aliases', () => { + const result = normalizeTimezone('Asia/Saigon', mockResolveTimezone) + expect(result).toBe('Asia/Ho_Chi_Minh') + }) - it("should return null for empty timezone", () => { - expect(normalizeTimezone(undefined, mockResolveTimezone)).toBeNull(); - expect(normalizeTimezone(null, mockResolveTimezone)).toBeNull(); - expect(normalizeTimezone("", mockResolveTimezone)).toBeNull(); - }); + it('should return null for empty timezone', () => { + expect(normalizeTimezone(undefined, mockResolveTimezone)).toBeNull() + expect(normalizeTimezone(null, mockResolveTimezone)).toBeNull() + expect(normalizeTimezone('', mockResolveTimezone)).toBeNull() + }) - it("should return same timezone if no alias", () => { - const result = normalizeTimezone("UTC", mockResolveTimezone); - expect(result).toBe("UTC"); - }); -}); + it('should return same timezone if no alias', () => { + const result = normalizeTimezone('UTC', mockResolveTimezone) + expect(result).toBe('UTC') + }) +}) -describe("detectRecurringEventChanges", () => { - const mockResolveTimezone = (tz: string) => tz; +describe('detectRecurringEventChanges', () => { + const mockResolveTimezone = (tz: string) => tz - it("should detect when only time changes", () => { + it('should detect when only time changes', () => { const oldEvent = { - start: "2025-10-14T07:00:00.000Z", - end: "2025-10-14T08:00:00.000Z", - timezone: "UTC", + start: '2025-10-14T07:00:00.000Z', + end: '2025-10-14T08:00:00.000Z', + timezone: 'UTC', allday: false, - repetition: { freq: "daily", interval: 1 } as RepetitionObject, - } as CalendarEvent; + repetition: { freq: 'daily', interval: 1 } as RepetitionObject + } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { - repetition: { freq: "daily", interval: 1 } as RepetitionObject, - timezone: "UTC", + repetition: { freq: 'daily', interval: 1 } as RepetitionObject, + timezone: 'UTC', allday: false, - start: "2025-10-15T09:00", - end: "2025-10-15T10:00", + start: '2025-10-15T09:00', + end: '2025-10-15T10:00' }, null, mockResolveTimezone - ); + ) - expect(result.timeChanged).toBe(true); - expect(result.repetitionRulesChanged).toBe(true); - }); + expect(result.timeChanged).toBe(true) + expect(result.repetitionRulesChanged).toBe(true) + }) - it("should not detect time change when time is same", () => { + it('should not detect time change when time is same', () => { const oldEvent = { - start: "2025-10-14T07:00:00.000Z", - end: "2025-10-14T08:00:00.000Z", - timezone: "UTC", + start: '2025-10-14T07:00:00.000Z', + end: '2025-10-14T08:00:00.000Z', + timezone: 'UTC', allday: false, - repetition: { freq: "daily", interval: 1 } as RepetitionObject, - } as CalendarEvent; + repetition: { freq: 'daily', interval: 1 } as RepetitionObject + } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { - repetition: { freq: "daily", interval: 1 } as RepetitionObject, - timezone: "UTC", + repetition: { freq: 'daily', interval: 1 } as RepetitionObject, + timezone: 'UTC', allday: false, - start: "2025-10-15T07:00", - end: "2025-10-15T08:00", + start: '2025-10-15T07:00', + end: '2025-10-15T08:00' }, null, mockResolveTimezone - ); + ) - expect(result.timeChanged).toBe(false); - expect(result.repetitionRulesChanged).toBe(false); - }); + expect(result.timeChanged).toBe(false) + expect(result.repetitionRulesChanged).toBe(false) + }) - it("should detect timezone changes", () => { + it('should detect timezone changes', () => { const oldEvent = { - start: "2025-10-14T07:00:00.000Z", - timezone: "UTC", + start: '2025-10-14T07:00:00.000Z', + timezone: 'UTC', allday: false, - repetition: { freq: "daily", interval: 1 } as RepetitionObject, - } as CalendarEvent; + repetition: { freq: 'daily', interval: 1 } as RepetitionObject + } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { - repetition: { freq: "daily", interval: 1 } as RepetitionObject, - timezone: "Asia/Ho_Chi_Minh", + repetition: { freq: 'daily', interval: 1 } as RepetitionObject, + timezone: 'Asia/Ho_Chi_Minh', allday: false, - start: "2025-10-15T07:00", - end: "2025-10-15T08:00", + start: '2025-10-15T07:00', + end: '2025-10-15T08:00' }, null, mockResolveTimezone - ); + ) - expect(result.timezoneChanged).toBe(true); - expect(result.repetitionRulesChanged).toBe(true); - }); + expect(result.timezoneChanged).toBe(true) + expect(result.repetitionRulesChanged).toBe(true) + }) - it("should resolve timezone aliases before comparison", () => { + it('should resolve timezone aliases before comparison', () => { const resolveWithAlias = (tz: string) => { - if (tz === "Asia/Saigon") return "Asia/Ho_Chi_Minh"; - return tz; - }; + if (tz === 'Asia/Saigon') return 'Asia/Ho_Chi_Minh' + return tz + } const oldEvent = { - start: "2025-10-14T07:00:00.000Z", - timezone: "Asia/Saigon", + start: '2025-10-14T07:00:00.000Z', + timezone: 'Asia/Saigon', allday: false, - repetition: { freq: "daily", interval: 1 } as RepetitionObject, - } as CalendarEvent; + repetition: { freq: 'daily', interval: 1 } as RepetitionObject + } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { - repetition: { freq: "daily", interval: 1 } as RepetitionObject, - timezone: "Asia/Ho_Chi_Minh", + repetition: { freq: 'daily', interval: 1 } as RepetitionObject, + timezone: 'Asia/Ho_Chi_Minh', allday: false, - start: "2025-10-15T07:00", - end: "2025-10-15T08:00", + start: '2025-10-15T07:00', + end: '2025-10-15T08:00' }, null, resolveWithAlias - ); + ) - expect(result.timezoneChanged).toBe(false); - }); + expect(result.timezoneChanged).toBe(false) + }) - it("should detect frequency changes", () => { + it('should detect frequency changes', () => { const oldEvent = { - start: "2025-10-14T07:00:00.000Z", - timezone: "UTC", + start: '2025-10-14T07:00:00.000Z', + timezone: 'UTC', allday: false, - repetition: { freq: "daily", interval: 1 } as RepetitionObject, - } as CalendarEvent; + repetition: { freq: 'daily', interval: 1 } as RepetitionObject + } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { - repetition: { freq: "weekly", interval: 1 } as RepetitionObject, - timezone: "UTC", + repetition: { freq: 'weekly', interval: 1 } as RepetitionObject, + timezone: 'UTC', allday: false, - start: "2025-10-15T07:00", - end: "2025-10-15T08:00", + start: '2025-10-15T07:00', + end: '2025-10-15T08:00' }, null, mockResolveTimezone - ); + ) - expect(result.repetitionRulesChanged).toBe(true); - }); + expect(result.repetitionRulesChanged).toBe(true) + }) - it("should detect interval changes", () => { + it('should detect interval changes', () => { const oldEvent = { - start: "2025-10-14T07:00:00.000Z", - timezone: "UTC", + start: '2025-10-14T07:00:00.000Z', + timezone: 'UTC', allday: false, - repetition: { freq: "daily", interval: 1 } as RepetitionObject, - } as CalendarEvent; + repetition: { freq: 'daily', interval: 1 } as RepetitionObject + } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { - repetition: { freq: "daily", interval: 2 } as RepetitionObject, - timezone: "UTC", + repetition: { freq: 'daily', interval: 2 } as RepetitionObject, + timezone: 'UTC', allday: false, - start: "2025-10-15T07:00", - end: "2025-10-15T08:00", + start: '2025-10-15T07:00', + end: '2025-10-15T08:00' }, null, mockResolveTimezone - ); + ) - expect(result.repetitionRulesChanged).toBe(true); - }); + expect(result.repetitionRulesChanged).toBe(true) + }) - it("should detect byday changes", () => { + it('should detect byday changes', () => { const oldEvent = { - start: "2025-10-14T07:00:00.000Z", - timezone: "UTC", + start: '2025-10-14T07:00:00.000Z', + timezone: 'UTC', allday: false, repetition: { - freq: "weekly", + freq: 'weekly', interval: 1, - byday: ["MO", "WE"], - } as RepetitionObject, - } as CalendarEvent; + byday: ['MO', 'WE'] + } as RepetitionObject + } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { repetition: { - freq: "weekly", + freq: 'weekly', interval: 1, - byday: ["MO", "FR"], + byday: ['MO', 'FR'] } as RepetitionObject, - timezone: "UTC", + timezone: 'UTC', allday: false, - start: "2025-10-15T07:00", - end: "2025-10-15T08:00", + start: '2025-10-15T07:00', + end: '2025-10-15T08:00' }, null, mockResolveTimezone - ); + ) - expect(result.repetitionRulesChanged).toBe(true); - }); + expect(result.repetitionRulesChanged).toBe(true) + }) - it("should detect when multiple properties change", () => { + it('should detect when multiple properties change', () => { const oldEvent = { - start: "2025-10-14T07:00:00.000Z", - timezone: "UTC", + start: '2025-10-14T07:00:00.000Z', + timezone: 'UTC', allday: false, - repetition: { freq: "daily", interval: 1 } as RepetitionObject, - } as CalendarEvent; + repetition: { freq: 'daily', interval: 1 } as RepetitionObject + } as CalendarEvent const result = detectRecurringEventChanges( oldEvent, { - repetition: { freq: "weekly", interval: 2 } as RepetitionObject, - timezone: "Asia/Ho_Chi_Minh", + repetition: { freq: 'weekly', interval: 2 } as RepetitionObject, + timezone: 'Asia/Ho_Chi_Minh', allday: true, - start: "2025-10-15T09:00", - end: "2025-10-15T10:00", + start: '2025-10-15T09:00', + end: '2025-10-15T10:00' }, null, mockResolveTimezone - ); + ) - expect(result.timeChanged).toBe(true); - expect(result.timezoneChanged).toBe(true); - expect(result.repetitionRulesChanged).toBe(true); - }); -}); + expect(result.timeChanged).toBe(true) + expect(result.timezoneChanged).toBe(true) + expect(result.repetitionRulesChanged).toBe(true) + }) +}) -describe("parseCalendarEvent - delegated calendar URL handling", () => { - const baseColor = { light: "#00FF00" }; +describe('parseCalendarEvent - delegated calendar URL handling', () => { + const baseColor = { light: '#00FF00' } const rawData: VObjectProperty[] = [ - ["uid", {}, "text", "event-uid-123"], - ["dtstart", {}, "date-time", "20240115T100000Z"], - ["dtend", {}, "date-time", "20240115T110000Z"], - ["summary", {}, "text", "Test Event"], - ]; + ['uid', {}, 'text', 'event-uid-123'], + ['dtstart', {}, 'date-time', '20240115T100000Z'], + ['dtend', {}, 'date-time', '20240115T110000Z'], + ['summary', {}, 'text', 'Test Event'] + ] - const eventURL = "/calendars/user2/cal1/event-uid-123.ics"; + const eventURL = '/calendars/user2/cal1/event-uid-123.ics' - describe("non-delegated calendar", () => { + describe('non-delegated calendar', () => { const calendar = { - id: "user1/cal1", + id: 'user1/cal1', delegated: false, - link: "/calendars/user1/cal1.json", - } as Calendar; + link: '/calendars/user1/cal1.json' + } as Calendar - it("uses the raw eventURL as-is", () => { - const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL); - expect(result.URL).toBe(eventURL); - }); - }); + it('uses the raw eventURL as-is', () => { + const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL) + expect(result.URL).toBe(eventURL) + }) + }) - describe("delegated calendar", () => { + describe('delegated calendar', () => { const calendar = { - id: "user2/cal1", + id: 'user2/cal1', delegated: true, - link: "/calendars/user2/cal1.json", - owner: { emails: ["owner@example.com"] }, - } as unknown as Calendar; + link: '/calendars/user2/cal1.json', + owner: { emails: ['owner@example.com'] } + } as unknown as Calendar - it("rewrites URL using calendar link base path", () => { - const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL); - expect(result.URL).toBe("/calendars/user2/cal1/event-uid-123.ics"); - }); + it('rewrites URL using calendar link base path', () => { + const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL) + expect(result.URL).toBe('/calendars/user2/cal1/event-uid-123.ics') + }) - it("preserves the event filename from the original URL", () => { - const differentSourceURL = "/calendars/someother/path/event-uid-123.ics"; + it('preserves the event filename from the original URL', () => { + const differentSourceURL = '/calendars/someother/path/event-uid-123.ics' const result = parseCalendarEvent( rawData, baseColor, calendar, differentSourceURL - ); + ) // filename is extracted and rebased onto calendar link expect(result.URL).toMatch( /\/calendars\/user2\/cal1\/event-uid-123\.ics$/ - ); - }); + ) + }) - it("still sets calId from calendar.id", () => { - const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL); - expect(result.calId).toBe("user2/cal1"); - }); - }); + it('still sets calId from calendar.id', () => { + const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL) + expect(result.calId).toBe('user2/cal1') + }) + }) - describe("empty calendar object (used in deleteEventInstance)", () => { - it("does not throw and sets URL to empty string", () => { - const result = parseCalendarEvent(rawData, baseColor, {} as Calendar, ""); - expect(result.URL).toBe(""); - expect(result.calId).toBeUndefined(); - }); - }); -}); + describe('empty calendar object (used in deleteEventInstance)', () => { + it('does not throw and sets URL to empty string', () => { + const result = parseCalendarEvent(rawData, baseColor, {} as Calendar, '') + expect(result.URL).toBe('') + expect(result.calId).toBeUndefined() + }) + }) +}) diff --git a/__test__/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.test.ts b/__test__/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.test.ts index d1712d8..8b90de2 100644 --- a/__test__/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.test.ts +++ b/__test__/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.test.ts @@ -1,213 +1,213 @@ -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { updateAttendeesAfterTimeChange } from "@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange"; -import { userAttendee } from "@/features/User/models/attendee"; -import { userOrganiser } from "@/features/User/userDataTypes"; +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { updateAttendeesAfterTimeChange } from '@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange' +import { userAttendee } from '@/features/User/models/attendee' +import { userOrganiser } from '@/features/User/userDataTypes' -describe("updateAttendeesAfterTimeChange", () => { +describe('updateAttendeesAfterTimeChange', () => { const mockAttendee: userAttendee = { - cal_address: "attendee@example.com", - partstat: "ACCEPTED", - rsvp: "FALSE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cn: "attendee", - }; + cal_address: 'attendee@example.com', + partstat: 'ACCEPTED', + rsvp: 'FALSE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cn: 'attendee' + } const mockOrganizer: userOrganiser = { - cn: "Organizer", - cal_address: "organizer@example.com", - }; + cn: 'Organizer', + cal_address: 'organizer@example.com' + } const organizerAttendee: userAttendee = { - cal_address: "organizer@example.com", - partstat: "ACCEPTED", - rsvp: "FALSE", - role: "CHAIR", - cutype: "INDIVIDUAL", - cn: "organizer", - }; + cal_address: 'organizer@example.com', + partstat: 'ACCEPTED', + rsvp: 'FALSE', + role: 'CHAIR', + cutype: 'INDIVIDUAL', + cn: 'organizer' + } const baseEvent: CalendarEvent = { - uid: "test-event", - URL: "/calendar/667037022b752d0026472254/cal1/test-event.ics", - title: "Test Event", - calId: "667037022b752d0026472254/cal1", - start: "2025-01-15T07:00:00.000Z", - end: "2025-01-15T08:00:00.000Z", + uid: 'test-event', + URL: '/calendar/667037022b752d0026472254/cal1/test-event.ics', + title: 'Test Event', + calId: '667037022b752d0026472254/cal1', + start: '2025-01-15T07:00:00.000Z', + end: '2025-01-15T08:00:00.000Z', allday: false, organizer: mockOrganizer, attendee: [mockAttendee, organizerAttendee], - timezone: "ETC/UTC", - }; + timezone: 'ETC/UTC' + } - describe("early returns", () => { - it("should return the event unchanged when attendee list is undefined", () => { - const event = { ...baseEvent, attendee: undefined }; + describe('early returns', () => { + it('should return the event unchanged when attendee list is undefined', () => { + const event = { ...baseEvent, attendee: undefined } const result = updateAttendeesAfterTimeChange( event as unknown as CalendarEvent - ); - expect(result).toBe(event); - }); + ) + expect(result).toBe(event) + }) - it("should NOT bail out early when organizer is undefined", () => { - const event = { ...baseEvent, organizer: undefined }; - const result = updateAttendeesAfterTimeChange(event); - expect(result.attendee).toBeDefined(); - expect(result.attendee).toHaveLength(2); - }); - }); + it('should NOT bail out early when organizer is undefined', () => { + const event = { ...baseEvent, organizer: undefined } + const result = updateAttendeesAfterTimeChange(event) + expect(result.attendee).toBeDefined() + expect(result.attendee).toHaveLength(2) + }) + }) - describe("without provided attendees list", () => { - it("should return attendees unchanged when timeChanged is false", () => { - const result = updateAttendeesAfterTimeChange(baseEvent, false); - expect(result.attendee?.[0].partstat).toBe("ACCEPTED"); - expect(result.attendee?.[0].rsvp).toBe("FALSE"); - }); + describe('without provided attendees list', () => { + it('should return attendees unchanged when timeChanged is false', () => { + const result = updateAttendeesAfterTimeChange(baseEvent, false) + expect(result.attendee?.[0].partstat).toBe('ACCEPTED') + expect(result.attendee?.[0].rsvp).toBe('FALSE') + }) - it("should mark non-organizer attendees as NEEDS-ACTION when timeChanged is true", () => { - const result = updateAttendeesAfterTimeChange(baseEvent, true); + it('should mark non-organizer attendees as NEEDS-ACTION when timeChanged is true', () => { + const result = updateAttendeesAfterTimeChange(baseEvent, true) const attendee = result.attendee?.find( - (a) => a.cal_address === "attendee@example.com" - ); - expect(attendee?.partstat).toBe("NEEDS-ACTION"); - expect(attendee?.rsvp).toBe("TRUE"); - }); + a => a.cal_address === 'attendee@example.com' + ) + expect(attendee?.partstat).toBe('NEEDS-ACTION') + expect(attendee?.rsvp).toBe('TRUE') + }) - it("should NOT mark the organizer as NEEDS-ACTION when timeChanged is true", () => { - const result = updateAttendeesAfterTimeChange(baseEvent, true); + it('should NOT mark the organizer as NEEDS-ACTION when timeChanged is true', () => { + const result = updateAttendeesAfterTimeChange(baseEvent, true) const organizer = result.attendee?.find( - (a) => a.cal_address === "organizer@example.com" - ); - expect(organizer?.partstat).toBe("ACCEPTED"); - expect(organizer?.rsvp).toBe("FALSE"); - }); + a => a.cal_address === 'organizer@example.com' + ) + expect(organizer?.partstat).toBe('ACCEPTED') + expect(organizer?.rsvp).toBe('FALSE') + }) - it("should return attendees unchanged when timeChanged is undefined", () => { - const result = updateAttendeesAfterTimeChange(baseEvent); - expect(result.attendee?.[0].partstat).toBe("ACCEPTED"); - }); - }); + it('should return attendees unchanged when timeChanged is undefined', () => { + const result = updateAttendeesAfterTimeChange(baseEvent) + expect(result.attendee?.[0].partstat).toBe('ACCEPTED') + }) + }) - describe("with provided attendees list", () => { + describe('with provided attendees list', () => { const newAttendee: userAttendee = { - cal_address: "new@example.com", - partstat: "ACCEPTED", - rsvp: "FALSE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cn: "new", - }; + cal_address: 'new@example.com', + partstat: 'ACCEPTED', + rsvp: 'FALSE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cn: 'new' + } - it("should use existing attendee data when the address already exists in the event", () => { + it('should use existing attendee data when the address already exists in the event', () => { const result = updateAttendeesAfterTimeChange(baseEvent, false, [ - mockAttendee, - ]); + mockAttendee + ]) const attendee = result.attendee?.find( - (a) => a.cal_address === "attendee@example.com" - ); - expect(attendee?.partstat).toBe("ACCEPTED"); - }); + a => a.cal_address === 'attendee@example.com' + ) + expect(attendee?.partstat).toBe('ACCEPTED') + }) - it("should mark new attendee as NEEDS-ACTION when not found in existing list", () => { + it('should mark new attendee as NEEDS-ACTION when not found in existing list', () => { const result = updateAttendeesAfterTimeChange(baseEvent, false, [ - newAttendee, - ]); + newAttendee + ]) const attendee = result.attendee?.find( - (a) => a.cal_address === "new@example.com" - ); - expect(attendee?.partstat).toBe("NEEDS-ACTION"); - expect(attendee?.rsvp).toBe("TRUE"); - }); + a => a.cal_address === 'new@example.com' + ) + expect(attendee?.partstat).toBe('NEEDS-ACTION') + expect(attendee?.rsvp).toBe('TRUE') + }) - it("should mark all attendees as NEEDS-ACTION when timeChanged is true", () => { + it('should mark all attendees as NEEDS-ACTION when timeChanged is true', () => { const result = updateAttendeesAfterTimeChange(baseEvent, true, [ mockAttendee, - newAttendee, - ]); + newAttendee + ]) const nonOrganizerAttendees = result.attendee?.filter( - (a) => a.cal_address !== "organizer@example.com" - ); - nonOrganizerAttendees?.forEach((a) => { - expect(a.partstat).toBe("NEEDS-ACTION"); - expect(a.rsvp).toBe("TRUE"); - }); - }); + a => a.cal_address !== 'organizer@example.com' + ) + nonOrganizerAttendees?.forEach(a => { + expect(a.partstat).toBe('NEEDS-ACTION') + expect(a.rsvp).toBe('TRUE') + }) + }) - it("should append organizer entry at the end of the attendee list", () => { + it('should append organizer entry at the end of the attendee list', () => { const result = updateAttendeesAfterTimeChange(baseEvent, false, [ - mockAttendee, - ]); - const last = result.attendee?.[result.attendee.length - 1]; - expect(last?.cal_address).toBe("organizer@example.com"); - }); + mockAttendee + ]) + const last = result.attendee?.[result.attendee.length - 1] + expect(last?.cal_address).toBe('organizer@example.com') + }) - it("should use existing organizer attendee data when found in event attendees", () => { + it('should use existing organizer attendee data when found in event attendees', () => { const result = updateAttendeesAfterTimeChange(baseEvent, false, [ - mockAttendee, - ]); + mockAttendee + ]) const organizer = result.attendee?.find( - (a) => a.cal_address === "organizer@example.com" - ); - expect(organizer?.partstat).toBe("ACCEPTED"); - }); + a => a.cal_address === 'organizer@example.com' + ) + expect(organizer?.partstat).toBe('ACCEPTED') + }) - it("should fall back to organizer defaults when organizer is not in existing attendees", () => { + it('should fall back to organizer defaults when organizer is not in existing attendees', () => { const eventWithoutOrganizerInAttendees: CalendarEvent = { ...baseEvent, - attendee: [mockAttendee], // organizer not in attendee list - }; + attendee: [mockAttendee] // organizer not in attendee list + } const result = updateAttendeesAfterTimeChange( eventWithoutOrganizerInAttendees, false, [mockAttendee] - ); + ) const organizer = result.attendee?.find( - (a) => a.cal_address === "organizer@example.com" - ); - expect(organizer?.role).toBe("CHAIR"); - expect(organizer?.partstat).toBe("NEEDS-ACTION"); - }); - }); + a => a.cal_address === 'organizer@example.com' + ) + expect(organizer?.role).toBe('CHAIR') + expect(organizer?.partstat).toBe('NEEDS-ACTION') + }) + }) - describe("organizer undefined", () => { + describe('organizer undefined', () => { const eventNoOrganizer: CalendarEvent = { ...baseEvent, - organizer: undefined, - }; + organizer: undefined + } - it("should return attendees without bailing out", () => { - const result = updateAttendeesAfterTimeChange(eventNoOrganizer); - expect(result.attendee).toHaveLength(2); - }); + it('should return attendees without bailing out', () => { + const result = updateAttendeesAfterTimeChange(eventNoOrganizer) + expect(result.attendee).toHaveLength(2) + }) - it("should mark attendees as NEEDS-ACTION when timeChanged is true", () => { - const result = updateAttendeesAfterTimeChange(eventNoOrganizer, true); - result.attendee?.forEach((a) => { - expect(a.partstat).toBe("NEEDS-ACTION"); - expect(a.rsvp).toBe("TRUE"); - }); - }); + it('should mark attendees as NEEDS-ACTION when timeChanged is true', () => { + const result = updateAttendeesAfterTimeChange(eventNoOrganizer, true) + result.attendee?.forEach(a => { + expect(a.partstat).toBe('NEEDS-ACTION') + expect(a.rsvp).toBe('TRUE') + }) + }) - it("should not mutate attendees when timeChanged is false", () => { - const result = updateAttendeesAfterTimeChange(eventNoOrganizer, false); - expect(result.attendee?.[0].partstat).toBe("ACCEPTED"); - expect(result.attendee?.[0].rsvp).toBe("FALSE"); - }); + it('should not mutate attendees when timeChanged is false', () => { + const result = updateAttendeesAfterTimeChange(eventNoOrganizer, false) + expect(result.attendee?.[0].partstat).toBe('ACCEPTED') + expect(result.attendee?.[0].rsvp).toBe('FALSE') + }) - it("should not append an organizer entry when organizer is undefined and attendees list is provided", () => { + it('should not append an organizer entry when organizer is undefined and attendees list is provided', () => { const newAttendee: userAttendee = { - cal_address: "new@example.com", - partstat: "ACCEPTED", - rsvp: "FALSE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cn: "new", - }; + cal_address: 'new@example.com', + partstat: 'ACCEPTED', + rsvp: 'FALSE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cn: 'new' + } const result = updateAttendeesAfterTimeChange(eventNoOrganizer, false, [ - newAttendee, - ]); - expect(result.attendee).toHaveLength(1); - expect(result.attendee?.[0].cal_address).toBe("new@example.com"); - }); - }); -}); + newAttendee + ]) + expect(result.attendee).toHaveLength(1) + expect(result.attendee?.[0].cal_address).toBe('new@example.com') + }) + }) +}) diff --git a/__test__/features/Search/EventSearchBar.test.tsx b/__test__/features/Search/EventSearchBar.test.tsx index 42bd31e..84f4a74 100644 --- a/__test__/features/Search/EventSearchBar.test.tsx +++ b/__test__/features/Search/EventSearchBar.test.tsx @@ -1,344 +1,344 @@ -import SearchBar from "@/components/Menubar/EventSearchBar"; -import * as searchThunk from "@/features/Search/SearchSlice"; -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import SearchBar from '@/components/Menubar/EventSearchBar' +import * as searchThunk from '@/features/Search/SearchSlice' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../../utils/Renderwithproviders' -describe("EventSearchBar", () => { - const today = new Date(); - const start = new Date(today); - start.setHours(10, 0, 0, 0); - const end = new Date(today); - end.setHours(11, 0, 0, 0); +describe('EventSearchBar', () => { + const today = new Date() + const start = new Date(today) + start.setHours(10, 0, 0, 0) + const end = new Date(today) + end.setHours(11, 0, 0, 0) const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "user1", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: 'user1' }, - tokens: { accessToken: "token" }, + tokens: { accessToken: 'token' } }, calendars: { list: { - "user1/cal1": { - name: "Calendar personal", - id: "user1/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["alice@example.com"] }, + 'user1/cal1': { + name: 'Calendar personal', + id: 'user1/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['alice@example.com'] }, events: { event1: { - id: "event1", - calId: "user1/cal1", - uid: "event1", - title: "Test Event", + id: 'event1', + calId: 'user1/cal1', + uid: 'event1', + title: 'Test Event', start: start.toISOString(), end: end.toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", - }, - ], - }, - }, + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' + } + ] + } + } }, - "user2/cal1": { - name: "Calendar delegated", + 'user2/cal1': { + name: 'Calendar delegated', delegated: true, - id: "user2/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["alice@example.com"] }, + id: 'user2/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['alice@example.com'] }, events: { event1: { - id: "event1", - calId: "user2/cal1", - uid: "event1", - title: "Test Event", + id: 'event1', + calId: 'user2/cal1', + uid: 'event1', + title: 'Test Event', start: start.toISOString(), end: end.toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", - }, - ], - }, - }, + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' + } + ] + } + } }, - "user3/cal1": { - name: "Calendar shared", - id: "user3/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["alice@example.com"] }, + 'user3/cal1': { + name: 'Calendar shared', + id: 'user3/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['alice@example.com'] }, events: { event1: { - id: "event1", - calId: "user3/cal1", - uid: "event1", - title: "Test Event", + id: 'event1', + calId: 'user3/cal1', + uid: 'event1', + title: 'Test Event', start: start.toISOString(), end: end.toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", - }, - ], - }, - }, - }, + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' + } + ] + } + } + } }, - pending: false, - }, - }; + pending: false + } + } - it("should render search icon button initially", () => { - renderWithProviders(, preloadedState); + it('should render search icon button initially', () => { + renderWithProviders(, preloadedState) - const searchButton = screen.getByRole("button"); - expect(searchButton).toBeInTheDocument(); - }); + const searchButton = screen.getByRole('button') + expect(searchButton).toBeInTheDocument() + }) - it("should expand search bar when icon is clicked", () => { - renderWithProviders(, preloadedState); + it('should expand search bar when icon is clicked', () => { + renderWithProviders(, preloadedState) - const searchButton = screen.getByRole("button"); - fireEvent.click(searchButton); + const searchButton = screen.getByRole('button') + fireEvent.click(searchButton) - const searchInput = screen.getByPlaceholderText("common.search"); - expect(searchInput).toBeInTheDocument(); - }); + const searchInput = screen.getByPlaceholderText('common.search') + expect(searchInput).toBeInTheDocument() + }) - it("should unexpand search bar when field is unfocused and empty", () => { - renderWithProviders(, preloadedState); + it('should unexpand search bar when field is unfocused and empty', () => { + renderWithProviders(, preloadedState) - const searchButton = screen.getByRole("button"); - fireEvent.click(searchButton); + const searchButton = screen.getByRole('button') + fireEvent.click(searchButton) - const searchInput = screen.getByPlaceholderText("common.search"); - fireEvent.mouseDown(document.body); - expect(searchInput).not.toBeInTheDocument(); - }); + const searchInput = screen.getByPlaceholderText('common.search') + fireEvent.mouseDown(document.body) + expect(searchInput).not.toBeInTheDocument() + }) - it("should not unexpand search bar when field is unfocused but not empty", () => { - renderWithProviders(, preloadedState); + it('should not unexpand search bar when field is unfocused but not empty', () => { + renderWithProviders(, preloadedState) - const searchButton = screen.getByRole("button"); - fireEvent.click(searchButton); + const searchButton = screen.getByRole('button') + fireEvent.click(searchButton) - const searchInput = screen.getByPlaceholderText("common.search"); - userEvent.type(searchInput, "test"); - fireEvent.blur(searchInput); - expect(searchInput).toBeInTheDocument(); - expect(searchInput).toHaveValue("test"); - }); + const searchInput = screen.getByPlaceholderText('common.search') + userEvent.type(searchInput, 'test') + fireEvent.blur(searchInput) + expect(searchInput).toBeInTheDocument() + expect(searchInput).toHaveValue('test') + }) - it("should update search value on input", () => { - renderWithProviders(, preloadedState); + it('should update search value on input', () => { + renderWithProviders(, preloadedState) - const searchButton = screen.getByRole("button"); - fireEvent.click(searchButton); + const searchButton = screen.getByRole('button') + fireEvent.click(searchButton) - const searchInput = screen.getByPlaceholderText("common.search"); - fireEvent.change(searchInput, { target: { value: "meeting" } }); + const searchInput = screen.getByPlaceholderText('common.search') + fireEvent.change(searchInput, { target: { value: 'meeting' } }) - expect(searchInput).toHaveValue("meeting"); - }); + expect(searchInput).toHaveValue('meeting') + }) - it("should open filter popover when tune icon is clicked", async () => { - renderWithProviders(, preloadedState); + it('should open filter popover when tune icon is clicked', async () => { + renderWithProviders(, preloadedState) // Expand search bar - const searchButton = screen.getByRole("button"); - fireEvent.click(searchButton); + const searchButton = screen.getByRole('button') + fireEvent.click(searchButton) // Click tune icon - const tuneButton = screen.getByTestId("TuneIcon"); - if (tuneButton) fireEvent.click(tuneButton); + const tuneButton = screen.getByTestId('TuneIcon') + if (tuneButton) fireEvent.click(tuneButton) await waitFor(() => { - expect(screen.getByText("search.searchIn")).toBeInTheDocument(); - }); - }); + expect(screen.getByText('search.searchIn')).toBeInTheDocument() + }) + }) - it("should clear search value when clear button is clicked", () => { - renderWithProviders(, preloadedState); + it('should clear search value when clear button is clicked', () => { + renderWithProviders(, preloadedState) - const searchButton = screen.getByRole("button"); - fireEvent.click(searchButton); + const searchButton = screen.getByRole('button') + fireEvent.click(searchButton) - const searchInput = screen.getByPlaceholderText("common.search"); - fireEvent.change(searchInput, { target: { value: "meeting" } }); + const searchInput = screen.getByPlaceholderText('common.search') + fireEvent.change(searchInput, { target: { value: 'meeting' } }) - const clearButton = screen.getByTestId("HighlightOffIcon"); - fireEvent.click(clearButton); + const clearButton = screen.getByTestId('HighlightOffIcon') + fireEvent.click(clearButton) - expect(searchInput).toHaveValue(""); - }); + expect(searchInput).toHaveValue('') + }) - it("should trigger search on Enter key", async () => { - const searchSpy = jest.spyOn(searchThunk, "searchEventsAsync"); + it('should trigger search on Enter key', async () => { + const searchSpy = jest.spyOn(searchThunk, 'searchEventsAsync') - renderWithProviders(, preloadedState); + renderWithProviders(, preloadedState) - const searchButton = screen.getByRole("button"); - fireEvent.click(searchButton); + const searchButton = screen.getByRole('button') + fireEvent.click(searchButton) - const searchInput = screen.getByPlaceholderText("common.search"); - fireEvent.change(searchInput, { target: { value: "test" } }); - fireEvent.keyDown(searchInput, { key: "Enter" }); + const searchInput = screen.getByPlaceholderText('common.search') + fireEvent.change(searchInput, { target: { value: 'test' } }) + fireEvent.keyDown(searchInput, { key: 'Enter' }) await waitFor(() => { expect(searchSpy).toHaveBeenCalledWith({ filters: { - keywords: "", + keywords: '', organizers: [], attendees: [], - searchIn: ["user1/cal1"], + searchIn: ['user1/cal1'] }, - search: "test", - }); - }); - }); - it("should not trigger search on Enter key when search is empty", async () => { - const searchSpy = jest.spyOn(searchThunk, "searchEventsAsync"); + search: 'test' + }) + }) + }) + it('should not trigger search on Enter key when search is empty', async () => { + const searchSpy = jest.spyOn(searchThunk, 'searchEventsAsync') - renderWithProviders(, preloadedState); + renderWithProviders(, preloadedState) - const searchButton = screen.getByRole("button"); - fireEvent.click(searchButton); + const searchButton = screen.getByRole('button') + fireEvent.click(searchButton) - const searchInput = screen.getByPlaceholderText("common.search"); - fireEvent.keyDown(searchInput, { key: "Enter" }); + const searchInput = screen.getByPlaceholderText('common.search') + fireEvent.keyDown(searchInput, { key: 'Enter' }) await waitFor(() => { - expect(searchSpy).not.toHaveBeenCalled(); - }); - }); + expect(searchSpy).not.toHaveBeenCalled() + }) + }) - it("should not trigger search when filter keywords is empty", async () => { - const searchSpy = jest.spyOn(searchThunk, "searchEventsAsync"); + it('should not trigger search when filter keywords is empty', async () => { + const searchSpy = jest.spyOn(searchThunk, 'searchEventsAsync') - renderWithProviders(, preloadedState); + renderWithProviders(, preloadedState) - const searchButton = screen.getByRole("button"); - fireEvent.click(searchButton); + const searchButton = screen.getByRole('button') + fireEvent.click(searchButton) // Click tune icon - const tuneButton = screen.getByTestId("TuneIcon"); - if (tuneButton) fireEvent.click(tuneButton); + const tuneButton = screen.getByTestId('TuneIcon') + if (tuneButton) fireEvent.click(tuneButton) - fireEvent.click(screen.getByText("common.search")); + fireEvent.click(screen.getByText('common.search')) await waitFor(() => { - expect(searchSpy).not.toHaveBeenCalled(); - }); - }); - it("keeps search bar expanded when popover opens", () => { - renderWithProviders(, preloadedState); + expect(searchSpy).not.toHaveBeenCalled() + }) + }) + it('keeps search bar expanded when popover opens', () => { + renderWithProviders(, preloadedState) - fireEvent.click(screen.getByRole("button")); + fireEvent.click(screen.getByRole('button')) const tuneBtn = screen - .getAllByRole("button") - .find((b) => b.querySelector('[data-testid="TuneIcon"]')); + .getAllByRole('button') + .find(b => b.querySelector('[data-testid="TuneIcon"]')) - fireEvent.click(tuneBtn!); + fireEvent.click(tuneBtn!) - expect(screen.getByPlaceholderText("common.search")).toBeInTheDocument(); - }); + expect(screen.getByPlaceholderText('common.search')).toBeInTheDocument() + }) - it("does NOT collapse search when clicking inside popover", () => { - renderWithProviders(, preloadedState); + it('does NOT collapse search when clicking inside popover', () => { + renderWithProviders(, preloadedState) - fireEvent.click(screen.getByRole("button")); + fireEvent.click(screen.getByRole('button')) const tuneBtn = screen - .getAllByRole("button") - .find((b) => b.querySelector('[data-testid="TuneIcon"]')); + .getAllByRole('button') + .find(b => b.querySelector('[data-testid="TuneIcon"]')) - fireEvent.click(tuneBtn!); + fireEvent.click(tuneBtn!) - fireEvent.mouseDown(screen.getByText("search.searchIn")); + fireEvent.mouseDown(screen.getByText('search.searchIn')) - expect(screen.getByPlaceholderText("common.search")).toBeInTheDocument(); - }); + expect(screen.getByPlaceholderText('common.search')).toBeInTheDocument() + }) - it("clicking Cancel closes popover and search collapses if empty", async () => { - renderWithProviders(, preloadedState); + it('clicking Cancel closes popover and search collapses if empty', async () => { + renderWithProviders(, preloadedState) - fireEvent.click(screen.getByRole("button")); + fireEvent.click(screen.getByRole('button')) const tuneBtn = screen - .getAllByRole("button") - .find((b) => b.querySelector('[data-testid="TuneIcon"]')); + .getAllByRole('button') + .find(b => b.querySelector('[data-testid="TuneIcon"]')) - fireEvent.click(tuneBtn!); + fireEvent.click(tuneBtn!) - fireEvent.click(screen.getByText("common.cancel")); + fireEvent.click(screen.getByText('common.cancel')) await waitFor(() => - expect(screen.queryByText("search.searchIn")).not.toBeInTheDocument() - ); - expect(screen.queryByPlaceholderText("common.search")).toBeNull(); - }); + expect(screen.queryByText('search.searchIn')).not.toBeInTheDocument() + ) + expect(screen.queryByPlaceholderText('common.search')).toBeNull() + }) - it("does not collapse when selecting a filter value", async () => { - renderWithProviders(, preloadedState); + it('does not collapse when selecting a filter value', async () => { + renderWithProviders(, preloadedState) - fireEvent.click(screen.getByRole("button")); + fireEvent.click(screen.getByRole('button')) const tuneBtn = screen - .getAllByRole("button") - .find((b) => b.querySelector('[data-testid="TuneIcon"]')); + .getAllByRole('button') + .find(b => b.querySelector('[data-testid="TuneIcon"]')) - fireEvent.click(tuneBtn!); + fireEvent.click(tuneBtn!) - fireEvent.mouseDown(screen.getByText("search.searchIn")); // simulate interaction inside menu + fireEvent.mouseDown(screen.getByText('search.searchIn')) // simulate interaction inside menu - expect(screen.getByPlaceholderText("common.search")).toBeInTheDocument(); - }); + expect(screen.getByPlaceholderText('common.search')).toBeInTheDocument() + }) - it("keeps input value after popover closes if not empty", () => { - renderWithProviders(, preloadedState); + it('keeps input value after popover closes if not empty', () => { + renderWithProviders(, preloadedState) - fireEvent.click(screen.getByRole("button")); - userEvent.type(screen.getByPlaceholderText("common.search"), "hello"); + fireEvent.click(screen.getByRole('button')) + userEvent.type(screen.getByPlaceholderText('common.search'), 'hello') const tuneBtn = screen - .getAllByRole("button") - .find((b) => b.querySelector('[data-testid="TuneIcon"]')); + .getAllByRole('button') + .find(b => b.querySelector('[data-testid="TuneIcon"]')) - fireEvent.click(tuneBtn!); - fireEvent.click(document.body); + fireEvent.click(tuneBtn!) + fireEvent.click(document.body) - const inputAfter = screen.getByPlaceholderText("common.search"); - expect(inputAfter).toHaveValue("hello"); - }); -}); + const inputAfter = screen.getByPlaceholderText('common.search') + expect(inputAfter).toHaveValue('hello') + }) +}) diff --git a/__test__/features/Search/SearchResultsPage.test.tsx b/__test__/features/Search/SearchResultsPage.test.tsx index 23c043b..2aa21c3 100644 --- a/__test__/features/Search/SearchResultsPage.test.tsx +++ b/__test__/features/Search/SearchResultsPage.test.tsx @@ -1,224 +1,224 @@ -import SearchResultsPage from "@/features/Search/SearchResultsPage"; -import { screen } from "@testing-library/react"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import SearchResultsPage from '@/features/Search/SearchResultsPage' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../utils/Renderwithproviders' -describe("SearchResultsPage", () => { - const today = new Date(); - const start = new Date(today); - start.setHours(10, 0, 0, 0); - const end = new Date(today); - end.setHours(11, 0, 0, 0); +describe('SearchResultsPage', () => { + const today = new Date() + const start = new Date(today) + start.setHours(10, 0, 0, 0) + const end = new Date(today) + end.setHours(11, 0, 0, 0) const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "mockSid", - openpaasId: "user1", + sub: 'test', + email: 'test@test.com', + sid: 'mockSid', + openpaasId: 'user1' }, - tokens: { accessToken: "token" }, + tokens: { accessToken: 'token' } }, calendars: { list: { - "user1/cal1": { - name: "Calendar personal", - id: "user1/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["alice@example.com"] }, + 'user1/cal1': { + name: 'Calendar personal', + id: 'user1/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['alice@example.com'] }, events: { event1: { - id: "event1", - calId: "user1/cal1", - uid: "event1", - title: "Test Event", + id: 'event1', + calId: 'user1/cal1', + uid: 'event1', + title: 'Test Event', start: start.toISOString(), end: end.toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", - }, - ], - }, - }, + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' + } + ] + } + } }, - "user2/cal1": { - name: "Calendar delegated", + 'user2/cal1': { + name: 'Calendar delegated', delegated: true, - id: "user2/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["alice@example.com"] }, + id: 'user2/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['alice@example.com'] }, events: { event1: { - id: "event1", - calId: "user2/cal1", - uid: "event1", - title: "Test Event", + id: 'event1', + calId: 'user2/cal1', + uid: 'event1', + title: 'Test Event', start: start.toISOString(), end: end.toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", - }, - ], - }, - }, + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' + } + ] + } + } }, - "user3/cal1": { - name: "Calendar shared", - id: "user3/cal1", - color: { light: "#FF0000", dark: "#000" }, - owner: { emails: ["alice@example.com"] }, + 'user3/cal1': { + name: 'Calendar shared', + id: 'user3/cal1', + color: { light: '#FF0000', dark: '#000' }, + owner: { emails: ['alice@example.com'] }, events: { event1: { - id: "event1", - calId: "user3/cal1", - uid: "event1", - title: "Test Event", + id: 'event1', + calId: 'user3/cal1', + uid: 'event1', + title: 'Test Event', start: start.toISOString(), end: end.toISOString(), - partstat: "ACCEPTED", + partstat: 'ACCEPTED', organizer: { - cn: "Alice", - cal_address: "alice@example.com", + cn: 'Alice', + cal_address: 'alice@example.com' }, attendee: [ { - cn: "Alice", - partstat: "ACCEPTED", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - cal_address: "alice@example.com", - }, - ], - }, - }, - }, + cn: 'Alice', + partstat: 'ACCEPTED', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'INDIVIDUAL', + cal_address: 'alice@example.com' + } + ] + } + } + } }, - pending: false, - }, - }; + pending: false + } + } - it("should show loading spinner when loading", () => { + it('should show loading spinner when loading', () => { renderWithProviders(, { ...preloadedState, - searchResult: { loading: true, error: null, hits: 0, results: [] }, - }); - expect(screen.getByRole("progressbar")).toBeInTheDocument(); - }); + searchResult: { loading: true, error: null, hits: 0, results: [] } + }) + expect(screen.getByRole('progressbar')).toBeInTheDocument() + }) - it("should show error message when error occurs", () => { + it('should show error message when error occurs', () => { renderWithProviders(, { ...preloadedState, searchResult: { loading: false, - error: "Network error", + error: 'Network error', hits: 0, - results: [], - }, - }); - expect(screen.getByText("Network error")).toBeInTheDocument(); - }); + results: [] + } + }) + expect(screen.getByText('Network error')).toBeInTheDocument() + }) - it("should show no results message when no hits", () => { + it('should show no results message when no hits', () => { renderWithProviders(, { ...preloadedState, - searchResult: { loading: false, error: null, hits: null, results: [] }, - }); - expect(screen.getByText("search.noResults")).toBeInTheDocument(); - }); + searchResult: { loading: false, error: null, hits: null, results: [] } + }) + expect(screen.getByText('search.noResults')).toBeInTheDocument() + }) - it("should render search results", () => { + it('should render search results', () => { const mockResults = [ { data: { - uid: "1", - summary: "Team Meeting", - start: "2025-06-26T15:00:00Z", - organizer: { cn: "John Doe", email: "john@example.com" }, - allDay: false, - }, + uid: '1', + summary: 'Team Meeting', + start: '2025-06-26T15:00:00Z', + organizer: { cn: 'John Doe', email: 'john@example.com' }, + allDay: false + } }, { data: { - uid: "2", - summary: "Project Review", - start: "2025-06-27T10:00:00Z", - organizer: { cn: "Jane Smith", email: "jane@example.com" }, - allDay: false, - }, - }, - ]; + uid: '2', + summary: 'Project Review', + start: '2025-06-27T10:00:00Z', + organizer: { cn: 'Jane Smith', email: 'jane@example.com' }, + allDay: false + } + } + ] renderWithProviders(, { ...preloadedState, - searchResult: { results: mockResults, hits: 2 }, - }); - expect(screen.getByText("search.resultsTitle")).toBeInTheDocument(); - expect(screen.getByText("Team Meeting")).toBeInTheDocument(); - expect(screen.getByText("Project Review")).toBeInTheDocument(); - expect(screen.getByText("John Doe")).toBeInTheDocument(); - expect(screen.getByText("Jane Smith")).toBeInTheDocument(); - }); + searchResult: { results: mockResults, hits: 2 } + }) + expect(screen.getByText('search.resultsTitle')).toBeInTheDocument() + expect(screen.getByText('Team Meeting')).toBeInTheDocument() + expect(screen.getByText('Project Review')).toBeInTheDocument() + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(screen.getByText('Jane Smith')).toBeInTheDocument() + }) - it("should handle events without organizer", () => { + it('should handle events without organizer', () => { const mockResults = [ { data: { - uid: "1", - summary: "Untitled Event", - start: "2025-06-26T15:00:00Z", - allDay: false, - }, - }, - ]; + uid: '1', + summary: 'Untitled Event', + start: '2025-06-26T15:00:00Z', + allDay: false + } + } + ] renderWithProviders(, { ...preloadedState, - searchResult: { results: mockResults, hits: 1 }, - }); + searchResult: { results: mockResults, hits: 1 } + }) - expect(screen.getByText("Untitled Event")).toBeInTheDocument(); - }); + expect(screen.getByText('Untitled Event')).toBeInTheDocument() + }) - it("should format all-day events correctly", () => { + it('should format all-day events correctly', () => { const mockResults = [ { data: { - uid: "1", - summary: "All Day Event", - start: "2025-06-26T00:00:00Z", - organizer: { cn: "Organizer" }, - allDay: true, - }, - }, - ]; + uid: '1', + summary: 'All Day Event', + start: '2025-06-26T00:00:00Z', + organizer: { cn: 'Organizer' }, + allDay: true + } + } + ] renderWithProviders(, { ...preloadedState, - searchResult: { results: mockResults, hits: 1 }, - }); + searchResult: { results: mockResults, hits: 1 } + }) - expect(screen.getByText("All Day Event")).toBeInTheDocument(); - }); -}); + expect(screen.getByText('All Day Event')).toBeInTheDocument() + }) +}) diff --git a/__test__/features/Search/SearchSlice.test.tsx b/__test__/features/Search/SearchSlice.test.tsx index 9fc5438..eaf9554 100644 --- a/__test__/features/Search/SearchSlice.test.tsx +++ b/__test__/features/Search/SearchSlice.test.tsx @@ -1,126 +1,126 @@ -import * as EventApi from "@/features/Events/EventApi"; +import * as EventApi from '@/features/Events/EventApi' import searchResultReducer, { searchEventsAsync, setHits, - setResults, -} from "@/features/Search/SearchSlice"; -import { configureStore } from "@reduxjs/toolkit"; + setResults +} from '@/features/Search/SearchSlice' +import { configureStore } from '@reduxjs/toolkit' -jest.mock("@/features/Events/EventApi"); +jest.mock('@/features/Events/EventApi') -describe("SearchSlice", () => { - let store: any; +describe('SearchSlice', () => { + let store: any beforeEach(() => { store = configureStore({ reducer: { - searchResult: searchResultReducer, - }, - }); - }); + searchResult: searchResultReducer + } + }) + }) - describe("initial state", () => { - it("should have correct initial state", () => { - const state = store.getState().searchResult; + describe('initial state', () => { + it('should have correct initial state', () => { + const state = store.getState().searchResult expect(state).toEqual({ results: [], hits: 0, error: null, - loading: false, - }); - }); - }); + loading: false + }) + }) + }) - describe("reducers", () => { - it("should handle setResults", () => { + describe('reducers', () => { + it('should handle setResults', () => { const mockResults = [ - { uid: "1", summary: "Event 1" }, - { uid: "2", summary: "Event 2" }, - ]; - store.dispatch(setResults(mockResults as any)); - expect(store.getState().searchResult.results).toEqual(mockResults); - }); + { uid: '1', summary: 'Event 1' }, + { uid: '2', summary: 'Event 2' } + ] + store.dispatch(setResults(mockResults as any)) + expect(store.getState().searchResult.results).toEqual(mockResults) + }) - it("should handle setHits", () => { - store.dispatch(setHits(42)); - expect(store.getState().searchResult.hits).toBe(42); - }); - }); + it('should handle setHits', () => { + store.dispatch(setHits(42)) + expect(store.getState().searchResult.hits).toBe(42) + }) + }) - describe("searchEventsAsync", () => { + describe('searchEventsAsync', () => { const mockFilters = { - searchIn: ["user1/calendar1"], - keywords: "meeting", - organizers: ["user@example.com"], - attendees: ["attendee@example.com"], - }; + searchIn: ['user1/calendar1'], + keywords: 'meeting', + organizers: ['user@example.com'], + attendees: ['attendee@example.com'] + } - it("should handle successful search", async () => { + it('should handle successful search', async () => { const mockResponse = { _total_hits: 5, _embedded: { events: [ - { data: { uid: "1", summary: "Test Event" } }, - { data: { uid: "2", summary: "Another Event" } }, - ], - }, - }; + { data: { uid: '1', summary: 'Test Event' } }, + { data: { uid: '2', summary: 'Another Event' } } + ] + } + } - (EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse); + ;(EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse) await store.dispatch( - searchEventsAsync({ search: "test", filters: mockFilters }) - ); + searchEventsAsync({ search: 'test', filters: mockFilters }) + ) - const state = store.getState().searchResult; - expect(state.loading).toBe(false); - expect(state.hits).toBe(5); - expect(state.results).toEqual(mockResponse._embedded.events); - expect(state.error).toBeNull(); - }); + const state = store.getState().searchResult + expect(state.loading).toBe(false) + expect(state.hits).toBe(5) + expect(state.results).toEqual(mockResponse._embedded.events) + expect(state.error).toBeNull() + }) - it("should handle search with no results", async () => { + it('should handle search with no results', async () => { const mockResponse = { _total_hits: 0, - _embedded: { events: [] }, - }; + _embedded: { events: [] } + } - (EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse); + ;(EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse) await store.dispatch( - searchEventsAsync({ search: "nonexistent", filters: mockFilters }) - ); + searchEventsAsync({ search: 'nonexistent', filters: mockFilters }) + ) - const state = store.getState().searchResult; - expect(state.hits).toBe(0); - expect(state.results).toEqual([]); - }); + const state = store.getState().searchResult + expect(state.hits).toBe(0) + expect(state.results).toEqual([]) + }) - it("should handle search error", async () => { - const mockError = new Error("Network error"); - (EventApi.searchEvent as jest.Mock).mockRejectedValue(mockError); + it('should handle search error', async () => { + const mockError = new Error('Network error') + ;(EventApi.searchEvent as jest.Mock).mockRejectedValue(mockError) await store.dispatch( - searchEventsAsync({ search: "test", filters: mockFilters }) - ); + searchEventsAsync({ search: 'test', filters: mockFilters }) + ) - const state = store.getState().searchResult; - expect(state.loading).toBe(false); - expect(state.error).toBeTruthy(); - expect(state.results).toEqual([]); - }); + const state = store.getState().searchResult + expect(state.loading).toBe(false) + expect(state.error).toBeTruthy() + expect(state.results).toEqual([]) + }) - it("should set loading state during search", async () => { - (EventApi.searchEvent as jest.Mock).mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 100)) - ); + it('should set loading state during search', async () => { + ;(EventApi.searchEvent as jest.Mock).mockImplementation( + () => new Promise(resolve => setTimeout(resolve, 100)) + ) const promise = store.dispatch( - searchEventsAsync({ search: "test", filters: mockFilters }) - ); + searchEventsAsync({ search: 'test', filters: mockFilters }) + ) - expect(store.getState().searchResult.loading).toBe(true); - await promise; - }); - }); -}); + expect(store.getState().searchResult.loading).toBe(true) + await promise + }) + }) +}) diff --git a/__test__/features/Settings/SettingUserSync.test.tsx b/__test__/features/Settings/SettingUserSync.test.tsx index 789f232..9271a40 100644 --- a/__test__/features/Settings/SettingUserSync.test.tsx +++ b/__test__/features/Settings/SettingUserSync.test.tsx @@ -1,482 +1,480 @@ -import SettingsPage from "@/features/Settings/SettingsPage"; +import SettingsPage from '@/features/Settings/SettingsPage' import settingsReducer, { setIsBrowserDefaultTimeZone, - setTimeZone, -} from "@/features/Settings/SettingsSlice"; + setTimeZone +} from '@/features/Settings/SettingsSlice' import userReducer, { getOpenPaasUserDataAsync, - setTimezone as setUserTimeZone, -} from "@/features/User/userSlice"; -import { api } from "@/utils/apiUtils"; -import { browserDefaultTimeZone } from "@/utils/timezone"; -import { configureStore } from "@reduxjs/toolkit"; -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; + setTimezone as setUserTimeZone +} from '@/features/User/userSlice' +import { api } from '@/utils/apiUtils' +import { browserDefaultTimeZone } from '@/utils/timezone' +import { configureStore } from '@reduxjs/toolkit' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../../utils/Renderwithproviders' -jest.mock("@/utils/apiUtils"); +jest.mock('@/utils/apiUtils') -describe("Timezone synchronization after getOpenPaasUserDataAsync", () => { - let apiGetSpy: jest.SpyInstance; +describe('Timezone synchronization after getOpenPaasUserDataAsync', () => { + let apiGetSpy: jest.SpyInstance beforeEach(() => { - jest.clearAllMocks(); - localStorage.clear(); - apiGetSpy = jest.spyOn(api, "get"); - }); + jest.clearAllMocks() + localStorage.clear() + apiGetSpy = jest.spyOn(api, 'get') + }) afterEach(() => { - apiGetSpy.mockRestore(); - }); + apiGetSpy.mockRestore() + }) - it("should sync timezone to both user and settings state after fetching user data", async () => { + it('should sync timezone to both user and settings state after fetching user data', async () => { const mockUserData = { - id: "667037022b752d0026472254", - firstname: "John", - lastname: "Doe", - preferredEmail: ["test@test.com"], + id: '667037022b752d0026472254', + firstname: 'John', + lastname: 'Doe', + preferredEmail: ['test@test.com'], configurations: { modules: [ { - name: "core", + name: 'core', configurations: [ - { name: "language", value: "fr" }, - { name: "datetime", value: { timeZone: "Europe/Paris" } }, - ], - }, - ], - }, - }; + { name: 'language', value: 'fr' }, + { name: 'datetime', value: { timeZone: 'Europe/Paris' } } + ] + } + ] + } + } // Return a fake fetch-like response apiGetSpy.mockResolvedValue({ - json: async () => mockUserData, - }); + json: async () => mockUserData + }) const store = configureStore({ reducer: { user: userReducer, settings: settingsReducer }, preloadedState: { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, - coreConfig: { language: "en", datetime: { timeZone: "UTC" } }, + coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } }, loading: false, - error: null, + error: null }, - settings: { language: "en", timeZone: "UTC", view: "calendar" }, - }, - }); + settings: { language: 'en', timeZone: 'UTC', view: 'calendar' } + } + }) - const result = await store.dispatch(getOpenPaasUserDataAsync()); + const result = await store.dispatch(getOpenPaasUserDataAsync()) - expect(result.type).toBe("user/getOpenPaasUserData/fulfilled"); + expect(result.type).toBe('user/getOpenPaasUserData/fulfilled') - const state = store.getState(); - expect(state.user.coreConfig.datetime.timeZone).toBe("Europe/Paris"); - expect(state.settings.timeZone).toBe("Europe/Paris"); - expect(localStorage.getItem("timeZone")).toBe("Europe/Paris"); - }); + const state = store.getState() + expect(state.user.coreConfig.datetime.timeZone).toBe('Europe/Paris') + expect(state.settings.timeZone).toBe('Europe/Paris') + expect(localStorage.getItem('timeZone')).toBe('Europe/Paris') + }) - it("should keep browser timezone if API response has no timezone configuration", async () => { + it('should keep browser timezone if API response has no timezone configuration', async () => { const mockUserData = { - id: "667037022b752d0026472254", - firstname: "John", - lastname: "Doe", - preferredEmail: "[test@test.com](mailto:test@test.com)", + id: '667037022b752d0026472254', + firstname: 'John', + lastname: 'Doe', + preferredEmail: '[test@test.com](mailto:test@test.com)', configurations: { modules: [ - { name: "core", configurations: [{ name: "language", value: "en" }] }, - ], - }, - }; + { name: 'core', configurations: [{ name: 'language', value: 'en' }] } + ] + } + } apiGetSpy.mockResolvedValue({ - json: async () => mockUserData, - }); + json: async () => mockUserData + }) - const browserTimezone = browserDefaultTimeZone ?? "UTC"; + const browserTimezone = browserDefaultTimeZone ?? 'UTC' const store = configureStore({ reducer: { user: userReducer, settings: settingsReducer }, preloadedState: { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, coreConfig: { - language: "en", - datetime: { timeZone: browserTimezone }, + language: 'en', + datetime: { timeZone: browserTimezone } }, loading: false, - error: null, + error: null }, settings: { - language: "en", + language: 'en', timeZone: browserTimezone, - view: "calendar", - }, - }, - }); + view: 'calendar' + } + } + }) - const result = await store.dispatch(getOpenPaasUserDataAsync()); + const result = await store.dispatch(getOpenPaasUserDataAsync()) - expect(result.type).toBe("user/getOpenPaasUserData/fulfilled"); + expect(result.type).toBe('user/getOpenPaasUserData/fulfilled') - const state = store.getState(); - expect(state.user.coreConfig.datetime.timeZone).toBe(null); - expect(state.settings.timeZone).toBe(browserTimezone); - }); -}); + const state = store.getState() + expect(state.user.coreConfig.datetime.timeZone).toBe(null) + expect(state.settings.timeZone).toBe(browserTimezone) + }) +}) // Mock localStorage const localStorageMock = (() => { - let store: Record = {}; + let store: Record = {} return { getItem: (key: string) => store[key] || null, setItem: (key: string, value: string) => { - store[key] = value; + store[key] = value }, removeItem: (key: string) => { - delete store[key]; + delete store[key] }, clear: () => { - store = {}; - }, - }; -})(); + store = {} + } + } +})() -Object.defineProperty(window, "localStorage", { - value: localStorageMock, -}); +Object.defineProperty(window, 'localStorage', { + value: localStorageMock +}) -describe("Timezone Logic - Backend to Frontend Flow", () => { - let store: any; +describe('Timezone Logic - Backend to Frontend Flow', () => { + let store: any beforeEach(() => { - localStorageMock.clear(); + localStorageMock.clear() store = configureStore({ reducer: { settings: settingsReducer, - user: userReducer, + user: userReducer }, preloadedState: { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, - coreConfig: { language: "en", datetime: { timeZone: "UTC" } }, + coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } }, loading: false, - error: null, + error: null }, - settings: { language: "en", timeZone: "UTC", view: "calendar" }, - }, - }); - }); + settings: { language: 'en', timeZone: 'UTC', view: 'calendar' } + } + }) + }) - describe("Backend Response Handling", () => { - test("Backend returns NULL => Should use browser default timezone", async () => { + describe('Backend Response Handling', () => { + test('Backend returns NULL => Should use browser default timezone', async () => { const backendResponse = { - firstname: "John", - lastname: "Doe", - id: "123", - preferredEmail: "john@example.com", + firstname: 'John', + lastname: 'Doe', + id: '123', + preferredEmail: 'john@example.com', configurations: { modules: [ { - name: "core", + name: 'core', configurations: [ { - name: "datetime", + name: 'datetime', value: { - timeZone: null, - }, - }, - ], - }, - ], - }, - }; + timeZone: null + } + } + ] + } + ] + } + } await store.dispatch( - getOpenPaasUserDataAsync.fulfilled(backendResponse, "", undefined) - ); + getOpenPaasUserDataAsync.fulfilled(backendResponse, '', undefined) + ) - const settingsState = store.getState().settings; - const userState = store.getState().user; + const settingsState = store.getState().settings + const userState = store.getState().user - expect(settingsState.timeZone).toBe(browserDefaultTimeZone); - expect(settingsState.isBrowserDefaultTimeZone).toBe(true); - expect(userState.coreConfig.datetime.timeZone).toBe(null); - expect(localStorage.getItem("timeZone")).toBe(browserDefaultTimeZone); - }); + expect(settingsState.timeZone).toBe(browserDefaultTimeZone) + expect(settingsState.isBrowserDefaultTimeZone).toBe(true) + expect(userState.coreConfig.datetime.timeZone).toBe(null) + expect(localStorage.getItem('timeZone')).toBe(browserDefaultTimeZone) + }) - test("Backend returns SPECIFIC VALUE => Should use that value", async () => { - const specificTimezone = "America/New_York"; + test('Backend returns SPECIFIC VALUE => Should use that value', async () => { + const specificTimezone = 'America/New_York' const backendResponse = { - firstname: "Jane", - lastname: "Smith", - id: "456", - preferredEmail: "jane@example.com", + firstname: 'Jane', + lastname: 'Smith', + id: '456', + preferredEmail: 'jane@example.com', configurations: { modules: [ { - name: "core", + name: 'core', configurations: [ { - name: "datetime", + name: 'datetime', value: { - timeZone: specificTimezone, - }, - }, - ], - }, - ], - }, - }; + timeZone: specificTimezone + } + } + ] + } + ] + } + } await store.dispatch( - getOpenPaasUserDataAsync.fulfilled(backendResponse, "", undefined) - ); + getOpenPaasUserDataAsync.fulfilled(backendResponse, '', undefined) + ) - const settingsState = store.getState().settings; - const userState = store.getState().user; + const settingsState = store.getState().settings + const userState = store.getState().user - expect(settingsState.timeZone).toBe(specificTimezone); - expect(settingsState.isBrowserDefaultTimeZone).toBe(false); - expect(userState.coreConfig.datetime.timeZone).toBe(specificTimezone); - expect(localStorage.getItem("timeZone")).toBe(specificTimezone); - }); + expect(settingsState.timeZone).toBe(specificTimezone) + expect(settingsState.isBrowserDefaultTimeZone).toBe(false) + expect(userState.coreConfig.datetime.timeZone).toBe(specificTimezone) + expect(localStorage.getItem('timeZone')).toBe(specificTimezone) + }) - test("Backend returns NO datetime config => Should use browser default", async () => { + test('Backend returns NO datetime config => Should use browser default', async () => { const backendResponse = { - firstname: "Bob", - lastname: "Johnson", - id: "789", - preferredEmail: "bob@example.com", + firstname: 'Bob', + lastname: 'Johnson', + id: '789', + preferredEmail: 'bob@example.com', configurations: { modules: [ { - name: "core", + name: 'core', configurations: [ // No datetime config - ], - }, - ], - }, - }; + ] + } + ] + } + } await store.dispatch( - getOpenPaasUserDataAsync.fulfilled(backendResponse, "", undefined) - ); + getOpenPaasUserDataAsync.fulfilled(backendResponse, '', undefined) + ) - const settingsState = store.getState().settings; - const userState = store.getState().user; + const settingsState = store.getState().settings + const userState = store.getState().user - expect(settingsState.timeZone).toBe(browserDefaultTimeZone); - expect(settingsState.isBrowserDefaultTimeZone).toBe(true); - expect(userState.coreConfig.datetime.timeZone).toBe(null); - expect(localStorage.getItem("timeZone")).toBe(browserDefaultTimeZone); - }); - }); + expect(settingsState.timeZone).toBe(browserDefaultTimeZone) + expect(settingsState.isBrowserDefaultTimeZone).toBe(true) + expect(userState.coreConfig.datetime.timeZone).toBe(null) + expect(localStorage.getItem('timeZone')).toBe(browserDefaultTimeZone) + }) + }) - test("Settings state ALWAYS has a concrete value (never null)", () => { + test('Settings state ALWAYS has a concrete value (never null)', () => { // Test 1: Browser default scenario - store.dispatch(setIsBrowserDefaultTimeZone(true)); - let settingsState = store.getState().settings; + store.dispatch(setIsBrowserDefaultTimeZone(true)) + let settingsState = store.getState().settings - expect(settingsState.timeZone).toBe(browserDefaultTimeZone); - expect(settingsState.timeZone).not.toBe(null); + expect(settingsState.timeZone).toBe(browserDefaultTimeZone) + expect(settingsState.timeZone).not.toBe(null) // Test 2: Specific timezone scenario - store.dispatch(setTimeZone("Europe/Paris")); - settingsState = store.getState().settings; + store.dispatch(setTimeZone('Europe/Paris')) + settingsState = store.getState().settings - expect(settingsState.timeZone).toBe("Europe/Paris"); - expect(settingsState.timeZone).not.toBe(null); - }); + expect(settingsState.timeZone).toBe('Europe/Paris') + expect(settingsState.timeZone).not.toBe(null) + }) - describe("User Actions - Changing Timezone", () => { + describe('User Actions - Changing Timezone', () => { beforeEach(() => { - (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); - }); - test("User enables browser default => Settings gets browser TZ, User gets null", async () => { + ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) + }) + test('User enables browser default => Settings gets browser TZ, User gets null', async () => { const { store } = renderWithProviders(, { user: { - userData: { sub: "test" }, + userData: { sub: 'test' }, organiserData: null, tokens: null, coreConfig: { - language: "en", - datetime: { timeZone: "America/Los_Angeles" }, + language: 'en', + datetime: { timeZone: 'America/Los_Angeles' } }, loading: false, - error: null, + error: null }, settings: { - language: "en", - timeZone: "America/Los_Angeles", + language: 'en', + timeZone: 'America/Los_Angeles', isBrowserDefaultTimeZone: false, - view: "settings", - }, - }); + view: 'settings' + } + }) const browserDefaultSwitch = screen.getAllByLabelText( - "settings.timeZoneBrowserDefault" - )[0]; - expect(browserDefaultSwitch).toBeInTheDocument(); + 'settings.timeZoneBrowserDefault' + )[0] + expect(browserDefaultSwitch).toBeInTheDocument() // Enable browser default - fireEvent.click(browserDefaultSwitch); + fireEvent.click(browserDefaultSwitch) await waitFor(() => { - const state = store.getState(); - expect(state.settings.isBrowserDefaultTimeZone).toBe(true); - }); + const state = store.getState() + expect(state.settings.isBrowserDefaultTimeZone).toBe(true) + }) await waitFor(() => { - const state = store.getState(); - expect(state.settings.timeZone).toBe(browserDefaultTimeZone); - }); + const state = store.getState() + expect(state.settings.timeZone).toBe(browserDefaultTimeZone) + }) await waitFor(() => { - const state = store.getState(); - expect(state.user.coreConfig.datetime.timeZone).toBe(null); - }); - }); + const state = store.getState() + expect(state.user.coreConfig.datetime.timeZone).toBe(null) + }) + }) - test("User selects specific timezone => Both states get the value", async () => { + test('User selects specific timezone => Both states get the value', async () => { const { store } = renderWithProviders(, { user: { - userData: { sub: "test" }, + userData: { sub: 'test' }, organiserData: null, tokens: null, - coreConfig: { language: "en", datetime: { timeZone: null } }, + coreConfig: { language: 'en', datetime: { timeZone: null } }, loading: false, - error: null, + error: null }, settings: { - language: "en", + language: 'en', timeZone: browserDefaultTimeZone, isBrowserDefaultTimeZone: true, - view: "settings", - }, - }); + view: 'settings' + } + }) const browserDefaultSwitch = screen.getAllByLabelText( - "settings.timeZoneBrowserDefault" - )[0]; + 'settings.timeZoneBrowserDefault' + )[0] // Disable browser default so manual selector appears - fireEvent.click(browserDefaultSwitch); + fireEvent.click(browserDefaultSwitch) // Now timezone combobox is visible - const timezoneInput = screen.getAllByRole("combobox")[1]; + const timezoneInput = screen.getAllByRole('combobox')[1] // Type to filter options fireEvent.change(timezoneInput, { - target: { value: "Australia/Sydney" }, - }); + target: { value: 'Australia/Sydney' } + }) // Select from autocomplete dropdown - const option = await screen.findByText(/Australia\/Sydney/i); - fireEvent.click(option); + const option = await screen.findByText(/Australia\/Sydney/i) + fireEvent.click(option) await waitFor(() => { - const state = store.getState(); - expect(state.settings.isBrowserDefaultTimeZone).toBe(false); - }); + const state = store.getState() + expect(state.settings.isBrowserDefaultTimeZone).toBe(false) + }) await waitFor(() => { - const state = store.getState(); - expect(state.settings.timeZone).toBe("Australia/Sydney"); - }); + const state = store.getState() + expect(state.settings.timeZone).toBe('Australia/Sydney') + }) await waitFor(() => { - const state = store.getState(); - expect(state.user.coreConfig.datetime.timeZone).toBe( - "Australia/Sydney" - ); - }); - }); - }); + const state = store.getState() + expect(state.user.coreConfig.datetime.timeZone).toBe('Australia/Sydney') + }) + }) + }) - describe("LocalStorage Persistence", () => { - test("LocalStorage always stores concrete values (browser TZ or specific TZ)", () => { + describe('LocalStorage Persistence', () => { + test('LocalStorage always stores concrete values (browser TZ or specific TZ)', () => { // Scenario 1: Browser default - store.dispatch(setTimeZone(browserDefaultTimeZone)); - expect(localStorage.getItem("timeZone")).toBe(browserDefaultTimeZone); - expect(localStorage.getItem("timeZone")).not.toBe("null"); + store.dispatch(setTimeZone(browserDefaultTimeZone)) + expect(localStorage.getItem('timeZone')).toBe(browserDefaultTimeZone) + expect(localStorage.getItem('timeZone')).not.toBe('null') // Scenario 2: Specific timezone - store.dispatch(setTimeZone("Africa/Cairo")); - expect(localStorage.getItem("timeZone")).toBe("Africa/Cairo"); - }); - }); + store.dispatch(setTimeZone('Africa/Cairo')) + expect(localStorage.getItem('timeZone')).toBe('Africa/Cairo') + }) + }) - test("Full user journey: Backend null -> User changes -> Backend saves", async () => { + test('Full user journey: Backend null -> User changes -> Backend saves', async () => { // Step 1: Backend returns null const backendResponseNull = { - firstname: "Test", - lastname: "User", - id: "999", - preferredEmail: "test@example.com", + firstname: 'Test', + lastname: 'User', + id: '999', + preferredEmail: 'test@example.com', configurations: { modules: [ { - name: "core", + name: 'core', configurations: [ { - name: "datetime", - value: { timeZone: null }, - }, - ], - }, - ], - }, - }; + name: 'datetime', + value: { timeZone: null } + } + ] + } + ] + } + } await store.dispatch( - getOpenPaasUserDataAsync.fulfilled(backendResponseNull, "", undefined) - ); + getOpenPaasUserDataAsync.fulfilled(backendResponseNull, '', undefined) + ) // Verify initial state - expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone); - expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null); + expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone) + expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null) // Step 2: User changes to specific timezone - const userSelectedTZ = "Europe/London"; - store.dispatch(setIsBrowserDefaultTimeZone(false)); - store.dispatch(setTimeZone(userSelectedTZ)); - store.dispatch(setUserTimeZone(userSelectedTZ)); + const userSelectedTZ = 'Europe/London' + store.dispatch(setIsBrowserDefaultTimeZone(false)) + store.dispatch(setTimeZone(userSelectedTZ)) + store.dispatch(setUserTimeZone(userSelectedTZ)) - expect(store.getState().settings.timeZone).toBe(userSelectedTZ); + expect(store.getState().settings.timeZone).toBe(userSelectedTZ) expect(store.getState().user.coreConfig.datetime.timeZone).toBe( userSelectedTZ - ); + ) // Step 3: User switches back to browser default - store.dispatch(setIsBrowserDefaultTimeZone(true)); - store.dispatch(setUserTimeZone(null)); - store.dispatch(setTimeZone(browserDefaultTimeZone)); + store.dispatch(setIsBrowserDefaultTimeZone(true)) + store.dispatch(setUserTimeZone(null)) + store.dispatch(setTimeZone(browserDefaultTimeZone)) - expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone); - expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null); - }); -}); + expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone) + expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null) + }) +}) diff --git a/__test__/features/Settings/SettingsPage.test.tsx b/__test__/features/Settings/SettingsPage.test.tsx index 423f66e..2c753b8 100644 --- a/__test__/features/Settings/SettingsPage.test.tsx +++ b/__test__/features/Settings/SettingsPage.test.tsx @@ -1,845 +1,843 @@ -import SettingsPage from "@/features/Settings/SettingsPage"; -import { api } from "@/utils/apiUtils"; -import * as timezoneUtils from "@/utils/timezone"; -import "@testing-library/jest-dom"; -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import SettingsPage from '@/features/Settings/SettingsPage' +import { api } from '@/utils/apiUtils' +import * as timezoneUtils from '@/utils/timezone' +import '@testing-library/jest-dom' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../../utils/Renderwithproviders' -jest.mock("@/utils/apiUtils"); +jest.mock('@/utils/apiUtils') -describe("SettingsPage", () => { +describe('SettingsPage', () => { beforeEach(() => { - jest.clearAllMocks(); - localStorage.clear(); - }); + jest.clearAllMocks() + localStorage.clear() + }) const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, coreConfig: { - language: "en", - datetime: { timeZone: "UTC" }, + language: 'en', + datetime: { timeZone: 'UTC' } }, alarmEmailsEnabled: false, loading: false, - error: null, + error: null }, settings: { - language: "en", - timeZone: "UTC", + language: 'en', + timeZone: 'UTC', isBrowserDefaultTimeZone: false, - view: "settings", - }, - }; + view: 'settings' + } + } - it("renders settings page with sidebar and content layout", () => { - renderWithProviders(, preloadedState); + it('renders settings page with sidebar and content layout', () => { + renderWithProviders(, preloadedState) - expect(screen.getByRole("main")).toHaveClass( - "main-layout", - "settings-layout" - ); - }); + expect(screen.getByRole('main')).toHaveClass( + 'main-layout', + 'settings-layout' + ) + }) - it("displays sidebar navigation with Settings and Notifications items", () => { - const { container } = renderWithProviders(, preloadedState); + it('displays sidebar navigation with Settings and Notifications items', () => { + const { container } = renderWithProviders(, preloadedState) // Check sidebar navigation items // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const sidebar = container.querySelector(".settings-sidebar"); - expect(sidebar).toBeInTheDocument(); - expect(screen.getAllByText(/settings.title/i).length).toBeGreaterThan(0); - expect(screen.getByText(/settings.notifications/i)).toBeInTheDocument(); - }); + const sidebar = container.querySelector('.settings-sidebar') + expect(sidebar).toBeInTheDocument() + expect(screen.getAllByText(/settings.title/i).length).toBeGreaterThan(0) + expect(screen.getByText(/settings.notifications/i)).toBeInTheDocument() + }) - it("highlights active navigation item", () => { - const { container } = renderWithProviders(, preloadedState); + it('highlights active navigation item', () => { + const { container } = renderWithProviders(, preloadedState) // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const settingsNavItem = container.querySelector( - ".settings-nav-item.active" - ); - expect(settingsNavItem).toBeInTheDocument(); - }); + const settingsNavItem = container.querySelector('.settings-nav-item.active') + expect(settingsNavItem).toBeInTheDocument() + }) - it("navigates back to calendar view when clicking back button", async () => { - const { store } = renderWithProviders(, preloadedState); + it('navigates back to calendar view when clicking back button', async () => { + const { store } = renderWithProviders(, preloadedState) - const backButton = screen.getByLabelText("settings.back"); - fireEvent.click(backButton); + const backButton = screen.getByLabelText('settings.back') + fireEvent.click(backButton) await waitFor(() => { - const state = store.getState(); - expect(state.settings.view).toBe("calendar"); - }); - }); + const state = store.getState() + expect(state.settings.view).toBe('calendar') + }) + }) - it("switches between Settings and Notifications tabs", () => { - renderWithProviders(, preloadedState); + it('switches between Settings and Notifications tabs', () => { + renderWithProviders(, preloadedState) - const notificationsTab = screen.getByRole("tab", { - name: /settings.notifications/i, - }); - fireEvent.click(notificationsTab); + const notificationsTab = screen.getByRole('tab', { + name: /settings.notifications/i + }) + fireEvent.click(notificationsTab) expect( screen.getByText(/settings.notifications.deliveryMethod/i) - ).toBeInTheDocument(); - }); + ).toBeInTheDocument() + }) - it("displays language select in Settings tab", () => { - renderWithProviders(, preloadedState); + it('displays language select in Settings tab', () => { + renderWithProviders(, preloadedState) expect( - screen.getByLabelText("settings.languageSelector") - ).toBeInTheDocument(); - }); + screen.getByLabelText('settings.languageSelector') + ).toBeInTheDocument() + }) - it("displays all available language options and uses language from user state", async () => { + it('displays all available language options and uses language from user state', async () => { const stateWithUserLanguage = { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, - coreConfig: { language: "fr", datetime: { timeZone: "UTC" } }, + coreConfig: { language: 'fr', datetime: { timeZone: 'UTC' } }, loading: false, - error: null, + error: null }, settings: { - language: "en", - timeZone: "UTC", + language: 'en', + timeZone: 'UTC', isBrowserDefaultTimeZone: false, - view: "settings", - }, - }; - renderWithProviders(, stateWithUserLanguage); + view: 'settings' + } + } + renderWithProviders(, stateWithUserLanguage) - const languageSelect = screen.getByLabelText("settings.languageSelector"); + const languageSelect = screen.getByLabelText('settings.languageSelector') // Verify Select exists - expect(languageSelect).toBeInTheDocument(); + expect(languageSelect).toBeInTheDocument() // Verify that the underlying native input reflects the user language ("fr") // eslint-disable-next-line testing-library/no-node-access const nativeInput = languageSelect.querySelector( 'input[aria-hidden="true"]' - ) as HTMLInputElement | null; - expect(nativeInput).not.toBeNull(); - expect(nativeInput?.value).toBe("fr"); - }); + ) as HTMLInputElement | null + expect(nativeInput).not.toBeNull() + expect(nativeInput?.value).toBe('fr') + }) - it("updates language immediately (optimistic update) and calls API in background", async () => { - (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); + it('updates language immediately (optimistic update) and calls API in background', async () => { + ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) - const { store } = renderWithProviders(, preloadedState); + const { store } = renderWithProviders(, preloadedState) - const languageSelect = screen.getByLabelText("settings.languageSelector"); + const languageSelect = screen.getByLabelText('settings.languageSelector') // MUI Select uses a native input element - find and change it // eslint-disable-next-line testing-library/no-node-access const nativeInput = languageSelect.querySelector( 'input[aria-hidden="true"]' - ) as HTMLInputElement; - expect(nativeInput).toBeInTheDocument(); + ) as HTMLInputElement + expect(nativeInput).toBeInTheDocument() // Simulate change event on the native input - Object.defineProperty(nativeInput, "value", { + Object.defineProperty(nativeInput, 'value', { writable: true, - value: "fr", - }); - fireEvent.change(nativeInput, { target: { value: "fr" } }); + value: 'fr' + }) + fireEvent.change(nativeInput, { target: { value: 'fr' } }) // Language should be updated immediately (optimistic update) await waitFor(() => { - const state = store.getState(); - expect(state.user?.coreConfig.language).toBe("fr"); - }); + const state = store.getState() + expect(state.user?.coreConfig.language).toBe('fr') + }) await waitFor(() => { - const state = store.getState(); - expect(state.settings.language).toBe("fr"); - }); + const state = store.getState() + expect(state.settings.language).toBe('fr') + }) // API should be called in background await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( - "api/configurations?scope=user", + 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ - name: "core", + name: 'core', configurations: expect.arrayContaining([ - expect.objectContaining({ name: "language", value: "fr" }), - ]), - }), - ]), + expect.objectContaining({ name: 'language', value: 'fr' }) + ]) + }) + ]) }) - ); - }); - }); + ) + }) + }) - it("saves language change to localStorage immediately (optimistic update)", async () => { - (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); + it('saves language change to localStorage immediately (optimistic update)', async () => { + ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) - renderWithProviders(, preloadedState); + renderWithProviders(, preloadedState) - const languageSelect = screen.getByLabelText("settings.languageSelector"); + const languageSelect = screen.getByLabelText('settings.languageSelector') // MUI Select uses a native input element - find and change it // eslint-disable-next-line testing-library/no-node-access const nativeInput = languageSelect.querySelector( 'input[aria-hidden="true"]' - ) as HTMLInputElement; - expect(nativeInput).toBeInTheDocument(); + ) as HTMLInputElement + expect(nativeInput).toBeInTheDocument() // Simulate change event on the native input - Object.defineProperty(nativeInput, "value", { + Object.defineProperty(nativeInput, 'value', { writable: true, - value: "fr", - }); - fireEvent.change(nativeInput, { target: { value: "fr" } }); + value: 'fr' + }) + fireEvent.change(nativeInput, { target: { value: 'fr' } }) // localStorage should be updated immediately (optimistic update) await waitFor(() => { - expect(localStorage.getItem("lang")).toBe("fr"); - }); - }); + expect(localStorage.getItem('lang')).toBe('fr') + }) + }) - it("rolls back language change if API call fails", async () => { - (api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); + it('rolls back language change if API call fails', async () => { + ;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error')) - const { store } = renderWithProviders(, preloadedState); + const { store } = renderWithProviders(, preloadedState) - const languageSelect = screen.getByLabelText("settings.languageSelector"); + const languageSelect = screen.getByLabelText('settings.languageSelector') // MUI Select uses a native input element - find and change it // eslint-disable-next-line testing-library/no-node-access const nativeInput = languageSelect.querySelector( 'input[aria-hidden="true"]' - ) as HTMLInputElement; - expect(nativeInput).toBeInTheDocument(); + ) as HTMLInputElement + expect(nativeInput).toBeInTheDocument() // Simulate change event on the native input - Object.defineProperty(nativeInput, "value", { + Object.defineProperty(nativeInput, 'value', { writable: true, - value: "fr", - }); - fireEvent.change(nativeInput, { target: { value: "fr" } }); + value: 'fr' + }) + fireEvent.change(nativeInput, { target: { value: 'fr' } }) // Wait for rollback - language should be rolled back to "en" after error await waitFor(() => { - const state = store.getState(); - expect(state.user?.coreConfig.language).toBe("en"); - }); + const state = store.getState() + expect(state.user?.coreConfig.language).toBe('en') + }) await waitFor( () => { - const state = store.getState(); - expect(state.settings.language).toBe("en"); + const state = store.getState() + expect(state.settings.language).toBe('en') }, { timeout: 3000 } - ); - }); + ) + }) - it("shows Email toggle in Notifications tab", () => { - renderWithProviders(, preloadedState); + it('shows Email toggle in Notifications tab', () => { + renderWithProviders(, preloadedState) - const notificationsTab = screen.getByRole("tab", { - name: /settings.notifications/i, - }); - fireEvent.click(notificationsTab); + const notificationsTab = screen.getByRole('tab', { + name: /settings.notifications/i + }) + fireEvent.click(notificationsTab) expect( screen.getByText(/settings.notifications.deliveryMethod/i) - ).toBeInTheDocument(); + ).toBeInTheDocument() expect( - screen.getByRole("switch", { name: /settings.notifications.email/i }) - ).toBeInTheDocument(); - }); + screen.getByRole('switch', { name: /settings.notifications.email/i }) + ).toBeInTheDocument() + }) - describe("Timezone Settings", () => { + describe('Timezone Settings', () => { beforeEach(() => { - (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); - }); - it("displays timezone selector in Settings tab", () => { - renderWithProviders(, preloadedState); + ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) + }) + it('displays timezone selector in Settings tab', () => { + renderWithProviders(, preloadedState) - expect(screen.getAllByRole("combobox")).toHaveLength(2); - }); + expect(screen.getAllByRole('combobox')).toHaveLength(2) + }) - it("displays timezone from user state", () => { + it('displays timezone from user state', () => { const stateWithUserTimeZone = { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, coreConfig: { - language: "en", - datetime: { timeZone: "America/New_York" }, + language: 'en', + datetime: { timeZone: 'America/New_York' } }, loading: false, - error: null, + error: null }, settings: { - language: "en", - timeZone: "UTC", + language: 'en', + timeZone: 'UTC', isBrowserDefaultTimeZone: false, - view: "settings", - }, - }; + view: 'settings' + } + } - renderWithProviders(, stateWithUserTimeZone); + renderWithProviders(, stateWithUserTimeZone) - expect(screen.getByDisplayValue(/America\/New York/i)).toBeDefined(); - }); + expect(screen.getByDisplayValue(/America\/New York/i)).toBeDefined() + }) - it("updates timezone immediately (optimistic update) and calls API", async () => { - const { store } = renderWithProviders(, preloadedState); + it('updates timezone immediately (optimistic update) and calls API', async () => { + const { store } = renderWithProviders(, preloadedState) - const timezoneInput = screen.getAllByRole("combobox")[1]; + const timezoneInput = screen.getAllByRole('combobox')[1] // Clear the input and type new timezone - fireEvent.change(timezoneInput, { target: { value: "Europe/Paris" } }); + fireEvent.change(timezoneInput, { target: { value: 'Europe/Paris' } }) // Find and click the timezone option from the dropdown - const option = await screen.findByText(/Europe\/Paris/i); - fireEvent.click(option); + const option = await screen.findByText(/Europe\/Paris/i) + fireEvent.click(option) // Timezone should be updated immediately (optimistic update) await waitFor(() => { - const state = store.getState(); - expect(state.user?.coreConfig.datetime.timeZone).toBe("Europe/Paris"); - }); + const state = store.getState() + expect(state.user?.coreConfig.datetime.timeZone).toBe('Europe/Paris') + }) await waitFor(() => { - const state = store.getState(); - expect(state.settings.timeZone).toBe("Europe/Paris"); - }); + const state = store.getState() + expect(state.settings.timeZone).toBe('Europe/Paris') + }) await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( - "api/configurations?scope=user", + 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ - name: "core", + name: 'core', configurations: expect.arrayContaining([ expect.objectContaining({ - name: "datetime", + name: 'datetime', value: expect.objectContaining({ - timeZone: "Europe/Paris", - }), - }), - ]), - }), - ]), + timeZone: 'Europe/Paris' + }) + }) + ]) + }) + ]) }) - ); - }); - }); + ) + }) + }) - it("handles timezone change with different timezone values", async () => { - const { store } = renderWithProviders(, preloadedState); + it('handles timezone change with different timezone values', async () => { + const { store } = renderWithProviders(, preloadedState) - const timezoneInput = screen.getAllByRole("combobox")[1]; + const timezoneInput = screen.getAllByRole('combobox')[1] // Test with Asia/Tokyo - fireEvent.change(timezoneInput, { target: { value: "Asia/Tokyo" } }); - const tokyoOption = await screen.findByText(/Asia\/Tokyo/i); - fireEvent.click(tokyoOption); + fireEvent.change(timezoneInput, { target: { value: 'Asia/Tokyo' } }) + const tokyoOption = await screen.findByText(/Asia\/Tokyo/i) + fireEvent.click(tokyoOption) await waitFor(() => { - const state = store.getState(); - expect(state.user?.coreConfig.datetime.timeZone).toBe("Asia/Tokyo"); - }); + const state = store.getState() + expect(state.user?.coreConfig.datetime.timeZone).toBe('Asia/Tokyo') + }) await waitFor(() => { - const state = store.getState(); - expect(state.settings.timeZone).toBe("Asia/Tokyo"); - }); + const state = store.getState() + expect(state.settings.timeZone).toBe('Asia/Tokyo') + }) await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( - "api/configurations?scope=user", + 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ - name: "core", + name: 'core', configurations: expect.arrayContaining([ expect.objectContaining({ - name: "datetime", + name: 'datetime', value: expect.objectContaining({ - timeZone: "Asia/Tokyo", - }), - }), - ]), - }), - ]), + timeZone: 'Asia/Tokyo' + }) + }) + ]) + }) + ]) }) - ); - }); + ) + }) - jest.clearAllMocks(); - (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); + jest.clearAllMocks() + ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) fireEvent.change(timezoneInput, { - target: { value: "America/Los_Angeles" }, - }); - const laOption = await screen.findByText(/America\/Los Angeles/i); - fireEvent.click(laOption); + target: { value: 'America/Los_Angeles' } + }) + const laOption = await screen.findByText(/America\/Los Angeles/i) + fireEvent.click(laOption) await waitFor(() => { - const state = store.getState(); + const state = store.getState() expect(state.user?.coreConfig.datetime.timeZone).toBe( - "America/Los_Angeles" - ); - }); + 'America/Los_Angeles' + ) + }) await waitFor(() => { - const state = store.getState(); - expect(state.settings.timeZone).toBe("America/Los_Angeles"); - }); + const state = store.getState() + expect(state.settings.timeZone).toBe('America/Los_Angeles') + }) await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( - "api/configurations?scope=user", + 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ - name: "core", + name: 'core', configurations: expect.arrayContaining([ expect.objectContaining({ - name: "datetime", + name: 'datetime', value: expect.objectContaining({ - timeZone: "America/Los_Angeles", - }), - }), - ]), - }), - ]), + timeZone: 'America/Los_Angeles' + }) + }) + ]) + }) + ]) }) - ); - }); - }); + ) + }) + }) - it("enables browser default timezone and calls API with null", async () => { - const { store } = renderWithProviders(, preloadedState); + it('enables browser default timezone and calls API with null', async () => { + const { store } = renderWithProviders(, preloadedState) - const browserDefaultSwitch = screen.getByRole("switch", { - name: /settings.timeZoneBrowserDefault/i, - }); + const browserDefaultSwitch = screen.getByRole('switch', { + name: /settings.timeZoneBrowserDefault/i + }) - expect(browserDefaultSwitch).not.toBeChecked(); + expect(browserDefaultSwitch).not.toBeChecked() - fireEvent.click(browserDefaultSwitch); + fireEvent.click(browserDefaultSwitch) await waitFor(() => { - expect(browserDefaultSwitch).toBeChecked(); - }); + expect(browserDefaultSwitch).toBeChecked() + }) await waitFor(() => { - const state = store.getState(); - expect(state.settings.isBrowserDefaultTimeZone).toBe(true); - }); + const state = store.getState() + expect(state.settings.isBrowserDefaultTimeZone).toBe(true) + }) await waitFor(() => { - const state = store.getState(); - expect(state.user?.coreConfig.datetime.timeZone).toBeNull(); - }); + const state = store.getState() + expect(state.user?.coreConfig.datetime.timeZone).toBeNull() + }) await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( - "api/configurations?scope=user", + 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ - name: "core", + name: 'core', configurations: expect.arrayContaining([ expect.objectContaining({ - name: "datetime", + name: 'datetime', value: expect.objectContaining({ - timeZone: null, - }), - }), - ]), - }), - ]), + timeZone: null + }) + }) + ]) + }) + ]) }) - ); - }); - }); + ) + }) + }) - it("disables browser default timezone and shows timezone selector", async () => { + it('disables browser default timezone and shows timezone selector', async () => { const stateWithBrowserDefault = { ...preloadedState, user: { ...preloadedState.user, coreConfig: { - language: "en", - datetime: { timeZone: null }, - }, + language: 'en', + datetime: { timeZone: null } + } }, settings: { ...preloadedState.settings, - isBrowserDefaultTimeZone: true, - }, - }; + isBrowserDefaultTimeZone: true + } + } - renderWithProviders(, stateWithBrowserDefault); + renderWithProviders(, stateWithBrowserDefault) - const browserDefaultSwitch = screen.getByRole("switch", { - name: /settings.timeZoneBrowserDefault/i, - }); + const browserDefaultSwitch = screen.getByRole('switch', { + name: /settings.timeZoneBrowserDefault/i + }) - expect(browserDefaultSwitch).toBeChecked(); + expect(browserDefaultSwitch).toBeChecked() - expect(screen.getAllByRole("combobox")).toHaveLength(1); + expect(screen.getAllByRole('combobox')).toHaveLength(1) - fireEvent.click(browserDefaultSwitch); + fireEvent.click(browserDefaultSwitch) await waitFor(() => { - expect(browserDefaultSwitch).not.toBeChecked(); - }); + expect(browserDefaultSwitch).not.toBeChecked() + }) await waitFor(() => { - expect(screen.getAllByRole("combobox")).toHaveLength(2); - }); - }); + expect(screen.getAllByRole('combobox')).toHaveLength(2) + }) + }) - it("rolls back timezone change if API call fails", async () => { - (api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); + it('rolls back timezone change if API call fails', async () => { + ;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error')) - const { store } = renderWithProviders(, preloadedState); + const { store } = renderWithProviders(, preloadedState) - const timezoneInput = screen.getAllByRole("combobox")[1]; + const timezoneInput = screen.getAllByRole('combobox')[1] - fireEvent.change(timezoneInput, { target: { value: "Europe/Paris" } }); - const option = await screen.findByText(/Europe\/Paris/i); - fireEvent.click(option); + fireEvent.change(timezoneInput, { target: { value: 'Europe/Paris' } }) + const option = await screen.findByText(/Europe\/Paris/i) + fireEvent.click(option) await waitFor(() => { - const state = store.getState(); - expect(state.user?.coreConfig.datetime.timeZone).toBe("UTC"); - }); + const state = store.getState() + expect(state.user?.coreConfig.datetime.timeZone).toBe('UTC') + }) await waitFor( () => { - const state = store.getState(); - expect(state.settings.timeZone).toBe("UTC"); + const state = store.getState() + expect(state.settings.timeZone).toBe('UTC') }, { timeout: 3000 } - ); + ) await waitFor(() => { expect( - screen.getByText("settings.timeZoneUpdateError") - ).toBeInTheDocument(); - }); - }); + screen.getByText('settings.timeZoneUpdateError') + ).toBeInTheDocument() + }) + }) - it("rolls back browser default change if API call fails", async () => { - (api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); + it('rolls back browser default change if API call fails', async () => { + ;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error')) - const { store } = renderWithProviders(, preloadedState); + const { store } = renderWithProviders(, preloadedState) - const browserDefaultSwitch = screen.getByRole("switch", { - name: /settings.timeZoneBrowserDefault/i, - }); + const browserDefaultSwitch = screen.getByRole('switch', { + name: /settings.timeZoneBrowserDefault/i + }) - fireEvent.click(browserDefaultSwitch); + fireEvent.click(browserDefaultSwitch) await waitFor( () => { - const state = store.getState(); - expect(state.settings.isBrowserDefaultTimeZone).toBe(false); + const state = store.getState() + expect(state.settings.isBrowserDefaultTimeZone).toBe(false) }, { timeout: 3000 } - ); + ) await waitFor(() => { - const state = store.getState(); - expect(state.user?.coreConfig.datetime.timeZone).toBe("UTC"); - }); + const state = store.getState() + expect(state.user?.coreConfig.datetime.timeZone).toBe('UTC') + }) await waitFor(() => { expect( - screen.getByText("settings.timeZoneUpdateError") - ).toBeInTheDocument(); - }); - }); + screen.getByText('settings.timeZoneUpdateError') + ).toBeInTheDocument() + }) + }) - it("uses UTC as default timezone when no timezone is set", () => { + it('uses UTC as default timezone when no timezone is set', () => { const stateWithoutTimeZone = { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, - coreConfig: { language: "en", datetime: { timeZone: undefined } }, + coreConfig: { language: 'en', datetime: { timeZone: undefined } }, loading: false, - error: null, + error: null }, settings: { - language: "en", + language: 'en', timeZone: undefined, isBrowserDefaultTimeZone: false, - view: "settings", - }, - }; + view: 'settings' + } + } - jest.spyOn(timezoneUtils, "getTimezoneOffset").mockReturnValue("UTC"); + jest.spyOn(timezoneUtils, 'getTimezoneOffset').mockReturnValue('UTC') - renderWithProviders(, stateWithoutTimeZone); + renderWithProviders(, stateWithoutTimeZone) - const timezoneInput = screen.getByDisplayValue("UTC (UTC)"); - expect(timezoneInput).toBeDefined(); - }); + const timezoneInput = screen.getByDisplayValue('UTC (UTC)') + expect(timezoneInput).toBeDefined() + }) - it("preserves other datetime properties when updating timezone", async () => { + it('preserves other datetime properties when updating timezone', async () => { const stateWithDatetimeConfig = { ...preloadedState, user: { ...preloadedState.user, coreConfig: { - language: "en", + language: 'en', datetime: { - timeZone: "UTC", - format: "24h", - firstDayOfWeek: 1, - }, - }, - }, - }; + timeZone: 'UTC', + format: '24h', + firstDayOfWeek: 1 + } + } + } + } - renderWithProviders(, stateWithDatetimeConfig); + renderWithProviders(, stateWithDatetimeConfig) - const timezoneInput = screen.getAllByRole("combobox")[1]; + const timezoneInput = screen.getAllByRole('combobox')[1] - fireEvent.change(timezoneInput, { target: { value: "Europe/Paris" } }); - const option = await screen.findByText(/Europe\/Paris/i); - fireEvent.click(option); + fireEvent.change(timezoneInput, { target: { value: 'Europe/Paris' } }) + const option = await screen.findByText(/Europe\/Paris/i) + fireEvent.click(option) await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( - "api/configurations?scope=user", + 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ - name: "core", + name: 'core', configurations: expect.arrayContaining([ expect.objectContaining({ - name: "datetime", + name: 'datetime', value: { - timeZone: "Europe/Paris", - format: "24h", - firstDayOfWeek: 1, - }, - }), - ]), - }), - ]), + timeZone: 'Europe/Paris', + format: '24h', + firstDayOfWeek: 1 + } + }) + ]) + }) + ]) }) - ); - }); - }); + ) + }) + }) - describe("Alarm Emails Settings", () => { + describe('Alarm Emails Settings', () => { beforeEach(() => { - (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); - }); + ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) + }) - it("displays alarmEmails toggle with correct initial state", () => { + it('displays alarmEmails toggle with correct initial state', () => { const stateWithAlarmEmailsEnabled = { ...preloadedState, user: { ...preloadedState.user, - alarmEmailsEnabled: true, - }, - }; - renderWithProviders(, stateWithAlarmEmailsEnabled); + alarmEmailsEnabled: true + } + } + renderWithProviders(, stateWithAlarmEmailsEnabled) - const notificationsTab = screen.getByRole("tab", { - name: /settings.notifications/i, - }); - fireEvent.click(notificationsTab); + const notificationsTab = screen.getByRole('tab', { + name: /settings.notifications/i + }) + fireEvent.click(notificationsTab) - const toggle = screen.getByRole("switch", { - name: /settings.notifications.email/i, - }) as HTMLInputElement; - expect(toggle).toBeInTheDocument(); - expect(toggle.checked).toBe(true); - }); + const toggle = screen.getByRole('switch', { + name: /settings.notifications.email/i + }) as HTMLInputElement + expect(toggle).toBeInTheDocument() + expect(toggle.checked).toBe(true) + }) - it("displays alarmEmails toggle as true when alarmEmailsEnabled is null", () => { + it('displays alarmEmails toggle as true when alarmEmailsEnabled is null', () => { const stateWithNullAlarmEmails = { ...preloadedState, user: { ...preloadedState.user, - alarmEmailsEnabled: null, - }, - }; - renderWithProviders(, stateWithNullAlarmEmails); + alarmEmailsEnabled: null + } + } + renderWithProviders(, stateWithNullAlarmEmails) - const notificationsTab = screen.getByRole("tab", { - name: /settings.notifications/i, - }); - fireEvent.click(notificationsTab); + const notificationsTab = screen.getByRole('tab', { + name: /settings.notifications/i + }) + fireEvent.click(notificationsTab) - const toggle = screen.getByRole("switch", { - name: /settings.notifications.email/i, - }) as HTMLInputElement; - expect(toggle).toBeInTheDocument(); - expect(toggle.checked).toBe(true); - }); + const toggle = screen.getByRole('switch', { + name: /settings.notifications.email/i + }) as HTMLInputElement + expect(toggle).toBeInTheDocument() + expect(toggle.checked).toBe(true) + }) - it("updates alarmEmails immediately (optimistic update) and calls API in background", async () => { - (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); + it('updates alarmEmails immediately (optimistic update) and calls API in background', async () => { + ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) - const { store } = renderWithProviders(, preloadedState); + const { store } = renderWithProviders(, preloadedState) - const notificationsTab = screen.getByRole("tab", { - name: /settings.notifications/i, - }); - fireEvent.click(notificationsTab); + const notificationsTab = screen.getByRole('tab', { + name: /settings.notifications/i + }) + fireEvent.click(notificationsTab) - const toggle = screen.getByRole("switch", { - name: /settings.notifications.email/i, - }) as HTMLInputElement; - expect(toggle.checked).toBe(false); + const toggle = screen.getByRole('switch', { + name: /settings.notifications.email/i + }) as HTMLInputElement + expect(toggle.checked).toBe(false) - fireEvent.click(toggle); + fireEvent.click(toggle) // AlarmEmails should be updated immediately (optimistic update) await waitFor(() => { - const state = store.getState(); - expect(state.user?.alarmEmailsEnabled).toBe(true); - }); + const state = store.getState() + expect(state.user?.alarmEmailsEnabled).toBe(true) + }) // API should be called in background await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( - "api/configurations?scope=user", + 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ - name: "calendar", + name: 'calendar', configurations: expect.arrayContaining([ expect.objectContaining({ - name: "alarmEmails", - value: true, - }), - ]), - }), - ]), + name: 'alarmEmails', + value: true + }) + ]) + }) + ]) }) - ); - }); - }); + ) + }) + }) - it("rolls back alarmEmails change if API call fails", async () => { - (api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); + it('rolls back alarmEmails change if API call fails', async () => { + ;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error')) - const { store } = renderWithProviders(, preloadedState); + const { store } = renderWithProviders(, preloadedState) - const notificationsTab = screen.getByRole("tab", { - name: /settings.notifications/i, - }); - fireEvent.click(notificationsTab); + const notificationsTab = screen.getByRole('tab', { + name: /settings.notifications/i + }) + fireEvent.click(notificationsTab) - const toggle = screen.getByRole("switch", { - name: /settings.notifications.email/i, - }) as HTMLInputElement; - expect(toggle.checked).toBe(false); + const toggle = screen.getByRole('switch', { + name: /settings.notifications.email/i + }) as HTMLInputElement + expect(toggle.checked).toBe(false) - fireEvent.click(toggle); + fireEvent.click(toggle) // Wait for rollback - alarmEmails should be rolled back to false after error await waitFor( () => { - const state = store.getState(); - expect(state.user?.alarmEmailsEnabled).toBe(false); + const state = store.getState() + expect(state.user?.alarmEmailsEnabled).toBe(false) }, { timeout: 3000 } - ); - }); + ) + }) - it("sends false value when toggle is turned off", async () => { - (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); + it('sends false value when toggle is turned off', async () => { + ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) const stateWithAlarmEmailsEnabled = { ...preloadedState, user: { ...preloadedState.user, - alarmEmailsEnabled: true, - }, - }; + alarmEmailsEnabled: true + } + } const { store } = renderWithProviders( , stateWithAlarmEmailsEnabled - ); + ) - const notificationsTab = screen.getByRole("tab", { - name: /settings.notifications/i, - }); - fireEvent.click(notificationsTab); + const notificationsTab = screen.getByRole('tab', { + name: /settings.notifications/i + }) + fireEvent.click(notificationsTab) - const toggle = screen.getByRole("switch", { - name: /settings.notifications.email/i, - }) as HTMLInputElement; - expect(toggle.checked).toBe(true); + const toggle = screen.getByRole('switch', { + name: /settings.notifications.email/i + }) as HTMLInputElement + expect(toggle.checked).toBe(true) - fireEvent.click(toggle); + fireEvent.click(toggle) // AlarmEmails should be updated immediately (optimistic update) await waitFor(() => { - const state = store.getState(); - expect(state.user?.alarmEmailsEnabled).toBe(false); - }); + const state = store.getState() + expect(state.user?.alarmEmailsEnabled).toBe(false) + }) // API should be called with false value await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( - "api/configurations?scope=user", + 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ - name: "calendar", + name: 'calendar', configurations: expect.arrayContaining([ expect.objectContaining({ - name: "alarmEmails", - value: false, - }), - ]), - }), - ]), + name: 'alarmEmails', + value: false + }) + ]) + }) + ]) }) - ); - }); - }); - }); - }); -}); + ) + }) + }) + }) + }) +}) diff --git a/__test__/features/Settings/WorkingDays.test.tsx b/__test__/features/Settings/WorkingDays.test.tsx index a1a85ef..69b0a3f 100644 --- a/__test__/features/Settings/WorkingDays.test.tsx +++ b/__test__/features/Settings/WorkingDays.test.tsx @@ -1,467 +1,463 @@ -import SettingsPage from "@/features/Settings/SettingsPage"; -import settingsReducer from "@/features/Settings/SettingsSlice"; +import SettingsPage from '@/features/Settings/SettingsPage' +import settingsReducer from '@/features/Settings/SettingsSlice' import userReducer, { - getOpenPaasUserDataAsync, -} from "@/features/User/userSlice"; -import { api } from "@/utils/apiUtils"; -import { configureStore } from "@reduxjs/toolkit"; -import "@testing-library/jest-dom"; -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; + getOpenPaasUserDataAsync +} from '@/features/User/userSlice' +import { api } from '@/utils/apiUtils' +import { configureStore } from '@reduxjs/toolkit' +import '@testing-library/jest-dom' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../../utils/Renderwithproviders' -jest.mock("@/utils/apiUtils"); +jest.mock('@/utils/apiUtils') const basePreloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, coreConfig: { - language: "en", - datetime: { timeZone: "UTC" }, + language: 'en', + datetime: { timeZone: 'UTC' } }, alarmEmailsEnabled: false, loading: false, - error: null, + error: null }, settings: { - language: "en", - timeZone: "UTC", + language: 'en', + timeZone: 'UTC', isBrowserDefaultTimeZone: false, - view: "settings", + view: 'settings', businessHours: { - start: "8:0", - end: "19:0", - daysOfWeek: [1, 2, 3, 4, 5], + start: '8:0', + end: '19:0', + daysOfWeek: [1, 2, 3, 4, 5] }, - workingDays: false, - }, -}; + workingDays: false + } +} -describe("Working Days and Business Hours Settings", () => { +describe('Working Days and Business Hours Settings', () => { beforeEach(() => { - jest.clearAllMocks(); - (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); - }); + jest.clearAllMocks() + ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) + }) - describe("Initial rendering", () => { - it("renders the working days section with day selector", () => { - renderWithProviders(, basePreloadedState); + describe('Initial rendering', () => { + it('renders the working days section with day selector', () => { + renderWithProviders(, basePreloadedState) + + expect(screen.getByText('settings.chooseWorkingDays')).toBeInTheDocument() + }) + + it('renders the show only working days toggle', () => { + renderWithProviders(, basePreloadedState) expect( - screen.getByText("settings.chooseWorkingDays") - ).toBeInTheDocument(); - }); + screen.getByRole('switch', { name: /settings.showOnlyWorkingDays/i }) + ).toBeInTheDocument() + }) - it("renders the show only working days toggle", () => { - renderWithProviders(, basePreloadedState); + it('shows working days toggle as unchecked when workingDays is false', () => { + renderWithProviders(, basePreloadedState) - expect( - screen.getByRole("switch", { name: /settings.showOnlyWorkingDays/i }) - ).toBeInTheDocument(); - }); + const toggle = screen.getByRole('switch', { + name: /settings.showOnlyWorkingDays/i + }) as HTMLInputElement + expect(toggle.checked).toBe(false) + }) - it("shows working days toggle as unchecked when workingDays is false", () => { - renderWithProviders(, basePreloadedState); - - const toggle = screen.getByRole("switch", { - name: /settings.showOnlyWorkingDays/i, - }) as HTMLInputElement; - expect(toggle.checked).toBe(false); - }); - - it("shows working days toggle as checked when workingDays is true", () => { + it('shows working days toggle as checked when workingDays is true', () => { renderWithProviders(, { ...basePreloadedState, - settings: { ...basePreloadedState.settings, workingDays: true }, - }); + settings: { ...basePreloadedState.settings, workingDays: true } + }) - const toggle = screen.getByRole("switch", { - name: /settings.showOnlyWorkingDays/i, - }) as HTMLInputElement; - expect(toggle.checked).toBe(true); - }); + const toggle = screen.getByRole('switch', { + name: /settings.showOnlyWorkingDays/i + }) as HTMLInputElement + expect(toggle.checked).toBe(true) + }) - it("shows working days toggle as unchecked when workingDays is null", () => { + it('shows working days toggle as unchecked when workingDays is null', () => { renderWithProviders(, { ...basePreloadedState, - settings: { ...basePreloadedState.settings, workingDays: null }, - }); + settings: { ...basePreloadedState.settings, workingDays: null } + }) - const toggle = screen.getByRole("switch", { - name: /settings.showOnlyWorkingDays/i, - }) as HTMLInputElement; - expect(toggle.checked).toBe(false); - }); - }); + const toggle = screen.getByRole('switch', { + name: /settings.showOnlyWorkingDays/i + }) as HTMLInputElement + expect(toggle.checked).toBe(false) + }) + }) - describe("workingDays toggle", () => { - it("updates workingDays immediately (optimistic update) and calls API", async () => { + describe('workingDays toggle', () => { + it('updates workingDays immediately (optimistic update) and calls API', async () => { const { store } = renderWithProviders( , basePreloadedState - ); + ) - const toggle = screen.getByRole("switch", { - name: /settings.showOnlyWorkingDays/i, - }); - fireEvent.click(toggle); + const toggle = screen.getByRole('switch', { + name: /settings.showOnlyWorkingDays/i + }) + fireEvent.click(toggle) await waitFor(() => { - expect(store.getState().settings.workingDays).toBe(true); - }); + expect(store.getState().settings.workingDays).toBe(true) + }) await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( - "api/configurations?scope=user", + 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ - name: "linagora.esn.calendar", + name: 'linagora.esn.calendar', configurations: expect.arrayContaining([ expect.objectContaining({ - name: "workingDays", - value: true, - }), - ]), - }), - ]), + name: 'workingDays', + value: true + }) + ]) + }) + ]) }) - ); - }); - }); + ) + }) + }) - it("rolls back workingDays if API call fails", async () => { - (api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); + it('rolls back workingDays if API call fails', async () => { + ;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error')) const { store } = renderWithProviders( , basePreloadedState - ); + ) - const toggle = screen.getByRole("switch", { - name: /settings.showOnlyWorkingDays/i, - }); - fireEvent.click(toggle); + const toggle = screen.getByRole('switch', { + name: /settings.showOnlyWorkingDays/i + }) + fireEvent.click(toggle) await waitFor( () => { - expect(store.getState().settings.workingDays).toBe(false); + expect(store.getState().settings.workingDays).toBe(false) }, { timeout: 3000 } - ); + ) await waitFor(() => { expect( - screen.getByText("settings.workingDaysUpdateError") - ).toBeInTheDocument(); - }); - }); + screen.getByText('settings.workingDaysUpdateError') + ).toBeInTheDocument() + }) + }) - it("toggles workingDays from true to false", async () => { + it('toggles workingDays from true to false', async () => { const { store } = renderWithProviders(, { ...basePreloadedState, - settings: { ...basePreloadedState.settings, workingDays: true }, - }); + settings: { ...basePreloadedState.settings, workingDays: true } + }) - const toggle = screen.getByRole("switch", { - name: /settings.showOnlyWorkingDays/i, - }); - fireEvent.click(toggle); + const toggle = screen.getByRole('switch', { + name: /settings.showOnlyWorkingDays/i + }) + fireEvent.click(toggle) await waitFor(() => { - expect(store.getState().settings.workingDays).toBe(false); - }); + expect(store.getState().settings.workingDays).toBe(false) + }) await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( - "api/configurations?scope=user", + 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ - name: "linagora.esn.calendar", + name: 'linagora.esn.calendar', configurations: expect.arrayContaining([ expect.objectContaining({ - name: "workingDays", - value: false, - }), - ]), - }), - ]), + name: 'workingDays', + value: false + }) + ]) + }) + ]) }) - ); - }); - }); - }); + ) + }) + }) + }) - describe("businessHours day selector", () => { - it("updates businessHours in Redux immediately when a day is clicked", async () => { + describe('businessHours day selector', () => { + it('updates businessHours in Redux immediately when a day is clicked', async () => { const { store } = renderWithProviders( , basePreloadedState - ); + ) // Sunday (0) is not in initial daysOfWeek [1,2,3,4,5], click it to add - const sundayButton = screen.getByRole("button", { - name: /sunday/i, - }); - fireEvent.click(sundayButton); + const sundayButton = screen.getByRole('button', { + name: /sunday/i + }) + fireEvent.click(sundayButton) await waitFor(() => { - expect(store.getState().settings.businessHours?.daysOfWeek).toContain( - 0 - ); - }); - }); + expect(store.getState().settings.businessHours?.daysOfWeek).toContain(0) + }) + }) - it("removes a day from businessHours when an already selected day is clicked", async () => { + it('removes a day from businessHours when an already selected day is clicked', async () => { const { store } = renderWithProviders( , basePreloadedState - ); + ) // Monday (1) is in initial daysOfWeek, click to remove - const mondayButton = screen.getByRole("button", { name: /monday/i }); - fireEvent.click(mondayButton); + const mondayButton = screen.getByRole('button', { name: /monday/i }) + fireEvent.click(mondayButton) await waitFor(() => { expect( store.getState().settings.businessHours?.daysOfWeek - ).not.toContain(1); - }); - }); + ).not.toContain(1) + }) + }) - it("debounces API call when multiple days are clicked rapidly", async () => { - jest.useFakeTimers(); + it('debounces API call when multiple days are clicked rapidly', async () => { + jest.useFakeTimers() - renderWithProviders(, basePreloadedState); + renderWithProviders(, basePreloadedState) - const sundayButton = screen.getByRole("button", { name: /sunday/i }); - const saturdayButton = screen.getByRole("button", { name: /saturday/i }); + const sundayButton = screen.getByRole('button', { name: /sunday/i }) + const saturdayButton = screen.getByRole('button', { name: /saturday/i }) - fireEvent.click(sundayButton); - fireEvent.click(saturdayButton); - fireEvent.click(sundayButton); + fireEvent.click(sundayButton) + fireEvent.click(saturdayButton) + fireEvent.click(sundayButton) // API should not have been called yet - expect(api.patch).not.toHaveBeenCalled(); + expect(api.patch).not.toHaveBeenCalled() // Advance timers past debounce threshold - jest.runAllTimers(); + jest.runAllTimers() await waitFor(() => { - expect(api.patch).toHaveBeenCalledTimes(1); - }); + expect(api.patch).toHaveBeenCalledTimes(1) + }) - jest.useRealTimers(); - }); + jest.useRealTimers() + }) - it("sends businessHours as array to API", async () => { - jest.useFakeTimers(); + it('sends businessHours as array to API', async () => { + jest.useFakeTimers() - renderWithProviders(, basePreloadedState); + renderWithProviders(, basePreloadedState) - const sundayButton = screen.getByRole("button", { name: /sunday/i }); - fireEvent.click(sundayButton); + const sundayButton = screen.getByRole('button', { name: /sunday/i }) + fireEvent.click(sundayButton) - jest.runAllTimers(); + jest.runAllTimers() await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( - "api/configurations?scope=user", + 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ - name: "core", + name: 'core', configurations: expect.arrayContaining([ expect.objectContaining({ - name: "businessHours", + name: 'businessHours', value: expect.arrayContaining([ expect.objectContaining({ - start: "8:0", - end: "19:0", - daysOfWeek: expect.arrayContaining([0]), - }), - ]), - }), - ]), - }), - ]), + start: '8:0', + end: '19:0', + daysOfWeek: expect.arrayContaining([0]) + }) + ]) + }) + ]) + }) + ]) }) - ); - }); + ) + }) - jest.useRealTimers(); - }); + jest.useRealTimers() + }) - it("rolls back businessHours if API call fails", async () => { - jest.useFakeTimers(); - (api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); + it('rolls back businessHours if API call fails', async () => { + jest.useFakeTimers() + ;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error')) const { store } = renderWithProviders( , basePreloadedState - ); + ) - const sundayButton = screen.getByRole("button", { name: /sunday/i }); - fireEvent.click(sundayButton); + const sundayButton = screen.getByRole('button', { name: /sunday/i }) + fireEvent.click(sundayButton) - jest.runAllTimers(); + jest.runAllTimers() await waitFor( () => { expect( store.getState().settings.businessHours?.daysOfWeek - ).not.toContain(0); + ).not.toContain(0) }, { timeout: 3000 } - ); + ) await waitFor(() => { expect( - screen.getByText("settings.workingDaysUpdateError") - ).toBeInTheDocument(); - }); + screen.getByText('settings.workingDaysUpdateError') + ).toBeInTheDocument() + }) - jest.useRealTimers(); - }); - }); + jest.useRealTimers() + }) + }) - describe("getOpenPaasUserDataAsync integration", () => { - it("populates businessHours from core module configurations", async () => { + describe('getOpenPaasUserDataAsync integration', () => { + it('populates businessHours from core module configurations', async () => { const store = configureStore({ reducer: { user: userReducer, settings: settingsReducer }, preloadedState: { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, - coreConfig: { language: "en", datetime: { timeZone: "UTC" } }, + coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } }, alarmEmailsEnabled: null, loading: false, - error: null, + error: null }, settings: { - language: "en", - timeZone: "UTC", + language: 'en', + timeZone: 'UTC', isBrowserDefaultTimeZone: false, - view: "calendar", + view: 'calendar', businessHours: null, - workingDays: null, - }, - }, - }); + workingDays: null + } + } + }) const mockResponse = { - id: "667037022b752d0026472254", - firstname: "John", - lastname: "Doe", - preferredEmail: "test@test.com", + id: '667037022b752d0026472254', + firstname: 'John', + lastname: 'Doe', + preferredEmail: 'test@test.com', configurations: { modules: [ { - name: "core", + name: 'core', configurations: [ { - name: "businessHours", + name: 'businessHours', value: [ - { start: "8:0", end: "19:0", daysOfWeek: [1, 2, 3, 4, 5] }, - ], - }, - ], + { start: '8:0', end: '19:0', daysOfWeek: [1, 2, 3, 4, 5] } + ] + } + ] }, { - name: "linagora.esn.calendar", - configurations: [{ name: "workingDays", value: true }], - }, - ], - }, - }; + name: 'linagora.esn.calendar', + configurations: [{ name: 'workingDays', value: true }] + } + ] + } + } await store.dispatch( - getOpenPaasUserDataAsync.fulfilled(mockResponse, "", undefined) - ); + getOpenPaasUserDataAsync.fulfilled(mockResponse, '', undefined) + ) - const state = store.getState(); + const state = store.getState() expect(state.settings.businessHours).toEqual({ - start: "8:0", - end: "19:0", - daysOfWeek: [1, 2, 3, 4, 5], - }); - expect(state.settings.workingDays).toBe(true); - }); + start: '8:0', + end: '19:0', + daysOfWeek: [1, 2, 3, 4, 5] + }) + expect(state.settings.workingDays).toBe(true) + }) - it("sets businessHours to null when not present in API response", async () => { + it('sets businessHours to null when not present in API response', async () => { const store = configureStore({ reducer: { user: userReducer, settings: settingsReducer }, preloadedState: { user: { userData: { - sub: "test", - email: "test@test.com", - family_name: "Doe", - name: "John", - sid: "mockSid", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + name: 'John', + sid: 'mockSid', + openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, - coreConfig: { language: "en", datetime: { timeZone: "UTC" } }, + coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } }, alarmEmailsEnabled: null, loading: false, - error: null, + error: null }, settings: { - language: "en", - timeZone: "UTC", + language: 'en', + timeZone: 'UTC', isBrowserDefaultTimeZone: false, - view: "calendar", + view: 'calendar', businessHours: null, - workingDays: null, - }, - }, - }); + workingDays: null + } + } + }) const mockResponse = { - id: "667037022b752d0026472254", - firstname: "John", - lastname: "Doe", - preferredEmail: "test@test.com", + id: '667037022b752d0026472254', + firstname: 'John', + lastname: 'Doe', + preferredEmail: 'test@test.com', configurations: { modules: [ { - name: "core", - configurations: [], + name: 'core', + configurations: [] }, { - name: "linagora.esn.calendar", - configurations: [{ name: "workingDays", value: null }], - }, - ], - }, - }; + name: 'linagora.esn.calendar', + configurations: [{ name: 'workingDays', value: null }] + } + ] + } + } await store.dispatch( - getOpenPaasUserDataAsync.fulfilled(mockResponse, "", undefined) - ); + getOpenPaasUserDataAsync.fulfilled(mockResponse, '', undefined) + ) - const state = store.getState(); - expect(state.settings.businessHours).toBeNull(); - expect(state.settings.workingDays).toBeNull(); - }); - }); -}); + const state = store.getState() + expect(state.settings.businessHours).toBeNull() + expect(state.settings.workingDays).toBeNull() + }) + }) +}) diff --git a/__test__/features/user/HandleLogin.test.tsx b/__test__/features/user/HandleLogin.test.tsx index b55e46b..ac42baf 100644 --- a/__test__/features/user/HandleLogin.test.tsx +++ b/__test__/features/user/HandleLogin.test.tsx @@ -1,45 +1,45 @@ -import * as appHooks from "@/app/hooks"; -import { AppDispatch, setupStore } from "@/app/store"; -import HandleLogin from "@/features/User/HandleLogin"; -import * as oidcAuth from "@/features/User/oidcAuth"; -import { clientConfig } from "@/features/User/oidcAuth"; -import { useInitializeApp } from "@/features/User/useInitializeApp"; -import * as apiUtils from "@/utils/apiUtils"; -import { renderHook, screen, waitFor } from "@testing-library/react"; -import { Provider } from "react-redux"; -import { push } from "redux-first-history"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; +import * as appHooks from '@/app/hooks' +import { AppDispatch, setupStore } from '@/app/store' +import HandleLogin from '@/features/User/HandleLogin' +import * as oidcAuth from '@/features/User/oidcAuth' +import { clientConfig } from '@/features/User/oidcAuth' +import { useInitializeApp } from '@/features/User/useInitializeApp' +import * as apiUtils from '@/utils/apiUtils' +import { renderHook, screen, waitFor } from '@testing-library/react' +import { Provider } from 'react-redux' +import { push } from 'redux-first-history' +import { renderWithProviders } from '../../utils/Renderwithproviders' -clientConfig.url = "https://example.com"; +clientConfig.url = 'https://example.com' -describe("HandleLogin", () => { +describe('HandleLogin', () => { beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(apiUtils, "redirectTo").mockImplementation(() => {}); - const dispatch = jest.fn() as AppDispatch; - jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch); - sessionStorage.clear(); - Object.defineProperty(window, "matchMedia", { + jest.clearAllMocks() + jest.spyOn(apiUtils, 'redirectTo').mockImplementation(() => {}) + const dispatch = jest.fn() as AppDispatch + jest.spyOn(appHooks, 'useAppDispatch').mockReturnValue(dispatch) + sessionStorage.clear() + Object.defineProperty(window, 'matchMedia', { writable: true, - value: jest.fn().mockImplementation((query) => ({ + value: jest.fn().mockImplementation(query => ({ matches: false, media: query, onchange: null, addEventListener: jest.fn(), removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), - }); - }); + dispatchEvent: jest.fn() + })) + }) + }) - test("redirects and sets sessionStorage when no userData", async () => { + test('redirects and sets sessionStorage when no userData', async () => { const loginUrlMock = { - code_verifier: "verifier123", - state: "state123", - redirectTo: new URL("http://login.url"), - }; + code_verifier: 'verifier123', + state: 'state123', + redirectTo: new URL('http://login.url') + } - jest.spyOn(oidcAuth, "Auth").mockResolvedValue(loginUrlMock); + jest.spyOn(oidcAuth, 'Auth').mockResolvedValue(loginUrlMock) const { result } = renderHook(() => useInitializeApp(), { wrapper: ({ children }) => ( @@ -50,118 +50,118 @@ describe("HandleLogin", () => { tokens: null, loading: false, error: null, - coreConfig: { language: "en" }, + coreConfig: { language: 'en' } }, - calendars: { list: {}, pending: false, error: null }, + calendars: { list: {}, pending: false, error: null } })} > {children} - ), - }); + ) + }) await waitFor( () => { - expect(oidcAuth.Auth).toHaveBeenCalled(); + expect(oidcAuth.Auth).toHaveBeenCalled() }, { timeout: 3000 } - ); + ) await waitFor( () => { - expect(sessionStorage.getItem("redirectState")).toEqual( + expect(sessionStorage.getItem('redirectState')).toEqual( JSON.stringify({ - code_verifier: "verifier123", - state: "state123", + code_verifier: 'verifier123', + state: 'state123' }) - ); + ) }, { timeout: 3000 } - ); + ) await waitFor( () => { expect(apiUtils.redirectTo).toHaveBeenCalledWith( loginUrlMock.redirectTo - ); + ) }, { timeout: 3000 } - ); - }); + ) + }) - test("does not render loading element when userData exists and calendars pending is true", () => { + test('does not render loading element when userData exists and calendars pending is true', () => { const preloadedState = { user: { userData: { - sub: "test", - email: "test@test.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "667037022b752d0026472254", + sub: 'test', + email: 'test@test.com', + sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro', + openpaasId: '667037022b752d0026472254' }, - tokens: { access_token: "test" }, - loading: false, + tokens: { access_token: 'test' }, + loading: false }, calendars: { list: {}, pending: true }, - loading: { isLoading: true }, - }; + loading: { isLoading: true } + } - renderWithProviders(, preloadedState); + renderWithProviders(, preloadedState) // HandleLogin now returns null, loading is shown at App level via appLoading state - expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); - }); - test("does not render loading element when userData exists and calendars pending is false", () => { + expect(screen.queryByTestId('loading')).not.toBeInTheDocument() + }) + test('does not render loading element when userData exists and calendars pending is false', () => { const preloadedState = { user: { userData: { - sub: "cmoussu", - email: "cmoussu@linagora.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "667037022b752d0026472254", + sub: 'cmoussu', + email: 'cmoussu@linagora.com', + sid: 'aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro', + openpaasId: '667037022b752d0026472254' }, - tokens: { access_token: "test" }, - loading: false, + tokens: { access_token: 'test' }, + loading: false }, calendars: { list: {}, pending: false }, - loading: { isLoading: false }, - }; - renderWithProviders(, preloadedState); + loading: { isLoading: false } + } + renderWithProviders(, preloadedState) // HandleLogin now returns null, loading is shown at App level via appLoading state - expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); - }); - test("goes to error page when there is error in user data", async () => { - const mockDispatch = jest.fn(); - jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(mockDispatch); - jest.spyOn(oidcAuth, "Auth").mockResolvedValue({ - code_verifier: "verifier123", - state: "state123", - redirectTo: new URL("http://login.url"), - }); + expect(screen.queryByTestId('loading')).not.toBeInTheDocument() + }) + test('goes to error page when there is error in user data', async () => { + const mockDispatch = jest.fn() + jest.spyOn(appHooks, 'useAppDispatch').mockReturnValue(mockDispatch) + jest.spyOn(oidcAuth, 'Auth').mockResolvedValue({ + code_verifier: 'verifier123', + state: 'state123', + redirectTo: new URL('http://login.url') + }) renderWithProviders(, { user: { error: true, loading: false, userData: { - sub: "test", - email: "test@test.com", - sid: "testSid", - openpaasId: "testId", + sub: 'test', + email: 'test@test.com', + sid: 'testSid', + openpaasId: 'testId' }, - tokens: { access_token: "test" }, + tokens: { access_token: 'test' } }, calendars: { list: {}, pending: false, - error: null, - }, - }); + error: null + } + }) await waitFor( () => { - expect(mockDispatch).toHaveBeenCalledWith(push("/error")); + expect(mockDispatch).toHaveBeenCalledWith(push('/error')) }, { timeout: 3000 } - ); - }); -}); + ) + }) +}) diff --git a/__test__/features/user/LoginCallback.test.tsx b/__test__/features/user/LoginCallback.test.tsx index 97f41a2..fb43ea3 100644 --- a/__test__/features/user/LoginCallback.test.tsx +++ b/__test__/features/user/LoginCallback.test.tsx @@ -1,183 +1,182 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { getCalendarsListAsync } from "@/features/Calendars/services/getCalendarsListAsync"; -import { CallbackResume } from "@/features/User/LoginCallback"; -import * as oidcAuth from "@/features/User/oidcAuth"; +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { getCalendarsListAsync } from '@/features/Calendars/services/getCalendarsListAsync' +import { CallbackResume } from '@/features/User/LoginCallback' +import * as oidcAuth from '@/features/User/oidcAuth' import { getOpenPaasUserDataAsync, setTokens, - setUserData, -} from "@/features/User/userSlice"; -import { render, waitFor } from "@testing-library/react"; -import { replace } from "redux-first-history"; -import { renderWithProviders } from "../../utils/Renderwithproviders"; -import { setAppLoading } from "@/app/loadingSlice"; + setUserData +} from '@/features/User/userSlice' +import { render, waitFor } from '@testing-library/react' +import { replace } from 'redux-first-history' +import { renderWithProviders } from '../../utils/Renderwithproviders' +import { setAppLoading } from '@/app/loadingSlice' // Mocks -jest.mock("@/app/hooks", () => ({ +jest.mock('@/app/hooks', () => ({ useAppDispatch: jest.fn(), useAppSelector: jest.fn(() => ({ user: { userData: null, tokens: null, loading: false, - error: null, + error: null }, calendars: { list: {}, pending: false, - error: null, - }, - })), -})); + error: null + } + })) +})) -jest.mock("@/features/User/oidcAuth", () => ({ - Callback: jest.fn(), -})); +jest.mock('@/features/User/oidcAuth', () => ({ + Callback: jest.fn() +})) -jest.mock("@/features/User/userSlice", () => { +jest.mock('@/features/User/userSlice', () => { const mockGetUser = Object.assign( - jest.fn(() => ({ type: "GET_USER_ID" })), + jest.fn(() => ({ type: 'GET_USER_ID' })), { - pending: { type: "GET_USER_ID/pending" }, - fulfilled: { type: "GET_USER_ID/fulfilled" }, - rejected: { type: "GET_USER_ID/rejected" }, + pending: { type: 'GET_USER_ID/pending' }, + fulfilled: { type: 'GET_USER_ID/fulfilled' }, + rejected: { type: 'GET_USER_ID/rejected' } } - ); + ) return { - setUserData: jest.fn((data) => ({ type: "SET_USER", payload: data })), - setTokens: jest.fn((tokens) => ({ type: "SET_TOKENS", payload: tokens })), - getOpenPaasUserDataAsync: mockGetUser, - }; -}); + setUserData: jest.fn(data => ({ type: 'SET_USER', payload: data })), + setTokens: jest.fn(tokens => ({ type: 'SET_TOKENS', payload: tokens })), + getOpenPaasUserDataAsync: mockGetUser + } +}) -jest.mock("@/features/Calendars/services/getCalendarsListAsync", () => { +jest.mock('@/features/Calendars/services/getCalendarsListAsync', () => { const mockGetCalendars = Object.assign( - jest.fn(() => ({ type: "GET_CALENDARS" })), + jest.fn(() => ({ type: 'GET_CALENDARS' })), { - pending: { type: "GET_CALENDARS/pending" }, - fulfilled: { type: "GET_CALENDARS/fulfilled" }, - rejected: { type: "GET_CALENDARS/rejected" }, + pending: { type: 'GET_CALENDARS/pending' }, + fulfilled: { type: 'GET_CALENDARS/fulfilled' }, + rejected: { type: 'GET_CALENDARS/rejected' } } - ); + ) return { - getCalendarsListAsync: mockGetCalendars, - }; -}); + getCalendarsListAsync: mockGetCalendars + } +}) -describe("CallbackResume", () => { - const dispatch = jest.fn(); - let mockUserState: any; - let mockCalendarsState: any; +describe('CallbackResume', () => { + const dispatch = jest.fn() + let mockUserState: any + let mockCalendarsState: any beforeEach(() => { - jest.clearAllMocks(); - (useAppDispatch as unknown as jest.Mock).mockReturnValue(dispatch); + jest.clearAllMocks() + ;(useAppDispatch as unknown as jest.Mock).mockReturnValue(dispatch) // Initialize mock states mockUserState = { userData: null, tokens: null, loading: false, - error: null, - }; + error: null + } mockCalendarsState = { list: {}, pending: false, - error: null, - }; - - (useAppSelector as jest.Mock).mockImplementation((selector) => { + error: null + } + ;(useAppSelector as jest.Mock).mockImplementation(selector => { const state = { user: mockUserState, - calendars: mockCalendarsState, - }; - return selector(state); - }); - }); + calendars: mockCalendarsState + } + return selector(state) + }) + }) - it("should call Callback and dispatch necessary actions", async () => { - const mockTokenSet = { access_token: "abc" }; - const mockUserInfo = { name: "Test User" }; + it('should call Callback and dispatch necessary actions', async () => { + const mockTokenSet = { access_token: 'abc' } + const mockUserInfo = { name: 'Test User' } const mockData = { tokenSet: mockTokenSet, - userinfo: mockUserInfo, - }; + userinfo: mockUserInfo + } - (oidcAuth.Callback as jest.Mock).mockResolvedValue(mockData); + ;(oidcAuth.Callback as jest.Mock).mockResolvedValue(mockData) sessionStorage.setItem( - "redirectState", - JSON.stringify({ code_verifier: "verifier123", state: "state456" }) - ); + 'redirectState', + JSON.stringify({ code_verifier: 'verifier123', state: 'state456' }) + ) - const { rerender } = render(); + const { rerender } = render() await waitFor(() => { - expect(oidcAuth.Callback).toHaveBeenCalledWith("verifier123", "state456"); - }); + expect(oidcAuth.Callback).toHaveBeenCalledWith('verifier123', 'state456') + }) await waitFor(() => { - expect(dispatch).toHaveBeenCalledWith(setAppLoading(true)); - }); + expect(dispatch).toHaveBeenCalledWith(setAppLoading(true)) + }) await waitFor(() => { - expect(dispatch).toHaveBeenCalledWith(setUserData(mockUserInfo)); - }); + expect(dispatch).toHaveBeenCalledWith(setUserData(mockUserInfo)) + }) await waitFor(() => { - expect(dispatch).toHaveBeenCalledWith(setTokens(mockTokenSet)); - }); + expect(dispatch).toHaveBeenCalledWith(setTokens(mockTokenSet)) + }) await waitFor(() => { - expect(dispatch).toHaveBeenCalledWith(getOpenPaasUserDataAsync()); - }); + expect(dispatch).toHaveBeenCalledWith(getOpenPaasUserDataAsync()) + }) await waitFor(() => { - expect(dispatch).toHaveBeenCalledWith(getCalendarsListAsync()); - }); + expect(dispatch).toHaveBeenCalledWith(getCalendarsListAsync()) + }) // Simulate async actions completing by updating mock state mockUserState = { userData: mockUserInfo, tokens: mockTokenSet, loading: false, - error: null, - }; + error: null + } mockCalendarsState = { list: { calendar1: {} }, pending: false, - error: null, - }; + error: null + } // Re-render to trigger navigation effect - rerender(); + rerender() await waitFor( () => { - expect(dispatch).toHaveBeenCalledWith(setAppLoading(false)); + expect(dispatch).toHaveBeenCalledWith(setAppLoading(false)) }, { timeout: 3000 } - ); + ) await waitFor( () => { - expect(dispatch).toHaveBeenCalledWith(replace("/calendar")); + expect(dispatch).toHaveBeenCalledWith(replace('/calendar')) }, { timeout: 3000 } - ); + ) await waitFor(() => { - expect(sessionStorage.getItem("redirectState")).toBe(null); - }); + expect(sessionStorage.getItem('redirectState')).toBe(null) + }) await waitFor(() => { - expect(sessionStorage.getItem("tokenSet")).toEqual( + expect(sessionStorage.getItem('tokenSet')).toEqual( JSON.stringify(mockTokenSet) - ); - }); - }); + ) + }) + }) - it("should handle missing redirectState gracefully", async () => { - sessionStorage.removeItem("redirectState"); - renderWithProviders(); + it('should handle missing redirectState gracefully', async () => { + sessionStorage.removeItem('redirectState') + renderWithProviders() await waitFor(() => { - expect(dispatch).toHaveBeenCalledWith(replace("/")); - }); - }); -}); + expect(dispatch).toHaveBeenCalledWith(replace('/')) + }) + }) +}) diff --git a/__test__/features/user/oidcAuth.test.tsx b/__test__/features/user/oidcAuth.test.tsx index 4a91461..143df46 100644 --- a/__test__/features/user/oidcAuth.test.tsx +++ b/__test__/features/user/oidcAuth.test.tsx @@ -4,169 +4,168 @@ import { Callback, clientConfig, getClientConfig, - Logout, -} from "@/features/User/oidcAuth"; -import * as apiUtils from "@/utils/apiUtils"; -import * as client from "openid-client"; + Logout +} from '@/features/User/oidcAuth' +import * as apiUtils from '@/utils/apiUtils' +import * as client from 'openid-client' -clientConfig.url = "https://example.com"; -const localAdress = "https://local.exemple.com"; +clientConfig.url = 'https://example.com' +const localAdress = 'https://local.exemple.com' -describe("OpenID Client Auth Module", () => { +describe('OpenID Client Auth Module', () => { beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(apiUtils, "getLocation").mockImplementation(() => localAdress); - }); + jest.clearAllMocks() + jest.spyOn(apiUtils, 'getLocation').mockImplementation(() => localAdress) + }) - describe("getClientConfig", () => { - it("should call discovery with clientConfig.url", async () => { - const discoveryMock = client.discovery as jest.Mock; - discoveryMock.mockResolvedValue("discoveredClient"); + describe('getClientConfig', () => { + it('should call discovery with clientConfig.url', async () => { + const discoveryMock = client.discovery as jest.Mock + discoveryMock.mockResolvedValue('discoveredClient') - const result = await getClientConfig(); + const result = await getClientConfig() expect(discoveryMock).toHaveBeenCalledWith( new URL(clientConfig.url), clientConfig.client_id - ); - expect(result).toBe("discoveredClient"); - }); - }); + ) + expect(result).toBe('discoveredClient') + }) + }) - describe("Auth", () => { - it("should generate PKCE and build authorization URL with PKCE", async () => { - (client.randomPKCECodeVerifier as jest.Mock).mockReturnValue( - "verifier123" - ); - (client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue( - "challenge123" - ); + describe('Auth', () => { + it('should generate PKCE and build authorization URL with PKCE', async () => { + ;(client.randomPKCECodeVerifier as jest.Mock).mockReturnValue( + 'verifier123' + ) + ;(client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue( + 'challenge123' + ) // Mock discovery returning an object with serverMetadata() const discoveredClient = { serverMetadata: jest.fn(() => ({ - supportsPKCE: () => true, - })), - }; - (client.discovery as jest.Mock).mockResolvedValue(discoveredClient); + supportsPKCE: () => true + })) + } + ;(client.discovery as jest.Mock).mockResolvedValue(discoveredClient) + ;(client.buildAuthorizationUrl as jest.Mock).mockReturnValue( + 'https://auth.url' + ) - (client.buildAuthorizationUrl as jest.Mock).mockReturnValue( - "https://auth.url" - ); + const result = await Auth() - const result = await Auth(); - - expect(client.randomPKCECodeVerifier).toHaveBeenCalled(); + expect(client.randomPKCECodeVerifier).toHaveBeenCalled() expect(client.calculatePKCECodeChallenge).toHaveBeenCalledWith( - "verifier123" - ); + 'verifier123' + ) expect(client.buildAuthorizationUrl).toHaveBeenCalledWith( discoveredClient, expect.objectContaining({ - code_challenge: "challenge123", + code_challenge: 'challenge123', code_challenge_method: clientConfig.code_challenge_method, redirect_uri: clientConfig.redirect_uri, - scope: clientConfig.scope, + scope: clientConfig.scope }) - ); + ) expect(result).toEqual({ - redirectTo: "https://auth.url", - code_verifier: "verifier123", - state: undefined, - }); - }); + redirectTo: 'https://auth.url', + code_verifier: 'verifier123', + state: undefined + }) + }) - it("should generate state when PKCE not supported", async () => { - (client.randomPKCECodeVerifier as jest.Mock).mockReturnValue( - "verifier123" - ); - (client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue( - "challenge123" - ); + it('should generate state when PKCE not supported', async () => { + ;(client.randomPKCECodeVerifier as jest.Mock).mockReturnValue( + 'verifier123' + ) + ;(client.calculatePKCECodeChallenge as jest.Mock).mockResolvedValue( + 'challenge123' + ) const discoveredClient = { serverMetadata: jest.fn(() => ({ - supportsPKCE: () => false, - })), - }; - (client.discovery as jest.Mock).mockResolvedValue(discoveredClient); - (client.randomState as jest.Mock).mockReturnValue("state123"); - (client.buildAuthorizationUrl as jest.Mock).mockReturnValue( - "https://auth.url" - ); + supportsPKCE: () => false + })) + } + ;(client.discovery as jest.Mock).mockResolvedValue(discoveredClient) + ;(client.randomState as jest.Mock).mockReturnValue('state123') + ;(client.buildAuthorizationUrl as jest.Mock).mockReturnValue( + 'https://auth.url' + ) - const result = await Auth(); + const result = await Auth() - expect(client.randomState).toHaveBeenCalled(); - expect(result.state).toBe("state123"); - expect(result.redirectTo).toBe("https://auth.url"); - }); - }); + expect(client.randomState).toHaveBeenCalled() + expect(result.state).toBe('state123') + expect(result.redirectTo).toBe('https://auth.url') + }) + }) - describe("Logout", () => { - it("should build end session URL", async () => { - const discoveredClient = {}; - (client.discovery as jest.Mock).mockResolvedValue(discoveredClient); - (client.buildEndSessionUrl as jest.Mock).mockReturnValue( - "https://logout.url" - ); + describe('Logout', () => { + it('should build end session URL', async () => { + const discoveredClient = {} + ;(client.discovery as jest.Mock).mockResolvedValue(discoveredClient) + ;(client.buildEndSessionUrl as jest.Mock).mockReturnValue( + 'https://logout.url' + ) - const result = await Logout(); + const result = await Logout() expect(client.buildEndSessionUrl).toHaveBeenCalledWith(discoveredClient, { - post_logout_redirect_uri: clientConfig.post_logout_redirect_uri, - }); - expect(result).toBe("https://logout.url"); - }); - }); + post_logout_redirect_uri: clientConfig.post_logout_redirect_uri + }) + expect(result).toBe('https://logout.url') + }) + }) - describe("Callback", () => { - it("should perform authorization code grant and fetch user info", async () => { - const discoveredClient = {}; - (client.discovery as jest.Mock).mockResolvedValue(discoveredClient); + describe('Callback', () => { + it('should perform authorization code grant and fetch user info', async () => { + const discoveredClient = {} + ;(client.discovery as jest.Mock).mockResolvedValue(discoveredClient) const mockTokenSet = { - access_token: "access123", - claims: jest.fn(() => ({ sub: "user123" })), - }; - (client.authorizationCodeGrant as jest.Mock).mockResolvedValue( + access_token: 'access123', + claims: jest.fn(() => ({ sub: 'user123' })) + } + ;(client.authorizationCodeGrant as jest.Mock).mockResolvedValue( mockTokenSet - ); - (client.fetchUserInfo as jest.Mock).mockResolvedValue({ name: "User" }); - const result = await Callback("verifier123", "state123"); + ) + ;(client.fetchUserInfo as jest.Mock).mockResolvedValue({ name: 'User' }) + const result = await Callback('verifier123', 'state123') expect(client.authorizationCodeGrant).toHaveBeenCalledWith( discoveredClient, new URL(localAdress), - { pkceCodeVerifier: "verifier123", expectedState: "state123" } - ); + { pkceCodeVerifier: 'verifier123', expectedState: 'state123' } + ) expect(client.fetchUserInfo).toHaveBeenCalledWith( discoveredClient, - "access123", - "user123" - ); + 'access123', + 'user123' + ) expect(result).toEqual({ tokenSet: mockTokenSet, - userinfo: { name: "User" }, - }); - }); + userinfo: { name: 'User' } + }) + }) - it("should catch and log errors", async () => { - const error = new Error("fail"); - (client.discovery as jest.Mock).mockResolvedValue({}); - (client.authorizationCodeGrant as jest.Mock).mockRejectedValue(error); + it('should catch and log errors', async () => { + const error = new Error('fail') + ;(client.discovery as jest.Mock).mockResolvedValue({}) + ;(client.authorizationCodeGrant as jest.Mock).mockRejectedValue(error) const consoleErrorSpy = jest - .spyOn(console, "error") - .mockImplementation(() => {}); + .spyOn(console, 'error') + .mockImplementation(() => {}) - const result = await Callback("verifier", "state"); + const result = await Callback('verifier', 'state') - expect(consoleErrorSpy).toHaveBeenCalledWith("Token grant error:", error); - expect(result).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalledWith('Token grant error:', error) + expect(result).toBeUndefined() - consoleErrorSpy.mockRestore(); - }); - }); -}); + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/__test__/features/user/userAPI.test.tsx b/__test__/features/user/userAPI.test.tsx index 6dcfe9a..85e51b6 100644 --- a/__test__/features/user/userAPI.test.tsx +++ b/__test__/features/user/userAPI.test.tsx @@ -1,114 +1,114 @@ -import { clientConfig } from "@/features/User/oidcAuth"; +import { clientConfig } from '@/features/User/oidcAuth' import { getOpenPaasUser, updateUserConfigurations, getResourceDetails, - getUserDetails, -} from "@/features/User/userAPI"; -import { api } from "@/utils/apiUtils"; + getUserDetails +} from '@/features/User/userAPI' +import { api } from '@/utils/apiUtils' -jest.mock("@/utils/apiUtils"); +jest.mock('@/utils/apiUtils') -clientConfig.url = "https://example.com"; +clientConfig.url = 'https://example.com' -describe("getOpenPaasUser", () => { - it("should fetch and return user data", async () => { - const mockUser = { id: "123", name: "OpenPaas User" }; +describe('getOpenPaasUser', () => { + it('should fetch and return user data', async () => { + const mockUser = { id: '123', name: 'OpenPaas User' } - (api.get as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue(mockUser), - }); + ;(api.get as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockUser) + }) - const result = await getOpenPaasUser(); + const result = await getOpenPaasUser() - expect(api.get).toHaveBeenCalledWith("api/user"); - expect(result).toEqual(mockUser); - }); -}); + expect(api.get).toHaveBeenCalledWith('api/user') + expect(result).toEqual(mockUser) + }) +}) -describe("getUserDetails", () => { - it("should fetch and return user details", async () => { +describe('getUserDetails', () => { + it('should fetch and return user details', async () => { const mockUser = { - firstname: "John", - lastname: "Doe", - emails: ["john@test.com"], - }; - const userId = "123"; + firstname: 'John', + lastname: 'Doe', + emails: ['john@test.com'] + } + const userId = '123' - (api.get as jest.Mock).mockReturnValue({ - json: jest.fn().mockResolvedValue(mockUser), - }); + ;(api.get as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockUser) + }) - const result = await getUserDetails(userId); + const result = await getUserDetails(userId) - expect(api.get).toHaveBeenCalledWith(`api/users/${userId}`); - expect(result).toEqual(mockUser); - }); -}); + expect(api.get).toHaveBeenCalledWith(`api/users/${userId}`) + expect(result).toEqual(mockUser) + }) +}) -describe("getResourceDetails", () => { - it("should fetch and return resource details", async () => { - const mockResource = { _id: "res-123", name: "Meeting Room A" }; - const resourceId = "res-123"; +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), - }); + ;(api.get as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResource) + }) - const result = await getResourceDetails(resourceId); + const result = await getResourceDetails(resourceId) - expect(api.get).toHaveBeenCalledWith(`api/resources/${resourceId}`); - expect(result).toEqual(mockResource); - }); -}); + expect(api.get).toHaveBeenCalledWith(`api/resources/${resourceId}`) + expect(result).toEqual(mockResource) + }) +}) -describe("updateUserConfigurations", () => { +describe('updateUserConfigurations', () => { beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - it("should PATCH configurations with language update", async () => { - const mockResponse = { status: 204 }; - (api.patch as jest.Mock).mockResolvedValue(mockResponse); + it('should PATCH configurations with language update', async () => { + const mockResponse = { status: 204 } + ;(api.patch as jest.Mock).mockResolvedValue(mockResponse) - await updateUserConfigurations({ language: "vi" }); + await updateUserConfigurations({ language: 'vi' }) - expect(api.patch).toHaveBeenCalledWith("api/configurations?scope=user", { + expect(api.patch).toHaveBeenCalledWith('api/configurations?scope=user', { json: [ { - name: "core", - configurations: [{ name: "language", value: "vi" }], - }, - ], - }); - }); + name: 'core', + configurations: [{ name: 'language', value: 'vi' }] + } + ] + }) + }) - it("should PATCH configurations with multiple updates", async () => { - const mockResponse = { status: 204 }; - (api.patch as jest.Mock).mockResolvedValue(mockResponse); + it('should PATCH configurations with multiple updates', async () => { + const mockResponse = { status: 204 } + ;(api.patch as jest.Mock).mockResolvedValue(mockResponse) await updateUserConfigurations({ - language: "fr", - timezone: "Europe/Paris", - }); + language: 'fr', + timezone: 'Europe/Paris' + }) - expect(api.patch).toHaveBeenCalledWith("api/configurations?scope=user", { + expect(api.patch).toHaveBeenCalledWith('api/configurations?scope=user', { json: [ { - name: "core", + name: 'core', configurations: [ - { name: "language", value: "fr" }, - { name: "datetime", value: { timeZone: "Europe/Paris" } }, - ], - }, - ], - }); - }); + { name: 'language', value: 'fr' }, + { name: 'datetime', value: { timeZone: 'Europe/Paris' } } + ] + } + ] + }) + }) - it("should handle empty updates without calling API", async () => { - const result = await updateUserConfigurations({}); + it('should handle empty updates without calling API', async () => { + const result = await updateUserConfigurations({}) - expect(api.patch).not.toHaveBeenCalled(); - expect(result).toEqual({ status: 204 }); - }); -}); + expect(api.patch).not.toHaveBeenCalled() + expect(result).toEqual({ status: 204 }) + }) +}) diff --git a/__test__/features/websocket/WebSocketGate.test.tsx b/__test__/features/websocket/WebSocketGate.test.tsx index ed591d0..14a3b1e 100644 --- a/__test__/features/websocket/WebSocketGate.test.tsx +++ b/__test__/features/websocket/WebSocketGate.test.tsx @@ -1,58 +1,58 @@ -import { setSelectedCalendars } from "@/utils/storage/setSelectedCalendars"; -import { createWebSocketConnection } from "@/websocket/connection/createConnection"; -import { registerToCalendars } from "@/websocket/operations/registerToCalendars"; -import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars"; -import { WebSocketGate } from "@/websocket/WebSocketGate"; -import { configureStore, Store } from "@reduxjs/toolkit"; -import { act, cleanup, render, waitFor } from "@testing-library/react"; -import { Provider } from "react-redux"; -import { I18nContext } from "twake-i18n"; +import { setSelectedCalendars } from '@/utils/storage/setSelectedCalendars' +import { createWebSocketConnection } from '@/websocket/connection/createConnection' +import { registerToCalendars } from '@/websocket/operations/registerToCalendars' +import { unregisterToCalendars } from '@/websocket/operations/unregisterToCalendars' +import { WebSocketGate } from '@/websocket/WebSocketGate' +import { configureStore, Store } from '@reduxjs/toolkit' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { Provider } from 'react-redux' +import { I18nContext } from 'twake-i18n' -jest.mock("@/websocket/connection/createConnection"); -jest.mock("@/websocket/operations/registerToCalendars"); -jest.mock("@/websocket/operations/unregisterToCalendars"); -jest.mock("@/websocket/connection/lifecycle/pingWebSocket"); +jest.mock('@/websocket/connection/createConnection') +jest.mock('@/websocket/operations/registerToCalendars') +jest.mock('@/websocket/operations/unregisterToCalendars') +jest.mock('@/websocket/connection/lifecycle/pingWebSocket') function TestWrapper({ store }: { store: Store }) { return ( ) => { - if (key === "locale") return "en"; + if (key === 'locale') return 'en' if (vars) { const params = Object.entries(vars) .map(([k, v]) => `${k}=${v}`) - .join(","); - return `${key}(${params})`; + .join(',') + return `${key}(${params})` } - return key; + return key }, f: (date: Date) => date.toString(), - lang: "en", + lang: 'en' }} > - ); + ) } -describe("WebSocketGate", () => { - let store: any; - let mockSocket: any; +describe('WebSocketGate', () => { + let store: any + let mockSocket: any const createMockStore = ( - userData: any = { id: "1" }, - tokens: any = { access: "token" } + userData: any = { id: '1' }, + tokens: any = { access: 'token' } ) => { return configureStore({ reducer: { user: (state = { userData, tokens }) => state, - calendars: (state = { list: {} }) => state, - }, - }); - }; + calendars: (state = { list: {} }) => state + } + }) + } const createMockSocket = (readyState = WebSocket.OPEN) => ({ readyState, @@ -61,948 +61,934 @@ describe("WebSocketGate", () => { addEventListener: jest.fn(), removeEventListener: jest.fn(), cleanup: jest.fn(), - onmessage: null, - }); + onmessage: null + }) beforeEach(() => { - store = createMockStore(); - mockSocket = createMockSocket(); - localStorage.clear(); - }); + store = createMockStore() + mockSocket = createMockSocket() + localStorage.clear() + }) afterEach(() => { - cleanup(); - jest.clearAllMocks(); - jest.clearAllTimers(); - localStorage.clear(); - }); + cleanup() + jest.clearAllMocks() + jest.clearAllTimers() + localStorage.clear() + }) - describe("Authentication", () => { + describe('Authentication', () => { beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - jest.resetModules(); - }); + jest.useFakeTimers() + jest.clearAllMocks() + jest.resetModules() + }) afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - it("should not create connection when user is not authenticated", () => { - const unauthStore = createMockStore(null, null); + jest.runOnlyPendingTimers() + jest.useRealTimers() + }) + it('should not create connection when user is not authenticated', () => { + const unauthStore = createMockStore(null, null) - render(); + render() - expect(createWebSocketConnection).not.toHaveBeenCalled(); - }); + expect(createWebSocketConnection).not.toHaveBeenCalled() + }) - it("should create connection when user is authenticated", async () => { - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + it('should create connection when user is authenticated', async () => { + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) + }) - it("should close existing socket when user logs out", async () => { - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + it('should close existing socket when user logs out', async () => { + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - const { rerender } = render(); + const { rerender } = render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalled(); - }); + expect(createWebSocketConnection).toHaveBeenCalled() + }) - const unauthStore = createMockStore(null, null); - rerender(); + const unauthStore = createMockStore(null, null) + rerender() await waitFor(() => { - expect(mockSocket.close).toHaveBeenCalled(); - }); - }); - }); + expect(mockSocket.close).toHaveBeenCalled() + }) + }) + }) - describe("Socket Connection Management", () => { - it("should create connection with callbacks", async () => { - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + describe('Socket Connection Management', () => { + it('should create connection with callbacks', async () => { + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - render(); + render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledWith( expect.objectContaining({ onMessage: expect.any(Function), onClose: expect.any(Function), - onError: expect.any(Function), + onError: expect.any(Function) }) - ); - }); - }); + ) + }) + }) - it("should handle socket close via callback", async () => { - let onCloseCallback: Function | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + it('should handle socket close via callback', async () => { + let onCloseCallback: Function | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalled(); - }); + expect(createWebSocketConnection).toHaveBeenCalled() + }) // Simulate close event await act(async () => { - onCloseCallback?.(new CloseEvent("close")); - }); + onCloseCallback?.(new CloseEvent('close')) + }) // Verify that subsequent calendar changes don't try to register await act(async () => { - setSelectedCalendars(["cal1"]); - }); + setSelectedCalendars(['cal1']) + }) await waitFor(() => { - expect(registerToCalendars).not.toHaveBeenCalled(); - }); - }); + expect(registerToCalendars).not.toHaveBeenCalled() + }) + }) - it("should handle connection errors gracefully", async () => { - const consoleError = jest.spyOn(console, "error").mockImplementation(); - (createWebSocketConnection as jest.Mock).mockRejectedValue( - new Error("Connection failed") - ); + it('should handle connection errors gracefully', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation() + ;(createWebSocketConnection as jest.Mock).mockRejectedValue( + new Error('Connection failed') + ) - render(); + render() await waitFor(() => { expect(consoleError).toHaveBeenCalledWith( - "Failed to create WebSocket connection:", + 'Failed to create WebSocket connection:', expect.any(Error) - ); - }); + ) + }) - consoleError.mockRestore(); - }); + consoleError.mockRestore() + }) - it("should close socket on component unmount", async () => { - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + it('should close socket on component unmount', async () => { + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - const { unmount } = render(); + const { unmount } = render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalled(); - }); + expect(createWebSocketConnection).toHaveBeenCalled() + }) - unmount(); + unmount() - expect(mockSocket.close).toHaveBeenCalled(); - }); - }); + expect(mockSocket.close).toHaveBeenCalled() + }) + }) - describe("Reconnection Logic", () => { + describe('Reconnection Logic', () => { beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - jest.resetModules(); - }); + jest.useFakeTimers() + jest.clearAllMocks() + jest.resetModules() + }) afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - it("should trigger reconnection on unexpected close (code 1006)", async () => { - jest.useFakeTimers(); - const consoleWarn = jest.spyOn(console, "warn").mockImplementation(); - let onCloseCallback: Function | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + jest.runOnlyPendingTimers() + jest.useRealTimers() + }) + it('should trigger reconnection on unexpected close (code 1006)', async () => { + jest.useFakeTimers() + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation() + let onCloseCallback: Function | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) // Simulate unexpected close await act(async () => { onCloseCallback?.( - new CloseEvent("close", { code: 1006, reason: "Connection lost" }) - ); - }); + new CloseEvent('close', { code: 1006, reason: 'Connection lost' }) + ) + }) expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining("WebSocket closed unexpectedly (code: 1006") - ); + expect.stringContaining('WebSocket closed unexpectedly (code: 1006') + ) // Advance timer to trigger reconnection await act(async () => { - jest.advanceTimersByTime(1000); - }); + jest.advanceTimersByTime(1000) + }) await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(2); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(2) + }) - consoleWarn.mockRestore(); - jest.useRealTimers(); - }); + consoleWarn.mockRestore() + jest.useRealTimers() + }) - it("should NOT reconnect on normal close (code 1000)", async () => { - jest.useFakeTimers(); - let onCloseCallback: Function | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + it('should NOT reconnect on normal close (code 1000)', async () => { + jest.useFakeTimers() + let onCloseCallback: Function | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) // Simulate normal close await act(async () => { - onCloseCallback?.(new CloseEvent("close", { code: 1000 })); - }); + onCloseCallback?.(new CloseEvent('close', { code: 1000 })) + }) // Advance timer await act(async () => { - jest.advanceTimersByTime(5000); - }); + jest.advanceTimersByTime(5000) + }) // Should NOT reconnect - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - jest.useRealTimers(); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + jest.useRealTimers() + }) - it("should NOT reconnect on going away (code 1001)", async () => { - jest.useFakeTimers(); - let onCloseCallback: Function | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + it('should NOT reconnect on going away (code 1001)', async () => { + jest.useFakeTimers() + let onCloseCallback: Function | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) // Simulate going away await act(async () => { - onCloseCallback?.(new CloseEvent("close", { code: 1001 })); - }); + onCloseCallback?.(new CloseEvent('close', { code: 1001 })) + }) await act(async () => { - jest.advanceTimersByTime(5000); - }); + jest.advanceTimersByTime(5000) + }) - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - jest.useRealTimers(); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + jest.useRealTimers() + }) - it("should reset reconnection attempts counter on successful connection", async () => { - jest.useFakeTimers(); - const consoleLog = jest.spyOn(console, "info").mockImplementation(); - let onCloseCallback: Function | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + it('should reset reconnection attempts counter on successful connection', async () => { + jest.useFakeTimers() + const consoleLog = jest.spyOn(console, 'info').mockImplementation() + let onCloseCallback: Function | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) // First failure await act(async () => { - onCloseCallback?.(new CloseEvent("close", { code: 1006 })); - jest.advanceTimersByTime(1000); - }); + onCloseCallback?.(new CloseEvent('close', { code: 1006 })) + jest.advanceTimersByTime(1000) + }) expect(consoleLog).toHaveBeenCalledWith( - expect.stringContaining("(attempt 1/10)") - ); + expect.stringContaining('(attempt 1/10)') + ) // Success - should reset counter // Next failure should start from attempt 1 again await act(async () => { - onCloseCallback?.(new CloseEvent("close", { code: 1006 })); - }); + onCloseCallback?.(new CloseEvent('close', { code: 1006 })) + }) // Should schedule with initial delay (attempt 1) expect(consoleLog).toHaveBeenCalledWith( - expect.stringContaining("(attempt 1/10)") - ); + expect.stringContaining('(attempt 1/10)') + ) - consoleLog.mockRestore(); - jest.useRealTimers(); - }); + consoleLog.mockRestore() + jest.useRealTimers() + }) - it("should not reconnect if authentication is lost during reconnection timeout", async () => { - jest.useFakeTimers(); - let onCloseCallback: Function | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + it('should not reconnect if authentication is lost during reconnection timeout', async () => { + jest.useFakeTimers() + let onCloseCallback: Function | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - const { rerender } = render(); + const { rerender } = render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) // Trigger reconnection await act(async () => { - onCloseCallback?.(new CloseEvent("close", { code: 1006 })); - }); + onCloseCallback?.(new CloseEvent('close', { code: 1006 })) + }) // Lose authentication before timeout fires - const unauthStore = createMockStore(null, null); - rerender(); + const unauthStore = createMockStore(null, null) + rerender() // Advance timer await act(async () => { - jest.advanceTimersByTime(1000); - }); + jest.advanceTimersByTime(1000) + }) // Should NOT reconnect (still only 1 connection) - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - jest.useRealTimers(); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + jest.useRealTimers() + }) - it("should clear reconnection timeout on component unmount", async () => { - jest.useFakeTimers(); - let onCloseCallback: Function | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + it('should clear reconnection timeout on component unmount', async () => { + jest.useFakeTimers() + let onCloseCallback: Function | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - const { unmount } = render(); + const { unmount } = render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) // Trigger reconnection await act(async () => { - onCloseCallback?.(new CloseEvent("close", { code: 1006 })); - }); + onCloseCallback?.(new CloseEvent('close', { code: 1006 })) + }) // Unmount before timeout fires - unmount(); + unmount() // Advance timer await act(async () => { - jest.advanceTimersByTime(1000); - }); + jest.advanceTimersByTime(1000) + }) // Should NOT reconnect after unmount - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - jest.useRealTimers(); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + jest.useRealTimers() + }) - it("should re-sync calendars after reconnection", async () => { - jest.useFakeTimers(); + it('should re-sync calendars after reconnection', async () => { + jest.useFakeTimers() localStorage.setItem( - "selectedCalendars", - JSON.stringify(["cal1", "cal2"]) - ); - let onCloseCallback: Function | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + 'selectedCalendars', + JSON.stringify(['cal1', 'cal2']) + ) + let onCloseCallback: Function | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - render(); + render() await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ - "/calendars/cal1", - "/calendars/cal2", - ]); - }); + '/calendars/cal1', + '/calendars/cal2' + ]) + }) - jest.clearAllMocks(); + jest.clearAllMocks() // Close and reconnect await act(async () => { - onCloseCallback?.(new CloseEvent("close", { code: 1006 })); - jest.advanceTimersByTime(1000); - }); + onCloseCallback?.(new CloseEvent('close', { code: 1006 })) + jest.advanceTimersByTime(1000) + }) // Should re-register all calendars after reconnection await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ - "/calendars/cal1", - "/calendars/cal2", - ]); - }); - jest.useRealTimers(); - }); - }); + '/calendars/cal1', + '/calendars/cal2' + ]) + }) + jest.useRealTimers() + }) + }) - describe("Browser Online/Offline Events", () => { + describe('Browser Online/Offline Events', () => { beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - jest.resetModules(); - }); + jest.useFakeTimers() + jest.clearAllMocks() + jest.resetModules() + }) afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - it("should trigger immediate reconnection when browser goes online", async () => { - let onCloseCallback: Function | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + jest.runOnlyPendingTimers() + jest.useRealTimers() + }) + it('should trigger immediate reconnection when browser goes online', async () => { + let onCloseCallback: Function | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) // Close connection await act(async () => { - onCloseCallback?.(new CloseEvent("close", { code: 1006 })); - }); + onCloseCallback?.(new CloseEvent('close', { code: 1006 })) + }) // Trigger online event await act(async () => { - window.dispatchEvent(new Event("online")); - }); + window.dispatchEvent(new Event('online')) + }) // Should trigger immediate reconnection (no delay) await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(2); - }); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(2) + }) + }) - it("should pause reconnection attempts when browser goes offline", async () => { - jest.useFakeTimers(); - let onCloseCallback: Function | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + it('should pause reconnection attempts when browser goes offline', async () => { + jest.useFakeTimers() + let onCloseCallback: Function | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) // Close connection await act(async () => { - onCloseCallback?.(new CloseEvent("close", { code: 1006 })); - }); + onCloseCallback?.(new CloseEvent('close', { code: 1006 })) + }) // Go offline before reconnection fires await act(async () => { - window.dispatchEvent(new Event("offline")); - }); + window.dispatchEvent(new Event('offline')) + }) // Advance timer - should NOT reconnect await act(async () => { - jest.advanceTimersByTime(10000); - }); + jest.advanceTimersByTime(10000) + }) - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - jest.useRealTimers(); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + jest.useRealTimers() + }) - it("should not reconnect when online event fires if already connected", async () => { - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + it('should not reconnect when online event fires if already connected', async () => { + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) // Trigger online event while connected await act(async () => { - window.dispatchEvent(new Event("online")); - }); + window.dispatchEvent(new Event('online')) + }) // Should NOT trigger new connection - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) - it("should reset attempt counter when online event fires", async () => { - jest.useFakeTimers(); - const consoleLog = jest.spyOn(console, "info").mockImplementation(); - let onCloseCallback: Function | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + it('should reset attempt counter when online event fires', async () => { + jest.useFakeTimers() + const consoleLog = jest.spyOn(console, 'info').mockImplementation() + let onCloseCallback: Function | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) // Multiple failed attempts await act(async () => { - onCloseCallback?.(new CloseEvent("close", { code: 1006 })); - jest.advanceTimersByTime(1000); - }); + onCloseCallback?.(new CloseEvent('close', { code: 1006 })) + jest.advanceTimersByTime(1000) + }) await act(async () => { - onCloseCallback?.(new CloseEvent("close", { code: 1006 })); - jest.advanceTimersByTime(2000); - }); + onCloseCallback?.(new CloseEvent('close', { code: 1006 })) + jest.advanceTimersByTime(2000) + }) // Go offline then online await act(async () => { - window.dispatchEvent(new Event("offline")); - window.dispatchEvent(new Event("online")); - }); + window.dispatchEvent(new Event('offline')) + window.dispatchEvent(new Event('online')) + }) // Next reconnection should start from attempt 1 await act(async () => { - onCloseCallback?.(new CloseEvent("close", { code: 1006 })); - }); + onCloseCallback?.(new CloseEvent('close', { code: 1006 })) + }) expect(consoleLog).toHaveBeenCalledWith( - expect.stringContaining("(attempt 1/10)") - ); + expect.stringContaining('(attempt 1/10)') + ) - consoleLog.mockRestore(); - jest.useRealTimers(); - }); - }); + consoleLog.mockRestore() + jest.useRealTimers() + }) + }) - describe("Calendar Registration", () => { - it("should not register if socket is not open", async () => { + describe('Calendar Registration', () => { + it('should not register if socket is not open', async () => { localStorage.setItem( - "selectedCalendars", - JSON.stringify(["cal1", "cal2"]) - ); + 'selectedCalendars', + JSON.stringify(['cal1', 'cal2']) + ) - const connectingSocket = createMockSocket(WebSocket.CONNECTING); - (createWebSocketConnection as jest.Mock).mockResolvedValue( + const connectingSocket = createMockSocket(WebSocket.CONNECTING) + ;(createWebSocketConnection as jest.Mock).mockResolvedValue( connectingSocket - ); + ) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalled(); - }); + expect(createWebSocketConnection).toHaveBeenCalled() + }) - expect(registerToCalendars).not.toHaveBeenCalled(); - }); + expect(registerToCalendars).not.toHaveBeenCalled() + }) - it("should register to calendars when socket opens and calendars are selected", async () => { + it('should register to calendars when socket opens and calendars are selected', async () => { localStorage.setItem( - "selectedCalendars", - JSON.stringify(["cal1", "cal2"]) - ); - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + 'selectedCalendars', + JSON.stringify(['cal1', 'cal2']) + ) + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - render(); + render() await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ - "/calendars/cal1", - "/calendars/cal2", - ]); - }); - }); + '/calendars/cal1', + '/calendars/cal2' + ]) + }) + }) - it("should register only new calendars when calendar list changes", async () => { - localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"])); - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + it('should register only new calendars when calendar list changes', async () => { + localStorage.setItem('selectedCalendars', JSON.stringify(['cal1'])) + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - render(); + render() await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ - "/calendars/cal1", - ]); - }); + '/calendars/cal1' + ]) + }) - jest.clearAllMocks(); + jest.clearAllMocks() await act(async () => { - setSelectedCalendars(["cal1", "cal2", "cal3"]); - }); + setSelectedCalendars(['cal1', 'cal2', 'cal3']) + }) await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ - "/calendars/cal2", - "/calendars/cal3", - ]); - }); - }); + '/calendars/cal2', + '/calendars/cal3' + ]) + }) + }) - it("should unregister removed calendars when calendar list changes", async () => { + it('should unregister removed calendars when calendar list changes', async () => { localStorage.setItem( - "selectedCalendars", - JSON.stringify(["cal1", "cal2", "cal3"]) - ); - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + 'selectedCalendars', + JSON.stringify(['cal1', 'cal2', 'cal3']) + ) + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - render(); + render() await waitFor(() => { - expect(registerToCalendars).toHaveBeenCalled(); - }); + expect(registerToCalendars).toHaveBeenCalled() + }) - jest.clearAllMocks(); + jest.clearAllMocks() await act(async () => { - setSelectedCalendars(["cal1"]); - }); + setSelectedCalendars(['cal1']) + }) await waitFor(() => { expect(unregisterToCalendars).toHaveBeenCalledWith(mockSocket, [ - "/calendars/cal2", - "/calendars/cal3", - ]); - }); - }); + '/calendars/cal2', + '/calendars/cal3' + ]) + }) + }) - it("should both register and unregister when calendar list partially changes", async () => { + it('should both register and unregister when calendar list partially changes', async () => { localStorage.setItem( - "selectedCalendars", - JSON.stringify(["cal1", "cal2"]) - ); - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + 'selectedCalendars', + JSON.stringify(['cal1', 'cal2']) + ) + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - render(); + render() await waitFor(() => { - expect(registerToCalendars).toHaveBeenCalled(); - }); + expect(registerToCalendars).toHaveBeenCalled() + }) - jest.clearAllMocks(); + jest.clearAllMocks() await act(async () => { - setSelectedCalendars(["cal1", "cal3"]); - }); + setSelectedCalendars(['cal1', 'cal3']) + }) await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ - "/calendars/cal3", - ]); + '/calendars/cal3' + ]) expect(unregisterToCalendars).toHaveBeenCalledWith(mockSocket, [ - "/calendars/cal2", - ]); - }); - }); + '/calendars/cal2' + ]) + }) + }) - it("should handle registration errors and not update previous calendar list", async () => { - const consoleError = jest.spyOn(console, "error").mockImplementation(); - localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"])); - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); - (registerToCalendars as jest.Mock).mockImplementation(() => { - throw new Error("Registration failed"); - }); + it('should handle registration errors and not update previous calendar list', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation() + localStorage.setItem('selectedCalendars', JSON.stringify(['cal1'])) + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) + ;(registerToCalendars as jest.Mock).mockImplementation(() => { + throw new Error('Registration failed') + }) - render(); + render() await waitFor(() => { expect(consoleError).toHaveBeenCalledWith( - "Failed to register calendar:", + 'Failed to register calendar:', expect.any(Error) - ); - }); + ) + }) await waitFor(() => { - expect(mockSocket.close).not.toHaveBeenCalled(); - }); + expect(mockSocket.close).not.toHaveBeenCalled() + }) - jest.clearAllMocks(); - (registerToCalendars as jest.Mock).mockImplementation(() => {}); + jest.clearAllMocks() + ;(registerToCalendars as jest.Mock).mockImplementation(() => {}) await act(async () => { - setSelectedCalendars(["cal1", "cal2"]); - }); + setSelectedCalendars(['cal1', 'cal2']) + }) await waitFor(() => { // Should register both calendars since previous update failed and didn't update the ref expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ - "/calendars/cal1", - "/calendars/cal2", - ]); - }); + '/calendars/cal1', + '/calendars/cal2' + ]) + }) - consoleError.mockRestore(); - }); + consoleError.mockRestore() + }) - it("should not attempt registration when no calendars are selected", async () => { - localStorage.setItem("selectedCalendars", JSON.stringify([])); - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + it('should not attempt registration when no calendars are selected', async () => { + localStorage.setItem('selectedCalendars', JSON.stringify([])) + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalled(); - }); + expect(createWebSocketConnection).toHaveBeenCalled() + }) - expect(registerToCalendars).not.toHaveBeenCalled(); - expect(unregisterToCalendars).not.toHaveBeenCalled(); - }); - }); + expect(registerToCalendars).not.toHaveBeenCalled() + expect(unregisterToCalendars).not.toHaveBeenCalled() + }) + }) - describe("Edge Cases", () => { - it("should handle rapid calendar changes correctly", async () => { - localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"])); - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + describe('Edge Cases', () => { + it('should handle rapid calendar changes correctly', async () => { + localStorage.setItem('selectedCalendars', JSON.stringify(['cal1'])) + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - render(); + render() await waitFor(() => { - expect(registerToCalendars).toHaveBeenCalledTimes(1); - }); + expect(registerToCalendars).toHaveBeenCalledTimes(1) + }) // Rapid changes await act(async () => { - setSelectedCalendars(["cal1", "cal2"]); - setSelectedCalendars(["cal1", "cal2", "cal3"]); - }); + setSelectedCalendars(['cal1', 'cal2']) + setSelectedCalendars(['cal1', 'cal2', 'cal3']) + }) await waitFor(() => { - expect(registerToCalendars).toHaveBeenCalled(); - }); - }); + expect(registerToCalendars).toHaveBeenCalled() + }) + }) - it("should handle socket closed during calendar update", async () => { - localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"])); - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + it('should handle socket closed during calendar update', async () => { + localStorage.setItem('selectedCalendars', JSON.stringify(['cal1'])) + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - render(); + render() await waitFor(() => { - expect(registerToCalendars).toHaveBeenCalled(); - }); + expect(registerToCalendars).toHaveBeenCalled() + }) - jest.clearAllMocks(); + jest.clearAllMocks() await act(async () => { - mockSocket.readyState = WebSocket.CLOSED; - setSelectedCalendars(["cal1", "cal2"]); - }); + mockSocket.readyState = WebSocket.CLOSED + setSelectedCalendars(['cal1', 'cal2']) + }) // Wait a bit to ensure the effect would have run if it was going to - jest.useFakeTimers(); - jest.advanceTimersByTime(100); - jest.useRealTimers(); - expect(registerToCalendars).not.toHaveBeenCalled(); - }); + jest.useFakeTimers() + jest.advanceTimersByTime(100) + jest.useRealTimers() + expect(registerToCalendars).not.toHaveBeenCalled() + }) - it("should handle unregistration errors gracefully", async () => { - const consoleError = jest.spyOn(console, "error").mockImplementation(); + it('should handle unregistration errors gracefully', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation() localStorage.setItem( - "selectedCalendars", - JSON.stringify(["cal1", "cal2"]) - ); - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + 'selectedCalendars', + JSON.stringify(['cal1', 'cal2']) + ) + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - render(); + render() await waitFor(() => { - expect(registerToCalendars).toHaveBeenCalled(); - }); + expect(registerToCalendars).toHaveBeenCalled() + }) - jest.clearAllMocks(); - (unregisterToCalendars as jest.Mock).mockImplementation(() => { - throw new Error("Unregistration failed"); - }); + jest.clearAllMocks() + ;(unregisterToCalendars as jest.Mock).mockImplementation(() => { + throw new Error('Unregistration failed') + }) await act(async () => { - setSelectedCalendars(["cal1"]); - }); + setSelectedCalendars(['cal1']) + }) await waitFor(() => { expect(consoleError).toHaveBeenCalledWith( - "Failed to unregister calendar:", + 'Failed to unregister calendar:', expect.any(Error) - ); - }); + ) + }) - consoleError.mockRestore(); - }); + consoleError.mockRestore() + }) - it("should handle socket that becomes open after initial connection", async () => { - const closedSocket = createMockSocket(WebSocket.CONNECTING); - localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"])); - (createWebSocketConnection as jest.Mock).mockResolvedValue(closedSocket); + it('should handle socket that becomes open after initial connection', async () => { + const closedSocket = createMockSocket(WebSocket.CONNECTING) + localStorage.setItem('selectedCalendars', JSON.stringify(['cal1'])) + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(closedSocket) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalled(); - }); + expect(createWebSocketConnection).toHaveBeenCalled() + }) // Socket is not open yet, should not register - expect(registerToCalendars).not.toHaveBeenCalled(); + expect(registerToCalendars).not.toHaveBeenCalled() // Simulate socket becoming open await act(async () => { - closedSocket.readyState = WebSocket.OPEN; + closedSocket.readyState = WebSocket.OPEN // Trigger a calendar change to re-run the effect - setSelectedCalendars(["cal1", "cal2"]); - }); + setSelectedCalendars(['cal1', 'cal2']) + }) // Still should not register because isSocketOpen state is false // The component only sets isSocketOpen to true when socket.readyState is OPEN during connection - expect(registerToCalendars).not.toHaveBeenCalled(); - }); - }); + expect(registerToCalendars).not.toHaveBeenCalled() + }) + }) - describe("Ping/Pong Integration", () => { + describe('Ping/Pong Integration', () => { beforeEach(() => { - jest.useFakeTimers(); - }); + jest.useFakeTimers() + }) afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); + jest.runOnlyPendingTimers() + jest.useRealTimers() + }) - it("should trigger reconnection when ping detects dead connection (via socket close)", async () => { - const consoleWarn = jest.spyOn(console, "warn").mockImplementation(); - let onCloseCallback: ((event: CloseEvent) => void) | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + it('should trigger reconnection when ping detects dead connection (via socket close)', async () => { + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation() + let onCloseCallback: ((event: CloseEvent) => void) | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); - - (createWebSocketConnection as jest.Mock).mockClear(); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) + ;(createWebSocketConnection as jest.Mock).mockClear() // In the real implementation, when ping detects dead connection, // it calls socket.close() which triggers the onClose callback // This simulates that flow await act(async () => { if (onCloseCallback) { - onCloseCallback(new CloseEvent("close", { code: 1006 })); + onCloseCallback(new CloseEvent('close', { code: 1006 })) } - }); + }) // Should schedule reconnection expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining("WebSocket closed unexpectedly") - ); + expect.stringContaining('WebSocket closed unexpectedly') + ) // Advance to trigger reconnection await act(async () => { - jest.advanceTimersByTime(1500); - }); + jest.advanceTimersByTime(1500) + }) // Should reconnect await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) - consoleWarn.mockRestore(); - }); + consoleWarn.mockRestore() + }) - it("should stop ping monitoring when socket closes normally", async () => { - let onCloseCallback: ((event: CloseEvent) => void) | undefined; - - (createWebSocketConnection as jest.Mock).mockImplementation( - (callbacks) => { - onCloseCallback = callbacks.onClose; - return Promise.resolve(mockSocket); + it('should stop ping monitoring when socket closes normally', async () => { + let onCloseCallback: ((event: CloseEvent) => void) | undefined + ;(createWebSocketConnection as jest.Mock).mockImplementation( + callbacks => { + onCloseCallback = callbacks.onClose + return Promise.resolve(mockSocket) } - ); + ) - render(); + render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalled(); - }); + expect(createWebSocketConnection).toHaveBeenCalled() + }) // Normal close (code 1000) - like logout or page navigation await act(async () => { if (onCloseCallback) { - onCloseCallback(new CloseEvent("close", { code: 1000 })); + onCloseCallback(new CloseEvent('close', { code: 1000 })) } - }); + }) // Should not attempt reconnection await act(async () => { - jest.advanceTimersByTime(5000); - }); + jest.advanceTimersByTime(5000) + }) - expect(createWebSocketConnection).toHaveBeenCalledTimes(1); - }); + expect(createWebSocketConnection).toHaveBeenCalledTimes(1) + }) - it("should cleanup ping monitoring on component unmount", async () => { - (createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket); + it('should cleanup ping monitoring on component unmount', async () => { + ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) - const { unmount } = render(); + const { unmount } = render() await waitFor(() => { - expect(createWebSocketConnection).toHaveBeenCalled(); - }); + expect(createWebSocketConnection).toHaveBeenCalled() + }) // Unmount should cleanup - unmount(); + unmount() - expect(mockSocket.close).toHaveBeenCalled(); - }); - }); -}); + expect(mockSocket.close).toHaveBeenCalled() + }) + }) +}) diff --git a/__test__/features/websocket/api/fetchWebSocketTicket.test.tsx b/__test__/features/websocket/api/fetchWebSocketTicket.test.tsx index 142362c..030487b 100644 --- a/__test__/features/websocket/api/fetchWebSocketTicket.test.tsx +++ b/__test__/features/websocket/api/fetchWebSocketTicket.test.tsx @@ -1,46 +1,46 @@ -import { api } from "@/utils/apiUtils"; -import { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket"; +import { api } from '@/utils/apiUtils' +import { fetchWebSocketTicket } from '@/websocket/api/fetchWebSocketTicket' -jest.mock("@/utils/apiUtils"); +jest.mock('@/utils/apiUtils') -describe("fetchWebSocketTicket", () => { +describe('fetchWebSocketTicket', () => { const mockTicket = { - clientAddress: "127.0.0.1", - value: "test-ticket-123", - generatedOn: "2025-01-12T10:00:00Z", - validUntil: "2025-01-12T11:00:00Z", - username: "testuser", - }; + clientAddress: '127.0.0.1', + value: 'test-ticket-123', + generatedOn: '2025-01-12T10:00:00Z', + validUntil: '2025-01-12T11:00:00Z', + username: 'testuser' + } beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) - it("should fetch ticket successfully", async () => { - (api.post as jest.Mock).mockResolvedValue({ + it('should fetch ticket successfully', async () => { + ;(api.post as jest.Mock).mockResolvedValue({ ok: true, - json: async () => mockTicket, - }); + json: async () => mockTicket + }) - const ticket = await fetchWebSocketTicket(); + const ticket = await fetchWebSocketTicket() - expect(api.post).toHaveBeenCalledWith("ws/ticket"); - expect(ticket).toEqual(mockTicket); - }); + expect(api.post).toHaveBeenCalledWith('ws/ticket') + expect(ticket).toEqual(mockTicket) + }) - it("should throw error when response is not ok", async () => { - (api.post as jest.Mock).mockResolvedValue({ - ok: false, - }); + it('should throw error when response is not ok', async () => { + ;(api.post as jest.Mock).mockResolvedValue({ + ok: false + }) await expect(fetchWebSocketTicket()).rejects.toThrow( - "Failed to fetch WebSocket ticket" - ); - }); + 'Failed to fetch WebSocket ticket' + ) + }) - it("should throw error when network fails", async () => { - (api.post as jest.Mock).mockRejectedValue(new Error("Network error")); + it('should throw error when network fails', async () => { + ;(api.post as jest.Mock).mockRejectedValue(new Error('Network error')) - await expect(fetchWebSocketTicket()).rejects.toThrow("Network error"); - }); -}); + await expect(fetchWebSocketTicket()).rejects.toThrow('Network error') + }) +}) diff --git a/__test__/features/websocket/createWebSocketConnection.test.tsx b/__test__/features/websocket/createWebSocketConnection.test.tsx index e7da29e..b3e1902 100644 --- a/__test__/features/websocket/createWebSocketConnection.test.tsx +++ b/__test__/features/websocket/createWebSocketConnection.test.tsx @@ -1,211 +1,210 @@ -import { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket"; -import { createWebSocketConnection } from "@/websocket/connection/createConnection"; -import { WS_INBOUND_EVENTS } from "@/websocket/protocols"; -import { waitFor } from "@testing-library/dom"; -import { setupWebsocket } from "./utils/setupWebsocket"; +import { fetchWebSocketTicket } from '@/websocket/api/fetchWebSocketTicket' +import { createWebSocketConnection } from '@/websocket/connection/createConnection' +import { WS_INBOUND_EVENTS } from '@/websocket/protocols' +import { waitFor } from '@testing-library/dom' +import { setupWebsocket } from './utils/setupWebsocket' -jest.mock("@/websocket/api/fetchWebSocketTicket"); +jest.mock('@/websocket/api/fetchWebSocketTicket') -describe("createWebSocketConnection", () => { - let mockWebSocket: jest.Mock; - let cleanup: () => void; - let webSocketInstances: any[] = []; +describe('createWebSocketConnection', () => { + let mockWebSocket: jest.Mock + let cleanup: () => void + let webSocketInstances: any[] = [] const mockTicket = { - value: "test-ticket-123", - clientAddress: "127.0.0.1", - generatedOn: "2025-01-12T10:00:00Z", - validUntil: "2025-01-12T11:00:00Z", - username: "testuser", - }; + value: 'test-ticket-123', + clientAddress: '127.0.0.1', + generatedOn: '2025-01-12T10:00:00Z', + validUntil: '2025-01-12T11:00:00Z', + username: 'testuser' + } /** ---------- Helpers ---------- */ - const getWs = () => webSocketInstances[0]; + const getWs = () => webSocketInstances[0] const triggerEvent = (ws: any, event: string, payload?: any) => { - ws._listeners[event]?.[0]?.(payload); - }; + ws._listeners[event]?.[0]?.(payload) + } const createAndOpenConnection = async () => { const mockCallbacks = { onMessage: jest.fn(), onClose: jest.fn(), - onError: jest.fn(), - }; - const promise = createWebSocketConnection(mockCallbacks); + onError: jest.fn() + } + const promise = createWebSocketConnection(mockCallbacks) await waitFor(() => { - expect(webSocketInstances.length).toBe(1); - }); + expect(webSocketInstances.length).toBe(1) + }) - triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED); - const socket = await promise; + triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED) + const socket = await promise - return { socket, ws: getWs(), promise, mockCallbacks }; - }; + return { socket, ws: getWs(), promise, mockCallbacks } + } /** ---------- Setup ---------- */ beforeEach(() => { - ({ webSocketInstances, mockWebSocket, cleanup } = setupWebsocket()); - window.WEBSOCKET_URL = "wss://calendar.example.com"; - - (fetchWebSocketTicket as jest.Mock).mockResolvedValue(mockTicket); - }); + ;({ webSocketInstances, mockWebSocket, cleanup } = setupWebsocket()) + window.WEBSOCKET_URL = 'wss://calendar.example.com' + ;(fetchWebSocketTicket as jest.Mock).mockResolvedValue(mockTicket) + }) afterEach(() => { - jest.clearAllMocks(); - delete window.WEBSOCKET_URL; - delete window.CALENDAR_BASE_URL; - cleanup(); - }); + jest.clearAllMocks() + delete window.WEBSOCKET_URL + delete window.CALENDAR_BASE_URL + cleanup() + }) /** ---------- Tests ---------- */ - it("throws when WEBSOCKET_URL is not defined", async () => { - delete window.WEBSOCKET_URL; + it('throws when WEBSOCKET_URL is not defined', async () => { + delete window.WEBSOCKET_URL const mockCallbacks = { - onMessage: jest.fn(), - }; + onMessage: jest.fn() + } await expect(createWebSocketConnection(mockCallbacks)).rejects.toThrow( - "WEBSOCKET_URL is not defined" - ); - }); + 'WEBSOCKET_URL is not defined' + ) + }) - it("fetches WebSocket ticket", async () => { - await createAndOpenConnection(); - expect(fetchWebSocketTicket).toHaveBeenCalledTimes(1); - }); + it('fetches WebSocket ticket', async () => { + await createAndOpenConnection() + expect(fetchWebSocketTicket).toHaveBeenCalledTimes(1) + }) - it("creates WebSocket with correct URL and ticket", async () => { - await createAndOpenConnection(); + it('creates WebSocket with correct URL and ticket', async () => { + await createAndOpenConnection() expect(mockWebSocket).toHaveBeenCalledWith( - "wss://calendar.example.com/ws?ticket=test-ticket-123" - ); - }); + 'wss://calendar.example.com/ws?ticket=test-ticket-123' + ) + }) - it("creates WebSocket with correct URL and ticket without the WEBSOCKET_URL", async () => { - delete window.WEBSOCKET_URL; - window.CALENDAR_BASE_URL = "https://calendar.example.com"; - await createAndOpenConnection(); + it('creates WebSocket with correct URL and ticket without the WEBSOCKET_URL', async () => { + delete window.WEBSOCKET_URL + window.CALENDAR_BASE_URL = 'https://calendar.example.com' + await createAndOpenConnection() expect(mockWebSocket).toHaveBeenCalledWith( - "wss://calendar.example.com/ws?ticket=test-ticket-123" - ); - }); + 'wss://calendar.example.com/ws?ticket=test-ticket-123' + ) + }) - it("resolves with socket when connection opens", async () => { - const { socket, ws } = await createAndOpenConnection(); - expect(socket).toBe(ws); - }); + it('resolves with socket when connection opens', async () => { + const { socket, ws } = await createAndOpenConnection() + expect(socket).toBe(ws) + }) - it("rejects when connection fails", async () => { + it('rejects when connection fails', async () => { const mockCallbacks = { - onMessage: jest.fn(), - }; - const promise = createWebSocketConnection(mockCallbacks); + onMessage: jest.fn() + } + const promise = createWebSocketConnection(mockCallbacks) await waitFor(() => { - expect(webSocketInstances.length).toBe(1); - }); + expect(webSocketInstances.length).toBe(1) + }) - triggerEvent(getWs(), WS_INBOUND_EVENTS.ERROR, new Event("error")); + triggerEvent(getWs(), WS_INBOUND_EVENTS.ERROR, new Event('error')) - await expect(promise).rejects.toThrow("WebSocket connection failed"); - }); + await expect(promise).rejects.toThrow('WebSocket connection failed') + }) - it("attaches message event listener", async () => { - const { ws } = await createAndOpenConnection(); + it('attaches message event listener', async () => { + const { ws } = await createAndOpenConnection() - expect(ws._listeners[WS_INBOUND_EVENTS.MESSAGE]).toBeDefined(); - expect(ws._listeners[WS_INBOUND_EVENTS.MESSAGE].length).toBeGreaterThan(0); - }); + expect(ws._listeners[WS_INBOUND_EVENTS.MESSAGE]).toBeDefined() + expect(ws._listeners[WS_INBOUND_EVENTS.MESSAGE].length).toBeGreaterThan(0) + }) - it("attaches close event listener", async () => { - const { ws } = await createAndOpenConnection(); - expect(ws._listeners[WS_INBOUND_EVENTS.CONNECTION_CLOSED]).toBeDefined(); - }); + it('attaches close event listener', async () => { + const { ws } = await createAndOpenConnection() + expect(ws._listeners[WS_INBOUND_EVENTS.CONNECTION_CLOSED]).toBeDefined() + }) - it("handles invalid JSON messages", async () => { - const errorSpy = jest.spyOn(console, "error").mockImplementation(); + it('handles invalid JSON messages', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation() - const { ws } = await createAndOpenConnection(); + const { ws } = await createAndOpenConnection() - triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { data: "invalid json" }); + triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { data: 'invalid json' }) expect(errorSpy).toHaveBeenCalledWith( - "Failed to parse WebSocket message:", + 'Failed to parse WebSocket message:', expect.any(Error) - ); + ) - errorSpy.mockRestore(); - }); + errorSpy.mockRestore() + }) - it("rejects on timeout", async () => { - jest.useFakeTimers(); + it('rejects on timeout', async () => { + jest.useFakeTimers() const mockCallbacks = { - onMessage: jest.fn(), - }; - const promise = createWebSocketConnection(mockCallbacks); + onMessage: jest.fn() + } + const promise = createWebSocketConnection(mockCallbacks) await waitFor(() => { - expect(webSocketInstances.length).toBe(1); - }); + expect(webSocketInstances.length).toBe(1) + }) - jest.advanceTimersByTime(10000); + jest.advanceTimersByTime(10000) - await expect(promise).rejects.toThrow("WebSocket connection timed out"); + await expect(promise).rejects.toThrow('WebSocket connection timed out') - jest.useRealTimers(); - }); + jest.useRealTimers() + }) - it("calls onMessage callback when message received", async () => { - const { ws, mockCallbacks } = await createAndOpenConnection(); + it('calls onMessage callback when message received', async () => { + const { ws, mockCallbacks } = await createAndOpenConnection() - const testMessage = { type: "test", payload: "data" }; + const testMessage = { type: 'test', payload: 'data' } triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { - data: JSON.stringify(testMessage), - }); + data: JSON.stringify(testMessage) + }) - expect(mockCallbacks.onMessage).toHaveBeenCalledWith(testMessage); - }); + expect(mockCallbacks.onMessage).toHaveBeenCalledWith(testMessage) + }) - it("does not call onMessage when JSON parsing fails", async () => { - const errorSpy = jest.spyOn(console, "error").mockImplementation(); - const { ws, mockCallbacks } = await createAndOpenConnection(); + it('does not call onMessage when JSON parsing fails', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation() + const { ws, mockCallbacks } = await createAndOpenConnection() - triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { data: "invalid json" }); + triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { data: 'invalid json' }) - expect(mockCallbacks.onMessage).not.toHaveBeenCalled(); + expect(mockCallbacks.onMessage).not.toHaveBeenCalled() expect(errorSpy).toHaveBeenCalledWith( - "Failed to parse WebSocket message:", + 'Failed to parse WebSocket message:', expect.any(Error) - ); + ) - errorSpy.mockRestore(); - }); + errorSpy.mockRestore() + }) - it("calls onClose callback when connection closes", async () => { - const { ws, mockCallbacks } = await createAndOpenConnection(); + it('calls onClose callback when connection closes', async () => { + const { ws, mockCallbacks } = await createAndOpenConnection() - const closeEvent = new CloseEvent("close", { + const closeEvent = new CloseEvent('close', { code: 1000, - reason: "Normal closure", - }); - triggerEvent(ws, WS_INBOUND_EVENTS.CONNECTION_CLOSED, closeEvent); + reason: 'Normal closure' + }) + triggerEvent(ws, WS_INBOUND_EVENTS.CONNECTION_CLOSED, closeEvent) - expect(mockCallbacks.onClose).toHaveBeenCalledWith(closeEvent); - }); + expect(mockCallbacks.onClose).toHaveBeenCalledWith(closeEvent) + }) - it("calls onError callback when error occurs", async () => { - const { ws, mockCallbacks } = await createAndOpenConnection(); + it('calls onError callback when error occurs', async () => { + const { ws, mockCallbacks } = await createAndOpenConnection() - const errorEvent = new Event("error"); - triggerEvent(ws, WS_INBOUND_EVENTS.ERROR, errorEvent); + const errorEvent = new Event('error') + triggerEvent(ws, WS_INBOUND_EVENTS.ERROR, errorEvent) - expect(mockCallbacks.onError).toHaveBeenCalledWith(errorEvent); - }); -}); + expect(mockCallbacks.onError).toHaveBeenCalledWith(errorEvent) + }) +}) diff --git a/__test__/features/websocket/useWebSocketReconnect.test.tsx b/__test__/features/websocket/useWebSocketReconnect.test.tsx index c573b0a..8e269b7 100644 --- a/__test__/features/websocket/useWebSocketReconnect.test.tsx +++ b/__test__/features/websocket/useWebSocketReconnect.test.tsx @@ -1,41 +1,41 @@ -import { getRetryDelay } from "@/utils/getRetryDelay"; +import { getRetryDelay } from '@/utils/getRetryDelay' import { MAX_RECONNECT_ATTEMPTS, RECONNECT_CONFIG, - useWebSocketReconnect, -} from "@/websocket/connection/lifecycle/useWebSocketReconnect"; -import { act, renderHook } from "@testing-library/react"; -import { MutableRefObject } from "react"; + useWebSocketReconnect +} from '@/websocket/connection/lifecycle/useWebSocketReconnect' +import { act, renderHook } from '@testing-library/react' +import { MutableRefObject } from 'react' // Mock the retry delay utility -jest.mock("@/utils/getRetryDelay"); +jest.mock('@/utils/getRetryDelay') const mockGetRetryDelay = getRetryDelay as jest.MockedFunction< typeof getRetryDelay ->; +> -describe("useWebSocketReconnect", () => { - let reconnectTimeoutRef: MutableRefObject; - let isAuthenticatedRef: MutableRefObject; - let reconnectAttemptsRef: MutableRefObject; - let setShouldConnect: jest.Mock; +describe('useWebSocketReconnect', () => { + let reconnectTimeoutRef: MutableRefObject + let isAuthenticatedRef: MutableRefObject + let reconnectAttemptsRef: MutableRefObject + let setShouldConnect: jest.Mock beforeEach(() => { - jest.useFakeTimers(); - reconnectTimeoutRef = { current: null }; - isAuthenticatedRef = { current: true }; - reconnectAttemptsRef = { current: 0 }; - setShouldConnect = jest.fn(); - mockGetRetryDelay.mockReturnValue(1000); // Default 1 second delay - }); + jest.useFakeTimers() + reconnectTimeoutRef = { current: null } + isAuthenticatedRef = { current: true } + reconnectAttemptsRef = { current: 0 } + setShouldConnect = jest.fn() + mockGetRetryDelay.mockReturnValue(1000) // Default 1 second delay + }) afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - jest.clearAllMocks(); - }); + jest.runOnlyPendingTimers() + jest.useRealTimers() + jest.clearAllMocks() + }) - describe("scheduleReconnect", () => { - it("should schedule a reconnection with correct delay", () => { + describe('scheduleReconnect', () => { + it('should schedule a reconnection with correct delay', () => { const { result } = renderHook(() => useWebSocketReconnect( reconnectTimeoutRef, @@ -43,26 +43,26 @@ describe("useWebSocketReconnect", () => { reconnectAttemptsRef, setShouldConnect ) - ); + ) act(() => { - result.current.scheduleReconnect(); - }); + result.current.scheduleReconnect() + }) - expect(mockGetRetryDelay).toHaveBeenCalledWith(0, RECONNECT_CONFIG); - expect(reconnectTimeoutRef.current).not.toBeNull(); - expect(setShouldConnect).not.toHaveBeenCalled(); + expect(mockGetRetryDelay).toHaveBeenCalledWith(0, RECONNECT_CONFIG) + expect(reconnectTimeoutRef.current).not.toBeNull() + expect(setShouldConnect).not.toHaveBeenCalled() act(() => { - jest.advanceTimersByTime(1000); - }); + jest.advanceTimersByTime(1000) + }) - expect(setShouldConnect).toHaveBeenCalledWith(expect.any(Function)); - expect(reconnectAttemptsRef.current).toBe(1); - }); + expect(setShouldConnect).toHaveBeenCalledWith(expect.any(Function)) + expect(reconnectAttemptsRef.current).toBe(1) + }) - it("should not schedule reconnection if not authenticated", () => { - isAuthenticatedRef = { current: false }; + it('should not schedule reconnection if not authenticated', () => { + isAuthenticatedRef = { current: false } const { result } = renderHook(() => useWebSocketReconnect( reconnectTimeoutRef, @@ -70,19 +70,19 @@ describe("useWebSocketReconnect", () => { reconnectAttemptsRef, setShouldConnect ) - ); + ) act(() => { - result.current.scheduleReconnect(); - }); + result.current.scheduleReconnect() + }) - expect(reconnectTimeoutRef.current).toBeNull(); - expect(mockGetRetryDelay).not.toHaveBeenCalled(); - }); + expect(reconnectTimeoutRef.current).toBeNull() + expect(mockGetRetryDelay).not.toHaveBeenCalled() + }) - it("should stop after MAX_RECONNECT_ATTEMPTS", () => { - const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); - reconnectAttemptsRef.current = MAX_RECONNECT_ATTEMPTS; + it('should stop after MAX_RECONNECT_ATTEMPTS', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() + reconnectAttemptsRef.current = MAX_RECONNECT_ATTEMPTS const { result } = renderHook(() => useWebSocketReconnect( @@ -91,23 +91,23 @@ describe("useWebSocketReconnect", () => { reconnectAttemptsRef, setShouldConnect ) - ); + ) act(() => { - result.current.scheduleReconnect(); - }); + result.current.scheduleReconnect() + }) expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining( `Max WebSocket reconnection attempts (${MAX_RECONNECT_ATTEMPTS})` ) - ); - expect(reconnectTimeoutRef.current).toBeNull(); + ) + expect(reconnectTimeoutRef.current).toBeNull() - consoleErrorSpy.mockRestore(); - }); + consoleErrorSpy.mockRestore() + }) - it("should increment attempt counter on each reconnection", () => { + it('should increment attempt counter on each reconnection', () => { const { result } = renderHook(() => useWebSocketReconnect( reconnectTimeoutRef, @@ -115,26 +115,26 @@ describe("useWebSocketReconnect", () => { reconnectAttemptsRef, setShouldConnect ) - ); + ) - expect(reconnectAttemptsRef.current).toBe(0); + expect(reconnectAttemptsRef.current).toBe(0) act(() => { - result.current.scheduleReconnect(); - jest.advanceTimersByTime(1000); - }); + result.current.scheduleReconnect() + jest.advanceTimersByTime(1000) + }) - expect(reconnectAttemptsRef.current).toBe(1); + expect(reconnectAttemptsRef.current).toBe(1) act(() => { - result.current.scheduleReconnect(); - jest.advanceTimersByTime(1000); - }); + result.current.scheduleReconnect() + jest.advanceTimersByTime(1000) + }) - expect(reconnectAttemptsRef.current).toBe(2); - }); + expect(reconnectAttemptsRef.current).toBe(2) + }) - it("should toggle setShouldConnect correctly", () => { + it('should toggle setShouldConnect correctly', () => { const { result } = renderHook(() => useWebSocketReconnect( reconnectTimeoutRef, @@ -142,24 +142,24 @@ describe("useWebSocketReconnect", () => { reconnectAttemptsRef, setShouldConnect ) - ); + ) act(() => { - result.current.scheduleReconnect(); - jest.advanceTimersByTime(1000); - }); + result.current.scheduleReconnect() + jest.advanceTimersByTime(1000) + }) - expect(setShouldConnect).toHaveBeenCalledTimes(1); + expect(setShouldConnect).toHaveBeenCalledTimes(1) // Test the toggle function - const toggleFn = setShouldConnect.mock.calls[0][0]; - expect(toggleFn(false)).toBe(true); - expect(toggleFn(true)).toBe(false); - }); - }); + const toggleFn = setShouldConnect.mock.calls[0][0] + expect(toggleFn(false)).toBe(true) + expect(toggleFn(true)).toBe(false) + }) + }) - describe("clearReconnectTimeout", () => { - it("should clear pending timeout", () => { + describe('clearReconnectTimeout', () => { + it('should clear pending timeout', () => { const { result } = renderHook(() => useWebSocketReconnect( reconnectTimeoutRef, @@ -167,29 +167,29 @@ describe("useWebSocketReconnect", () => { reconnectAttemptsRef, setShouldConnect ) - ); + ) act(() => { - result.current.scheduleReconnect(); - }); + result.current.scheduleReconnect() + }) - expect(reconnectTimeoutRef.current).not.toBeNull(); + expect(reconnectTimeoutRef.current).not.toBeNull() act(() => { - result.current.clearReconnectTimeout(); - }); + result.current.clearReconnectTimeout() + }) - expect(reconnectTimeoutRef.current).toBeNull(); + expect(reconnectTimeoutRef.current).toBeNull() // Timeout should not fire act(() => { - jest.advanceTimersByTime(1000); - }); + jest.advanceTimersByTime(1000) + }) - expect(setShouldConnect).not.toHaveBeenCalled(); - }); + expect(setShouldConnect).not.toHaveBeenCalled() + }) - it("should handle multiple clears gracefully", () => { + it('should handle multiple clears gracefully', () => { const { result } = renderHook(() => useWebSocketReconnect( reconnectTimeoutRef, @@ -197,18 +197,18 @@ describe("useWebSocketReconnect", () => { reconnectAttemptsRef, setShouldConnect ) - ); + ) act(() => { - result.current.clearReconnectTimeout(); - result.current.clearReconnectTimeout(); - result.current.clearReconnectTimeout(); - }); + result.current.clearReconnectTimeout() + result.current.clearReconnectTimeout() + result.current.clearReconnectTimeout() + }) - expect(reconnectTimeoutRef.current).toBeNull(); - }); + expect(reconnectTimeoutRef.current).toBeNull() + }) - it("should clear timeout before scheduling new one", () => { + it('should clear timeout before scheduling new one', () => { const { result } = renderHook(() => useWebSocketReconnect( reconnectTimeoutRef, @@ -216,35 +216,35 @@ describe("useWebSocketReconnect", () => { reconnectAttemptsRef, setShouldConnect ) - ); + ) // Schedule first reconnection act(() => { - result.current.scheduleReconnect(); - }); + result.current.scheduleReconnect() + }) - const firstTimeout = reconnectTimeoutRef.current; - expect(firstTimeout).not.toBeNull(); + const firstTimeout = reconnectTimeoutRef.current + expect(firstTimeout).not.toBeNull() // Schedule second reconnection (should clear first) act(() => { - result.current.scheduleReconnect(); - }); + result.current.scheduleReconnect() + }) - const secondTimeout = reconnectTimeoutRef.current; - expect(secondTimeout).not.toBeNull(); - expect(secondTimeout).not.toBe(firstTimeout); + const secondTimeout = reconnectTimeoutRef.current + expect(secondTimeout).not.toBeNull() + expect(secondTimeout).not.toBe(firstTimeout) // Only second timeout should fire act(() => { - jest.advanceTimersByTime(1000); - }); + jest.advanceTimersByTime(1000) + }) - expect(setShouldConnect).toHaveBeenCalledTimes(1); - }); - }); - it("should stop reconnecting after MAX_RECONNECT_ATTEMPTS (10)", () => { - const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + expect(setShouldConnect).toHaveBeenCalledTimes(1) + }) + }) + it('should stop reconnecting after MAX_RECONNECT_ATTEMPTS (10)', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() const { result } = renderHook(() => useWebSocketReconnect( reconnectTimeoutRef, @@ -252,32 +252,32 @@ describe("useWebSocketReconnect", () => { reconnectAttemptsRef, setShouldConnect ) - ); + ) // Simulate 10 reconnection attempts for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) { act(() => { - result.current.scheduleReconnect(); + result.current.scheduleReconnect() jest.advanceTimersByTime( mockGetRetryDelay.mock.results[i]?.value || 1000 - ); - }); + ) + }) } - expect(reconnectAttemptsRef.current).toBe(MAX_RECONNECT_ATTEMPTS); - expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS); + expect(reconnectAttemptsRef.current).toBe(MAX_RECONNECT_ATTEMPTS) + expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS) // Try to schedule one more reconnection - should fail act(() => { - result.current.scheduleReconnect(); - }); + result.current.scheduleReconnect() + }) expect(consoleErrorSpy).toHaveBeenCalledWith( `Max WebSocket reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Giving up.` - ); - expect(reconnectTimeoutRef.current).toBeNull(); - expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS); // Should not increment + ) + expect(reconnectTimeoutRef.current).toBeNull() + expect(setShouldConnect).toHaveBeenCalledTimes(MAX_RECONNECT_ATTEMPTS) // Should not increment - consoleErrorSpy.mockRestore(); - }); -}); + consoleErrorSpy.mockRestore() + }) +}) diff --git a/__test__/features/websocket/utils/notificationStorm.test.tsx b/__test__/features/websocket/utils/notificationStorm.test.tsx index 2ef2d90..a3634b3 100644 --- a/__test__/features/websocket/utils/notificationStorm.test.tsx +++ b/__test__/features/websocket/utils/notificationStorm.test.tsx @@ -1,135 +1,135 @@ -import type { AppDispatch, RootState } from "@/app/store"; -import { store } from "@/app/store"; -import { refreshCalendarWithSyncToken } from "@/features/Calendars/services"; -import { getDisplayedCalendarRange } from "@/utils"; -import { updateCalendars } from "@/websocket/messaging/updateCalendars"; +import type { AppDispatch, RootState } from '@/app/store' +import { store } from '@/app/store' +import { refreshCalendarWithSyncToken } from '@/features/Calendars/services' +import { getDisplayedCalendarRange } from '@/utils' +import { updateCalendars } from '@/websocket/messaging/updateCalendars' -jest.mock("@/features/Calendars/services", () => ({ - refreshCalendarWithSyncToken: jest.fn(), -})); +jest.mock('@/features/Calendars/services', () => ({ + refreshCalendarWithSyncToken: jest.fn() +})) -jest.mock("@/utils", () => ({ +jest.mock('@/utils', () => ({ getDisplayedCalendarRange: jest.fn(), - findCalendarById: jest.requireActual("@/utils").findCalendarById, -})); + findCalendarById: jest.requireActual('@/utils').findCalendarById +})) -jest.mock("@/app/store", () => ({ +jest.mock('@/app/store', () => ({ store: { - getState: jest.fn(), - }, -})); + getState: jest.fn() + } +})) -jest.useFakeTimers(); -const mockDispatch = jest.fn(); +jest.useFakeTimers() +const mockDispatch = jest.fn() const mockRange = { - start: new Date("2025-01-15T10:00:00Z"), - end: new Date("2025-01-16T10:00:00Z"), -}; + start: new Date('2025-01-15T10:00:00Z'), + end: new Date('2025-01-16T10:00:00Z') +} const mockState = { calendars: { list: { - "cal1/entry1": { id: "cal1/entry1", name: "Calendar 1", syncToken: 1 }, - "cal2/entry2": { id: "cal2/entry2", name: "Calendar 2", syncToken: 1 }, - "cal/A": { id: "cal/A", name: "Cal A", syncToken: 1 }, - "cal/B": { id: "cal/B", name: "Cal B", syncToken: 1 }, - "cal/C": { id: "cal/C", name: "Cal C", syncToken: 1 }, + 'cal1/entry1': { id: 'cal1/entry1', name: 'Calendar 1', syncToken: 1 }, + 'cal2/entry2': { id: 'cal2/entry2', name: 'Calendar 2', syncToken: 1 }, + 'cal/A': { id: 'cal/A', name: 'Cal A', syncToken: 1 }, + 'cal/B': { id: 'cal/B', name: 'Cal B', syncToken: 1 }, + 'cal/C': { id: 'cal/C', name: 'Cal C', syncToken: 1 } }, - templist: {}, - }, -} as unknown as RootState; + templist: {} + } +} as unknown as RootState const mockAccumulators: { - calendarsToRefresh: Map; - calendarsToHide: Set; - debouncedUpdateFn?: (dispatch: AppDispatch) => void; - shouldRefreshCalendarListRef: React.MutableRefObject; - currentDebouncePeriod?: number; + calendarsToRefresh: Map + calendarsToHide: Set + debouncedUpdateFn?: (dispatch: AppDispatch) => void + shouldRefreshCalendarListRef: React.MutableRefObject + currentDebouncePeriod?: number } = { calendarsToRefresh: new Map(), calendarsToHide: new Set(), shouldRefreshCalendarListRef: { current: false }, currentDebouncePeriod: 0, - debouncedUpdateFn: undefined, -}; + debouncedUpdateFn: undefined +} -describe("websocket messages storm", () => { +describe('websocket messages storm', () => { beforeEach(() => { - (refreshCalendarWithSyncToken as unknown as jest.Mock).mockClear(); - jest.clearAllMocks(); - jest.clearAllTimers(); - jest.resetModules(); - (getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange); - (store.getState as jest.Mock).mockReturnValue(mockState); - window.WS_DEBOUNCE_PERIOD_MS = 500; - mockAccumulators.calendarsToRefresh = new Map(); - mockAccumulators.calendarsToHide = new Set(); - mockAccumulators.shouldRefreshCalendarListRef.current = false; - mockAccumulators.currentDebouncePeriod = 0; + ;(refreshCalendarWithSyncToken as unknown as jest.Mock).mockClear() + jest.clearAllMocks() + jest.clearAllTimers() + jest.resetModules() + ;(getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange) + ;(store.getState as jest.Mock).mockReturnValue(mockState) + window.WS_DEBOUNCE_PERIOD_MS = 500 + mockAccumulators.calendarsToRefresh = new Map() + mockAccumulators.calendarsToHide = new Set() + mockAccumulators.shouldRefreshCalendarListRef.current = false + mockAccumulators.currentDebouncePeriod = 0 - mockAccumulators.debouncedUpdateFn = undefined; - }); - it("debounces calendar updates during message storm", () => { + mockAccumulators.debouncedUpdateFn = undefined + }) + it('debounces calendar updates during message storm', () => { const mockMessage = { - "/calendars/cal1/entry1": { - syncToken: "ldsk", - }, - }; + '/calendars/cal1/entry1': { + syncToken: 'ldsk' + } + } for (let i = 0; i < 50; i++) { - updateCalendars(mockMessage, mockDispatch, mockAccumulators); + updateCalendars(mockMessage, mockDispatch, mockAccumulators) } // Dispatch called once because of leading edge - expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1); + expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1) // Trailing edge - jest.advanceTimersByTime(500); + jest.advanceTimersByTime(500) // only one call for the last message + leading edge message - expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2); - }); + expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2) + }) - it("debounces calendar updates during message storm with multiple updates", () => { + it('debounces calendar updates during message storm with multiple updates', () => { // Send a storm with mixed messages for (let i = 0; i < 50; i++) { if (i % 3 === 0) updateCalendars( - { "/calendars/cal/A": { syncToken: "ldskfjsld" + i } }, + { '/calendars/cal/A': { syncToken: 'ldskfjsld' + i } }, mockDispatch, mockAccumulators - ); + ) else if (i % 3 === 1) updateCalendars( - { "/calendars/cal/B": { syncToken: "ldskfjsld" + i } }, + { '/calendars/cal/B': { syncToken: 'ldskfjsld' + i } }, mockDispatch, mockAccumulators - ); + ) else updateCalendars( - { "/calendars/cal/C": { syncToken: "ldskfjsld" + i } }, + { '/calendars/cal/C': { syncToken: 'ldskfjsld' + i } }, mockDispatch, mockAccumulators - ); + ) } // Dispatch called once because of leading edge - expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1); + expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1) // Trailing edge - jest.advanceTimersByTime(500); + jest.advanceTimersByTime(500) // Trailing edge updates once per calendar + the original leading edge - expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(4); - }); + expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(4) + }) - it("executes immediately when debounce is disabled", () => { - window.WS_DEBOUNCE_PERIOD_MS = 0; + it('executes immediately when debounce is disabled', () => { + window.WS_DEBOUNCE_PERIOD_MS = 0 updateCalendars( - { "/calendars/cal1/entry1": { syncToken: "abc" } }, + { '/calendars/cal1/entry1': { syncToken: 'abc' } }, mockDispatch, mockAccumulators - ); + ) - expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1); - }); -}); + expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(1) + }) +}) diff --git a/__test__/features/websocket/utils/setupWebsocket.ts b/__test__/features/websocket/utils/setupWebsocket.ts index efde303..5279eab 100644 --- a/__test__/features/websocket/utils/setupWebsocket.ts +++ b/__test__/features/websocket/utils/setupWebsocket.ts @@ -1,6 +1,6 @@ export function setupWebsocket() { - const originalWebSocket = global.WebSocket; - const webSocketInstances: any[] = []; + const originalWebSocket = global.WebSocket + const webSocketInstances: any[] = [] const mockWebSocket = jest.fn().mockImplementation((url: string) => { const ws = { url, @@ -9,27 +9,27 @@ export function setupWebsocket() { removeEventListener: jest.fn(), send: jest.fn(), close: jest.fn(), - _listeners: {} as Record, - }; + _listeners: {} as Record + } ws.addEventListener.mockImplementation((event, handler) => { - ws._listeners[event] ??= []; - ws._listeners[event].push(handler); - }); + ws._listeners[event] ??= [] + ws._listeners[event].push(handler) + }) ws.removeEventListener.mockImplementation((event, handler) => { ws._listeners[event] = - ws._listeners[event]?.filter((h) => h !== handler) ?? []; - }); + ws._listeners[event]?.filter(h => h !== handler) ?? [] + }) - webSocketInstances.push(ws); - return ws; - }); + webSocketInstances.push(ws) + return ws + }) - global.WebSocket = mockWebSocket as any; + global.WebSocket = mockWebSocket as any const cleanup = () => { - global.WebSocket = originalWebSocket; - webSocketInstances.length = 0; - }; - return { webSocketInstances, mockWebSocket, cleanup }; + global.WebSocket = originalWebSocket + webSocketInstances.length = 0 + } + return { webSocketInstances, mockWebSocket, cleanup } } diff --git a/__test__/features/websocket/utils/updateCalendars.test.tsx b/__test__/features/websocket/utils/updateCalendars.test.tsx index a117642..2bfebd9 100644 --- a/__test__/features/websocket/utils/updateCalendars.test.tsx +++ b/__test__/features/websocket/utils/updateCalendars.test.tsx @@ -1,147 +1,145 @@ -import { AppDispatch, RootState, store } from "@/app/store"; -import { refreshCalendarWithSyncToken } from "@/features/Calendars/services/refreshCalendar"; -import { getDisplayedCalendarRange } from "@/utils/CalendarRangeManager"; -import { updateCalendars } from "@/websocket/messaging/updateCalendars"; -import { WS_INBOUND_EVENTS } from "@/websocket/protocols"; -import { waitFor } from "@testing-library/dom"; +import { AppDispatch, RootState, store } from '@/app/store' +import { refreshCalendarWithSyncToken } from '@/features/Calendars/services/refreshCalendar' +import { getDisplayedCalendarRange } from '@/utils/CalendarRangeManager' +import { updateCalendars } from '@/websocket/messaging/updateCalendars' +import { WS_INBOUND_EVENTS } from '@/websocket/protocols' +import { waitFor } from '@testing-library/dom' -jest.mock("@/features/Calendars/services/refreshCalendar"); -jest.mock("@/utils/CalendarRangeManager"); -jest.mock("@/app/store", () => ({ +jest.mock('@/features/Calendars/services/refreshCalendar') +jest.mock('@/utils/CalendarRangeManager') +jest.mock('@/app/store', () => ({ store: { - getState: jest.fn(), - }, -})); + getState: jest.fn() + } +})) -describe("updateCalendars", () => { - let mockDispatch: jest.Mock; +describe('updateCalendars', () => { + let mockDispatch: jest.Mock const mockRange = { - start: new Date("2025-01-15T10:00:00Z"), - end: new Date("2025-01-16T10:00:00Z"), - }; + start: new Date('2025-01-15T10:00:00Z'), + end: new Date('2025-01-16T10:00:00Z') + } const mockState = { calendars: { list: { - "cal1/entry1": { id: "cal1/entry1", name: "Calendar 1", syncToken: 1 }, - "cal2/entry2": { id: "cal2/entry2", name: "Calendar 2", syncToken: 1 }, + 'cal1/entry1': { id: 'cal1/entry1', name: 'Calendar 1', syncToken: 1 }, + 'cal2/entry2': { id: 'cal2/entry2', name: 'Calendar 2', syncToken: 1 } }, - templist: {}, - }, - } as unknown as RootState; + templist: {} + } + } as unknown as RootState const mockAccumulators: { - calendarsToRefresh: Map; - calendarsToHide: Set; - debouncedUpdateFn?: (dispatch: AppDispatch) => void; - shouldRefreshCalendarListRef: React.MutableRefObject; - currentDebouncePeriod?: number; + calendarsToRefresh: Map + calendarsToHide: Set + debouncedUpdateFn?: (dispatch: AppDispatch) => void + shouldRefreshCalendarListRef: React.MutableRefObject + currentDebouncePeriod?: number } = { calendarsToRefresh: new Map(), calendarsToHide: new Set(), shouldRefreshCalendarListRef: { current: false }, currentDebouncePeriod: 0, - debouncedUpdateFn: jest.fn(), - }; + debouncedUpdateFn: jest.fn() + } beforeEach(() => { - jest.clearAllMocks(); - mockDispatch = jest.fn(); - (getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange); - (store.getState as jest.Mock).mockReturnValue(mockState); - mockAccumulators.calendarsToRefresh = new Map(); - mockAccumulators.calendarsToHide = new Set(); - mockAccumulators.currentDebouncePeriod = 0; - mockAccumulators.shouldRefreshCalendarListRef.current = false; - mockAccumulators.debouncedUpdateFn = jest.fn(); - }); + jest.clearAllMocks() + mockDispatch = jest.fn() + ;(getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange) + ;(store.getState as jest.Mock).mockReturnValue(mockState) + mockAccumulators.calendarsToRefresh = new Map() + mockAccumulators.calendarsToHide = new Set() + mockAccumulators.currentDebouncePeriod = 0 + mockAccumulators.shouldRefreshCalendarListRef.current = false + mockAccumulators.debouncedUpdateFn = jest.fn() + }) - it("should not dispatch for non-object messages", () => { - updateCalendars(null, mockDispatch, mockAccumulators); - updateCalendars("string", mockDispatch, mockAccumulators); - updateCalendars(123, mockDispatch, mockAccumulators); + it('should not dispatch for non-object messages', () => { + updateCalendars(null, mockDispatch, mockAccumulators) + updateCalendars('string', mockDispatch, mockAccumulators) + updateCalendars(123, mockDispatch, mockAccumulators) - expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled(); - }); + expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled() + }) - it("should dispatch for registered calendars", async () => { + it('should dispatch for registered calendars', async () => { const message = { [WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [ - "/calendars/cal1/entry1", - "/calendars/cal2/entry2", - ], - }; + '/calendars/cal1/entry1', + '/calendars/cal2/entry2' + ] + } - updateCalendars(message, mockDispatch, mockAccumulators); + updateCalendars(message, mockDispatch, mockAccumulators) await waitFor(() => expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2) - ); - }); + ) + }) - it("should dispatch for calendar path updates", async () => { + it('should dispatch for calendar path updates', async () => { const message = { - "/calendars/cal1/entry1": { updated: true }, - }; + '/calendars/cal1/entry1': { updated: true } + } - updateCalendars(message, mockDispatch, mockAccumulators); - await waitFor(() => - expect(refreshCalendarWithSyncToken).toHaveBeenCalled() - ); + updateCalendars(message, mockDispatch, mockAccumulators) + await waitFor(() => expect(refreshCalendarWithSyncToken).toHaveBeenCalled()) expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({ - calendar: mockState.calendars.list["cal1/entry1"], + calendar: mockState.calendars.list['cal1/entry1'], calType: undefined, - calendarRange: mockRange, - }); - }); + calendarRange: mockRange + }) + }) - it("should use displayed calendar range", async () => { + it('should use displayed calendar range', async () => { const message = { - "/calendars/cal1/entry1": {}, - }; + '/calendars/cal1/entry1': {} + } - updateCalendars(message, mockDispatch, mockAccumulators); - await waitFor(() => expect(getDisplayedCalendarRange).toHaveBeenCalled()); - }); + updateCalendars(message, mockDispatch, mockAccumulators) + await waitFor(() => expect(getDisplayedCalendarRange).toHaveBeenCalled()) + }) - it("should handle temp calendars", async () => { + it('should handle temp calendars', async () => { const stateWithTemp = { calendars: { list: {}, templist: { - "temp1/entry1": { - id: "temp1/entry1", - name: "Temp Calendar", - syncToken: 1, - }, - }, - }, - }; + 'temp1/entry1': { + id: 'temp1/entry1', + name: 'Temp Calendar', + syncToken: 1 + } + } + } + } - (store.getState as jest.Mock).mockReturnValue(stateWithTemp); + ;(store.getState as jest.Mock).mockReturnValue(stateWithTemp) const message = { - "/calendars/temp1/entry1": {}, - }; + '/calendars/temp1/entry1': {} + } - updateCalendars(message, mockDispatch, mockAccumulators); + updateCalendars(message, mockDispatch, mockAccumulators) await waitFor(() => expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({ - calendar: stateWithTemp.calendars.templist["temp1/entry1"], - calType: "temp", - calendarRange: mockRange, + calendar: stateWithTemp.calendars.templist['temp1/entry1'], + calType: 'temp', + calendarRange: mockRange }) - ); - }); + ) + }) - it("should handle invalid calendar paths gracefully", () => { + it('should handle invalid calendar paths gracefully', () => { const message = { - "/invalid/path": {}, - "not-a-path": {}, - }; + '/invalid/path': {}, + 'not-a-path': {} + } - updateCalendars(message, mockDispatch, mockAccumulators); + updateCalendars(message, mockDispatch, mockAccumulators) - expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled(); - }); -}); + expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled() + }) +}) diff --git a/__test__/features/websocket/websocketAPI/parseMessage.test.ts b/__test__/features/websocket/websocketAPI/parseMessage.test.ts index 6315fbc..ceaa65e 100644 --- a/__test__/features/websocket/websocketAPI/parseMessage.test.ts +++ b/__test__/features/websocket/websocketAPI/parseMessage.test.ts @@ -1,76 +1,76 @@ -import { parseMessage } from "@/websocket/messaging/parseMessage"; -import { WS_INBOUND_EVENTS } from "@/websocket/protocols"; +import { parseMessage } from '@/websocket/messaging/parseMessage' +import { WS_INBOUND_EVENTS } from '@/websocket/protocols' -describe("parseMessage", () => { - it("should return empty set for non-object messages", () => { - const result1 = parseMessage(null); - const result2 = parseMessage("string"); - const result3 = parseMessage(123); +describe('parseMessage', () => { + it('should return empty set for non-object messages', () => { + const result1 = parseMessage(null) + const result2 = parseMessage('string') + const result3 = parseMessage(123) - expect(result1.calendarsToRefresh).toEqual(new Set()); - expect(result2.calendarsToRefresh).toEqual(new Set()); - expect(result3.calendarsToRefresh).toEqual(new Set()); - }); + expect(result1.calendarsToRefresh).toEqual(new Set()) + expect(result2.calendarsToRefresh).toEqual(new Set()) + expect(result3.calendarsToRefresh).toEqual(new Set()) + }) - it("should handle registered event", () => { + it('should handle registered event', () => { const message = { [WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [ - "/calendars/cal1/entry1", - "/calendars/cal2/entry2", - ], - }; + '/calendars/cal1/entry1', + '/calendars/cal2/entry2' + ] + } - const result = parseMessage(message); + const result = parseMessage(message) - expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1"); - expect(result.calendarsToRefresh).toContain("/calendars/cal2/entry2"); - expect(result.calendarsToRefresh.size).toBe(2); - }); + expect(result.calendarsToRefresh).toContain('/calendars/cal1/entry1') + expect(result.calendarsToRefresh).toContain('/calendars/cal2/entry2') + expect(result.calendarsToRefresh.size).toBe(2) + }) - it("should handle unregistered event", () => { + it('should handle unregistered event', () => { const message = { - [WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ["/calendars/cal1/entry1"], - }; + [WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ['/calendars/cal1/entry1'] + } - const result = parseMessage(message); + const result = parseMessage(message) - expect(result.calendarsToHide).toContain("/calendars/cal1/entry1"); - }); + expect(result.calendarsToHide).toContain('/calendars/cal1/entry1') + }) - it("should handle calendar path updates", () => { + it('should handle calendar path updates', () => { const message = { - "/calendars/cal1/entry1": { updated: true }, - }; + '/calendars/cal1/entry1': { updated: true } + } - const result = parseMessage(message); + const result = parseMessage(message) - expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1"); - expect(result.calendarsToRefresh.size).toBe(1); - }); + expect(result.calendarsToRefresh).toContain('/calendars/cal1/entry1') + expect(result.calendarsToRefresh.size).toBe(1) + }) - it("should parse multiple calendar paths", () => { + it('should parse multiple calendar paths', () => { const message = { - "/calendars/cal1/entry1": {}, - "/calendars/cal2/entry2": {}, - }; + '/calendars/cal1/entry1': {}, + '/calendars/cal2/entry2': {} + } - const result = parseMessage(message); + const result = parseMessage(message) - expect(result.calendarsToRefresh.size).toBe(2); - expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1"); - expect(result.calendarsToRefresh).toContain("/calendars/cal2/entry2"); - }); + expect(result.calendarsToRefresh.size).toBe(2) + expect(result.calendarsToRefresh).toContain('/calendars/cal1/entry1') + expect(result.calendarsToRefresh).toContain('/calendars/cal2/entry2') + }) - it("should handle multiple event types in single message", () => { + it('should handle multiple event types in single message', () => { const message = { - [WS_INBOUND_EVENTS.CLIENT_REGISTERED]: ["/calendars/cal1/entry1"], - [WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ["/calendars/cal2/entry2"], - "/calendars/cal1/entry1": {}, - }; + [WS_INBOUND_EVENTS.CLIENT_REGISTERED]: ['/calendars/cal1/entry1'], + [WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ['/calendars/cal2/entry2'], + '/calendars/cal1/entry1': {} + } - const result = parseMessage(message); + const result = parseMessage(message) - expect(result.calendarsToRefresh.size).toBe(1); - expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1"); - }); -}); + expect(result.calendarsToRefresh.size).toBe(1) + expect(result.calendarsToRefresh).toContain('/calendars/cal1/entry1') + }) +}) diff --git a/__test__/features/websocket/websocketAPI/registerToCalendars.test.tsx b/__test__/features/websocket/websocketAPI/registerToCalendars.test.tsx index cf20800..fc77f93 100644 --- a/__test__/features/websocket/websocketAPI/registerToCalendars.test.tsx +++ b/__test__/features/websocket/websocketAPI/registerToCalendars.test.tsx @@ -1,57 +1,57 @@ -import { registerToCalendars } from "@/websocket/operations/registerToCalendars"; +import { registerToCalendars } from '@/websocket/operations/registerToCalendars' -describe("registerToCalendars", () => { - let mockSocket: any; +describe('registerToCalendars', () => { + let mockSocket: any beforeEach(() => { mockSocket = { readyState: WebSocket.OPEN, - send: jest.fn(), - }; - }); + send: jest.fn() + } + }) - it("should send registration message with calendar URIs", () => { - const calendarURIs = ["/calendars/cal1", "/calendars/cal2"]; + it('should send registration message with calendar URIs', () => { + const calendarURIs = ['/calendars/cal1', '/calendars/cal2'] - registerToCalendars(mockSocket, calendarURIs); + registerToCalendars(mockSocket, calendarURIs) expect(mockSocket.send).toHaveBeenCalledWith( JSON.stringify({ - register: calendarURIs, + register: calendarURIs }) - ); - }); + ) + }) - it("should throw error if socket is not open", () => { - mockSocket.readyState = WebSocket.CONNECTING; - const calendarURIs = ["/calendars/cal1"]; + it('should throw error if socket is not open', () => { + mockSocket.readyState = WebSocket.CONNECTING + const calendarURIs = ['/calendars/cal1'] expect(() => registerToCalendars(mockSocket, calendarURIs)).toThrow( - "Cannot register: WebSocket is not open" - ); - }); + 'Cannot register: WebSocket is not open' + ) + }) - it("should handle empty calendar list", () => { - registerToCalendars(mockSocket, []); + it('should handle empty calendar list', () => { + registerToCalendars(mockSocket, []) expect(mockSocket.send).toHaveBeenCalledWith( JSON.stringify({ - register: [], + register: [] }) - ); - }); + ) + }) - it("should log registration", () => { - const consoleInfoSpy = jest.spyOn(console, "info").mockImplementation(); - const calendarURIs = ["/calendars/cal1", "/calendars/cal2"]; + it('should log registration', () => { + const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation() + const calendarURIs = ['/calendars/cal1', '/calendars/cal2'] - registerToCalendars(mockSocket, calendarURIs); + registerToCalendars(mockSocket, calendarURIs) expect(consoleInfoSpy).toHaveBeenCalledWith( - "Registered to calendars", + 'Registered to calendars', calendarURIs - ); + ) - consoleInfoSpy.mockRestore(); - }); -}); + consoleInfoSpy.mockRestore() + }) +}) diff --git a/__test__/features/websocket/websocketAPI/unregisterToCalendars.test.tsx b/__test__/features/websocket/websocketAPI/unregisterToCalendars.test.tsx index bfa77c7..8530e22 100644 --- a/__test__/features/websocket/websocketAPI/unregisterToCalendars.test.tsx +++ b/__test__/features/websocket/websocketAPI/unregisterToCalendars.test.tsx @@ -1,58 +1,58 @@ -import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars"; +import { unregisterToCalendars } from '@/websocket/operations/unregisterToCalendars' -describe("unregisterToCalendars", () => { - let mockSocket: any; +describe('unregisterToCalendars', () => { + let mockSocket: any beforeEach(() => { mockSocket = { readyState: WebSocket.OPEN, - send: jest.fn(), - }; - jest.clearAllMocks(); - }); + send: jest.fn() + } + jest.clearAllMocks() + }) - it("should send unregistration message with calendar URIs", () => { - const calendarURIs = ["/calendars/cal1", "/calendars/cal2"]; + it('should send unregistration message with calendar URIs', () => { + const calendarURIs = ['/calendars/cal1', '/calendars/cal2'] - unregisterToCalendars(mockSocket, calendarURIs); + unregisterToCalendars(mockSocket, calendarURIs) expect(mockSocket.send).toHaveBeenCalledWith( JSON.stringify({ - unregister: calendarURIs, + unregister: calendarURIs }) - ); - }); + ) + }) - it("should throw error if socket is not open", () => { - mockSocket.readyState = WebSocket.CONNECTING; - const calendarURIs = ["/calendars/cal1"]; + it('should throw error if socket is not open', () => { + mockSocket.readyState = WebSocket.CONNECTING + const calendarURIs = ['/calendars/cal1'] expect(() => unregisterToCalendars(mockSocket, calendarURIs)).toThrow( - "Cannot unregister: WebSocket is not open" - ); - }); + 'Cannot unregister: WebSocket is not open' + ) + }) - it("should handle empty calendar list", () => { - unregisterToCalendars(mockSocket, []); + it('should handle empty calendar list', () => { + unregisterToCalendars(mockSocket, []) expect(mockSocket.send).toHaveBeenCalledWith( JSON.stringify({ - unregister: [], + unregister: [] }) - ); - }); + ) + }) - it("should log unregistration", () => { - const consoleInfoSpy = jest.spyOn(console, "info").mockImplementation(); - const calendarURIs = ["/calendars/cal1", "/calendars/cal2"]; + it('should log unregistration', () => { + const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation() + const calendarURIs = ['/calendars/cal1', '/calendars/cal2'] - unregisterToCalendars(mockSocket, calendarURIs); + unregisterToCalendars(mockSocket, calendarURIs) expect(consoleInfoSpy).toHaveBeenCalledWith( - "Unregistered to calendars", + 'Unregistered to calendars', calendarURIs - ); + ) - consoleInfoSpy.mockRestore(); - }); -}); + consoleInfoSpy.mockRestore() + }) +}) diff --git a/__test__/utils/CalendarRangeManager.test.ts b/__test__/utils/CalendarRangeManager.test.ts index 19f2041..137fff8 100644 --- a/__test__/utils/CalendarRangeManager.test.ts +++ b/__test__/utils/CalendarRangeManager.test.ts @@ -2,72 +2,72 @@ import { calendarRangeManager, getDisplayedCalendarRange, getDisplayedDate, - setDisplayedDateAndRange, -} from "@/utils/CalendarRangeManager"; + setDisplayedDateAndRange +} from '@/utils/CalendarRangeManager' -describe("CalendarRangeManager", () => { +describe('CalendarRangeManager', () => { beforeEach(() => { - setDisplayedDateAndRange(new Date()); - }); + setDisplayedDateAndRange(new Date()) + }) - it("should return a singleton instance", () => { - const instance1 = calendarRangeManager; - const instance2 = calendarRangeManager; + it('should return a singleton instance', () => { + const instance1 = calendarRangeManager + const instance2 = calendarRangeManager - expect(instance1).toStrictEqual(instance2); - }); + expect(instance1).toStrictEqual(instance2) + }) - it("should compute and return calendar range when date is set", () => { - const testDate = new Date("2025-06-15T10:00:00Z"); - setDisplayedDateAndRange(testDate); + it('should compute and return calendar range when date is set', () => { + const testDate = new Date('2025-06-15T10:00:00Z') + setDisplayedDateAndRange(testDate) - const range = getDisplayedCalendarRange(); - expect(range.start).toBeInstanceOf(Date); - expect(range.end).toBeInstanceOf(Date); - expect(range.start.getTime()).toBeLessThanOrEqual(testDate.getTime()); - expect(range.end.getTime()).toBeGreaterThanOrEqual(testDate.getTime()); - }); + const range = getDisplayedCalendarRange() + expect(range.start).toBeInstanceOf(Date) + expect(range.end).toBeInstanceOf(Date) + expect(range.start.getTime()).toBeLessThanOrEqual(testDate.getTime()) + expect(range.end.getTime()).toBeGreaterThanOrEqual(testDate.getTime()) + }) - it("should get the default date", () => { - const date = getDisplayedDate(); - expect(date).toBeInstanceOf(Date); - }); + it('should get the default date', () => { + const date = getDisplayedDate() + expect(date).toBeInstanceOf(Date) + }) - it("should set and get a date", () => { - const testDate = new Date("2025-01-15T10:00:00Z"); - setDisplayedDateAndRange(testDate); + it('should set and get a date', () => { + const testDate = new Date('2025-01-15T10:00:00Z') + setDisplayedDateAndRange(testDate) - const retrievedDate = getDisplayedDate(); - expect(retrievedDate).toStrictEqual(testDate); - }); + const retrievedDate = getDisplayedDate() + expect(retrievedDate).toStrictEqual(testDate) + }) - it("should persist date across multiple calls", () => { - const testDate = new Date("2025-06-20T15:30:00Z"); - setDisplayedDateAndRange(testDate); + it('should persist date across multiple calls', () => { + const testDate = new Date('2025-06-20T15:30:00Z') + setDisplayedDateAndRange(testDate) - const date1 = getDisplayedDate(); - const date2 = getDisplayedDate(); + const date1 = getDisplayedDate() + const date2 = getDisplayedDate() - expect(date1).toStrictEqual(date2); - expect(date1).toStrictEqual(testDate); - }); + expect(date1).toStrictEqual(date2) + expect(date1).toStrictEqual(testDate) + }) - it("should update date when set multiple times", () => { - const date1 = new Date("2025-01-01T00:00:00Z"); - const date2 = new Date("2025-12-31T23:59:59Z"); + it('should update date when set multiple times', () => { + const date1 = new Date('2025-01-01T00:00:00Z') + const date2 = new Date('2025-12-31T23:59:59Z') - setDisplayedDateAndRange(date1); - expect(getDisplayedDate()).toStrictEqual(date1); + setDisplayedDateAndRange(date1) + expect(getDisplayedDate()).toStrictEqual(date1) - setDisplayedDateAndRange(date2); - expect(getDisplayedDate()).toStrictEqual(date2); - }); + setDisplayedDateAndRange(date2) + expect(getDisplayedDate()).toStrictEqual(date2) + }) - it("should maintain shared state (singleton behavior)", () => { - const testDate = new Date("2025-03-01"); - setDisplayedDateAndRange(testDate); + it('should maintain shared state (singleton behavior)', () => { + const testDate = new Date('2025-03-01') + setDisplayedDateAndRange(testDate) // Verify CalendarRangeManager reflects the same state - expect(calendarRangeManager.getDate()).toStrictEqual(testDate); - }); -}); + expect(calendarRangeManager.getDate()).toStrictEqual(testDate) + }) +}) diff --git a/__test__/utils/Renderwithproviders.tsx b/__test__/utils/Renderwithproviders.tsx index 8e9be85..dd58cd4 100644 --- a/__test__/utils/Renderwithproviders.tsx +++ b/__test__/utils/Renderwithproviders.tsx @@ -1,15 +1,15 @@ -import type { AppStore, RootState } from "@/app/store"; -import { setupStore } from "@/app/store"; -import { TwakeMuiThemeProvider } from "@linagora/twake-mui"; -import type { RenderOptions } from "@testing-library/react"; -import { render } from "@testing-library/react"; -import React, { PropsWithChildren } from "react"; -import { Provider } from "react-redux"; -import { MemoryRouter } from "react-router-dom"; -import { I18nContext } from "twake-i18n"; -interface ExtendedRenderOptions extends Omit { - preloadedState?: Partial; - store?: AppStore; +import type { AppStore, RootState } from '@/app/store' +import { setupStore } from '@/app/store' +import { TwakeMuiThemeProvider } from '@linagora/twake-mui' +import type { RenderOptions } from '@testing-library/react' +import { render } from '@testing-library/react' +import React, { PropsWithChildren } from 'react' +import { Provider } from 'react-redux' +import { MemoryRouter } from 'react-router-dom' +import { I18nContext } from 'twake-i18n' +interface ExtendedRenderOptions extends Omit { + preloadedState?: Partial + store?: AppStore } export function renderWithProviders( @@ -18,7 +18,7 @@ export function renderWithProviders( extendedRenderOptions: ExtendedRenderOptions = {} ) { const { store = setupStore(preloadedState), ...renderOptions } = - extendedRenderOptions; + extendedRenderOptions const Wrapper = ({ children }: PropsWithChildren) => { return ( @@ -26,31 +26,31 @@ export function renderWithProviders( ) => { - if (key === "locale") { - return "en"; + if (key === 'locale') { + return 'en' } if (vars) { const params = Object.entries(vars) .map(([k, v]) => `${k}=${v}`) - .join(","); - return `${key}(${params})`; + .join(',') + return `${key}(${params})` } - return key; + return key }, f: (date: Date, formatStr: string) => date.toString(), - lang: "en", + lang: 'en' }} > - + {children} - ); - }; + ) + } return { store, - ...render(ui, { wrapper: Wrapper, ...renderOptions }), - }; + ...render(ui, { wrapper: Wrapper, ...renderOptions }) + } } diff --git a/__test__/utils/avatarUtils.test.ts b/__test__/utils/avatarUtils.test.ts index 8f06d4f..a897315 100644 --- a/__test__/utils/avatarUtils.test.ts +++ b/__test__/utils/avatarUtils.test.ts @@ -1,113 +1,113 @@ -import { getInitials, stringToGradient } from "@/utils/avatarUtils"; +import { getInitials, stringToGradient } from '@/utils/avatarUtils' -jest.mock("@linagora/twake-mui", () => ({ +jest.mock('@linagora/twake-mui', () => ({ nameToColor: jest.fn((name: string) => { - if (!name) return undefined; + if (!name) return undefined const colors = [ - "sunrise", - "downy", - "sugarCoral", - "pinkBonnet", - "blueMana", - "nightBlue", - "snowPea", - "pluviophile", - "cornflower", - "paleGreen", - "moonBlue", - ]; + 'sunrise', + 'downy', + 'sugarCoral', + 'pinkBonnet', + 'blueMana', + 'nightBlue', + 'snowPea', + 'pluviophile', + 'cornflower', + 'paleGreen', + 'moonBlue' + ] const hash = Array.from(name.toUpperCase()) - .map((letter) => letter.charCodeAt(0)) - .reduce((sum, number) => sum + number, 0); - return colors[hash % colors.length]; - }), -})); + .map(letter => letter.charCodeAt(0)) + .reduce((sum, number) => sum + number, 0) + return colors[hash % colors.length] + }) +})) -describe("avatarUtils", () => { - describe("getInitials", () => { - it("returns 2-letter initials for full name", () => { - expect(getInitials("John Doe")).toBe("JD"); - expect(getInitials("Alice Smith")).toBe("AS"); - expect(getInitials("Nguyễn Văn")).toBe("NV"); - }); +describe('avatarUtils', () => { + describe('getInitials', () => { + it('returns 2-letter initials for full name', () => { + expect(getInitials('John Doe')).toBe('JD') + expect(getInitials('Alice Smith')).toBe('AS') + expect(getInitials('Nguyễn Văn')).toBe('NV') + }) - it("returns single letter for single word", () => { - expect(getInitials("Alice")).toBe("A"); - expect(getInitials("John")).toBe("J"); - }); + it('returns single letter for single word', () => { + expect(getInitials('Alice')).toBe('A') + expect(getInitials('John')).toBe('J') + }) - it("returns uppercase initials", () => { - expect(getInitials("john doe")).toBe("JD"); - expect(getInitials("ALICE SMITH")).toBe("AS"); - expect(getInitials("a")).toBe("A"); - }); + it('returns uppercase initials', () => { + expect(getInitials('john doe')).toBe('JD') + expect(getInitials('ALICE SMITH')).toBe('AS') + expect(getInitials('a')).toBe('A') + }) - it("handles multiple spaces between words", () => { - expect(getInitials("John Doe")).toBe("JD"); - expect(getInitials("Alice Smith Brown")).toBe("AS"); - }); + it('handles multiple spaces between words', () => { + expect(getInitials('John Doe')).toBe('JD') + expect(getInitials('Alice Smith Brown')).toBe('AS') + }) - it("handles email addresses", () => { - expect(getInitials("john.doe@email.com")).toBe("J"); - expect(getInitials("test@example.com")).toBe("T"); - }); + it('handles email addresses', () => { + expect(getInitials('john.doe@email.com')).toBe('J') + expect(getInitials('test@example.com')).toBe('T') + }) - it("handles empty string", () => { - expect(getInitials("")).toBe(""); - }); + it('handles empty string', () => { + expect(getInitials('')).toBe('') + }) - it("handles whitespace-only string", () => { - expect(getInitials(" ")).toBe(""); - }); + it('handles whitespace-only string', () => { + expect(getInitials(' ')).toBe('') + }) - it("handles single character", () => { - expect(getInitials("A")).toBe("A"); - expect(getInitials("a")).toBe("A"); - }); + it('handles single character', () => { + expect(getInitials('A')).toBe('A') + expect(getInitials('a')).toBe('A') + }) - it("handles names with special characters", () => { - expect(getInitials("Jean-Pierre")).toBe("J"); - expect(getInitials("Mary Jane Watson")).toBe("MJ"); - }); + it('handles names with special characters', () => { + expect(getInitials('Jean-Pierre')).toBe('J') + expect(getInitials('Mary Jane Watson')).toBe('MJ') + }) - it("handles unicode characters", () => { - expect(getInitials("Nguyễn Văn")).toBe("NV"); - expect(getInitials("José María")).toBe("JM"); - }); + it('handles unicode characters', () => { + expect(getInitials('Nguyễn Văn')).toBe('NV') + expect(getInitials('José María')).toBe('JM') + }) - it("takes first two words for names with more than two words", () => { - expect(getInitials("John Michael Smith")).toBe("JM"); - expect(getInitials("Alice Bob Charlie")).toBe("AB"); - }); - }); + it('takes first two words for names with more than two words', () => { + expect(getInitials('John Michael Smith')).toBe('JM') + expect(getInitials('Alice Bob Charlie')).toBe('AB') + }) + }) - describe("stringToGradient", () => { - it("returns color for valid string", () => { - const result = stringToGradient("John Doe"); - expect(result).toBeDefined(); - expect(typeof result).toBe("string"); - }); + describe('stringToGradient', () => { + it('returns color for valid string', () => { + const result = stringToGradient('John Doe') + expect(result).toBeDefined() + expect(typeof result).toBe('string') + }) - it("returns same color for same input", () => { - const result1 = stringToGradient("John Doe"); - const result2 = stringToGradient("John Doe"); - expect(result1).toBe(result2); - }); + it('returns same color for same input', () => { + const result1 = stringToGradient('John Doe') + const result2 = stringToGradient('John Doe') + expect(result1).toBe(result2) + }) - it("returns undefined for empty string", () => { - expect(stringToGradient("")).toBeUndefined(); - }); + it('returns undefined for empty string', () => { + expect(stringToGradient('')).toBeUndefined() + }) - it("returns color for single character", () => { - const result = stringToGradient("A"); - expect(result).toBeDefined(); - expect(typeof result).toBe("string"); - }); + it('returns color for single character', () => { + const result = stringToGradient('A') + expect(result).toBeDefined() + expect(typeof result).toBe('string') + }) - it("returns color for email address", () => { - const result = stringToGradient("test@example.com"); - expect(result).toBeDefined(); - expect(typeof result).toBe("string"); - }); - }); -}); + it('returns color for email address', () => { + const result = stringToGradient('test@example.com') + expect(result).toBeDefined() + expect(typeof result).toBe('string') + }) + }) +}) diff --git a/__test__/utils/buildDelegatedEvent.test.ts b/__test__/utils/buildDelegatedEvent.test.ts index 0a31073..5556237 100644 --- a/__test__/utils/buildDelegatedEvent.test.ts +++ b/__test__/utils/buildDelegatedEvent.test.ts @@ -1,51 +1,51 @@ -import { Calendar } from "@/features/Calendars/CalendarTypes"; -import { buildDelegatedEventURL } from "@/features/Events/utils"; +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { buildDelegatedEventURL } from '@/features/Events/utils' const makeCalendar = (link: string): Calendar => ({ - id: "user2/cal1", + id: 'user2/cal1', delegated: true, link, - owner: { emails: ["owner@example.com"] }, - }) as Calendar; + owner: { emails: ['owner@example.com'] } + }) as Calendar -describe("buildDelegatedEventURL", () => { - it("rebases event filename onto calendar link base path", () => { - const calendar = makeCalendar("/calendars/user2/cal1.json"); - const eventURL = "/calendars/someother/path/event-abc.ics"; +describe('buildDelegatedEventURL', () => { + it('rebases event filename onto calendar link base path', () => { + const calendar = makeCalendar('/calendars/user2/cal1.json') + const eventURL = '/calendars/someother/path/event-abc.ics' expect(buildDelegatedEventURL(calendar, eventURL)).toBe( - "/calendars/user2/cal1/event-abc.ics" - ); - }); + '/calendars/user2/cal1/event-abc.ics' + ) + }) - it("strips .json suffix from calendar link", () => { - const calendar = makeCalendar("/calendars/user2/cal1.json"); - const eventURL = "/calendars/user2/cal1/event-abc.ics"; - const result = buildDelegatedEventURL(calendar, eventURL); - expect(result).not.toContain(".json"); - }); + it('strips .json suffix from calendar link', () => { + const calendar = makeCalendar('/calendars/user2/cal1.json') + const eventURL = '/calendars/user2/cal1/event-abc.ics' + const result = buildDelegatedEventURL(calendar, eventURL) + expect(result).not.toContain('.json') + }) - it("preserves the exact filename from the event URL", () => { - const calendar = makeCalendar("/calendars/user2/cal1.json"); - const eventURL = "/calendars/user2/cal1/some-uid-with-dashes.ics"; + it('preserves the exact filename from the event URL', () => { + const calendar = makeCalendar('/calendars/user2/cal1.json') + const eventURL = '/calendars/user2/cal1/some-uid-with-dashes.ics' expect(buildDelegatedEventURL(calendar, eventURL)).toBe( - "/calendars/user2/cal1/some-uid-with-dashes.ics" - ); - }); + '/calendars/user2/cal1/some-uid-with-dashes.ics' + ) + }) - it("throws when event URL has no filename", () => { - const calendar = makeCalendar("/calendars/user2/cal1.json"); - const eventURL = ""; + it('throws when event URL has no filename', () => { + const calendar = makeCalendar('/calendars/user2/cal1.json') + const eventURL = '' expect(() => buildDelegatedEventURL(calendar, eventURL)).toThrow( /Cannot extract filename from event URL/ - ); - }); + ) + }) - it("works with nested calendar link paths", () => { - const calendar = makeCalendar("/dav/calendars/users/user2/cal1.json"); - const eventURL = "/other/path/event-xyz.ics"; + it('works with nested calendar link paths', () => { + const calendar = makeCalendar('/dav/calendars/users/user2/cal1.json') + const eventURL = '/other/path/event-xyz.ics' expect(buildDelegatedEventURL(calendar, eventURL)).toBe( - "/dav/calendars/users/user2/cal1/event-xyz.ics" - ); - }); -}); + '/dav/calendars/users/user2/cal1/event-xyz.ics' + ) + }) +}) diff --git a/__test__/utils/calendarUtils.test.tsx b/__test__/utils/calendarUtils.test.tsx index 5951f58..086d89c 100644 --- a/__test__/utils/calendarUtils.test.tsx +++ b/__test__/utils/calendarUtils.test.tsx @@ -1,231 +1,231 @@ import { eventToFullCalendarFormat, - getCalendarVisibility, -} from "@/components/Calendar/utils/calendarUtils"; -import { Calendar, DelegationAccess } from "@/features/Calendars/CalendarTypes"; -import { AclEntry } from "@/features/Calendars/types/CalendarData"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { userOrganiser } from "@/features/User/userDataTypes"; + getCalendarVisibility +} from '@/components/Calendar/utils/calendarUtils' +import { Calendar, DelegationAccess } from '@/features/Calendars/CalendarTypes' +import { AclEntry } from '@/features/Calendars/types/CalendarData' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { userOrganiser } from '@/features/User/userDataTypes' -describe("getCalendarVisibility", () => { +describe('getCalendarVisibility', () => { it("returns 'public' when {DAV:}authenticated has {DAV:}read", () => { const acl: AclEntry[] = [ { - privilege: "{DAV:}read", - principal: "{DAV:}authenticated", - protected: true, - }, - ]; - expect(getCalendarVisibility(acl)).toBe("public"); - }); + privilege: '{DAV:}read', + principal: '{DAV:}authenticated', + protected: true + } + ] + expect(getCalendarVisibility(acl)).toBe('public') + }) it("returns 'private' when {DAV:}authenticated only has read-free-busy", () => { const acl: AclEntry[] = [ { - privilege: "{urn:ietf:params:xml:ns:caldav}read-free-busy", - principal: "{DAV:}authenticated", - protected: true, - }, - ]; - expect(getCalendarVisibility(acl)).toBe("private"); - }); + privilege: '{urn:ietf:params:xml:ns:caldav}read-free-busy', + principal: '{DAV:}authenticated', + protected: true + } + ] + expect(getCalendarVisibility(acl)).toBe('private') + }) it("returns 'private' when {DAV:}authenticated is not present", () => { const acl: AclEntry[] = [ { - privilege: "{DAV:}read", - principal: "principals/users/123", - protected: true, - }, - ]; - expect(getCalendarVisibility(acl)).toBe("private"); - }); + privilege: '{DAV:}read', + principal: 'principals/users/123', + protected: true + } + ] + expect(getCalendarVisibility(acl)).toBe('private') + }) it("ignores non-authenticated principals and still returns 'private'", () => { const acl: AclEntry[] = [ { - privilege: "{DAV:}read", - principal: "principals/users/other", - protected: true, + privilege: '{DAV:}read', + principal: 'principals/users/other', + protected: true }, { - privilege: "{DAV:}write", - principal: "principals/users/other", - protected: true, - }, - ]; - expect(getCalendarVisibility(acl)).toBe("private"); - }); + privilege: '{DAV:}write', + principal: 'principals/users/other', + protected: true + } + ] + expect(getCalendarVisibility(acl)).toBe('private') + }) - it("stops early when it finds a {DAV:}read for {DAV:}authenticated", () => { + it('stops early when it finds a {DAV:}read for {DAV:}authenticated', () => { const acl: AclEntry[] = [ { - privilege: "{DAV:}read", - principal: "{DAV:}authenticated", - protected: true, + privilege: '{DAV:}read', + principal: '{DAV:}authenticated', + protected: true }, { - privilege: "{DAV:}write", - principal: "{DAV:}authenticated", - protected: true, - }, - ]; - expect(getCalendarVisibility(acl)).toBe("public"); - }); -}); + privilege: '{DAV:}write', + principal: '{DAV:}authenticated', + protected: true + } + ] + expect(getCalendarVisibility(acl)).toBe('public') + }) +}) -jest.mock("twake-i18n", () => ({ - useI18n: () => ({ t: (key: string) => key }), -})); +jest.mock('twake-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }) +})) const baseEvent: CalendarEvent = { - uid: "event-1", - calId: "user1/cal1", - title: "Test Event", - start: "2024-01-15T10:00:00", - end: "2024-01-15T11:00:00", + uid: 'event-1', + calId: 'user1/cal1', + title: 'Test Event', + start: '2024-01-15T10:00:00', + end: '2024-01-15T11:00:00', allday: false, - organizer: { cal_address: "owner@example.com" } as userOrganiser, - color: { light: "#FF0000" }, - class: "PUBLIC", -} as unknown as CalendarEvent; + organizer: { cal_address: 'owner@example.com' } as userOrganiser, + color: { light: '#FF0000' }, + class: 'PUBLIC' +} as unknown as CalendarEvent const makeCalendar = (overrides: Partial = {}): Calendar => ({ - id: "user1/cal1", - name: "Test Calendar", + id: 'user1/cal1', + name: 'Test Calendar', delegated: false, access: {} as DelegationAccess, - owner: { emails: ["alice@example.com"], firstname: "Alice" }, - color: { light: "#FF0000" }, + owner: { emails: ['alice@example.com'], firstname: 'Alice' }, + color: { light: '#FF0000' }, events: {}, - ...overrides, - }) as Calendar; + ...overrides + }) as Calendar const callFormat = ( event: CalendarEvent, calendars: Record, - userAddress = "alice@example.com", - userId = "user1" + userAddress = 'alice@example.com', + userId = 'user1' ) => - eventToFullCalendarFormat([event], [], userId, userAddress, false, calendars); + eventToFullCalendarFormat([event], [], userId, userAddress, false, calendars) -describe("eventToFullCalendarFormat - editable flag", () => { - describe("personal calendar (non-delegated)", () => { - const calendar = makeCalendar({ delegated: false }); +describe('eventToFullCalendarFormat - editable flag', () => { + describe('personal calendar (non-delegated)', () => { + const calendar = makeCalendar({ delegated: false }) - it("is editable when user is organizer", () => { + it('is editable when user is organizer', () => { const event = { ...baseEvent, - organizer: { cal_address: "alice@example.com" }, - } as CalendarEvent; - const [result] = callFormat(event, { "user1/cal1": calendar }); - expect(result.editable).toBe(true); - }); + organizer: { cal_address: 'alice@example.com' } + } as CalendarEvent + const [result] = callFormat(event, { 'user1/cal1': calendar }) + expect(result.editable).toBe(true) + }) - it("is not editable when user is not organizer", () => { + it('is not editable when user is not organizer', () => { const event = { ...baseEvent, - organizer: { cal_address: "other@example.com" }, - } as CalendarEvent; - const [result] = callFormat(event, { "user1/cal1": calendar }); - expect(result.editable).toBe(false); - }); + organizer: { cal_address: 'other@example.com' } + } as CalendarEvent + const [result] = callFormat(event, { 'user1/cal1': calendar }) + expect(result.editable).toBe(false) + }) - it("is not editable when pending", () => { + it('is not editable when pending', () => { const event = { ...baseEvent, - organizer: { cal_address: "alice@example.com" }, - } as CalendarEvent; + organizer: { cal_address: 'alice@example.com' } + } as CalendarEvent const [result] = eventToFullCalendarFormat( [event], [], - "user1", - "alice@example.com", + 'user1', + 'alice@example.com', true, - { "user1/cal1": calendar } - ); - expect(result.editable).toBe(false); - }); - }); + { 'user1/cal1': calendar } + ) + expect(result.editable).toBe(false) + }) + }) - describe("delegated calendar", () => { + describe('delegated calendar', () => { const writeDelegatedCalendar = makeCalendar({ - id: "user2/cal1", + id: 'user2/cal1', delegated: true, access: { write: true } as DelegationAccess, - owner: { emails: ["owner@example.com"], firstname: "Owner" }, - }); + owner: { emails: ['owner@example.com'], firstname: 'Owner' } + }) const readDelegatedCalendar = makeCalendar({ - id: "user2/cal1", + id: 'user2/cal1', delegated: true, access: { read: true } as DelegationAccess, - owner: { emails: ["owner@example.com"], firstname: "Owner" }, - }); - const event = { ...baseEvent, calId: "user2/cal1" }; + owner: { emails: ['owner@example.com'], firstname: 'Owner' } + }) + const event = { ...baseEvent, calId: 'user2/cal1' } - it("is editable when owner is organizer and delegated user has write rights", () => { + it('is editable when owner is organizer and delegated user has write rights', () => { const [result] = callFormat( { ...event, - organizer: { cal_address: "owner@example.com" }, + organizer: { cal_address: 'owner@example.com' } } as CalendarEvent, - { "user2/cal1": writeDelegatedCalendar } - ); - expect(result.editable).toBe(true); - }); + { 'user2/cal1': writeDelegatedCalendar } + ) + expect(result.editable).toBe(true) + }) - it("is not editable when owner is organizer but delegated user only has read access", () => { + it('is not editable when owner is organizer but delegated user only has read access', () => { const [result] = callFormat( { ...event, - organizer: { cal_address: "owner@example.com" }, + organizer: { cal_address: 'owner@example.com' } } as CalendarEvent, - { "user2/cal1": readDelegatedCalendar } - ); - expect(result.editable).toBe(false); - }); + { 'user2/cal1': readDelegatedCalendar } + ) + expect(result.editable).toBe(false) + }) - it("is not editable when owner is not organizer", () => { + it('is not editable when owner is not organizer', () => { const [result] = callFormat( { ...event, - organizer: { cal_address: "someone-else@example.com" }, + organizer: { cal_address: 'someone-else@example.com' } } as CalendarEvent, - { "user2/cal1": writeDelegatedCalendar } - ); - expect(result.editable).toBe(false); - }); + { 'user2/cal1': writeDelegatedCalendar } + ) + expect(result.editable).toBe(false) + }) - it("is not editable when pending even if owner is organizer", () => { + it('is not editable when pending even if owner is organizer', () => { const [result] = eventToFullCalendarFormat( [ { ...event, - organizer: { cal_address: "owner@example.com" }, - } as CalendarEvent, + organizer: { cal_address: 'owner@example.com' } + } as CalendarEvent ], [], - "user1", - "alice@example.com", + 'user1', + 'alice@example.com', true, - { "user2/cal1": writeDelegatedCalendar } - ); - expect(result.editable).toBe(false); - }); + { 'user2/cal1': writeDelegatedCalendar } + ) + expect(result.editable).toBe(false) + }) - it("uses owner email not logged-in user email for organizer check", () => { + it('uses owner email not logged-in user email for organizer check', () => { // logged-in user is alice, owner is owner@example.com // organizer matches owner → editable const [result] = callFormat( { ...event, - organizer: { cal_address: "owner@example.com" }, + organizer: { cal_address: 'owner@example.com' } } as CalendarEvent, - { "user2/cal1": writeDelegatedCalendar }, - "alice@example.com" // logged-in user, should NOT be used for delegated - ); - expect(result.editable).toBe(true); - }); - }); -}); + { 'user2/cal1': writeDelegatedCalendar }, + 'alice@example.com' // logged-in user, should NOT be used for delegated + ) + expect(result.editable).toBe(true) + }) + }) +}) diff --git a/__test__/utils/createEventContext.test.ts b/__test__/utils/createEventContext.test.ts index 351f45a..6b8973f 100644 --- a/__test__/utils/createEventContext.test.ts +++ b/__test__/utils/createEventContext.test.ts @@ -1,120 +1,120 @@ -import { createEventContext } from "@/features/Events/createEventContext"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { userData } from "@/features/User/userDataTypes"; -import { userAttendee } from "@/features/User/models/attendee"; +import { createEventContext } from '@/features/Events/createEventContext' +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { userData } from '@/features/User/userDataTypes' +import { userAttendee } from '@/features/User/models/attendee' const makeUser = (email: string): userData => ({ email, - given_name: "Alice", - family_name: "User", - name: "Alice User", - sid: "user1", - sub: "user1", -}); + given_name: 'Alice', + family_name: 'User', + name: 'Alice User', + sid: 'user1', + sub: 'user1' +}) const makeEvent = (overrides: Partial = {}): CalendarEvent => ({ - uid: "event-1", - calId: "user1/cal1", - title: "Test Event", - start: "2024-01-15T10:00:00", - end: "2024-01-15T11:00:00", - organizer: { cal_address: "alice@example.com" }, + uid: 'event-1', + calId: 'user1/cal1', + title: 'Test Event', + start: '2024-01-15T10:00:00', + end: '2024-01-15T11:00:00', + organizer: { cal_address: 'alice@example.com' }, attendee: [ - { cal_address: "alice@example.com", partstat: "ACCEPTED" }, - { cal_address: "owner@example.com", partstat: "ACCEPTED" }, - { cal_address: "other@example.com", partstat: "NEEDS-ACTION" }, + { cal_address: 'alice@example.com', partstat: 'ACCEPTED' }, + { cal_address: 'owner@example.com', partstat: 'ACCEPTED' }, + { cal_address: 'other@example.com', partstat: 'NEEDS-ACTION' } ], - ...overrides, - }) as CalendarEvent; + ...overrides + }) as CalendarEvent const makeCalendar = (overrides: Partial = {}): Calendar => ({ - id: "user1/cal1", - name: "Test Calendar", + id: 'user1/cal1', + name: 'Test Calendar', delegated: false, - owner: { emails: ["alice@example.com"], firstname: "Alice" }, + owner: { emails: ['alice@example.com'], firstname: 'Alice' }, events: {}, - ...overrides, - }) as Calendar; + ...overrides + }) as Calendar -describe("createEventContext", () => { - describe("non-delegated calendar", () => { - const calendar = makeCalendar({ delegated: false }); - const user = makeUser("alice@example.com"); +describe('createEventContext', () => { + describe('non-delegated calendar', () => { + const calendar = makeCalendar({ delegated: false }) + const user = makeUser('alice@example.com') - it("finds currentUserAttendee by logged-in user email", () => { - const event = makeEvent(); - const ctx = createEventContext(event, calendar, user); - expect(ctx.currentUserAttendee?.cal_address).toBe("alice@example.com"); - }); + it('finds currentUserAttendee by logged-in user email', () => { + const event = makeEvent() + const ctx = createEventContext(event, calendar, user) + expect(ctx.currentUserAttendee?.cal_address).toBe('alice@example.com') + }) - it("returns null currentUserAttendee when user is not an attendee", () => { - const event = makeEvent({ attendee: [] }); - const ctx = createEventContext(event, calendar, user); - expect(ctx.currentUserAttendee).toBeUndefined(); - }); + it('returns null currentUserAttendee when user is not an attendee', () => { + const event = makeEvent({ attendee: [] }) + const ctx = createEventContext(event, calendar, user) + expect(ctx.currentUserAttendee).toBeUndefined() + }) - it("isOwn is true when user email is in owner emails", () => { - const ctx = createEventContext(makeEvent(), calendar, user); - expect(ctx.isOwn).toBe(true); - }); + it('isOwn is true when user email is in owner emails', () => { + const ctx = createEventContext(makeEvent(), calendar, user) + expect(ctx.isOwn).toBe(true) + }) - it("isOwn is false when user email is not in owner emails", () => { - const other = makeUser("other@example.com"); - const ctx = createEventContext(makeEvent(), calendar, other); - expect(ctx.isOwn).toBe(false); - }); - }); + it('isOwn is false when user email is not in owner emails', () => { + const other = makeUser('other@example.com') + const ctx = createEventContext(makeEvent(), calendar, other) + expect(ctx.isOwn).toBe(false) + }) + }) - describe("delegated calendar", () => { + describe('delegated calendar', () => { const calendar = makeCalendar({ - id: "user2/cal1", + id: 'user2/cal1', delegated: true, - owner: { emails: ["owner@example.com"], firstname: "Owner" }, - }); - const user = makeUser("alice@example.com"); // logged-in user, not the owner + owner: { emails: ['owner@example.com'], firstname: 'Owner' } + }) + const user = makeUser('alice@example.com') // logged-in user, not the owner - it("finds currentUserAttendee by owner email, not logged-in user email", () => { - const event = makeEvent({ calId: "user2/cal1" }); - const ctx = createEventContext(event, calendar, user); - expect(ctx.currentUserAttendee?.cal_address).toBe("owner@example.com"); - }); + it('finds currentUserAttendee by owner email, not logged-in user email', () => { + const event = makeEvent({ calId: 'user2/cal1' }) + const ctx = createEventContext(event, calendar, user) + expect(ctx.currentUserAttendee?.cal_address).toBe('owner@example.com') + }) - it("does not find logged-in user as attendee for delegated calendar", () => { + it('does not find logged-in user as attendee for delegated calendar', () => { const event = makeEvent({ - calId: "user2/cal1", + calId: 'user2/cal1', attendee: [ { - cal_address: "owner@example.com", - partstat: "ACCEPTED", - } as userAttendee, - ], - }); - const ctx = createEventContext(event, calendar, user); + cal_address: 'owner@example.com', + partstat: 'ACCEPTED' + } as userAttendee + ] + }) + const ctx = createEventContext(event, calendar, user) // currentUserAttendee should be owner's, not alice's - expect(ctx.currentUserAttendee?.cal_address).toBe("owner@example.com"); - }); + expect(ctx.currentUserAttendee?.cal_address).toBe('owner@example.com') + }) - it("returns undefined currentUserAttendee when owner is not an attendee", () => { + it('returns undefined currentUserAttendee when owner is not an attendee', () => { const event = makeEvent({ - calId: "user2/cal1", + calId: 'user2/cal1', attendee: [ { - cal_address: "alice@example.com", - partstat: "ACCEPTED", - } as userAttendee, - ], - }); - const ctx = createEventContext(event, calendar, user); - expect(ctx.currentUserAttendee).toBeUndefined(); - }); + cal_address: 'alice@example.com', + partstat: 'ACCEPTED' + } as userAttendee + ] + }) + const ctx = createEventContext(event, calendar, user) + expect(ctx.currentUserAttendee).toBeUndefined() + }) - it("passes event through unchanged", () => { - const event = makeEvent({ calId: "user2/cal1" }); - const ctx = createEventContext(event, calendar, user); - expect(ctx.event).toBe(event); - }); - }); -}); + it('passes event through unchanged', () => { + const event = makeEvent({ calId: 'user2/cal1' }) + const ctx = createEventContext(event, calendar, user) + expect(ctx.event).toBe(event) + }) + }) +}) diff --git a/__test__/utils/dateUtils.test.ts b/__test__/utils/dateUtils.test.ts index f27c8b1..17f7169 100644 --- a/__test__/utils/dateUtils.test.ts +++ b/__test__/utils/dateUtils.test.ts @@ -1,38 +1,38 @@ -import { getCalendarRange } from "@/utils/dateUtils"; +import { getCalendarRange } from '@/utils/dateUtils' -describe("getCalendarRange", () => { - it("Nov 2025 (5 weeks): 2025-10-27 to 2025-11-30", () => { +describe('getCalendarRange', () => { + it('Nov 2025 (5 weeks): 2025-10-27 to 2025-11-30', () => { const { start, end } = getCalendarRange( - new Date("2025-11-01T00:00:00.000Z") - ); - expect(start.toISOString().slice(0, 10)).toBe("2025-10-27"); - expect(end.toISOString().slice(0, 10)).toBe("2025-11-30"); - }); + new Date('2025-11-01T00:00:00.000Z') + ) + expect(start.toISOString().slice(0, 10)).toBe('2025-10-27') + expect(end.toISOString().slice(0, 10)).toBe('2025-11-30') + }) - it("Dec 2025 (6 weeks): end Sunday 2026-01-04", () => { + it('Dec 2025 (6 weeks): end Sunday 2026-01-04', () => { const { start, end } = getCalendarRange( - new Date("2025-12-01T00:00:00.000Z") - ); - expect(start.toISOString().slice(0, 10)).toBe("2025-12-01"); - expect(end.toISOString().slice(0, 10)).toBe("2026-01-04"); - }); + new Date('2025-12-01T00:00:00.000Z') + ) + expect(start.toISOString().slice(0, 10)).toBe('2025-12-01') + expect(end.toISOString().slice(0, 10)).toBe('2026-01-04') + }) - it("Feb 2025 (short month): end Sunday 2025-03-02", () => { + it('Feb 2025 (short month): end Sunday 2025-03-02', () => { const { start, end } = getCalendarRange( - new Date("2025-02-01T00:00:00.000Z") - ); - expect(start.toISOString().slice(0, 10)).toBe("2025-01-27"); - expect(end.toISOString().slice(0, 10)).toBe("2025-03-02"); - }); + new Date('2025-02-01T00:00:00.000Z') + ) + expect(start.toISOString().slice(0, 10)).toBe('2025-01-27') + expect(end.toISOString().slice(0, 10)).toBe('2025-03-02') + }) - it("sets boundary times correctly (start 00:00, end 23:59)", () => { + it('sets boundary times correctly (start 00:00, end 23:59)', () => { const { start, end } = getCalendarRange( - new Date("2025-11-01T00:00:00.000Z") - ); - expect(start.getHours()).toBe(0); - expect(start.getMinutes()).toBe(0); - expect(start.getSeconds()).toBe(0); - expect(end.getHours()).toBe(23); - expect(end.getMinutes()).toBe(59); - }); -}); + new Date('2025-11-01T00:00:00.000Z') + ) + expect(start.getHours()).toBe(0) + expect(start.getMinutes()).toBe(0) + expect(start.getSeconds()).toBe(0) + expect(end.getHours()).toBe(23) + expect(end.getMinutes()).toBe(59) + }) +}) diff --git a/__test__/utils/findCalendarById.test.ts b/__test__/utils/findCalendarById.test.ts index 6676a87..a8b9fac 100644 --- a/__test__/utils/findCalendarById.test.ts +++ b/__test__/utils/findCalendarById.test.ts @@ -1,99 +1,99 @@ -import { RootState } from "@/app/store"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; -import { findCalendarById } from "@/utils"; +import { RootState } from '@/app/store' +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { findCalendarById } from '@/utils' -describe("findCalendarById", () => { +describe('findCalendarById', () => { const mockCalendar1: Calendar = { - id: "cal1", - name: "Personal", - color: { light: "#FF0000" }, - } as unknown as Calendar; + id: 'cal1', + name: 'Personal', + color: { light: '#FF0000' } + } as unknown as Calendar const mockCalendar2: Calendar = { - id: "cal2", - name: "Work", - color: { light: "#0000FF" }, - } as unknown as Calendar; + id: 'cal2', + name: 'Work', + color: { light: '#0000FF' } + } as unknown as Calendar const mockTempCalendar: Calendar = { - id: "temp1", - name: "Temporary", - color: { light: "#00FF00" }, - } as unknown as Calendar; + id: 'temp1', + name: 'Temporary', + color: { light: '#00FF00' } + } as unknown as Calendar const mockState = { calendars: { list: { cal1: mockCalendar1, - cal2: mockCalendar2, + cal2: mockCalendar2 }, templist: { - temp1: mockTempCalendar, - }, - }, - } as unknown as Partial; + temp1: mockTempCalendar + } + } + } as unknown as Partial - it("should find calendar in main list", () => { - const result = findCalendarById(mockState, "cal1"); + it('should find calendar in main list', () => { + const result = findCalendarById(mockState, 'cal1') - expect(result?.calendar).toEqual(mockCalendar1); - expect(result?.type).toBeUndefined(); - }); + expect(result?.calendar).toEqual(mockCalendar1) + expect(result?.type).toBeUndefined() + }) - it("should find calendar in temp list", () => { - const result = findCalendarById(mockState, "temp1"); + it('should find calendar in temp list', () => { + const result = findCalendarById(mockState, 'temp1') - expect(result?.calendar).toEqual(mockTempCalendar); - expect(result?.type).toBe("temp"); - }); + expect(result?.calendar).toEqual(mockTempCalendar) + expect(result?.type).toBe('temp') + }) - it("should not return calendar for non-existent id", () => { - const result = findCalendarById(mockState, "nonexistent"); + it('should not return calendar for non-existent id', () => { + const result = findCalendarById(mockState, 'nonexistent') - expect(result).toBeUndefined(); - }); + expect(result).toBeUndefined() + }) - it("should not return calendar for empty string", () => { - const result = findCalendarById(mockState, ""); + it('should not return calendar for empty string', () => { + const result = findCalendarById(mockState, '') - expect(result).toBeUndefined(); - }); + expect(result).toBeUndefined() + }) - it("should prioritize temp list over main list", () => { + it('should prioritize temp list over main list', () => { const stateWithDuplicate = { calendars: { list: { - dup1: mockCalendar1, + dup1: mockCalendar1 }, templist: { - dup1: mockTempCalendar, - }, - }, - } as unknown as Partial; + dup1: mockTempCalendar + } + } + } as unknown as Partial - const result = findCalendarById(stateWithDuplicate, "dup1"); + const result = findCalendarById(stateWithDuplicate, 'dup1') - expect(result?.calendar).toEqual(mockTempCalendar); - expect(result?.type).toBe("temp"); - }); + expect(result?.calendar).toEqual(mockTempCalendar) + expect(result?.type).toBe('temp') + }) - it("should handle undefined list or templist", () => { + it('should handle undefined list or templist', () => { const stateWithPartialCalendars = { calendars: { list: undefined, - templist: { temp1: mockTempCalendar }, - }, - } as unknown as Partial; + templist: { temp1: mockTempCalendar } + } + } as unknown as Partial - const result = findCalendarById(stateWithPartialCalendars, "temp1"); - expect(result?.calendar).toEqual(mockTempCalendar); - }); + const result = findCalendarById(stateWithPartialCalendars, 'temp1') + expect(result?.calendar).toEqual(mockTempCalendar) + }) - it("should handle missing calendars state", () => { - const emptyState = {}; + it('should handle missing calendars state', () => { + const emptyState = {} - const result = findCalendarById(emptyState, "cal1"); + const result = findCalendarById(emptyState, 'cal1') - expect(result).toBeUndefined(); - }); -}); + expect(result).toBeUndefined() + }) +}) diff --git a/__test__/utils/userUtils.test.ts b/__test__/utils/userUtils.test.ts index 173e38c..8d6295e 100644 --- a/__test__/utils/userUtils.test.ts +++ b/__test__/utils/userUtils.test.ts @@ -1,92 +1,92 @@ -import { userData } from "@/features/User/userDataTypes"; -import { getUserDisplayName } from "@/utils/userUtils"; +import { userData } from '@/features/User/userDataTypes' +import { getUserDisplayName } from '@/utils/userUtils' -describe("userUtils", () => { - describe("getUserDisplayName", () => { - it("returns full name when user has name and family_name", () => { +describe('userUtils', () => { + describe('getUserDisplayName', () => { + it('returns full name when user has name and family_name', () => { const user: userData = { - sub: "test", - email: "test@test.com", - family_name: "Doe", - given_name: "John", - name: "John", - sid: "mockSid", - }; + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + given_name: 'John', + name: 'John', + sid: 'mockSid' + } - expect(getUserDisplayName(user)).toBe("John Doe"); - }); + expect(getUserDisplayName(user)).toBe('John Doe') + }) - it("returns email when user does not have name and family_name", () => { + it('returns email when user does not have name and family_name', () => { const user: userData = { - sub: "test", - email: "test@test.com", - family_name: "", - given_name: "", - name: "", - sid: "mockSid", - }; + sub: 'test', + email: 'test@test.com', + family_name: '', + given_name: '', + name: '', + sid: 'mockSid' + } - expect(getUserDisplayName(user)).toBe("test@test.com"); - }); + expect(getUserDisplayName(user)).toBe('test@test.com') + }) - it("returns email when user has only email", () => { + it('returns email when user has only email', () => { const user: userData = { - sub: "test", - email: "user@example.com", - family_name: "", - given_name: "", - name: "", - sid: "mockSid", - }; + sub: 'test', + email: 'user@example.com', + family_name: '', + given_name: '', + name: '', + sid: 'mockSid' + } - expect(getUserDisplayName(user)).toBe("user@example.com"); - }); + expect(getUserDisplayName(user)).toBe('user@example.com') + }) - it("returns empty string when user is null", () => { - expect(getUserDisplayName(null)).toBe(""); - }); + it('returns empty string when user is null', () => { + expect(getUserDisplayName(null)).toBe('') + }) - it("returns empty string when user is undefined", () => { - expect(getUserDisplayName(undefined)).toBe(""); - }); + it('returns empty string when user is undefined', () => { + expect(getUserDisplayName(undefined)).toBe('') + }) - it("returns empty string when user has no name, family_name, or email", () => { + it('returns empty string when user has no name, family_name, or email', () => { const user: userData = { - sub: "test", - email: "", - family_name: "", - given_name: "", - name: "", - sid: "mockSid", - }; + sub: 'test', + email: '', + family_name: '', + given_name: '', + name: '', + sid: 'mockSid' + } - expect(getUserDisplayName(user)).toBe(""); - }); + expect(getUserDisplayName(user)).toBe('') + }) - it("handles user with only name (no family_name)", () => { + it('handles user with only name (no family_name)', () => { const user: userData = { - sub: "test", - email: "test@test.com", - family_name: "", - given_name: "John", - name: "John", - sid: "mockSid", - }; + sub: 'test', + email: 'test@test.com', + family_name: '', + given_name: 'John', + name: 'John', + sid: 'mockSid' + } - expect(getUserDisplayName(user)).toBe("test@test.com"); - }); + expect(getUserDisplayName(user)).toBe('test@test.com') + }) - it("handles user with only family_name (no name)", () => { + it('handles user with only family_name (no name)', () => { const user: userData = { - sub: "test", - email: "test@test.com", - family_name: "Doe", - given_name: "", - name: "", - sid: "mockSid", - }; + sub: 'test', + email: 'test@test.com', + family_name: 'Doe', + given_name: '', + name: '', + sid: 'mockSid' + } - expect(getUserDisplayName(user)).toBe("test@test.com"); - }); - }); -}); + expect(getUserDisplayName(user)).toBe('test@test.com') + }) + }) +}) diff --git a/babel.config.js b/babel.config.js index 3c6ae13..72598f2 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,6 +1,6 @@ module.exports = { presets: [ - ["@babel/preset-env", { targets: { node: "current" } }], - ["@babel/preset-react", { runtime: "automatic", importSource: "preact" }], - ], -}; + ['@babel/preset-env', { targets: { node: 'current' } }], + ['@babel/preset-react', { runtime: 'automatic', importSource: 'preact' }] + ] +} diff --git a/eslint.config.js b/eslint.config.js index cafa9c6..5d45332 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,74 +1,99 @@ -import js from "@eslint/js"; -import tseslint from "typescript-eslint"; -import react from "eslint-plugin-react"; -import reactHooks from "eslint-plugin-react-hooks"; -import importPlugin from "eslint-plugin-import"; -import jest from "eslint-plugin-jest"; -import prettier from "eslint-config-prettier"; +import js from '@eslint/js' +import tseslint from 'typescript-eslint' +import tanstackQuery from '@tanstack/eslint-plugin-query' +import cozyReact from 'eslint-config-cozy-app/react' + +// Flatten cozyReact config (may be an object or array) +const cozyReactConfigs = Array.isArray(cozyReact) ? cozyReact : [cozyReact] export default [ { - ignores: ["dist", "build", "node_modules"], + ignores: [ + 'dist/', + 'build/', + 'node_modules/', + 'coverage/', + 'public/', + '*.config.js', + '*.config.ts', + 'fileTransformer.ts' + ] }, js.configs.recommended, ...tseslint.configs.recommended, + // TanStack Query recommended rules + ...tanstackQuery.configs['flat/recommended'], + + // Cozy React recommended rules + ...cozyReactConfigs, + { - files: ["**/*.{js,jsx,ts,tsx}"], + files: ['**/*.{js,jsx,ts,tsx}'], languageOptions: { parser: tseslint.parser, parserOptions: { - ecmaVersion: "latest", - sourceType: "module", + ecmaVersion: 'latest', + sourceType: 'module', ecmaFeatures: { jsx: true }, - project: true, - }, - }, - - plugins: { - react, - "react-hooks": reactHooks, - import: importPlugin, - jest, + project: true + } }, settings: { react: { - version: "detect", - }, + version: 'detect' + } }, rules: { /* React */ - "react/react-in-jsx-scope": "off", // React 18+ - "react/prop-types": "off", + 'react/react-in-jsx-scope': 'off', // React 18+ + 'react/prop-types': 'off', /* Hooks */ - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn", + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', /* Imports */ - "import/order": "off", + 'import/order': 'off', /* TS */ - "@typescript-eslint/no-unused-vars": [ - "error", - { argsIgnorePattern: "^_" }, + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' } ], - /* Jest */ - "jest/no-disabled-tests": "warn", - "jest/no-focused-tests": "error", + // TO DO: Turn these back on warning + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-unsafe-call': 'warn', + '@typescript-eslint/explicit-function-return-type': 'warn', + '@typescript-eslint/no-unsafe-assignment': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-unsafe-member-access': 'warn', + '@typescript-eslint/no-unsafe-return': 'warn', + '@typescript-eslint/no-base-to-string': 'warn', + '@typescript-eslint/no-misused-promises': 'warn', + '@typescript-eslint/no-dynamic-delete': 'warn', + '@typescript-eslint/await-thenable': 'warn', + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-redundant-type-constituents': 'warn', + '@typescript-eslint/require-await': 'warn', - /* Prettier compatibility */ - ...prettier.rules, + /* Jest */ + 'jest/no-disabled-tests': 'warn', + 'jest/no-focused-tests': 'error', + + /* Keep cozyReact prettier options but skip singleQuote/semi to avoid unnecessary changes */ + 'prettier/prettier': 'error', /* No noises */ - "no-debugger": "error", - "no-console": ["warn", { allow: ["info", "warn", "error"] }], - }, - }, -]; + 'no-debugger': 'error', + 'no-console': ['warn', { allow: ['info', 'warn', 'error'] }] + } + } +] diff --git a/fileTransformer.ts b/fileTransformer.ts index 2c32ed7..b172650 100644 --- a/fileTransformer.ts +++ b/fileTransformer.ts @@ -1,9 +1,9 @@ -const path = require("path"); +const path = require('path') module.exports = { process(sourceText, sourcePath, options) { return { - code: `module.exports = ${JSON.stringify(path.basename(sourcePath))};`, - }; - }, -}; + code: `module.exports = ${JSON.stringify(path.basename(sourcePath))};` + } + } +} diff --git a/jest.config.ts b/jest.config.ts index a02a470..11c9348 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,78 +1,78 @@ -import type { Config } from "jest"; +import type { Config } from 'jest' // Set timezone to UTC for consistent test results across all environments -process.env.TZ = "UTC"; +process.env.TZ = 'UTC' const config: Config = { collectCoverage: true, - coverageDirectory: "coverage", + coverageDirectory: 'coverage', projects: [ { - displayName: "dom", + displayName: 'dom', clearMocks: true, moduleFileExtensions: [ - "js", - "mjs", - "cjs", - "jsx", - "ts", - "tsx", - "json", - "node", + 'js', + 'mjs', + 'cjs', + 'jsx', + 'ts', + 'tsx', + 'json', + 'node' ], - testEnvironment: "jsdom", - testMatch: ["**/*.test.tsx"], + testEnvironment: 'jsdom', + testMatch: ['**/*.test.tsx'], testTimeout: 15000, transform: { - "^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json" }], - "^.+\\.(js|jsx|mjs)$": "babel-jest", - "^.+\\.(css|scss|sass|less|styl|stylus)$": - "jest-preview/transforms/css", - "^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)": - "jest-preview/transforms/file", - "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": - "/fileTransformer.ts", + '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }], + '^.+\\.(js|jsx|mjs)$': 'babel-jest', + '^.+\\.(css|scss|sass|less|styl|stylus)$': + 'jest-preview/transforms/css', + '^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)': + 'jest-preview/transforms/file', + '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': + '/fileTransformer.ts' }, transformIgnorePatterns: [ - "/node_modules/(?!(preact|@fullcalendar|react-calendar|get-user-locale|memoize|mimic-function|@wojtekmaj|ky|cozy-ui|p-map|@linagora/twake-mui|@lottiefiles)/)", + '/node_modules/(?!(preact|@fullcalendar|react-calendar|get-user-locale|memoize|mimic-function|@wojtekmaj|ky|cozy-ui|p-map|@linagora/twake-mui|@lottiefiles)/)' ], moduleNameMapper: { - "^preact(/(.*)|$)": "preact$1", - "^react$": "/node_modules/react", - "^react-dom$": "/node_modules/react-dom", - "^@/(.*)$": "/src/$1", + '^preact(/(.*)|$)': 'preact$1', + '^react$': '/node_modules/react', + '^react-dom$': '/node_modules/react-dom', + '^@/(.*)$': '/src/$1' }, - setupFilesAfterEnv: ["/src/setupTests.ts"], + setupFilesAfterEnv: ['/src/setupTests.ts'] }, { - displayName: "node", + displayName: 'node', clearMocks: true, moduleFileExtensions: [ - "js", - "mjs", - "cjs", - "jsx", - "ts", - "tsx", - "json", - "node", + 'js', + 'mjs', + 'cjs', + 'jsx', + 'ts', + 'tsx', + 'json', + 'node' ], - testEnvironment: "node", - testMatch: ["**/*.test.ts"], + testEnvironment: 'node', + testMatch: ['**/*.test.ts'], testTimeout: 15000, transform: { - "^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json" }], - "^.+\\.(js|jsx|mjs)$": "babel-jest", + '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }], + '^.+\\.(js|jsx|mjs)$': 'babel-jest' }, - transformIgnorePatterns: ["/node_modules/(?!(ky|@linagora/twake-mui)/)"], - setupFilesAfterEnv: ["/src/setupTests.ts"], + transformIgnorePatterns: ['/node_modules/(?!(ky|@linagora/twake-mui)/)'], + setupFilesAfterEnv: ['/src/setupTests.ts'], moduleNameMapper: { - "^@/(.*)$": "/src/$1", - }, - }, - ], -}; + '^@/(.*)$': '/src/$1' + } + } + ] +} -export default config; +export default config diff --git a/package-lock.json b/package-lock.json index c4589e9..787d662 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@mui/x-date-pickers": "^8.14.0", "@reduxjs/toolkit": "^2.8.2", "cozy-external-bridge": "^1.2.1", + "date-fns": "^4.1.0", "dayjs": "^1.11.19", "i18next": "^23.7.9", "ical.js": "^2.2.0", @@ -50,6 +51,7 @@ "@rsbuild/plugin-react": "^1.4.2", "@rsbuild/plugin-stylus": "^1.2.0", "@rsbuild/plugin-svgr": "^1.2.3", + "@tanstack/eslint-plugin-query": "^5.95.2", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -63,13 +65,9 @@ "@typescript-eslint/parser": "^8.48.1", "babel-jest": "^30.2.0", "babel-plugin-istanbul": "^7.0.1", - "eslint": "^9.39.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jest": "^29.13.0", + "eslint": "10.0.2", + "eslint-config-cozy-app": "7.0.0", "eslint-plugin-promise": "^7.2.1", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", "history": "^5.3.0", "i18next-resources-for-ts": "1.4.0", "jest": "^30.2.0", @@ -2332,198 +2330,111 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "node_modules/@eslint/compat": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.2.tgz", + "integrity": "sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/core": "^1.1.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^8.40 || 9 || 10" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", - "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.1.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", - "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.3", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", - "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@fullcalendar/core": { @@ -4333,6 +4244,29 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.95.2.tgz", + "integrity": "sha512-EYUFRaqjBep4EHMPpZR12sXP7Kr5qv9iDIlq93NfbhHwhITaW6Txu3ROO6dLFz5r84T8p+oZXBG77pa2Wuok7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.48.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "^5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -4516,6 +4450,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -6751,19 +6692,13 @@ } }, "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/dayjs": { @@ -7365,33 +7300,30 @@ } }, "node_modules/eslint": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", - "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", + "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.3", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.2", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.0", + "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.1", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.1.1", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -7401,8 +7333,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -7410,7 +7341,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -7424,6 +7355,36 @@ } } }, + "node_modules/eslint-config-cozy-app": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-cozy-app/-/eslint-config-cozy-app-7.0.0.tgz", + "integrity": "sha512-NphYDGxEfzQPSQr2RNivCKhxqJviJ5HPtKcjkPWjVlV/OFuaaIwgGF3QPQ6qlDKRxayY/JK3zMbGuf4U2j9DSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint/compat": "2.0.2", + "@eslint/js": "10.0.1", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-import": "2.32.0", + "eslint-plugin-jest": "29.15.0", + "eslint-plugin-prettier": "5.5.5", + "eslint-plugin-promise": "7.2.1", + "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "7.0.1", + "globals": "17.4.0", + "prettier": "3.8.1", + "typescript-eslint": "8.56.1" + }, + "peerDependencies": { + "eslint": "^10.0.0", + "typescript": "^5.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/eslint-config-prettier": { "version": "10.1.8", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", @@ -7595,6 +7556,37 @@ } } }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-plugin-promise": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-7.2.1.tgz", @@ -7756,49 +7748,33 @@ "node": ">=10" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint/node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7847,19 +7823,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", - "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/eslint/node_modules/p-locate": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", @@ -7877,31 +7840,31 @@ } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -8046,6 +8009,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -8579,9 +8549,9 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", "dev": true, "license": "MIT", "engines": { @@ -11193,13 +11163,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -11363,13 +11326,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", - "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -12261,6 +12224,19 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -14104,6 +14080,22 @@ "react-dom": "^16 || ^17 || ^18" } }, + "node_modules/twake-i18n/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 805db56..acff403 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@mui/x-date-pickers": "^8.14.0", "@reduxjs/toolkit": "^2.8.2", "cozy-external-bridge": "^1.2.1", + "date-fns": "^4.1.0", "dayjs": "^1.11.19", "i18next": "^23.7.9", "ical.js": "^2.2.0", @@ -69,6 +70,7 @@ "@rsbuild/plugin-react": "^1.4.2", "@rsbuild/plugin-stylus": "^1.2.0", "@rsbuild/plugin-svgr": "^1.2.3", + "@tanstack/eslint-plugin-query": "^5.95.2", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -82,13 +84,9 @@ "@typescript-eslint/parser": "^8.48.1", "babel-jest": "^30.2.0", "babel-plugin-istanbul": "^7.0.1", - "eslint": "^9.39.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jest": "^29.13.0", + "eslint": "10.0.2", + "eslint-config-cozy-app": "7.0.0", "eslint-plugin-promise": "^7.2.1", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", "history": "^5.3.0", "i18next-resources-for-ts": "1.4.0", "jest": "^30.2.0", @@ -99,5 +97,9 @@ "ts-node": "^10.9.2", "typescript": "^4.9.5", "typescript-eslint": "^8.54.0" + }, + "overrides": { + "eslint": "$eslint", + "typescript": "$typescript" } } diff --git a/public/.env.example.js b/public/.env.example.js index 82612c5..de3c67d 100644 --- a/public/.env.example.js +++ b/public/.env.example.js @@ -1,18 +1,18 @@ -var SSO_BASE_URL = "https://example.com"; -var SSO_CLIENT_ID = "example"; -var SSO_SCOPE = "openid profile email"; -var SSO_REDIRECT_URI = "https://example.com/callback"; -var SSO_RESPONSE_TYPE = "code"; -var SSO_CODE_CHALLENGE_METHOD = "S256"; -var SSO_POST_LOGOUT_REDIRECT = "http://example.com?logout=1"; -var CALENDAR_BASE_URL = "https://calendar.example.com"; -var DAV_BASE_URL = "https://dav.example.com"; -var MAIL_SPA_URL = "https://mail.example.com"; -var VIDEO_CONFERENCE_BASE_URL = "https://meet.linagora.com"; -var SUPPORT_URL = "https://twake.app/support/"; -var DEBUG = false; -var LANG = "en"; -var WEBSOCKET_URL = "wss://calendar.example.com"; -var WS_DEBOUNCE_PERIOD_MS = 100; // milliseconds, remove or set to 0 to disable debounce -var WS_PING_PERIOD_MS = 30000; -var WS_PING_TIMEOUT_PERIOD_MS = 35000; +var SSO_BASE_URL = 'https://example.com' +var SSO_CLIENT_ID = 'example' +var SSO_SCOPE = 'openid profile email' +var SSO_REDIRECT_URI = 'https://example.com/callback' +var SSO_RESPONSE_TYPE = 'code' +var SSO_CODE_CHALLENGE_METHOD = 'S256' +var SSO_POST_LOGOUT_REDIRECT = 'http://example.com?logout=1' +var CALENDAR_BASE_URL = 'https://calendar.example.com' +var DAV_BASE_URL = 'https://dav.example.com' +var MAIL_SPA_URL = 'https://mail.example.com' +var VIDEO_CONFERENCE_BASE_URL = 'https://meet.linagora.com' +var SUPPORT_URL = 'https://twake.app/support/' +var DEBUG = false +var LANG = 'en' +var WEBSOCKET_URL = 'wss://calendar.example.com' +var WS_DEBOUNCE_PERIOD_MS = 100 // milliseconds, remove or set to 0 to disable debounce +var WS_PING_PERIOD_MS = 30000 +var WS_PING_TIMEOUT_PERIOD_MS = 35000 diff --git a/public/appList.example.js b/public/appList.example.js index 37d54e0..038c3c8 100644 --- a/public/appList.example.js +++ b/public/appList.example.js @@ -1,17 +1,17 @@ var appList = [ { - name: "Chat", - link: "/twake", - icon: "/assets/images/svg/app-chat.svg", + name: 'Chat', + link: '/twake', + icon: '/assets/images/svg/app-chat.svg' }, { - name: "Drive", - link: "/drive", - icon: "/assets/images/svg/app-drive.svg", + name: 'Drive', + link: '/drive', + icon: '/assets/images/svg/app-drive.svg' }, { - name: "Mail", - link: "/mail", - icon: "/assets/images/svg/app-mail.svg", - }, -]; + name: 'Mail', + link: '/mail', + icon: '/assets/images/svg/app-mail.svg' + } +] diff --git a/public/fonts/fonts.css b/public/fonts/fonts.css index 36abe1b..6069c3f 100644 --- a/public/fonts/fonts.css +++ b/public/fonts/fonts.css @@ -1,22 +1,22 @@ @font-face { - font-family: "Inter"; - src: url("InterVariable.ttf") format("truetype"); + font-family: 'Inter'; + src: url('InterVariable.ttf') format('truetype'); font-weight: 100 900; font-style: normal; font-display: swap; } @font-face { - font-family: "Roboto"; - src: url("Roboto-VariableFont_wdth,wght.ttf") format("truetype"); + font-family: 'Roboto'; + src: url('Roboto-VariableFont_wdth,wght.ttf') format('truetype'); font-weight: 100 900; font-style: normal; font-display: swap; } @font-face { - font-family: "Cal Sans"; - src: url("CalSans-SemiBold.woff2") format("woff2"); + font-family: 'Cal Sans'; + src: url('CalSans-SemiBold.woff2') format('woff2'); font-weight: 600; font-style: normal; font-display: swap; diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 71c1992..d7932fc 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -1,32 +1,32 @@ -import { defineConfig } from "@rsbuild/core"; -import { pluginReact } from "@rsbuild/plugin-react"; -import { pluginStylus } from "@rsbuild/plugin-stylus"; -import { pluginSvgr } from "@rsbuild/plugin-svgr"; +import { defineConfig } from '@rsbuild/core' +import { pluginReact } from '@rsbuild/plugin-react' +import { pluginStylus } from '@rsbuild/plugin-stylus' +import { pluginSvgr } from '@rsbuild/plugin-svgr' export default defineConfig({ plugins: [pluginReact(), pluginStylus(), pluginSvgr()], html: { - template: "./public/index.html", + template: './public/index.html' }, server: { port: 5000, - historyApiFallback: true, + historyApiFallback: true }, source: { entry: { - index: "./src/index.tsx", - }, + index: './src/index.tsx' + } }, output: { distPath: { - root: "dist", + root: 'dist' }, - minify: false, + minify: false }, resolve: { alias: { - react: require.resolve("react"), - "react-dom": require.resolve("react-dom"), - }, - }, -}); + react: require.resolve('react'), + 'react-dom': require.resolve('react-dom') + } + } +}) diff --git a/src/App.tsx b/src/App.tsx index b0ad299..a40df7c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,84 +1,84 @@ -import { TwakeMuiThemeProvider } from "@linagora/twake-mui"; -import { Suspense, useEffect, useState } from "react"; -import { Route, Routes } from "react-router-dom"; -import { push } from "redux-first-history"; -import { HistoryRouter as Router } from "redux-first-history/rr6"; -import "./App.styl"; -import { useAppDispatch, useAppSelector } from "./app/hooks"; -import { history } from "./app/store"; -import CalendarLayout from "./components/Calendar/CalendarLayout"; -import { Error as ErrorPage } from "./components/Error/Error"; -import { ErrorSnackbar } from "./components/Error/ErrorSnackbar"; -import { Loading } from "./components/Loading/Loading"; -import { AVAILABLE_LANGUAGES } from "./features/Settings/constants"; -import HandleLogin from "./features/User/HandleLogin"; -import { CallbackResume } from "./features/User/LoginCallback"; -import { useInitializeApp } from "./features/User/useInitializeApp"; -import { ScreenTooSmall } from "./ScreenTooSmall"; -import { WebSocketGate } from "./websocket/WebSocketGate"; +import { TwakeMuiThemeProvider } from '@linagora/twake-mui' +import { Suspense, useEffect, useState } from 'react' +import { Route, Routes } from 'react-router-dom' +import { push } from 'redux-first-history' +import { HistoryRouter as Router } from 'redux-first-history/rr6' +import './App.styl' +import { useAppDispatch, useAppSelector } from './app/hooks' +import { history } from './app/store' +import CalendarLayout from './components/Calendar/CalendarLayout' +import { Error as ErrorPage } from './components/Error/Error' +import { ErrorSnackbar } from './components/Error/ErrorSnackbar' +import { Loading } from './components/Loading/Loading' +import { AVAILABLE_LANGUAGES } from './features/Settings/constants' +import HandleLogin from './features/User/HandleLogin' +import { CallbackResume } from './features/User/LoginCallback' +import { useInitializeApp } from './features/User/useInitializeApp' +import { ScreenTooSmall } from './ScreenTooSmall' +import { WebSocketGate } from './websocket/WebSocketGate' import { enGB, fr as frLocale, ru as ruLocale, - vi as viLocale, -} from "date-fns/locale"; + vi as viLocale +} from 'date-fns/locale' -import I18n from "twake-i18n"; -import en from "./locales/en.json"; -import fr from "./locales/fr.json"; -import ru from "./locales/ru.json"; -import vi from "./locales/vi.json"; +import I18n from 'twake-i18n' +import en from './locales/en.json' +import fr from './locales/fr.json' +import ru from './locales/ru.json' +import vi from './locales/vi.json' -const locale = { en, fr, ru, vi }; -const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale }; +const locale = { en, fr, ru, vi } +const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale } -const SUPPORTED_LANGUAGES = AVAILABLE_LANGUAGES.map((lang) => lang.code); -type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; +const SUPPORTED_LANGUAGES = AVAILABLE_LANGUAGES.map(lang => lang.code) +type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number] const isValidLanguage = ( lang: string | null | undefined ): lang is SupportedLanguage => { - return !!lang && SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage); -}; + return !!lang && SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage) +} function App() { - const error = useAppSelector((state) => state.user.error); - const appLoading = useAppSelector((state) => state.loading.isLoading); - const userLanguage = useAppSelector( - (state) => state.user.coreConfig.language - ); - const settingsLanguage = useAppSelector((state) => state.settings.language); - const savedLang = localStorage.getItem("lang"); - const defaultLang = window.LANG; + const error = useAppSelector(state => state.user.error) + const appLoading = useAppSelector(state => state.loading.isLoading) + const userLanguage = useAppSelector(state => state.user.coreConfig.language) + const settingsLanguage = useAppSelector(state => state.settings.language) + const savedLang = localStorage.getItem('lang') + const defaultLang = window.LANG const lang = [userLanguage, settingsLanguage, savedLang, defaultLang].find( - (l) => !!l && isValidLanguage(l) - ) || "en"; + l => !!l && isValidLanguage(l) + ) || 'en' - const dispatch = useAppDispatch(); + const dispatch = useAppDispatch() useEffect(() => { if (error) { - dispatch(push("/error")); + dispatch(push('/error')) } - }, [error, dispatch]); + }, [error, dispatch]) - const SMALL_SCREEN_QUERY = "(max-width: 925px)"; + const SMALL_SCREEN_QUERY = '(max-width: 925px)' const [isTooSmall, setIsTooSmall] = useState( () => window.matchMedia(SMALL_SCREEN_QUERY).matches - ); + ) - useInitializeApp(); + useInitializeApp() useEffect(() => { - const mediaQuery = window.matchMedia(SMALL_SCREEN_QUERY); + const mediaQuery = window.matchMedia(SMALL_SCREEN_QUERY) const onChange = (event: MediaQueryListEvent) => - setIsTooSmall(event.matches); - setIsTooSmall(mediaQuery.matches); - mediaQuery.addEventListener("change", onChange); - return () => mediaQuery.removeEventListener("change", onChange); - }, []); + setIsTooSmall(event.matches) + const onChangeInitial = () => setIsTooSmall(mediaQuery.matches) + + onChangeInitial() + mediaQuery.addEventListener('change', onChange) + return () => mediaQuery.removeEventListener('change', onChange) + }, []) return ( @@ -108,7 +108,7 @@ function App() { )} - ); + ) } -export default App; +export default App diff --git a/src/Intl.d.ts b/src/Intl.d.ts index c5edc51..f1a16aa 100644 --- a/src/Intl.d.ts +++ b/src/Intl.d.ts @@ -1,11 +1,11 @@ declare namespace Intl { type Key = - | "calendar" - | "collation" - | "currency" - | "numberingSystem" - | "timeZone" - | "unit"; + | 'calendar' + | 'collation' + | 'currency' + | 'numberingSystem' + | 'timeZone' + | 'unit' - function supportedValuesOf(input: Key): string[]; + function supportedValuesOf(input: Key): string[] } diff --git a/src/ScreenTooSmall.tsx b/src/ScreenTooSmall.tsx index 9ccf169..195d65c 100644 --- a/src/ScreenTooSmall.tsx +++ b/src/ScreenTooSmall.tsx @@ -1,32 +1,32 @@ -import calendarImage from "@/static/images/calendar.svg"; -import { Box, Typography } from "@linagora/twake-mui"; -import { useI18n } from "twake-i18n"; +import calendarImage from '@/static/images/calendar.svg' +import { Box, Typography } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' export function ScreenTooSmall() { - const { t } = useI18n(); + const { t } = useI18n() return ( logo - {t("mobile.comingSoon.title")} + {t('mobile.comingSoon.title')} - {t("mobile.comingSoon.subtitle")} + {t('mobile.comingSoon.subtitle')} - ); + ) } diff --git a/src/app/hooks.ts b/src/app/hooks.ts index 7fd1eea..31f67a3 100644 --- a/src/app/hooks.ts +++ b/src/app/hooks.ts @@ -1,5 +1,5 @@ -import { useDispatch, useSelector } from "react-redux"; -import type { AppDispatch, RootState } from "./store"; +import { useDispatch, useSelector } from 'react-redux' +import type { AppDispatch, RootState } from './store' -export const useAppDispatch = useDispatch.withTypes(); -export const useAppSelector = useSelector.withTypes(); +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector = useSelector.withTypes() diff --git a/src/app/loadingSlice.ts b/src/app/loadingSlice.ts index 6eb9a52..b5de07e 100644 --- a/src/app/loadingSlice.ts +++ b/src/app/loadingSlice.ts @@ -1,30 +1,30 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { createSlice, PayloadAction } from '@reduxjs/toolkit' interface LoadingState { - isLoading: boolean; - message?: string; + isLoading: boolean + message?: string } const initialState: LoadingState = { isLoading: false, - message: undefined, -}; + message: undefined +} const loadingSlice = createSlice({ - name: "loading", + name: 'loading', initialState, reducers: { setAppLoading: (state, action: PayloadAction) => { - state.isLoading = action.payload; + state.isLoading = action.payload if (!action.payload) { - state.message = undefined; + state.message = undefined } }, setLoadingMessage: (state, action: PayloadAction) => { - state.message = action.payload; - }, - }, -}); + state.message = action.payload + } + } +}) -export const { setAppLoading, setLoadingMessage } = loadingSlice.actions; -export default loadingSlice.reducer; +export const { setAppLoading, setLoadingMessage } = loadingSlice.actions +export default loadingSlice.reducer diff --git a/src/app/selectors/selectCalendars.tsx b/src/app/selectors/selectCalendars.tsx index fde5bf7..37dfb49 100644 --- a/src/app/selectors/selectCalendars.tsx +++ b/src/app/selectors/selectCalendars.tsx @@ -1,7 +1,7 @@ -import { RootState } from "@/app/store"; -import { createSelector } from "@reduxjs/toolkit"; +import { RootState } from '@/app/store' +import { createSelector } from '@reduxjs/toolkit' export const selectCalendars = createSelector( (state: RootState) => state.calendars.list, - (list) => Object.values(list) -); + list => Object.values(list) +) diff --git a/src/app/store.ts b/src/app/store.ts index 595852c..c831016 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -1,14 +1,14 @@ -import eventsCalendar from "@/features/Calendars/CalendarSlice"; -import searchResultReducer from "@/features/Search/SearchSlice"; -import settingsReducer from "@/features/Settings/SettingsSlice"; -import userReducer from "@/features/User/userSlice"; -import { combineReducers, configureStore } from "@reduxjs/toolkit"; -import loadingReducer from "./loadingSlice"; -import { createBrowserHistory } from "history"; -import { createReduxHistoryContext } from "redux-first-history"; +import eventsCalendar from '@/features/Calendars/CalendarSlice' +import searchResultReducer from '@/features/Search/SearchSlice' +import settingsReducer from '@/features/Settings/SettingsSlice' +import userReducer from '@/features/User/userSlice' +import { combineReducers, configureStore } from '@reduxjs/toolkit' +import loadingReducer from './loadingSlice' +import { createBrowserHistory } from 'history' +import { createReduxHistoryContext } from 'redux-first-history' const { createReduxHistory, routerMiddleware, routerReducer } = - createReduxHistoryContext({ history: createBrowserHistory() }); + createReduxHistoryContext({ history: createBrowserHistory() }) const rootReducer = combineReducers({ router: routerReducer, @@ -16,22 +16,22 @@ const rootReducer = combineReducers({ calendars: eventsCalendar, settings: settingsReducer, searchResult: searchResultReducer, - loading: loadingReducer, -}); + loading: loadingReducer +}) export const setupStore = (preloadedState?: Partial) => { return configureStore({ reducer: rootReducer, preloadedState, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat(routerMiddleware), - }); -}; + middleware: getDefaultMiddleware => + getDefaultMiddleware().concat(routerMiddleware) + }) +} -export const store = setupStore(); +export const store = setupStore() -export const history = createReduxHistory(store); +export const history = createReduxHistory(store) -export type RootState = ReturnType; -export type AppStore = ReturnType; -export type AppDispatch = AppStore["dispatch"]; +export type RootState = ReturnType +export type AppStore = ReturnType +export type AppDispatch = AppStore['dispatch'] diff --git a/src/components/Attendees/AttendeeSearch.tsx b/src/components/Attendees/AttendeeSearch.tsx index 5a78bb9..9f7c18d 100644 --- a/src/components/Attendees/AttendeeSearch.tsx +++ b/src/components/Attendees/AttendeeSearch.tsx @@ -1,22 +1,22 @@ -import { userAttendee } from "@/features/User/models/attendee"; -import { createAttendee } from "@/features/User/models/attendee.mapper"; -import { useRef, useState } from "react"; -import { FreeBusyIndicator } from "./FreeBusyIndicator"; +import { userAttendee } from '@/features/User/models/attendee' +import { createAttendee } from '@/features/User/models/attendee.mapper' +import { useEffect, useRef, useState } from 'react' +import { FreeBusyIndicator } from './FreeBusyIndicator' import { ExtendedAutocompleteRenderInputParams, PeopleSearch, - User, -} from "./PeopleSearch"; -import { FreeBusyMap, useAttendeesFreeBusy } from "./useFreeBusy"; + User +} from './PeopleSearch' +import { FreeBusyMap, useAttendeesFreeBusy } from './useFreeBusy' -const attendeeToUser = (a: userAttendee, openpaasId = ""): User => ({ +const attendeeToUser = (a: userAttendee, openpaasId = ''): User => ({ email: a.cal_address, - displayName: a.cn ?? "", - avatarUrl: "", - openpaasId, -}); + displayName: a.cn ?? '', + avatarUrl: '', + openpaasId +}) -const hasCalendar = (u: User) => u.objectType === "user" && !!u.openpaasId; +const hasCalendar = (u: User) => u.objectType === 'user' && !!u.openpaasId export default function AttendeeSearch({ attendees, @@ -27,98 +27,109 @@ export default function AttendeeSearch({ start, end, timezone, - eventUid, + eventUid }: { - attendees: userAttendee[]; - setAttendees: (attendees: userAttendee[]) => void; - disabled?: boolean; - inputSlot?: ( - params: ExtendedAutocompleteRenderInputParams - ) => React.ReactNode; - placeholder?: string; - start?: string; - end?: string; - timezone?: string; - eventUid?: string | null; + attendees: userAttendee[] + setAttendees: (attendees: userAttendee[]) => void + disabled?: boolean + inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode + placeholder?: string + start?: string + end?: string + timezone?: string + eventUid?: string | null }) { - const [userIdMap, setUserIdMap] = useState>({}); - const [addedUsers, setAddedUsers] = useState([]); - const initialEmailsRef = useRef | null>(null); - if (initialEmailsRef.current === null && !!eventUid && attendees.length > 0) { - initialEmailsRef.current = new Set(attendees.map((a) => a.cal_address)); - } - const initialEmails = eventUid - ? (initialEmailsRef.current ?? new Set()) - : new Set(); + const [userIdMap, setUserIdMap] = useState>({}) + const [addedUsers, setAddedUsers] = useState([]) + const initialEmailsRef = useRef | null>(null) + const [initialEmailsSet, setInitialEmailsSet] = useState>( + new Set() + ) + + useEffect(() => { + const updateInitialEmailsSet = () => { + if ( + initialEmailsRef.current === null && + !!eventUid && + attendees.length > 0 + ) { + initialEmailsRef.current = new Set(attendees.map(a => a.cal_address)) + setInitialEmailsSet(initialEmailsRef.current) + } + } + updateInitialEmailsSet() + }, [eventUid, attendees]) + + const initialEmails = eventUid ? initialEmailsSet : new Set() const selectedUsers: User[] = [ ...addedUsers, ...attendees - .map((a) => attendeeToUser(a, userIdMap[a.cal_address])) - .filter((a) => !addedUsers.find((u) => a.email === u.email)), - ]; + .map(a => attendeeToUser(a, userIdMap[a.cal_address])) + .filter(a => !addedUsers.find(u => a.email === u.email)) + ] const toAttendee = (u: User) => ({ email: u.email, - userId: u.openpaasId || userIdMap[u.email] || null, - }); + userId: u.openpaasId || userIdMap[u.email] || null + }) const existingAttendees = selectedUsers - .filter((u) => initialEmails.has(u.email)) - .map(toAttendee); + .filter(u => initialEmails.has(u.email)) + .map(toAttendee) const newAttendees = selectedUsers - .filter((u) => !initialEmails.has(u.email) && hasCalendar(u)) - .map(toAttendee); + .filter(u => !initialEmails.has(u.email) && hasCalendar(u)) + .map(toAttendee) // Contacts and freeSolo users get a static "contact" status — no API call needed const contactMap: FreeBusyMap = Object.fromEntries( selectedUsers - .filter((u) => !initialEmails.has(u.email) && !hasCalendar(u)) - .map((u) => [u.email, "contact" as const]) - ); + .filter(u => !initialEmails.has(u.email) && !hasCalendar(u)) + .map(u => [u.email, 'contact' as const]) + ) const freeBusyMap = useAttendeesFreeBusy({ existingAttendees, newAttendees, - start: start ?? "", - end: end ?? "", - timezone: timezone ?? "", + start: start ?? '', + end: end ?? '', + timezone: timezone ?? '', eventUid, - enabled: !!(start && end && selectedUsers.length > 0), - }); + enabled: !!(start && end && selectedUsers.length > 0) + }) - const statusMap = { ...freeBusyMap, ...contactMap }; + const statusMap = { ...freeBusyMap, ...contactMap } return ( ( - + ? user => ( + ) : undefined } onChange={(_event, value: User[]) => { - setUserIdMap((prev) => { - const next = { ...prev }; + setUserIdMap(prev => { + const next = { ...prev } for (const u of value) { - if (u.openpaasId && u.email) next[u.email] = u.openpaasId; + if (u.openpaasId && u.email) next[u.email] = u.openpaasId } - return next; - }); - setAddedUsers(value.filter((u) => !initialEmails.has(u.email))); + return next + }) + setAddedUsers(value.filter(u => !initialEmails.has(u.email))) setAttendees( - value.map((u) => + value.map(u => createAttendee({ cal_address: u.email, cn: u.displayName }) ) - ); + ) }} freeSolo /> - ); + ) } diff --git a/src/components/Attendees/FreeBusyIndicator.tsx b/src/components/Attendees/FreeBusyIndicator.tsx index cd4a22d..321f94f 100644 --- a/src/components/Attendees/FreeBusyIndicator.tsx +++ b/src/components/Attendees/FreeBusyIndicator.tsx @@ -1,32 +1,35 @@ -import { Tooltip } from "@linagora/twake-mui"; -import AccessTimeFilledIcon from "@mui/icons-material/AccessTimeFilled"; -import CloseIcon from "@mui/icons-material/Close"; -import { useEffect, useState } from "react"; -import { useI18n } from "twake-i18n"; -import { FreeBusyStatus } from "./useFreeBusy"; +import { Tooltip } from '@linagora/twake-mui' +import AccessTimeFilledIcon from '@mui/icons-material/AccessTimeFilled' +import CloseIcon from '@mui/icons-material/Close' +import { useEffect, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { FreeBusyStatus } from './useFreeBusy' interface FreeBusyIndicatorProps { - status: FreeBusyStatus; - size?: number; + status: FreeBusyStatus + size?: number } export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) { - const { t } = useI18n(); - const [open, setOpen] = useState(false); + const { t } = useI18n() + const [open, setOpen] = useState(false) useEffect(() => { - if (status === "busy") setOpen(true); - }, [status]); + const triggerOpen = () => { + if (status === 'busy') setOpen(true) + } + triggerOpen() + }, [status]) - if (status !== "busy") return null; + if (status !== 'busy') return null return ( - {t("event.freeBusy.busy")} + + {t('event.freeBusy.busy')} setOpen(false)} /> @@ -35,16 +38,16 @@ export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) { disableHoverListener placement="bottom-start" onClose={() => setOpen(false)} - slotProps={{ tooltip: { sx: { opacity: 1, bgcolor: "grey.900" } } }} + slotProps={{ tooltip: { sx: { opacity: 1, bgcolor: 'grey.900' } } }} > - ); + ) } diff --git a/src/components/Attendees/PeopleSearch.tsx b/src/components/Attendees/PeopleSearch.tsx index 1eeb45c..31ebc1d 100644 --- a/src/components/Attendees/PeopleSearch.tsx +++ b/src/components/Attendees/PeopleSearch.tsx @@ -1,8 +1,8 @@ -import { getAccessiblePair } from "@/utils/getAccessiblePair"; -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 { getAccessiblePair } from '@/utils/getAccessiblePair' +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 { Autocomplete, Avatar, @@ -15,35 +15,35 @@ import { PopperProps, TextField, useTheme, - type AutocompleteRenderInputParams, -} from "@linagora/twake-mui"; -import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined"; + type AutocompleteRenderInputParams +} from '@linagora/twake-mui' +import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined' import { HTMLAttributes, useCallback, type ReactNode, - type SyntheticEvent, -} from "react"; -import { useI18n } from "twake-i18n"; -import { ResourceIcon } from "./ResourceIcon"; -import { isValidEmail } from "../../utils/isValidEmail"; -import { usePasteHandler } from "./usePasteHandler"; + type SyntheticEvent +} from 'react' +import { useI18n } from 'twake-i18n' +import { ResourceIcon } from './ResourceIcon' +import { isValidEmail } from '../../utils/isValidEmail' +import { usePasteHandler } from './usePasteHandler' export interface User { - email: string; - displayName: string; - avatarUrl?: string; - openpaasId?: string; - color?: Record; - objectType?: string; + email: string + displayName: string + avatarUrl?: string + openpaasId?: string + color?: Record + objectType?: string } export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams { - error?: boolean; - helperText?: string | null; - placeholder?: string; - label?: string; - onKeyDown?: (e: React.KeyboardEvent) => void; + error?: boolean + helperText?: string | null + placeholder?: string + label?: string + onKeyDown?: (e: React.KeyboardEvent) => void } export function PeopleSearch({ @@ -57,33 +57,31 @@ export function PeopleSearch({ inputSlot, customRenderInput, customSlotProps, - getChipIcon, + getChipIcon }: { - selectedUsers: User[]; - onChange: (event: SyntheticEvent, users: User[]) => void; - objectTypes: string[]; - disabled?: boolean; - freeSolo?: boolean; - onToggleEventPreview?: () => void; - placeholder?: string; - inputSlot?: ( - params: ExtendedAutocompleteRenderInputParams - ) => React.ReactNode; + selectedUsers: User[] + onChange: (event: SyntheticEvent, users: User[]) => 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; + ) => ReactNode customSlotProps?: { - popper?: Partial; - paper?: Partial; - listbox?: Partial>; - }; - getChipIcon?: (user: User) => ReactNode; + popper?: Partial + paper?: Partial + listbox?: Partial> + } + getChipIcon?: (user: User) => ReactNode }) { - const { t } = useI18n(); - const searchPlaceholder = placeholder ?? t("peopleSearch.placeholder"); - const errorMessage = t("peopleSearch.searchError"); + const { t } = useI18n() + const searchPlaceholder = placeholder ?? t('peopleSearch.placeholder') + const errorMessage = t('peopleSearch.searchError') const { query, @@ -98,32 +96,32 @@ export function PeopleSearch({ snackbarOpen, setSnackbarOpen, snackbarMessage, - setSnackbarMessage, - } = useUserSearch({ objectTypes, errorMessage }); + setSnackbarMessage + } = useUserSearch({ objectTypes, errorMessage }) - const theme = useTheme(); + const theme = useTheme() const handleBlurCommit = useCallback( (event: React.SyntheticEvent) => { - const trimmed = query.trim(); - if (!trimmed) return; + const trimmed = query.trim() + if (!trimmed) return if (!isValidEmail(trimmed)) { setInputError( - t("peopleSearch.invalidEmail").replace("%{email}", trimmed) - ); - return; + t('peopleSearch.invalidEmail').replace('%{email}', trimmed) + ) + return } - if (selectedUsers.find((u) => u.email === trimmed)) { - setQuery(""); - return; + if (selectedUsers.find(u => u.email === trimmed)) { + setQuery('') + return } - setInputError(null); - const newUser: User = { email: trimmed, displayName: trimmed }; - onChange(event, [...selectedUsers, newUser]); - setQuery(""); + setInputError(null) + const newUser: User = { email: trimmed, displayName: trimmed } + onChange(event, [...selectedUsers, newUser]) + setQuery('') }, [query, selectedUsers, onChange, t, setInputError, setQuery] - ); + ) const handlePaste = usePasteHandler({ freeSolo, @@ -131,8 +129,8 @@ export function PeopleSearch({ onChange, setQuery, setInputError, - t, - }); + t + }) const defaultRenderInput = useCallback( (params: AutocompleteRenderInputParams) => { @@ -143,7 +141,7 @@ export function PeopleSearch({ ) : ( ), endAdornment: ( @@ -151,61 +149,61 @@ export function PeopleSearch({ {loading ? : null} {params.InputProps.endAdornment} - ), - }; + ) + } const enhancedParams = { ...params, InputProps: inputProps, inputProps: { ...params.inputProps, - autoComplete: "off", - onPaste: handlePaste, - }, - }; + autoComplete: 'off', + onPaste: handlePaste + } + } const handleEnterKey = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && onToggleEventPreview) { - e.preventDefault(); - onToggleEventPreview(); + if (e.key === 'Enter' && onToggleEventPreview) { + e.preventDefault() + onToggleEventPreview() } - }; + } const defaultTextFieldProps = { error: !!inputError, helperText: inputError, placeholder: searchPlaceholder, - label: "", + label: '', onKeyDown: handleEnterKey, slotProps: { input: { - ...inputProps, - }, - }, - }; + ...inputProps + } + } + } if (inputSlot) { return ( <> {inputSlot({ ...enhancedParams, error: !!inputError, helperText: inputError, placeholder: searchPlaceholder, - label: "", - onKeyDown: handleEnterKey, + label: '', + onKeyDown: handleEnterKey })} - ); + ) } return ( <> - ); + ) }, // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -223,9 +221,9 @@ export function PeopleSearch({ onToggleEventPreview, loading, searchPlaceholder, - handlePaste, + handlePaste ] - ); + ) return ( <> @@ -246,68 +244,68 @@ export function PeopleSearch({ onClose={() => setIsOpen(false)} disabled={disabled} loading={loading} - filterOptions={(x) => x} + filterOptions={x => x} fullWidth - noOptionsText={t("peopleSearch.noResults")} - loadingText={t("peopleSearch.loading")} - getOptionLabel={(option) => { - if (typeof option === "object") { - return option.displayName || option.email; + noOptionsText={t('peopleSearch.noResults')} + loadingText={t('peopleSearch.loading')} + getOptionLabel={option => { + if (typeof option === 'object') { + return option.displayName || option.email } else { - return option; + return option } }} sx={{ - "& .MuiAutocomplete-inputRoot": { - py: 0, - }, + '& .MuiAutocomplete-inputRoot': { + py: 0 + } }} filterSelectedOptions value={selectedUsers} inputValue={query} onInputChange={(_event, value) => setQuery(value)} onChange={(event, value) => { - const last = value[value.length - 1]; - if (typeof last === "string" && !isValidEmail(last.trim())) { - const invalidEmailMessage = t("peopleSearch.invalidEmail").replace( - "%{email}", + const last = value[value.length - 1] + if (typeof last === 'string' && !isValidEmail(last.trim())) { + const invalidEmailMessage = t('peopleSearch.invalidEmail').replace( + '%{email}', last - ); - setInputError(invalidEmailMessage); - return; + ) + setInputError(invalidEmailMessage) + return } - setInputError(null); + setInputError(null) const mapped = value .map((v: string | User) => - typeof v === "string" + typeof v === 'string' ? { email: v.trim(), displayName: v.trim() } : v ) .filter( (user, index, self) => - self.findIndex((u) => u.email === user.email) === index - ); - onChange(event, mapped); + self.findIndex(u => u.email === user.email) === index + ) + onChange(event, mapped) }} slotProps={{ ...customSlotProps, popper: { - placement: "bottom-start", - sx: { minWidth: "300px", ...customSlotProps?.popper?.sx }, - ...customSlotProps?.popper, - }, + placement: 'bottom-start', + sx: { minWidth: '300px', ...customSlotProps?.popper?.sx }, + ...customSlotProps?.popper + } }} forcePopupIcon={false} disableClearable - renderInput={(params) => + renderInput={params => customRenderInput ? customRenderInput(params, query, setQuery) : defaultRenderInput(params) } renderOption={(props, option) => { - if (selectedUsers.find((u) => u.email === option.email)) return null; - const { key, ...otherProps } = props; - const isResource = option.objectType === "resource"; + if (selectedUsers.find(u => u.email === option.email)) return null + const { key, ...otherProps } = props + const isResource = option.objectType === 'resource' return ( @@ -321,25 +319,23 @@ export function PeopleSearch({ - ); + ) }} renderValue={(value, getTagProps) => value.map((option, index) => { - const isString = typeof option === "string"; - const label = isString - ? option - : option.displayName || option.email; + const isString = typeof option === 'string' + const label = isString ? option : option.displayName || option.email const chipColor = isString ? theme.palette.grey[200] - : (option.color?.light ?? theme.palette.grey[200]); - const textColor = getAccessiblePair(chipColor, theme); + : (option.color?.light ?? theme.palette.grey[200]) + const textColor = getAccessiblePair(chipColor, theme) return ( } style={{ backgroundColor: chipColor, - color: textColor, + color: textColor }} label={label} /> - ); + ) }) } /> { - setSnackbarOpen(open); + setSnackbarOpen(open) if (!open) { - setSnackbarMessage(""); + setSnackbarMessage('') } }} message={snackbarMessage} severity="error" /> - ); + ) } diff --git a/src/components/Attendees/ResourceIcon.tsx b/src/components/Attendees/ResourceIcon.tsx index 081833b..062c55f 100644 --- a/src/components/Attendees/ResourceIcon.tsx +++ b/src/components/Attendees/ResourceIcon.tsx @@ -1,41 +1,41 @@ -import { Avatar, Box } from "@linagora/twake-mui"; -import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined"; +import { Avatar, Box } from '@linagora/twake-mui' +import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined' interface ResourceIconProps { - avatarUrl?: string; - colorIcon?: boolean; - color?: string; + avatarUrl?: string + colorIcon?: boolean + color?: string } export function ResourceIcon({ avatarUrl, colorIcon, - color, + color }: ResourceIconProps) { if (colorIcon && avatarUrl) { return ( - ); + ) } return avatarUrl ? ( ) : ( - ); + ) } diff --git a/src/components/Attendees/ResourceSearch.tsx b/src/components/Attendees/ResourceSearch.tsx index d63affc..59c8a19 100644 --- a/src/components/Attendees/ResourceSearch.tsx +++ b/src/components/Attendees/ResourceSearch.tsx @@ -1,7 +1,7 @@ -import { getAccessiblePair } from "@/utils/getAccessiblePair"; -import { useUserSearch } from "./useUserSearch"; -import { ResourceIcon } from "./ResourceIcon"; -import { SnackbarAlert } from "@/components/Loading/SnackBarAlert"; +import { getAccessiblePair } from '@/utils/getAccessiblePair' +import { useUserSearch } from './useUserSearch' +import { ResourceIcon } from './ResourceIcon' +import { SnackbarAlert } from '@/components/Loading/SnackBarAlert' import { Autocomplete, Chip, @@ -14,31 +14,31 @@ import { TextField, useTheme, Typography, - type AutocompleteRenderInputParams, -} from "@linagora/twake-mui"; -import SearchIcon from "@mui/icons-material/Search"; + 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"; + type SyntheticEvent +} from 'react' +import { useI18n } from 'twake-i18n' export interface Resource { - email?: string; - displayName: string; - avatarUrl?: string; - openpaasId?: string; - color?: Record; + 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; + error?: boolean + helperText?: string | null + placeholder?: string + label?: string + onKeyDown?: (e: React.KeyboardEvent) => void } export function ResourceSearch({ @@ -52,33 +52,31 @@ export function ResourceSearch({ inputSlot, customRenderInput, customSlotProps, - hideLabel, + hideLabel }: { - selectedResources: Resource[]; - onChange: (event: SyntheticEvent, users: Resource[]) => void; - objectTypes: string[]; - disabled?: boolean; - freeSolo?: boolean; - onToggleEventPreview?: () => void; - placeholder?: string; - inputSlot?: ( - params: ExtendedAutocompleteRenderInputParams - ) => React.ReactNode; + 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; + ) => ReactNode customSlotProps?: { - popper?: Partial; - paper?: Partial; - listbox?: Partial>; - }; - hideLabel?: boolean; + popper?: Partial + paper?: Partial + listbox?: Partial> + } + hideLabel?: boolean }) { - const { t } = useI18n(); - const searchPlaceholder = placeholder ?? t("resourceSearch.placeholder"); - const errorMessage = t("resourceSearch.searchError"); + const { t } = useI18n() + const searchPlaceholder = placeholder ?? t('resourceSearch.placeholder') + const errorMessage = t('resourceSearch.searchError') const { query, @@ -93,26 +91,26 @@ export function ResourceSearch({ snackbarOpen, setSnackbarOpen, snackbarMessage, - setSnackbarMessage, - } = useUserSearch({ objectTypes, errorMessage }); + setSnackbarMessage + } = useUserSearch({ objectTypes, errorMessage }) - const theme = useTheme(); + 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; + 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(""); + setInputError(null) + const newResource: Resource = { displayName: trimmed } + onChange(event, [...selectedResources, newResource]) + setQuery('') }, [query, selectedResources, onChange, setInputError, setQuery] - ); + ) const defaultRenderInput = useCallback( (params: AutocompleteRenderInputParams) => { @@ -123,7 +121,7 @@ export function ResourceSearch({ {!selectedResources?.length ? ( ) : null} {params.InputProps.startAdornment} @@ -134,44 +132,44 @@ export function ResourceSearch({ {loading ? : null} {!selectedResources?.length ? params.InputProps.endAdornment : null} - ), - }; + ) + } const enhancedParams = { ...params, InputProps: inputProps, inputProps: { ...params.inputProps, - autoComplete: "off", - }, - }; + autoComplete: 'off' + } + } const handleEnterKey = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && onToggleEventPreview) { - e.preventDefault(); - onToggleEventPreview(); + if (e.key === 'Enter' && onToggleEventPreview) { + e.preventDefault() + onToggleEventPreview() } - }; + } const defaultTextFieldProps = { error: !!inputError, helperText: inputError, placeholder: searchPlaceholder, - label: "", + label: '', onKeyDown: handleEnterKey, slotProps: { input: { - ...inputProps, - }, - }, - }; + ...inputProps + } + } + } if (inputSlot) { return ( <> {!hideLabel && ( - - {t("resourceSearch.label")} + + {t('resourceSearch.label')} )} {inputSlot({ @@ -179,18 +177,18 @@ export function ResourceSearch({ error: !!inputError, helperText: inputError, placeholder: searchPlaceholder, - label: "", - onKeyDown: handleEnterKey, + label: '', + onKeyDown: handleEnterKey })} - ); + ) } return ( <> {!hideLabel && ( - - {t("resourceSearch.label")} + + {t('resourceSearch.label')} )} - ); + ) }, // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -209,9 +207,9 @@ export function ResourceSearch({ onToggleEventPreview, loading, searchPlaceholder, - selectedResources?.length, + selectedResources?.length ] - ); + ) return ( <> @@ -234,55 +232,53 @@ export function ResourceSearch({ loading={loading} filterOptions={(options: Resource[]) => options} fullWidth - noOptionsText={t("resourceSearch.noResults")} - loadingText={t("resourceSearch.loading")} + noOptionsText={t('resourceSearch.noResults')} + loadingText={t('resourceSearch.loading')} getOptionLabel={(option: Resource | string) => { - if (typeof option === "object") { - return option.displayName; + if (typeof option === 'object') { + return option.displayName } else { - return option; + return option } }} sx={{ - "& .MuiAutocomplete-inputRoot": { - py: 0, - }, + '& .MuiAutocomplete-inputRoot': { + py: 0 + } }} filterSelectedOptions value={selectedResources} inputValue={query} onInputChange={(_event, value: string) => setQuery(value)} onChange={(event, value: string[] | Resource[]) => { - setInputError(null); + setInputError(null) const mapped = value .map((v: string | Resource) => - typeof v === "string" ? { displayName: v.trim() } : v + typeof v === 'string' ? { displayName: v.trim() } : v ) - .filter((v) => v.displayName.trim().length > 0); - onChange(event, mapped); + .filter(v => v.displayName.trim().length > 0) + onChange(event, mapped) }} slotProps={{ ...customSlotProps, popper: { - placement: "bottom-start", - sx: { minWidth: "300px", ...customSlotProps?.popper?.sx }, - ...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) => + 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; + 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 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); + : (option.color?.light ?? theme.palette.grey[300]) + const textColor = getAccessiblePair(chipColor, theme) return ( - ); + ) }) } /> { - setSnackbarOpen(open); + setSnackbarOpen(open) if (!open) { - setSnackbarMessage(""); + setSnackbarMessage('') } }} message={snackbarMessage} severity="error" /> - ); + ) } diff --git a/src/components/Attendees/useFreeBusy.ts b/src/components/Attendees/useFreeBusy.ts index ca685c5..abbaf42 100644 --- a/src/components/Attendees/useFreeBusy.ts +++ b/src/components/Attendees/useFreeBusy.ts @@ -1,99 +1,92 @@ -import { getFreeBusyForAddedAttendeesREPORT } from "@/features/Events/api/getFreeBusyForAddedAttendeesREPORT"; -import { getFreeBusyForEventAttendeesPOST } from "@/features/Events/api/getFreeBusyForEventAttendeesPOST"; -import { getUserDataFromEmail } from "@/features/Events/api/getUserDataFromEmail"; -import moment from "moment-timezone"; -import { useEffect, useRef, useState } from "react"; +import { getFreeBusyForAddedAttendeesREPORT } from '@/features/Events/api/getFreeBusyForAddedAttendeesREPORT' +import { getFreeBusyForEventAttendeesPOST } from '@/features/Events/api/getFreeBusyForEventAttendeesPOST' +import { getUserDataFromEmail } from '@/features/Events/api/getUserDataFromEmail' +import moment from 'moment-timezone' +import { useEffect, useRef, useState } from 'react' -export type FreeBusyStatus = - | "free" - | "busy" - | "loading" - | "contact" - | "unknown"; -export type FreeBusyMap = Record; +export type FreeBusyStatus = 'free' | 'busy' | 'loading' | 'contact' | 'unknown' +export type FreeBusyMap = Record interface Attendee { - email: string; - userId?: string | null; + email: string + userId?: string | null } interface ResolvedAttendee { - email: string; - userId: string; + email: string + userId: string } // Helpers async function resolveUserId(attendee: Attendee): Promise { - if (attendee.userId) return attendee.userId; + if (attendee.userId) return attendee.userId return getUserDataFromEmail(attendee.email) - .then((u) => u[0]?._id ?? null) - .catch(() => null); + .then(u => u[0]?._id ?? null) + .catch(() => null) } async function resolveAll(attendees: Attendee[]): Promise { const results = await Promise.all( - attendees.map(async (a) => { - const userId = await resolveUserId(a); - return userId ? { email: a.email, userId } : null; + attendees.map(async a => { + const userId = await resolveUserId(a) + return userId ? { email: a.email, userId } : null }) - ); - return results.filter((r): r is ResolvedAttendee => r !== null); + ) + return results.filter((r): r is ResolvedAttendee => r !== null) } export function hasFreeBusyConflict(data: unknown): boolean { try { - const jcal = (data as { data: unknown[] }).data; - if (!Array.isArray(jcal) || jcal[0] !== "vcalendar") return false; - const components = jcal[2] as unknown[][]; - return ( - Array.isArray(components) && components.some(isVFreeBusyWithConflict) - ); + const jcal = (data as { data: unknown[] }).data + if (!Array.isArray(jcal) || jcal[0] !== 'vcalendar') return false + const components = jcal[2] as unknown[][] + return Array.isArray(components) && components.some(isVFreeBusyWithConflict) } catch { - return false; + return false } } function isVFreeBusyWithConflict(component: unknown): boolean { - if (!Array.isArray(component) || component[0] !== "vfreebusy") return false; - const props = component[1] as unknown[][]; + if (!Array.isArray(component) || component[0] !== 'vfreebusy') return false + const props = component[1] as unknown[][] return ( Array.isArray(props) && - props.some((p) => Array.isArray(p) && p[0] === "freebusy") - ); + props.some(p => Array.isArray(p) && p[0] === 'freebusy') + ) } function toUtcIcal(datetime: string, timezone: string): string { - return moment.tz(datetime, timezone).utc().format("YYYYMMDDTHHmmss"); + return moment.tz(datetime, timezone).utc().format('YYYYMMDDTHHmmss') } async function fetchFreeBusyMap( attendees: Attendee[], fetcher: (resolved: ResolvedAttendee[]) => Promise ): Promise { - const resolved = await resolveAll(attendees); + const resolved = await resolveAll(attendees) const unresolved: FreeBusyMap = Object.fromEntries( attendees - .filter((a) => !resolved.find((r) => r.email === a.email)) - .map((a) => [a.email, "unknown" as FreeBusyStatus]) - ); + .filter(a => !resolved.find(r => r.email === a.email)) + .map(a => [a.email, 'unknown' as FreeBusyStatus]) + ) - if (resolved.length === 0) return unresolved; + if (resolved.length === 0) return unresolved - const fetched = await fetcher(resolved); - return { ...unresolved, ...fetched }; + const fetched = await fetcher(resolved) + return { ...unresolved, ...fetched } } function toLoadingMap(attendees: Attendee[]): FreeBusyMap { return Object.fromEntries( - attendees.map((a) => [a.email, "loading" as FreeBusyStatus]) - ); + attendees.map(a => [a.email, 'loading' as FreeBusyStatus]) + ) } function toUnknownMap(attendees: Attendee[]): FreeBusyMap { return Object.fromEntries( - attendees.map((a) => [a.email, "unknown" as FreeBusyStatus]) - ); + attendees.map(a => [a.email, 'unknown' as FreeBusyStatus]) + ) } function toFreeBusyMap( @@ -101,26 +94,26 @@ function toFreeBusyMap( ): (busyByUserId: Record) => FreeBusyMap { const userIdToEmail = Object.fromEntries( resolved.map(({ email, userId }) => [userId, email]) - ); - return (busyByUserId) => + ) + return busyByUserId => Object.fromEntries( Object.entries(busyByUserId).flatMap(([uid, busy]) => { - const email = userIdToEmail[uid]; + const email = userIdToEmail[uid] return email - ? [[email, (busy ? "busy" : "free") as FreeBusyStatus]] - : []; + ? [[email, (busy ? 'busy' : 'free') as FreeBusyStatus]] + : [] }) - ); + ) } interface UseAttendeesFreeBusyOptions { - existingAttendees: Attendee[]; - newAttendees: Attendee[]; - start: string; - end: string; - timezone: string; - eventUid?: string | null; - enabled?: boolean; + existingAttendees: Attendee[] + newAttendees: Attendee[] + start: string + end: string + timezone: string + eventUid?: string | null + enabled?: boolean } export function useAttendeesFreeBusy({ @@ -130,18 +123,18 @@ export function useAttendeesFreeBusy({ end, timezone, eventUid, - enabled = true, + enabled = true }: UseAttendeesFreeBusyOptions): FreeBusyMap { - const [statusMap, setStatusMap] = useState({}); - const fetchedNewEmailsRef = useRef>(new Set()); + const [statusMap, setStatusMap] = useState({}) + const fetchedNewEmailsRef = useRef>(new Set()) - const existingKey = existingAttendees.map((a) => a.email).join(","); - const newKey = newAttendees.map((a) => a.email).join(","); + const existingKey = existingAttendees.map(a => a.email).join(',') + const newKey = newAttendees.map(a => a.email).join(',') useEffect(() => { - fetchedNewEmailsRef.current = new Set(); - setStatusMap({}); - }, [start, end, timezone]); + fetchedNewEmailsRef.current = new Set() + setStatusMap({}) + }, [start, end, timezone]) useEffect(() => { if ( @@ -151,101 +144,105 @@ export function useAttendeesFreeBusy({ !eventUid || existingAttendees.length === 0 ) - return; + return - let cancelled = false; - setStatusMap((prev) => ({ ...prev, ...toLoadingMap(existingAttendees) })); + let cancelled = false + setStatusMap(prev => ({ ...prev, ...toLoadingMap(existingAttendees) })) - fetchFreeBusyMap(existingAttendees, (resolved) => + fetchFreeBusyMap(existingAttendees, resolved => getFreeBusyForEventAttendeesPOST( - resolved.map((r) => r.userId), + resolved.map(r => r.userId), toUtcIcal(start, timezone), toUtcIcal(end, timezone), - eventUid! + eventUid ).then(toFreeBusyMap(resolved)) ) - .then((updates) => { - if (!cancelled) setStatusMap((prev) => ({ ...prev, ...updates })); + .then(updates => { + if (!cancelled) { + setStatusMap(prev => ({ ...prev, ...updates })) + } + return }) .catch(() => { if (!cancelled) - setStatusMap((prev) => ({ + setStatusMap(prev => ({ ...prev, - ...toUnknownMap(existingAttendees), - })); - }); + ...toUnknownMap(existingAttendees) + })) + }) return () => { - cancelled = true; - }; + cancelled = true + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [existingKey, start, end, eventUid, enabled, timezone]); + }, [existingKey, start, end, eventUid, enabled, timezone]) useEffect(() => { - if (!enabled || !start || !end) return; + if (!enabled || !start || !end) return - const currentEmails = new Set(newAttendees.map((a) => a.email)); + const currentEmails = new Set(newAttendees.map(a => a.email)) const removedEmails = [...fetchedNewEmailsRef.current].filter( - (e) => !currentEmails.has(e) - ); + e => !currentEmails.has(e) + ) if (removedEmails.length > 0) { - removedEmails.forEach((e) => { - fetchedNewEmailsRef.current.delete(e); - }); - setStatusMap((prev) => { - const next = { ...prev }; - removedEmails.forEach((e) => { - delete next[e]; - }); - return next; - }); + removedEmails.forEach(e => { + fetchedNewEmailsRef.current.delete(e) + }) + setStatusMap(prev => { + const next = { ...prev } + removedEmails.forEach(e => { + delete next[e] + }) + return next + }) } const toFetch = newAttendees.filter( - (a) => !fetchedNewEmailsRef.current.has(a.email) - ); - if (toFetch.length === 0) return; + a => !fetchedNewEmailsRef.current.has(a.email) + ) + if (toFetch.length === 0) return - let cancelled = false; - setStatusMap((prev) => ({ ...prev, ...toLoadingMap(toFetch) })); - fetchFreeBusyMap(toFetch, (resolved) => + let cancelled = false + setStatusMap(prev => ({ ...prev, ...toLoadingMap(toFetch) })) + fetchFreeBusyMap(toFetch, resolved => Promise.all( resolved.map(async ({ email, userId }) => { try { const busy = await getFreeBusyForAddedAttendeesREPORT( userId, - moment.tz(start, timezone).utc().format("YYYYMMDDTHHmmss"), - moment.tz(end, timezone).utc().format("YYYYMMDDTHHmmss") - ); - return [email, (busy ? "busy" : "free") as FreeBusyStatus] as const; + moment.tz(start, timezone).utc().format('YYYYMMDDTHHmmss'), + moment.tz(end, timezone).utc().format('YYYYMMDDTHHmmss') + ) + return [email, (busy ? 'busy' : 'free') as FreeBusyStatus] as const } catch { - return [email, "unknown" as FreeBusyStatus] as const; + return [email, 'unknown' as FreeBusyStatus] as const } }) ).then(Object.fromEntries) ) - .then((updates) => { + .then(updates => { if (!cancelled) { - Object.keys(updates).forEach((e) => { - fetchedNewEmailsRef.current.add(e); - }); - setStatusMap((prev) => ({ ...prev, ...updates })); + Object.keys(updates).forEach(e => { + fetchedNewEmailsRef.current.add(e) + }) + setStatusMap(prev => ({ ...prev, ...updates })) } + return }) .catch(() => { if (!cancelled) { - toFetch.forEach((a) => { - fetchedNewEmailsRef.current.add(a.email); - }); - setStatusMap((prev) => ({ ...prev, ...toUnknownMap(toFetch) })); + toFetch.forEach(a => { + fetchedNewEmailsRef.current.add(a.email) + }) + setStatusMap(prev => ({ ...prev, ...toUnknownMap(toFetch) })) } - }); + }) return () => { - cancelled = true; - }; + cancelled = true + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [newKey, start, end, enabled, timezone]); + }, [newKey, start, end, enabled, timezone]) - return statusMap; + return statusMap } diff --git a/src/components/Attendees/usePasteHandler.ts b/src/components/Attendees/usePasteHandler.ts index c4139d6..49f57ae 100644 --- a/src/components/Attendees/usePasteHandler.ts +++ b/src/components/Attendees/usePasteHandler.ts @@ -1,7 +1,7 @@ -import { useCallback } from "react"; -import type { SyntheticEvent } from "react"; -import { isValidEmail } from "../../utils/isValidEmail"; -import type { User } from "./PeopleSearch"; +import { useCallback } from 'react' +import type { SyntheticEvent } from 'react' +import { isValidEmail } from '../../utils/isValidEmail' +import type { User } from './PeopleSearch' export function usePasteHandler({ freeSolo, @@ -9,62 +9,62 @@ export function usePasteHandler({ onChange, setQuery, setInputError, - t, + t }: { - freeSolo?: boolean; - selectedUsers: User[]; - onChange: (event: SyntheticEvent, users: User[]) => void; - setQuery: (value: string) => void; - setInputError: (error: string | null) => void; - t: (key: string) => string; + freeSolo?: boolean + selectedUsers: User[] + onChange: (event: SyntheticEvent, users: User[]) => void + setQuery: (value: string) => void + setInputError: (error: string | null) => void + t: (key: string) => string }) { return useCallback( (event: React.ClipboardEvent) => { - if (!freeSolo) return; + if (!freeSolo) return - const pasted = event.clipboardData.getData("text/plain"); - if (!pasted) return; + const pasted = event.clipboardData.getData('text/plain') + if (!pasted) return // Split by comma, semicolon, newline, or whitespace const chunks = pasted .split(/[,;\n\r\s]+/) - .map((s) => s.trim()) - .filter(Boolean); + .map(s => s.trim()) + .filter(Boolean) // If there is only one chunk, let the default Autocomplete behaviour handle it - if (chunks.length <= 1) return; + if (chunks.length <= 1) return - event.preventDefault(); + event.preventDefault() - const existingEmails = new Set(selectedUsers.map((u) => u.email)); - const validUsers: User[] = []; - const invalid: string[] = []; + const existingEmails = new Set(selectedUsers.map(u => u.email)) + const validUsers: User[] = [] + const invalid: string[] = [] for (const chunk of chunks) { if (!isValidEmail(chunk)) { - invalid.push(chunk); + invalid.push(chunk) } else if (!existingEmails.has(chunk)) { - existingEmails.add(chunk); - validUsers.push({ email: chunk, displayName: chunk }); + existingEmails.add(chunk) + validUsers.push({ email: chunk, displayName: chunk }) } // silently skip duplicates } if (validUsers.length > 0) { - onChange(event, [...selectedUsers, ...validUsers]); + onChange(event, [...selectedUsers, ...validUsers]) } if (invalid.length > 0) { // Leave the invalid text in the input for manual correction - setQuery(invalid.join(", ")); + setQuery(invalid.join(', ')) setInputError( - t("peopleSearch.invalidEmail").replace("%{email}", invalid.join(", ")) - ); + t('peopleSearch.invalidEmail').replace('%{email}', invalid.join(', ')) + ) } else { - setQuery(""); - setInputError(null); + setQuery('') + setInputError(null) } }, [freeSolo, selectedUsers, onChange, setQuery, setInputError, t] - ); + ) } diff --git a/src/components/Attendees/useUserSearch.ts b/src/components/Attendees/useUserSearch.ts index 5d0c693..2bf97d0 100644 --- a/src/components/Attendees/useUserSearch.ts +++ b/src/components/Attendees/useUserSearch.ts @@ -1,67 +1,67 @@ -import { useState, useEffect } from "react"; -import { searchUsers } from "@/features/User/userAPI"; +import { useState, useEffect } from 'react' +import { searchUsers } from '@/features/User/userAPI' export interface UseUserSearchProps { - objectTypes: string[]; - errorMessage: string; + objectTypes: string[] + errorMessage: string } export function useUserSearch({ objectTypes, - errorMessage, + 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 [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(""); + const [inputError, setInputError] = useState(null) + const [snackbarOpen, setSnackbarOpen] = useState(false) + const [snackbarMessage, setSnackbarMessage] = useState('') useEffect(() => { - let cancelled = false; + let cancelled = false const delayDebounceFn = setTimeout(async () => { if (!query.trim()) { if (!cancelled) { - setOptions([]); - setLoading(false); - setHasSearched(false); + setOptions([]) + setLoading(false) + setHasSearched(false) } - return; + return } if (!cancelled) { - setLoading(true); - setHasSearched(false); + setLoading(true) + setHasSearched(false) } try { - const res = await searchUsers(query, objectTypes); + const res = await searchUsers(query, objectTypes) if (!cancelled) { - setOptions(res as unknown as T[]); - setHasSearched(true); + setOptions(res as unknown as T[]) + setHasSearched(true) } } catch { if (!cancelled) { - setHasSearched(false); - setSnackbarMessage(errorMessage); - setSnackbarOpen(true); + setHasSearched(false) + setSnackbarMessage(errorMessage) + setSnackbarOpen(true) } } finally { if (!cancelled) { - setLoading(false); + setLoading(false) } } - }, 300); + }, 300) return () => { - cancelled = true; - clearTimeout(delayDebounceFn); - }; - }, [objectTypes, query, errorMessage]); + cancelled = true + clearTimeout(delayDebounceFn) + } + }, [objectTypes, query, errorMessage]) return { query, @@ -76,6 +76,6 @@ export function useUserSearch({ snackbarOpen, setSnackbarOpen, snackbarMessage, - setSnackbarMessage, - }; + setSnackbarMessage + } } diff --git a/src/components/Calendar/AccessTab.tsx b/src/components/Calendar/AccessTab.tsx index 1452a54..fd72c71 100644 --- a/src/components/Calendar/AccessTab.tsx +++ b/src/components/Calendar/AccessTab.tsx @@ -1,8 +1,5 @@ -import { - exportCalendar, - getSecretLink, -} from "@/features/Calendars/CalendarApi"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { exportCalendar, getSecretLink } from '@/features/Calendars/CalendarApi' +import { Calendar } from '@/features/Calendars/CalendarTypes' import { Box, Button, @@ -10,90 +7,90 @@ import { IconButton, InputAdornment, TextField, - Typography, -} from "@linagora/twake-mui"; -import ContentCopyIcon from "@mui/icons-material/ContentCopy"; -import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined"; -import { useEffect, useMemo, useState } from "react"; -import { useI18n } from "twake-i18n"; -import { ErrorSnackbar } from "../Error/ErrorSnackbar"; -import { FieldWithLabel } from "../Event/components/FieldWithLabel"; -import { SnackbarAlert } from "../Loading/SnackBarAlert"; -import { CalendarAccessRights, UserWithAccess } from "./CalendarAccessRights"; + Typography +} from '@linagora/twake-mui' +import ContentCopyIcon from '@mui/icons-material/ContentCopy' +import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined' +import { useEffect, useMemo, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { ErrorSnackbar } from '../Error/ErrorSnackbar' +import { FieldWithLabel } from '../Event/components/FieldWithLabel' +import { SnackbarAlert } from '../Loading/SnackBarAlert' +import { CalendarAccessRights, UserWithAccess } from './CalendarAccessRights' interface AccessTabProps { - calendar: Calendar; - usersWithAccess: UserWithAccess[]; - onUsersWithAccessChange: (users: UserWithAccess[]) => void; - onInvitesLoaded: (users: UserWithAccess[]) => void; + calendar: Calendar + usersWithAccess: UserWithAccess[] + onUsersWithAccessChange: (users: UserWithAccess[]) => void + onInvitesLoaded: (users: UserWithAccess[]) => void } export function AccessTab({ calendar, usersWithAccess, onUsersWithAccessChange, - onInvitesLoaded, + onInvitesLoaded }: AccessTabProps) { - const { t } = useI18n(); + const { t } = useI18n() - const calDAVLink = `${window.DAV_BASE_URL}${calendar.link.replace(".json", "")}`; + const calDAVLink = `${window.DAV_BASE_URL}${calendar.link.replace('.json', '')}` const isResource = useMemo( () => calendar?.owner?.resource, [calendar?.owner?.resource] - ); + ) - const [secretLink, setSecretLink] = useState(""); - const [open, setOpen] = useState(false); + const [secretLink, setSecretLink] = useState('') + const [open, setOpen] = useState(false) useEffect(() => { async function fetchSecret() { const existing = await getSecretLink( - calendar.link.replace(".json", ""), + calendar.link.replace('.json', ''), false - ); - setSecretLink(existing.secretLink); + ) + setSecretLink(existing.secretLink) } - fetchSecret(); - }, [calendar.link]); + fetchSecret() + }, [calendar.link]) const handleCopy = (content: string) => { - navigator.clipboard.writeText(content); - setOpen(true); - }; + navigator.clipboard.writeText(content) + setOpen(true) + } const handleResetSecretLink = async () => { const newSecret = await getSecretLink( - calendar.link.replace(".json", ""), + calendar.link.replace('.json', ''), true - ); - setSecretLink(newSecret.secretLink); - }; + ) + setSecretLink(newSecret.secretLink) + } - const [exportLoading, setExportLoading] = useState(false); - const [exportError, setExportError] = useState(""); + const [exportLoading, setExportLoading] = useState(false) + const [exportError, setExportError] = useState('') const handleExport = async () => { try { - setExportLoading(true); + setExportLoading(true) const exportedData = await exportCalendar( - calendar.link.replace(".json", "") - ); - const blob = new Blob([exportedData], { type: "text/calendar" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `${calendar.id.split("/")[1]}.ics`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); + calendar.link.replace('.json', '') + ) + const blob = new Blob([exportedData], { type: 'text/calendar' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${calendar.id.split('/')[1]}.ics` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) } catch (e) { - setExportError((e as Error).message); + setExportError((e as Error).message) } finally { - setExportLoading(false); + setExportLoading(false) } - }; + } return ( <> @@ -105,13 +102,13 @@ export function AccessTab({ /> {!!window.DAV_BASE_URL && !isResource && ( - + - ), + ) }} /> )} - + - ), + ) }} /> - {t("calendar.secretUrlDesc")} + {t('calendar.secretUrlDesc')} - + - {t("calendar.exportDesc")} + {t('calendar.exportDesc')} @@ -196,9 +193,9 @@ export function AccessTab({ - ); + ) } diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index cd72ea7..993b3e1 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -1,195 +1,199 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import EventPopover from "@/features/Events/EventModal"; -import EventPreviewModal from "@/features/Events/EventPreview"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import ImportAlert from "@/features/Events/ImportAlert"; -import SearchResultsPage from "@/features/Search/SearchResultsPage"; -import { setTimeZone } from "@/features/Settings/SettingsSlice"; -import { setDisplayedDateAndRange } from "@/utils/CalendarRangeManager"; -import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; -import { setSelectedCalendars as setSelectedCalendarsToStorage } from "@/utils/storage/setSelectedCalendars"; -import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars"; -import { browserDefaultTimeZone } from "@/utils/timezone"; -import type { EventApi, LocaleInput } from "@fullcalendar/core"; -import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; -import frLocale from "@fullcalendar/core/locales/fr"; -import ruLocale from "@fullcalendar/core/locales/ru"; -import viLocale from "@fullcalendar/core/locales/vi"; -import dayGridPlugin from "@fullcalendar/daygrid"; -import interactionPlugin from "@fullcalendar/interaction"; -import momentTimezonePlugin from "@fullcalendar/moment-timezone"; -import FullCalendar from "@fullcalendar/react"; -import timeGridPlugin from "@fullcalendar/timegrid"; -import { Box, Button, radius } from "@linagora/twake-mui"; -import AddIcon from "@mui/icons-material/Add"; -import moment from "moment-timezone"; -import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react"; -import { useI18n } from "twake-i18n"; -import { useCalendarDataLoader } from "../../features/Calendars/useCalendarLoader"; -import { User } from "../Attendees/PeopleSearch"; -import { EventErrorSnackbar } from "../Error/ErrorSnackbar"; -import { EventErrorHandler } from "../Error/EventErrorHandler"; -import { EditModeDialog } from "../Event/EditModeDialog"; -import { Menubar, MenubarProps } from "../Menubar/Menubar"; -import "./Calendar.styl"; -import CalendarSelection from "./CalendarSelection"; -import "./CustomCalendar.styl"; -import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers"; -import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers"; -import { MiniCalendar } from "./MiniCalendar"; -import { TempCalendarsInput } from "./TempCalendarsInput"; -import { TimezoneSelector } from "./TimezoneSelector"; +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import EventPopover from '@/features/Events/EventModal' +import EventPreviewModal from '@/features/Events/EventPreview' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import ImportAlert from '@/features/Events/ImportAlert' +import SearchResultsPage from '@/features/Search/SearchResultsPage' +import { setTimeZone } from '@/features/Settings/SettingsSlice' +import { setDisplayedDateAndRange } from '@/utils/CalendarRangeManager' +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' +import { setSelectedCalendars as setSelectedCalendarsToStorage } from '@/utils/storage/setSelectedCalendars' +import { useSelectedCalendars } from '@/utils/storage/useSelectedCalendars' +import { browserDefaultTimeZone } from '@/utils/timezone' +import type { EventApi, LocaleInput } from '@fullcalendar/core' +import { CalendarApi, DateSelectArg } from '@fullcalendar/core' +import frLocale from '@fullcalendar/core/locales/fr' +import ruLocale from '@fullcalendar/core/locales/ru' +import viLocale from '@fullcalendar/core/locales/vi' +import dayGridPlugin from '@fullcalendar/daygrid' +import interactionPlugin from '@fullcalendar/interaction' +import momentTimezonePlugin from '@fullcalendar/moment-timezone' +import FullCalendar from '@fullcalendar/react' +import timeGridPlugin from '@fullcalendar/timegrid' +import { Box, Button, radius } from '@linagora/twake-mui' +import AddIcon from '@mui/icons-material/Add' +import moment from 'moment-timezone' +import { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { useCalendarDataLoader } from '../../features/Calendars/useCalendarLoader' +import { User } from '../Attendees/PeopleSearch' +import { EventErrorSnackbar } from '../Error/ErrorSnackbar' +import { EventErrorHandler } from '../Error/EventErrorHandler' +import { EditModeDialog } from '../Event/EditModeDialog' +import { Menubar, MenubarProps } from '../Menubar/Menubar' +import './Calendar.styl' +import CalendarSelection from './CalendarSelection' +import './CustomCalendar.styl' +import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers' +import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers' +import { MiniCalendar } from './MiniCalendar' +import { TempCalendarsInput } from './TempCalendarsInput' +import { TimezoneSelector } from './TimezoneSelector' import { eventToFullCalendarFormat, extractEvents, - updateSlotLabelVisibility, -} from "./utils/calendarUtils"; + updateSlotLabelVisibility +} from './utils/calendarUtils' const localeMap: Record = { fr: frLocale, ru: ruLocale, vi: viLocale, - en: undefined, -}; + en: undefined +} interface CalendarAppProps { - calendarRef: MutableRefObject; - onDateChange?: (date: Date) => void; - onViewChange?: (view: string) => void; - menubarProps?: MenubarProps; + calendarRef: MutableRefObject + onDateChange?: (date: Date) => void + onViewChange?: (view: string) => void + menubarProps?: MenubarProps } export default function CalendarApp({ calendarRef, onDateChange, onViewChange, - menubarProps, + menubarProps }: CalendarAppProps) { - const [selectedDate, setSelectedDate] = useState(new Date()); - const [debouncedDate, setDebouncedDate] = useState(new Date()); + const [selectedDate, setSelectedDate] = useState(new Date()) + const [debouncedDate, setDebouncedDate] = useState(new Date()) useEffect(() => { - const t = setTimeout(() => setDebouncedDate(selectedDate), 300); - return () => clearTimeout(t); - }, [selectedDate]); - const [selectedMiniDate, setSelectedMiniDate] = useState(new Date()); - const userId = - useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; - const dispatch = useAppDispatch(); - const view = useAppSelector((state) => state.settings.view); - const userData = useAppSelector((state) => state.user.userData); + const t = setTimeout(() => setDebouncedDate(selectedDate), 300) + return () => clearTimeout(t) + }, [selectedDate]) + const [selectedMiniDate, setSelectedMiniDate] = useState(new Date()) + const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? '' + const dispatch = useAppDispatch() + const view = useAppSelector(state => state.settings.view) + const userData = useAppSelector(state => state.user.userData) const workingDays = useAppSelector( - (state) => state.settings.businessHours?.daysOfWeek - ); - const hideWorkingDays = useAppSelector((state) => state.settings.workingDays); + state => state.settings.businessHours?.daysOfWeek + ) + const hideWorkingDays = useAppSelector(state => state.settings.workingDays) const hideDeclinedEvents = useAppSelector( - (state) => state.settings.hideDeclinedEvents - ); + state => state.settings.hideDeclinedEvents + ) const hiddenDays = useMemo(() => { - if (!hideWorkingDays || !workingDays || workingDays.length === 0) return []; - const validWorkingDays = workingDays.filter((d) => d >= 0 && d <= 6); - if (validWorkingDays.length === 0) return []; - return [0, 1, 2, 3, 4, 5, 6].filter((d) => !validWorkingDays.includes(d)); - }, [hideWorkingDays, workingDays]); + if (!hideWorkingDays || !workingDays || workingDays.length === 0) return [] + const validWorkingDays = workingDays.filter(d => d >= 0 && d <= 6) + if (validWorkingDays.length === 0) return [] + return [0, 1, 2, 3, 4, 5, 6].filter(d => !validWorkingDays.includes(d)) + }, [hideWorkingDays, workingDays]) - const calendars = useAppSelector((state) => state.calendars.list); - const isPending = useAppSelector((state) => state.calendars.pending); + const calendars = useAppSelector(state => state.calendars.list) + const isPending = useAppSelector(state => state.calendars.pending) const displayWeekNumbers = useAppSelector( - (state) => state.settings.displayWeekNumbers - ); - const tempcalendars = useAppSelector((state) => state.calendars.templist); - const storedCalendars = useSelectedCalendars(); + state => state.settings.displayWeekNumbers + ) + const tempcalendars = useAppSelector(state => state.calendars.templist) + const storedCalendars = useSelectedCalendars() const [selectedCalendars, setSelectedCalendars] = - useState(storedCalendars); + useState(storedCalendars) const calendarIdsString = useMemo( () => Object.keys(calendars || {}) .sort() - .join(","), + .join(','), [calendars] - ); + ) const calendarIds = useMemo( - () => (calendarIdsString ? calendarIdsString.split(",") : []), + () => (calendarIdsString ? calendarIdsString.split(',') : []), [calendarIdsString] - ); + ) - const [currentView, setCurrentView] = useState("timeGridWeek"); + const [currentView, setCurrentView] = useState('timeGridWeek') const timezone = - useAppSelector((state) => state.settings.timeZone) ?? - browserDefaultTimeZone; + useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone // Auto-select personal calendars when first loaded - const initialLoadRef = useRef(true); - const [eventErrors, setEventErrors] = useState([]); - const errorHandler = useRef(new EventErrorHandler()); + const initialLoadRef = useRef(true) + const [eventErrors, setEventErrors] = useState([]) + const errorHandler = useRef(new EventErrorHandler()) useEffect(() => { - const handler = errorHandler.current; - handler.setErrorCallback(setEventErrors); - return () => handler.setErrorCallback(() => {}); - }, []); + const handler = errorHandler.current + handler.setErrorCallback(setEventErrors) + return () => handler.setErrorCallback(() => {}) + }, []) const handleErrorClose = () => { - setEventErrors([]); - errorHandler.current.clearAll(); - }; + setEventErrors([]) + errorHandler.current.clearAll() + } useEffect(() => { - if (initialLoadRef.current && calendarIds.length > 0 && userId) { - const cached = localStorage.getItem("selectedCalendars"); - if (cached && cached.length > 0) { - const parsed = JSON.parse(cached) as string[]; - const valid = parsed.filter((id) => calendars[id]); - setSelectedCalendars(valid); - } else { - const personalCalendarIds = calendarIds.filter( - (id) => extractEventBaseUuid(id) === userId - ); - setSelectedCalendars(personalCalendarIds); + const updateSelectedCalendars = () => { + if (initialLoadRef.current && calendarIds.length > 0 && userId) { + const cached = localStorage.getItem('selectedCalendars') + if (cached && cached.length > 0) { + const parsed = JSON.parse(cached) as string[] + const valid = parsed.filter(id => calendars[id]) + setSelectedCalendars(valid) + } else { + const personalCalendarIds = calendarIds.filter( + id => extractEventBaseUuid(id) === userId + ) + setSelectedCalendars(personalCalendarIds) + } + initialLoadRef.current = false } - initialLoadRef.current = false; } - }, [calendarIds, calendars, userId]); + updateSelectedCalendars() + }, [calendarIds, calendars, userId]) // Save selected cals to cache useEffect(() => { if (calendarIds.length > 0) { - setSelectedCalendarsToStorage(selectedCalendars); + setSelectedCalendarsToStorage(selectedCalendars) } - }, [selectedCalendars, calendarIds.length]); + }, [selectedCalendars, calendarIds.length]) useEffect(() => { - if (calendarIds.length === 0) return; - const validCalendarIds = new Set(calendarIds); - setSelectedCalendars((prev) => { - const filtered = prev.filter((calId) => validCalendarIds.has(calId)); - if (filtered.length === prev.length) { - const unchanged = filtered.every((id, index) => id === prev[index]); - if (unchanged) { - return prev; + const updateSelectedCalendarsOnCalendarChange = () => { + if (calendarIds.length === 0) return + const validCalendarIds = new Set(calendarIds) + setSelectedCalendars(prev => { + const filtered = prev.filter(calId => validCalendarIds.has(calId)) + if (filtered.length === prev.length) { + const unchanged = filtered.every((id, index) => id === prev[index]) + if (unchanged) { + return prev + } } - } - return filtered; - }); - }, [calendarIds]); + return filtered + }) + } + updateSelectedCalendarsOnCalendarChange() + }, [calendarIds]) const sortedSelectedCalendars = useMemo( () => [...selectedCalendars].sort(), [selectedCalendars] - ); + ) const tempCalendarIdsString = useMemo( () => Object.keys(tempcalendars || {}) .sort() - .join(","), + .join(','), [tempcalendars] - ); + ) const tempCalendarIds = useMemo( - () => (tempCalendarIdsString ? tempCalendarIdsString.split(",") : []), + () => (tempCalendarIdsString ? tempCalendarIdsString.split(',') : []), [tempCalendarIdsString] - ); + ) useCalendarDataLoader({ selectedDate: debouncedDate, @@ -198,47 +202,47 @@ export default function CalendarApp({ sortedSelectedCalendars, calendarIds, calendarIdsString, - tempCalendarIds, - }); + tempCalendarIds + }) const filteredEvents: CalendarEvent[] = extractEvents( selectedCalendars, calendars || {}, userData?.email, hideDeclinedEvents - ); + ) const filteredTempEvents: CalendarEvent[] = extractEvents( tempCalendarIds, tempcalendars || {}, userData?.email, hideDeclinedEvents - ); + ) - const [anchorEl, setAnchorEl] = useState(null); - const [openEventDisplay, setOpenEventDisplay] = useState(false); - const [eventDisplayedId, setEventDisplayedId] = useState(""); - const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false); - const [eventDisplayedCalId, setEventDisplayedCalId] = useState(""); + const [anchorEl, setAnchorEl] = useState(null) + const [openEventDisplay, setOpenEventDisplay] = useState(false) + const [eventDisplayedId, setEventDisplayedId] = useState('') + const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false) + const [eventDisplayedCalId, setEventDisplayedCalId] = useState('') // Listen for eventModalError event to reopen modal on API failure useEffect(() => { const handleEventModalError = (event: CustomEvent) => { - if (event.detail?.type === "create") { + if (event.detail?.type === 'create') { // Reopen create event modal - setAnchorEl(document.body); - } else if (event.detail?.type === "update") { + setAnchorEl(document.body) + } else if (event.detail?.type === 'update') { // Store update modal info to sessionStorage for EventDisplayPreview to pick up try { sessionStorage.setItem( - "eventUpdateModalReopen", + 'eventUpdateModalReopen', JSON.stringify({ eventId: event.detail.eventId, calId: event.detail.calId, typeOfAction: event.detail.typeOfAction, - timestamp: Date.now(), + timestamp: Date.now() }) - ); + ) // Open EventDisplayPreview if it's not already open with matching event, so it can pick up the sessionStorage if ( @@ -246,56 +250,52 @@ export default function CalendarApp({ eventDisplayedId !== event.detail.eventId || eventDisplayedCalId !== event.detail.calId ) { - setEventDisplayedId(event.detail.eventId); - setEventDisplayedCalId(event.detail.calId); - setEventDisplayedTemp(false); - setOpenEventDisplay(true); + setEventDisplayedId(event.detail.eventId) + setEventDisplayedCalId(event.detail.calId) + setEventDisplayedTemp(false) + setOpenEventDisplay(true) } else { // If EventDisplayPreview is already open, trigger reopen by dispatching a custom event window.dispatchEvent( - new CustomEvent("eventUpdateModalReopen", { + new CustomEvent('eventUpdateModalReopen', { detail: { eventId: event.detail.eventId, calId: event.detail.calId, - typeOfAction: event.detail.typeOfAction, - }, + typeOfAction: event.detail.typeOfAction + } }) - ); + ) } } catch { // Ignore sessionStorage errors } } - }; + } window.addEventListener( - "eventModalError", + 'eventModalError', handleEventModalError as EventListener - ); + ) return () => { window.removeEventListener( - "eventModalError", + 'eventModalError', handleEventModalError as EventListener - ); - }; - }, [openEventDisplay, eventDisplayedId, eventDisplayedCalId]); + ) + } + }, [openEventDisplay, eventDisplayedId, eventDisplayedCalId]) const [openEditModePopup, setOpenEditModePopup] = useState( null - ); - const [, setTypeOfAction] = useState<"solo" | "all" | undefined>(undefined); + ) + const [, setTypeOfAction] = useState<'solo' | 'all' | undefined>(undefined) const [afterChoiceFunc, setAfterChoiceFunc] = useState< - ((type: "solo" | "all" | undefined) => void) | undefined - >(); - const [, setSelectedEvent] = useState({} as CalendarEvent); - const [selectedRange, setSelectedRange] = useState( - null - ); + ((type: 'solo' | 'all' | undefined) => void) | undefined + >() + const [, setSelectedEvent] = useState({} as CalendarEvent) + const [selectedRange, setSelectedRange] = useState(null) - const [tempUsers, setTempUsers] = useState([]); - const [tempEvent, setTempEvent] = useState( - {} as CalendarEvent - ); + const [tempUsers, setTempUsers] = useState([]) + const [tempEvent, setTempEvent] = useState({} as CalendarEvent) // Event handlers const eventHandlers = useCalendarEventHandlers({ @@ -313,8 +313,8 @@ export default function CalendarApp({ setOpenEditModePopup, tempUsers, setTempEvent, - timezone, - }); + timezone + }) // View handlers const viewHandlers = useCalendarViewHandlers({ @@ -324,18 +324,22 @@ export default function CalendarApp({ onViewChange, calendars, tempcalendars, - errorHandler: errorHandler.current, - }); + // Note: To preserve current logic, this will temporarily disable eslint for react-hooks/refs + // eslint-disable-next-line react-hooks/refs + errorHandler: errorHandler.current + }) - if (process.env.NODE_ENV === "test") { - window.__calendarRef = calendarRef; - } + useEffect(() => { + if (process.env.NODE_ENV === 'test') { + window.__calendarRef = calendarRef + } + }, [calendarRef]) - const { t, lang } = useI18n(); + const { t, lang } = useI18n() return (
@@ -385,7 +389,7 @@ export default function CalendarApp({ tempUsers={tempUsers} setTempUsers={setTempUsers} handleToggleEventPreview={() => { - eventHandlers.handleDateSelect(null as unknown as DateSelectArg); + eventHandlers.handleDateSelect(null as unknown as DateSelectArg) }} /> @@ -399,19 +403,19 @@ export default function CalendarApp({
{menubarProps?.isIframe && } - {view === "calendar" && ( + {view === 'calendar' && ( { + key={hiddenDays.join(',')} + ref={ref => { if (ref) { - calendarRef.current = ref.getApi(); + calendarRef.current = ref.getApi() } }} plugins={[ dayGridPlugin, timeGridPlugin, interactionPlugin, - momentTimezonePlugin, + momentTimezonePlugin ]} initialView="timeGridWeek" firstDay={1} @@ -420,16 +424,16 @@ export default function CalendarApp({ hiddenDays={hiddenDays} selectable={true} timeZone={timezone} - height={"100%"} + height="100%" select={eventHandlers.handleDateSelect} nowIndicator - slotLabelClassNames={(arg) => [ - updateSlotLabelVisibility(new Date(), arg, timezone), + slotLabelClassNames={arg => [ + updateSlotLabelVisibility(new Date(), arg, timezone) ]} nowIndicatorContent={viewHandlers.handleNowIndicatorContent} headerToolbar={false} views={{ - timeGridWeek: { titleFormat: { month: "long", year: "numeric" } }, + timeGridWeek: { titleFormat: { month: 'long', year: 'numeric' } } }} dayMaxEvents={true} events={eventToFullCalendarFormat( @@ -444,15 +448,15 @@ export default function CalendarApp({ a.extendedProps.priority - b.extendedProps.priority } weekNumbers={ - currentView === "timeGridWeek" || currentView === "timeGridDay" + currentView === 'timeGridWeek' || currentView === 'timeGridDay' } - weekNumberFormat={{ week: "long" }} - weekNumberContent={(arg) => { + weekNumberFormat={{ week: 'long' }} + weekNumberContent={arg => { return (
{displayWeekNumbers && (
- {t("menubar.views.week")} {arg.num} + {t('menubar.views.week')} {arg.num}
)}
- ); + ) }} - dayCellContent={(arg) => { - const month = arg.date.toLocaleDateString(t("locale"), { - month: "short", - timeZone: timezone, - }); - if (arg.view.type === "dayGridMonth") { + dayCellContent={arg => { + const month = arg.date.toLocaleDateString(t('locale'), { + month: 'short', + timeZone: timezone + }) + if (arg.view.type === 'dayGridMonth') { return ( - {arg.dayNumberText === "1" ? month : ""} {arg.dayNumberText} + {arg.dayNumberText === '1' ? month : ''} {arg.dayNumberText} - ); + ) } }} - slotDuration={"00:30:00"} - slotLabelInterval={"01:00:00"} + slotDuration="00:30:00" + slotLabelInterval="01:00:00" scrollTime="12:00:00" unselectAuto={false} allDayText="" slotLabelFormat={{ - hour: "2-digit", - minute: "2-digit", - hour12: false, + hour: '2-digit', + minute: '2-digit', + hour12: false }} - datesSet={(arg) => { - setCurrentView(arg.view.type); + datesSet={arg => { + setCurrentView(arg.view.type) const calendarCurrentDate = - calendarRef.current?.getDate() || new Date(arg.start); - setDisplayedDateAndRange(calendarCurrentDate); + calendarRef.current?.getDate() || new Date(arg.start) + setDisplayedDateAndRange(calendarCurrentDate) - if (arg.view.type === "dayGridMonth") { - const start = new Date(arg.start).getTime(); - const end = new Date(arg.end).getTime(); - const middle = start + (end - start) / 2; - setSelectedDate(new Date(middle)); - setSelectedMiniDate(calendarCurrentDate); + if (arg.view.type === 'dayGridMonth') { + const start = new Date(arg.start).getTime() + const end = new Date(arg.end).getTime() + const middle = start + (end - start) / 2 + setSelectedDate(new Date(middle)) + setSelectedMiniDate(calendarCurrentDate) } else { - setSelectedDate(calendarCurrentDate); - setSelectedMiniDate(calendarCurrentDate); + setSelectedDate(calendarCurrentDate) + setSelectedMiniDate(calendarCurrentDate) } // Always use the calendar's current date for consistency if (onDateChange) { - onDateChange(calendarCurrentDate); + onDateChange(calendarCurrentDate) } // Notify parent about view change if (onViewChange) { - onViewChange(arg.view.type); + onViewChange(arg.view.type) } // Update slot label visibility when view changes setTimeout(() => { - updateSlotLabelVisibility(new Date()); - }, 100); + updateSlotLabelVisibility(new Date()) + }, 100) }} - dayHeaderContent={(arg) => { - const m = moment.tz(arg.date, timezone); + dayHeaderContent={arg => { + const m = moment.tz(arg.date, timezone) - const date = m.date(); + const date = m.date() const weekDay = m .toDate() - .toLocaleDateString(t("locale"), { - weekday: "short", - timeZone: timezone, + .toLocaleDateString(t('locale'), { + weekday: 'short', + timeZone: timezone }) - .toUpperCase(); + .toUpperCase() return (
{weekDay} - {arg.view.type !== "dayGridMonth" && ( + {arg.view.type !== 'dayGridMonth' && ( {date} )}
- ); + ) }} dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount} dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount} @@ -561,7 +565,7 @@ export default function CalendarApp({ eventDidMount={viewHandlers.handleEventDidMount} /> )} - {view === "search" && } + {view === 'search' && } { - setTypeOfAction(type); + eventAction={(type: 'solo' | 'all' | undefined) => { + setTypeOfAction(type) if (afterChoiceFunc) { - afterChoiceFunc(type); + afterChoiceFunc(type) } }} /> @@ -593,5 +597,5 @@ export default function CalendarApp({
- ); + ) } diff --git a/src/components/Calendar/CalendarAccessRights.tsx b/src/components/Calendar/CalendarAccessRights.tsx index 197badd..54e73c9 100644 --- a/src/components/Calendar/CalendarAccessRights.tsx +++ b/src/components/Calendar/CalendarAccessRights.tsx @@ -1,8 +1,8 @@ -import { useAppSelector } from "@/app/hooks"; -import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes"; -import { getUserDetails } from "@/features/User/userAPI"; -import { makeDisplayName } from "@/utils/makeDisplayName"; -import { normalizeEmail } from "@/utils/normalizeEmail"; +import { useAppSelector } from '@/app/hooks' +import { AccessRight, Calendar } from '@/features/Calendars/CalendarTypes' +import { getUserDetails } from '@/features/User/userAPI' +import { makeDisplayName } from '@/utils/makeDisplayName' +import { normalizeEmail } from '@/utils/normalizeEmail' import { AutocompleteRenderInputParams, Avatar, @@ -13,236 +13,236 @@ import { MenuItem, Select, TextField, - Typography, -} from "@linagora/twake-mui"; -import HighlightOffIcon from "@mui/icons-material/HighlightOff"; -import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useI18n } from "twake-i18n"; -import { PeopleSearch, User } from "../Attendees/PeopleSearch"; -import { FieldWithLabel } from "../Event/components/FieldWithLabel"; -import { stringAvatar } from "../Event/utils/eventUtils"; -import { ResourceAdmin } from "./ResourceAdmins"; + Typography +} from '@linagora/twake-mui' +import HighlightOffIcon from '@mui/icons-material/HighlightOff' +import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { PeopleSearch, User } from '../Attendees/PeopleSearch' +import { FieldWithLabel } from '../Event/components/FieldWithLabel' +import { stringAvatar } from '../Event/utils/eventUtils' +import { ResourceAdmin } from './ResourceAdmins' export interface UserWithAccess extends User { - accessRight: AccessRight; + accessRight: AccessRight } interface CalendarAccessRightsProps { - calendar: Calendar; - value: UserWithAccess[]; - onChange: (users: UserWithAccess[]) => void; - onInvitesLoaded: (users: UserWithAccess[]) => void; + calendar: Calendar + value: UserWithAccess[] + onChange: (users: UserWithAccess[]) => void + onInvitesLoaded: (users: UserWithAccess[]) => void } interface UserInCalendar { - id: string; - access: AccessRight; + id: string + access: AccessRight } export function CalendarAccessRights({ calendar, value: usersWithAccess, onChange, - onInvitesLoaded, + onInvitesLoaded }: CalendarAccessRightsProps) { - const { t } = useI18n(); - const userData = useAppSelector((state) => state.user.userData); - const isPersonalCalendar = userData?.openpaasId === calendar.id.split("/")[0]; - const currentUserEmail = normalizeEmail(userData?.email); - const isDelegatedWithAdministration = !!calendar.invite?.some((invite) => { - const invitedEmail = normalizeEmail(invite.href.replace(/^mailto:/i, "")); - return invitedEmail === currentUserEmail && invite.access === 5; - }); + const { t } = useI18n() + const userData = useAppSelector(state => state.user.userData) + const isPersonalCalendar = userData?.openpaasId === calendar.id.split('/')[0] + const currentUserEmail = normalizeEmail(userData?.email) + const isDelegatedWithAdministration = !!calendar.invite?.some(invite => { + const invitedEmail = normalizeEmail(invite.href.replace(/^mailto:/i, '')) + return invitedEmail === currentUserEmail && invite.access === 5 + }) const ownerEmail = - calendar.owner?.preferredEmail ?? calendar.owner?.emails?.[0] ?? ""; - const ownerName = makeDisplayName(calendar) ?? ownerEmail; + calendar.owner?.preferredEmail ?? calendar.owner?.emails?.[0] ?? '' + const ownerName = makeDisplayName(calendar) ?? ownerEmail - const containerRef = useRef(null); - const [searchWidth, setSearchWidth] = useState(undefined); - const [accessRight, setAccessRight] = useState(2); - const [inviteLoading, setInvitesLoading] = useState(false); - const [resourceAdmins, setResourceAdmins] = useState([]); - const [adminLoading, setAdminLoading] = useState(false); + const containerRef = useRef(null) + const [searchWidth, setSearchWidth] = useState(undefined) + const [accessRight, setAccessRight] = useState(2) + const [inviteLoading, setInvitesLoading] = useState(false) + const [resourceAdmins, setResourceAdmins] = useState([]) + const [adminLoading, setAdminLoading] = useState(false) - const currentUsersRef = useRef(usersWithAccess); + const currentUsersRef = useRef(usersWithAccess) useEffect(() => { - currentUsersRef.current = usersWithAccess; - }, [usersWithAccess]); + currentUsersRef.current = usersWithAccess + }, [usersWithAccess]) useEffect(() => { - if (!containerRef.current) return; - const observer = new ResizeObserver((entries) => { + if (!containerRef.current) return + const observer = new ResizeObserver(entries => { for (const entry of entries) { - setSearchWidth(entry.contentRect.width); + setSearchWidth(entry.contentRect.width) } - }); - observer.observe(containerRef.current); - return () => observer.disconnect(); - }, []); + }) + observer.observe(containerRef.current) + return () => observer.disconnect() + }, []) const handleLoadUsers = useCallback( async (usersInCal: UserInCalendar[], cancelled: boolean) => { const results = await Promise.allSettled( - usersInCal.map(async (user) => { - const details = await getUserDetails(user.id); - const email = details?.preferredEmail ?? details?.emails?.[0] ?? ""; + usersInCal.map(async user => { + const details = await getUserDetails(user.id) + const email = details?.preferredEmail ?? details?.emails?.[0] ?? '' return { openpaasId: user.id, displayName: [details?.firstname, details?.lastname] .filter(Boolean) - .join(" ") + .join(' ') .trim() || email, email, - accessRight: user.access as AccessRight, - } satisfies UserWithAccess; + accessRight: user.access + } satisfies UserWithAccess }) - ); + ) if (cancelled) { - return [] as UserWithAccess[]; + return [] as UserWithAccess[] } const loaded: UserWithAccess[] = results - .filter((r) => r.status === "fulfilled") - .map((r) => (r as PromiseFulfilledResult).value) - .filter(Boolean) as UserWithAccess[]; + .filter(r => r.status === 'fulfilled') + .map(r => (r as PromiseFulfilledResult).value) + .filter(Boolean) - return loaded; + return loaded }, [] - ); + ) useEffect(() => { - if (!calendar.invite?.length) return; + if (!calendar.invite?.length) return - let cancelled = false; + let cancelled = false async function loadInvitedUsers() { - setInvitesLoading(true); + setInvitesLoading(true) try { const usersInCal = (calendar.invite - ?.map((invite) => { - const principalId = invite.principal.split("/").pop(); - if (!principalId) return null; + ?.map(invite => { + const principalId = invite.principal.split('/').pop() + if (!principalId) return null return { id: principalId, - access: invite.access, - }; + access: invite.access + } }) ?.filter( - (invite) => + invite => !!invite && !calendar.owner.administrators?.some( - (admin) => admin.id === invite.id + admin => admin.id === invite.id ) - ) || []) as UserInCalendar[]; + ) || []) as UserInCalendar[] - const loaded = await handleLoadUsers(usersInCal, cancelled); + const loaded = await handleLoadUsers(usersInCal, cancelled) - const loadedIds = new Set(loaded.map((u) => normalizeEmail(u.email))); + const loadedIds = new Set(loaded.map(u => normalizeEmail(u.email))) const manuallyAdded = currentUsersRef.current.filter( - (u) => !loadedIds.has(normalizeEmail(u.email)) - ); - const merged = [...loaded, ...manuallyAdded]; + u => !loadedIds.has(normalizeEmail(u.email)) + ) + const merged = [...loaded, ...manuallyAdded] - onInvitesLoaded(loaded); - onChange(merged); + onInvitesLoaded(loaded) + onChange(merged) } finally { - if (!cancelled) setInvitesLoading(false); + if (!cancelled) setInvitesLoading(false) } } - loadInvitedUsers(); + loadInvitedUsers() return () => { - cancelled = true; - }; + cancelled = true + } }, [ calendar.invite, calendar.owner.administrators, handleLoadUsers, onChange, - onInvitesLoaded, - ]); + onInvitesLoaded + ]) useEffect(() => { - const isResource = calendar.owner.resource; - const resourceAdmins = calendar.owner.administrators || []; - if (!isResource || !resourceAdmins?.length) return; + const isResource = calendar.owner.resource + const resourceAdmins = calendar.owner.administrators || [] + if (!isResource || !resourceAdmins?.length) return - let cancelled = false; + let cancelled = false async function loadAdmins() { try { - setAdminLoading(true); + setAdminLoading(true) const resourceAdminsWithoutOwner = resourceAdmins - .filter((admin) => admin.id !== calendar.owner._id) - .map((admin) => ({ + .filter(admin => admin.id !== calendar.owner._id) + .map(admin => ({ id: admin.id, - access: 5, // ADMIN - })) satisfies UserInCalendar[]; + access: 5 // ADMIN + })) satisfies UserInCalendar[] const admins = await handleLoadUsers( resourceAdminsWithoutOwner, cancelled - ); + ) - setResourceAdmins(admins); + setResourceAdmins(admins) } catch (error) { - console.error(error); + console.error(error) } finally { - if (!cancelled) setAdminLoading(false); + if (!cancelled) setAdminLoading(false) } } - loadAdmins(); + loadAdmins() return () => { - cancelled = true; - }; - }, [calendar.owner, handleLoadUsers, setResourceAdmins]); + cancelled = true + } + }, [calendar.owner, handleLoadUsers, setResourceAdmins]) const handleUserSelect = (_event: unknown, users: User[]) => { - const updated: UserWithAccess[] = users.map((user) => { + const updated: UserWithAccess[] = users.map(user => { const existing = usersWithAccess.find( - (u) => normalizeEmail(u.email) === normalizeEmail(user.email) - ); - return existing ?? { ...user, accessRight }; - }); - onChange(updated); - }; + u => normalizeEmail(u.email) === normalizeEmail(user.email) + ) + return existing ?? { ...user, accessRight } + }) + onChange(updated) + } const handleRemoveUser = (email: string) => { onChange( usersWithAccess.filter( - (u) => normalizeEmail(u.email) !== normalizeEmail(email) + u => normalizeEmail(u.email) !== normalizeEmail(email) ) - ); - }; + ) + } const handleChangeUserRight = (email: string, right: AccessRight) => { onChange( - usersWithAccess.map((u) => + usersWithAccess.map(u => normalizeEmail(u.email) === normalizeEmail(email) ? { ...u, accessRight: right } : u ) - ); - }; + ) + } const accessRightOptions: { value: AccessRight; label: string }[] = [ - { value: 2, label: t("calendarPopover.access.viewAllEvents") }, - { value: 3, label: t("calendarPopover.access.editor") }, - { value: 5, label: t("calendarPopover.access.administrator") }, - ]; + { value: 2, label: t('calendarPopover.access.viewAllEvents') }, + { value: 3, label: t('calendarPopover.access.editor') }, + { value: 5, label: t('calendarPopover.access.administrator') } + ] return ( @@ -251,27 +251,27 @@ export function CalendarAccessRights({ {}} customSlotProps={{ popper: { anchorEl: containerRef.current, - placement: "bottom-start", + placement: 'bottom-start', sx: { minWidth: searchWidth, - "& .MuiPaper-root": { - width: "100%", - }, + '& .MuiPaper-root': { + width: '100%' + } }, modifiers: [ { - name: "offset", + name: 'offset', options: { - offset: [0, 8], - }, - }, - ], - }, + offset: [0, 8] + } + } + ] + } }} customRenderInput={( params: AutocompleteRenderInputParams, @@ -282,33 +282,33 @@ export function CalendarAccessRights({ {...params} fullWidth autoFocus - placeholder={t("peopleSearch.label")} + placeholder={t('peopleSearch.label')} value={query} - inputRef={(el) => { - const ref = params.InputProps.ref; - if (typeof ref === "function") { - ref(el); - } else if (ref && "current" in ref) { - ( + inputRef={el => { + const ref = params.InputProps.ref + if (typeof ref === 'function') { + ref(el) + } else if (ref && 'current' in ref) { + ;( ref as React.MutableRefObject - ).current = el; + ).current = el } }} - onChange={(e) => setQuery(e.target.value)} + onChange={e => setQuery(e.target.value)} variant="outlined" inputProps={{ ...params.inputProps, sx: { - fontSize: "14px", - "&::placeholder": { fontSize: "14px" }, - }, + fontSize: '14px', + '&::placeholder': { fontSize: '14px' } + } }} InputProps={{ ...params.InputProps, startAdornment: ( ), @@ -316,34 +316,34 @@ export function CalendarAccessRights({ - ), + ) }} /> )} @@ -360,14 +360,14 @@ export function CalendarAccessRights({ px={1} py={0.5} sx={{ - borderRadius: "8px", - "&:hover": { backgroundColor: "action.hover" }, + borderRadius: '8px', + '&:hover': { backgroundColor: 'action.hover' } }} > {ownerName} @@ -379,7 +379,7 @@ export function CalendarAccessRights({ - {t("calendarPopover.access.owner")} + {t('calendarPopover.access.owner')} @@ -388,7 +388,7 @@ export function CalendarAccessRights({ ) : ( - resourceAdmins.map((admin) => ( + resourceAdmins.map(admin => ( )) )} @@ -398,7 +398,7 @@ export function CalendarAccessRights({
) : ( usersWithAccess.length > 0 && - usersWithAccess.map((user) => ( + usersWithAccess.map(user => ( {user.displayName} @@ -427,7 +427,7 @@ export function CalendarAccessRights({ setImportFile(e.target.files?.[0] ?? null)} + onChange={e => setImportFile(e.target.files?.[0] ?? null)} /> {importFile && ( {importFile.name} @@ -82,28 +82,28 @@ export function ImportTab({ variant="caption" color="text.secondary" display="block" - sx={{ marginTop: "6px" }} + sx={{ marginTop: '6px' }} > - {t("calendar.import_file_description")} + {t('calendar.import_file_description')} )} {/* Form group 2: URL field */} - {importMode === "url" && ( + {importMode === 'url' && ( setImportUrl(e.target.value)} + onChange={e => setImportUrl(e.target.value)} size="small" margin="dense" sx={{ - "&.MuiFormControl-root.MuiFormControl-marginDense": { - marginTop: "6px", - marginBottom: 0, - }, + '&.MuiFormControl-root.MuiFormControl-marginDense': { + marginTop: '6px', + marginBottom: 0 + } }} /> @@ -116,33 +116,33 @@ export function ImportTab({ size="small" margin="dense" sx={{ - "&.MuiFormControl-root.MuiFormControl-marginDense": { - marginTop: "6px", - marginBottom: 0, - }, + '&.MuiFormControl-root.MuiFormControl-marginDense': { + marginTop: '6px', + marginBottom: 0 + } }} > - {t("calendar.import_to")} + {t('calendar.import_to')} {/* Form group 4: SettingsTab (when importing to new calendar) */} - {importTarget === "new" && ( + {importTarget === 'new' && ( )} - ); + ) } diff --git a/src/components/Calendar/MiniCalendar.tsx b/src/components/Calendar/MiniCalendar.tsx index e9a0013..09721e7 100644 --- a/src/components/Calendar/MiniCalendar.tsx +++ b/src/components/Calendar/MiniCalendar.tsx @@ -1,121 +1,126 @@ -import { useAppDispatch } from "@/app/hooks"; -import { setView } from "@/features/Settings/SettingsSlice"; -import { computeStartOfTheWeek } from "@/utils/dateUtils"; -import type { CalendarApi } from "@fullcalendar/core"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import { DateCalendar } from "@mui/x-date-pickers"; -import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment"; -import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; -import moment from "moment"; -import { useEffect, useState } from "react"; -import { useI18n } from "twake-i18n"; +import { useAppDispatch } from '@/app/hooks' +import { setView } from '@/features/Settings/SettingsSlice' +import { computeStartOfTheWeek } from '@/utils/dateUtils' +import type { CalendarApi } from '@fullcalendar/core' +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' +import { DateCalendar } from '@mui/x-date-pickers' +import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import moment from 'moment' +import { useEffect, useState } from 'react' +import { useI18n } from 'twake-i18n' export function MiniCalendar({ calendarRef, selectedDate, - setSelectedMiniDate, + setSelectedMiniDate }: { - calendarRef: React.MutableRefObject; - selectedDate: Date; - setSelectedMiniDate: (d: Date) => void; + calendarRef: React.MutableRefObject + selectedDate: Date + setSelectedMiniDate: (d: Date) => void }) { - const dispatch = useAppDispatch(); - const [visibleDate, setVisibleDate] = useState(selectedDate); - const { t } = useI18n(); + const dispatch = useAppDispatch() + const [visibleDate, setVisibleDate] = useState(selectedDate) + const { t } = useI18n() - useEffect(() => setVisibleDate(selectedDate), [selectedDate]); + useEffect(() => { + const handleVisibleDateChange = () => { + setVisibleDate(selectedDate) + } + handleVisibleDateChange() + }, [selectedDate]) return ( { - if (!dateMoment) return; - const date = dateMoment.toDate(); - if (selectionState === "finish") { - await dispatch(setView("calendar")); - setSelectedMiniDate(date); - calendarRef.current?.gotoDate(date); + if (!dateMoment) return + const date = dateMoment.toDate() + if (selectionState === 'finish') { + await dispatch(setView('calendar')) + setSelectedMiniDate(date) + calendarRef.current?.gotoDate(date) } }} showDaysOutsideCurrentMonth - onMonthChange={(month) => { - setVisibleDate(month.toDate()); + onMonthChange={month => { + setVisibleDate(month.toDate()) }} - views={["month", "day"]} + views={['month', 'day']} slots={{ - switchViewIcon: KeyboardArrowDownIcon, + switchViewIcon: KeyboardArrowDownIcon }} sx={{ - width: "100%", - height: "300px", - "& .MuiPickersCalendarHeader-root": { - marginTop: 3, - }, + width: '100%', + height: '300px', + '& .MuiPickersCalendarHeader-root': { + marginTop: 3 + } }} slotProps={{ - day: (ownerState) => { - const date = ownerState.day.toDate(); - const today = new Date(); - today.setHours(0, 0, 0, 0); - const selected = new Date(selectedDate); - selected.setHours(0, 0, 0, 0); + day: ownerState => { + const date = ownerState.day.toDate() + const today = new Date() + today.setHours(0, 0, 0, 0) + const selected = new Date(selectedDate) + selected.setHours(0, 0, 0, 0) - const isToday = date.getTime() === today.getTime(); + const isToday = date.getTime() === today.getTime() const isSelectedDay = - calendarRef.current?.view.type === "timeGridDay" && - date.getTime() === selected.getTime(); + calendarRef.current?.view.type === 'timeGridDay' && + date.getTime() === selected.getTime() const isInSelectedWeek = - calendarRef.current?.view.type === "timeGridWeek" || + calendarRef.current?.view.type === 'timeGridWeek' || calendarRef.current?.view.type === undefined ? (() => { - const startOfWeek = computeStartOfTheWeek(selected); - const endOfWeek = new Date(startOfWeek); - endOfWeek.setDate(startOfWeek.getDate() + 6); - endOfWeek.setHours(23, 59, 59, 999); - return date >= startOfWeek && date <= endOfWeek; + const startOfWeek = computeStartOfTheWeek(selected) + const endOfWeek = new Date(startOfWeek) + endOfWeek.setDate(startOfWeek.getDate() + 6) + endOfWeek.setHours(23, 59, 59, 999) + return date >= startOfWeek && date <= endOfWeek })() - : false; + : false const classNames = [ - isToday ? "today" : "", - isSelectedDay ? "selectedDay" : "", - isInSelectedWeek ? "selectedWeek" : "", - ].join(" "); + isToday ? 'today' : '', + isSelectedDay ? 'selectedDay' : '', + isInSelectedWeek ? 'selectedWeek' : '' + ].join(' ') return { className: classNames, - selected: classNames.includes("selectedWeek"), + selected: classNames.includes('selectedWeek'), outsideCurrentMonth: ownerState.isDayOutsideMonth, disableMargin: false, style: { - backgroundColor: "transparent", - position: "relative", - flexDirection: "column", - border: 0, + backgroundColor: 'transparent', + position: 'relative', + flexDirection: 'column', + border: 0 }, sx: { - "&.Mui-selected": { - color: "inherit !important", - fontWeight: "inherit !important", + '&.Mui-selected': { + color: 'inherit !important', + fontWeight: 'inherit !important' }, - "&.selectedDay": { - backgroundColor: "lightgray !important", - }, - "&.today": { - background: "orange !important", - color: "white !important", + '&.selectedDay': { + backgroundColor: 'lightgray !important' }, + '&.today': { + background: 'orange !important', + color: 'white !important' + } }, - "data-testid": `date-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`, - children: <>{ownerState.day.date()}, - }; - }, + 'data-testid': `date-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`, + children: <>{ownerState.day.date()} + } + } }} /> - ); + ) } diff --git a/src/components/Calendar/OwnerCaption.tsx b/src/components/Calendar/OwnerCaption.tsx index fb2e67b..1813cf8 100644 --- a/src/components/Calendar/OwnerCaption.tsx +++ b/src/components/Calendar/OwnerCaption.tsx @@ -1,23 +1,23 @@ -import { Typography } from "@linagora/twake-mui"; +import { Typography } from '@linagora/twake-mui' export function OwnerCaption({ showCaption, - ownerDisplayName, + ownerDisplayName }: { - showCaption: boolean; - ownerDisplayName: string; + showCaption: boolean + ownerDisplayName: string }) { return ( {showCaption && ownerDisplayName} - ); + ) } diff --git a/src/components/Calendar/ResourceAdmins.tsx b/src/components/Calendar/ResourceAdmins.tsx index 3fb35cf..835c48f 100644 --- a/src/components/Calendar/ResourceAdmins.tsx +++ b/src/components/Calendar/ResourceAdmins.tsx @@ -1,14 +1,14 @@ -import { Avatar, Box, Typography } from "@linagora/twake-mui"; -import { useI18n } from "twake-i18n"; -import { stringAvatar } from "../Event/utils/eventUtils"; -import { UserWithAccess } from "./CalendarAccessRights"; +import { Avatar, Box, Typography } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' +import { stringAvatar } from '../Event/utils/eventUtils' +import { UserWithAccess } from './CalendarAccessRights' interface ResourceAdminProps { - admin: UserWithAccess; + admin: UserWithAccess } export function ResourceAdmin({ admin }: ResourceAdminProps) { - const { t } = useI18n(); + const { t } = useI18n() return ( {admin.displayName} @@ -37,9 +37,9 @@ export function ResourceAdmin({ admin }: ResourceAdminProps) { - {t("calendarPopover.access.administrator")} + {t('calendarPopover.access.administrator')} - ); + ) } diff --git a/src/components/Calendar/SettingsTab.tsx b/src/components/Calendar/SettingsTab.tsx index fae8442..527d0c8 100644 --- a/src/components/Calendar/SettingsTab.tsx +++ b/src/components/Calendar/SettingsTab.tsx @@ -1,23 +1,23 @@ -import { useAppSelector } from "@/app/hooks"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; -import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; +import { useAppSelector } from '@/app/hooks' +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' import { Box, TextField, ToggleButton, ToggleButtonGroup, Typography, - useTheme, -} from "@linagora/twake-mui"; -import LockOutlineIcon from "@mui/icons-material/LockOutline"; -import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined"; -import { alpha } from "@mui/material/styles"; -import PublicIcon from "@mui/icons-material/Public"; -import { useEffect, useMemo, useState } from "react"; -import { useI18n } from "twake-i18n"; -import { AddDescButton } from "../Event/AddDescButton"; -import { ColorPicker } from "./CalendarColorPicker"; -import { InfoRow } from "../Event/InfoRow"; + useTheme +} from '@linagora/twake-mui' +import LockOutlineIcon from '@mui/icons-material/LockOutline' +import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined' +import { alpha } from '@mui/material/styles' +import PublicIcon from '@mui/icons-material/Public' +import { useEffect, useMemo, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { AddDescButton } from '../Event/AddDescButton' +import { ColorPicker } from './CalendarColorPicker' +import { InfoRow } from '../Event/InfoRow' export function SettingsTab({ name, @@ -28,35 +28,37 @@ export function SettingsTab({ setColor, visibility, setVisibility, - calendar, + calendar }: { - name: string; - setName: (name: string) => void; - description: string; - setDescription: (d: string) => void; - color: Record; - setColor: (color: Record) => void; - visibility: "public" | "private"; - setVisibility: (visibility: "public" | "private") => void; - calendar?: Calendar; + name: string + setName: (name: string) => void + description: string + setDescription: (d: string) => void + color: Record + setColor: (color: Record) => void + visibility: 'public' | 'private' + setVisibility: (visibility: 'public' | 'private') => void + calendar?: Calendar }) { - const { t } = useI18n(); - const [toggleDesc, setToggleDesc] = useState(Boolean(description)); - const userId = - useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; - const isOwn = calendar ? extractEventBaseUuid(calendar.id) === userId : true; - const theme = useTheme(); - const infoIconColor = alpha(theme.palette.grey[900], 0.9); - const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor }; + const { t } = useI18n() + const [toggleDesc, setToggleDesc] = useState(Boolean(description)) + const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? '' + const isOwn = calendar ? extractEventBaseUuid(calendar.id) === userId : true + const theme = useTheme() + const infoIconColor = alpha(theme.palette.grey[900], 0.9) + const infoIconSx = { minWidth: '25px', marginRight: 2, color: infoIconColor } const isResource = useMemo( () => calendar?.owner?.resource, [calendar?.owner?.resource] - ); + ) useEffect(() => { - if (description) setToggleDesc(true); - }, [description]); + const handleToggleDesc = () => { + if (description) setToggleDesc(true) + } + handleToggleDesc() + }, [description]) return ( <> @@ -64,15 +66,15 @@ export function SettingsTab({ {t( isResource - ? "calendarPopover.settings.resourceName" - : "calendarPopover.settings.calendarName" + ? 'calendarPopover.settings.resourceName' + : 'calendarPopover.settings.calendarName' )} - + {isResource ? ( ) : ( setName(e.target.value)} + onChange={e => setName(e.target.value)} size="small" sx={{ - "&.MuiFormControl-root": { + '&.MuiFormControl-root': { marginTop: 0, - marginBottom: 0, - }, + marginBottom: 0 + } }} /> )} @@ -123,11 +125,11 @@ export function SettingsTab({ {/* Form group 3: Color */} - {t("calendar.color")} + {t('calendar.color')} - + setColor(color)} + onChange={color => setColor(color)} selectedColor={color} /> @@ -137,29 +139,29 @@ export function SettingsTab({ {isOwn && ( - {t("calendar.newEventsVisibility")} + {t('calendar.newEventsVisibility')} - + val && setVisibility(val)} size="medium" - sx={{ borderRadius: "12px" }} + sx={{ borderRadius: '12px' }} > - + - {t("common.all")} + {t('common.all')} - + - {t("common.you")} + {t('common.you')} )} - ); + ) } diff --git a/src/components/Calendar/TempCalendarsInput.tsx b/src/components/Calendar/TempCalendarsInput.tsx index bed18c6..6b5e0f2 100644 --- a/src/components/Calendar/TempCalendarsInput.tsx +++ b/src/components/Calendar/TempCalendarsInput.tsx @@ -1,156 +1,153 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { removeTempCal } from "@/features/Calendars/CalendarSlice"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; -import { getTempCalendarsListAsync } from "@/features/Calendars/services"; -import { setView } from "@/features/Settings/SettingsSlice"; -import { defaultColors } from "@/utils/defaultColors"; -import { TextField } from "@linagora/twake-mui"; -import { useRef } from "react"; -import { useI18n } from "twake-i18n"; -import { PeopleSearch, User } from "../Attendees/PeopleSearch"; +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { removeTempCal } from '@/features/Calendars/CalendarSlice' +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { getTempCalendarsListAsync } from '@/features/Calendars/services' +import { setView } from '@/features/Settings/SettingsSlice' +import { defaultColors } from '@/utils/defaultColors' +import { TextField } from '@linagora/twake-mui' +import { useRef } from 'react' +import { useI18n } from 'twake-i18n' +import { PeopleSearch, User } from '../Attendees/PeopleSearch' -const requestControllers = new Map(); +const requestControllers = new Map() export function TempCalendarsInput({ tempUsers, setTempUsers, - handleToggleEventPreview, + handleToggleEventPreview }: { - tempUsers: User[]; - setTempUsers: (users: User[]) => void; - handleToggleEventPreview: () => void; + tempUsers: User[] + setTempUsers: (users: User[]) => void + handleToggleEventPreview: () => void }) { - const dispatch = useAppDispatch(); - const tempcalendars = - useAppSelector((state) => state.calendars.templist) ?? {}; - const { t } = useI18n(); + const dispatch = useAppDispatch() + const tempcalendars = useAppSelector(state => state.calendars.templist) ?? {} + const { t } = useI18n() - const prevUsersRef = useRef([]); + const prevUsersRef = useRef([]) const userColorsRef = useRef( new Map() - ); + ) - const handleUserChange = async (_: React.SyntheticEvent, users: User[]) => { - setTempUsers(users); + const handleUserChange = (_: React.SyntheticEvent, users: User[]) => { + setTempUsers(users) - const prevUsers = prevUsersRef.current; + const prevUsers = prevUsersRef.current const addedUsers = users.filter( - (u) => !prevUsers.some((p) => p.email === u.email) - ); + u => !prevUsers.some(p => p.email === u.email) + ) const removedUsers = prevUsers.filter( - (p) => !users.some((u) => u.email === p.email) - ); + p => !users.some(u => u.email === p.email) + ) - prevUsersRef.current = users; + prevUsersRef.current = users if (addedUsers.length > 0) { - dispatch(setView("calendar")); + dispatch(setView('calendar')) for (const user of addedUsers) { - const controller = new AbortController(); - requestControllers.set(user.email, controller); + const controller = new AbortController() + requestControllers.set(user.email, controller) if (!userColorsRef.current.has(user.email)) { const usedLights = Array.from(userColorsRef.current.values()).map( - (c) => c.light - ); - const colorPair = generateDistinctColor(usedLights); - userColorsRef.current.set(user.email, colorPair); + c => c.light + ) + const colorPair = generateDistinctColor(usedLights) + userColorsRef.current.set(user.email, colorPair) } - user.color = userColorsRef.current.get(user.email)!; - dispatch( - getTempCalendarsListAsync(user, { signal: controller.signal }) - ); + user.color = userColorsRef.current.get(user.email) ?? defaultColors[0] + dispatch(getTempCalendarsListAsync(user, { signal: controller.signal })) } } for (const user of removedUsers) { - const controller = requestControllers.get(user.email); + const controller = requestControllers.get(user.email) if (controller) { - controller.abort(); - requestControllers.delete(user.email); + controller.abort() + requestControllers.delete(user.email) } - const calIds = buildEmailToCalendarMap(tempcalendars).get(user.email); - calIds?.forEach((id) => dispatch(removeTempCal(id))); - userColorsRef.current.delete(user.email); + const calIds = buildEmailToCalendarMap(tempcalendars).get(user.email) + calIds?.forEach(id => dispatch(removeTempCal(id))) + userColorsRef.current.delete(user.email) } - }; + } return ( ( + placeholder={t('peopleSearch.availabilityPlaceholder')} + inputSlot={params => ( 0 ? { - "& .MuiOutlinedInput-root": { - flexDirection: "column", - alignItems: "start", - "& .MuiInputBase-input": { - width: "100%", - }, - }, + '& .MuiOutlinedInput-root': { + flexDirection: 'column', + alignItems: 'start', + '& .MuiInputBase-input': { + width: '100%' + } + } } : undefined } /> )} /> - ); + ) } function buildEmailToCalendarMap(calRecord: Record) { - const map = new Map(); + const map = new Map() for (const [id, cal] of Object.entries(calRecord)) { - cal.owner?.emails?.forEach((email) => { - const existing = map.get(email); + cal.owner?.emails?.forEach(email => { + const existing = map.get(email) if (existing) { - existing.push(id); + existing.push(id) } else { - map.set(email, [id]); + map.set(email, [id]) } - }); + }) } - return map; + return map } function shiftLightness(hex: string, amount: number): string { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) - const clamp = (v: number) => Math.max(0, Math.min(255, v)); + const clamp = (v: number) => Math.max(0, Math.min(255, v)) const toHex = (v: number) => clamp(v + amount) .toString(16) - .padStart(2, "0"); + .padStart(2, '0') - return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + return `#${toHex(r)}${toHex(g)}${toHex(b)}` } function generateDistinctColor(usedLights: string[]): { - light: string; - dark: string; + light: string + dark: string } { for (const color of defaultColors) { - if (!usedLights.includes(color.light)) return color; + if (!usedLights.includes(color.light)) return color } - const cycle = usedLights.length % defaultColors.length; - const round = Math.floor(usedLights.length / defaultColors.length); - const base = defaultColors[cycle]; + const cycle = usedLights.length % defaultColors.length + const round = Math.floor(usedLights.length / defaultColors.length) + const base = defaultColors[cycle] return { light: shiftLightness(base.light, round * 12), - dark: shiftLightness(base.dark, -(round * 10)), - }; + dark: shiftLightness(base.dark, -(round * 10)) + } } diff --git a/src/components/Calendar/TimezoneSelector.tsx b/src/components/Calendar/TimezoneSelector.tsx index 5097e0e..7d27295 100644 --- a/src/components/Calendar/TimezoneSelector.tsx +++ b/src/components/Calendar/TimezoneSelector.tsx @@ -1,45 +1,45 @@ import { browserDefaultTimeZone, getTimezoneOffset, - resolveTimezone, -} from "@/utils/timezone"; -import { TIMEZONES } from "@/utils/timezone-data"; -import { Button, Popover } from "@linagora/twake-mui"; -import { MouseEvent, useMemo, useRef, useState } from "react"; -import { useI18n } from "twake-i18n"; -import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete"; + resolveTimezone +} from '@/utils/timezone' +import { TIMEZONES } from '@/utils/timezone-data' +import { Button, Popover } from '@linagora/twake-mui' +import { MouseEvent, useMemo, useRef, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { TimezoneAutocomplete } from '../Timezone/TimezoneAutocomplete' interface TimezoneSelectProps { - value: string; - onChange: (value: string) => void; - referenceDate: Date; + value: string + onChange: (value: string) => void + referenceDate: Date } export function TimezoneSelector({ value, onChange, - referenceDate, + referenceDate }: TimezoneSelectProps) { - const [anchorEl, setAnchorEl] = useState(null); - const inputRef = useRef(null); + const [anchorEl, setAnchorEl] = useState(null) + const inputRef = useRef(null) - const timezoneList = useTimeZoneList(); + const timezoneList = useTimeZoneList() const effectiveTimezone = value ? resolveTimezone(value) - : timezoneList.browserTz; - const selectedOffset = getTimezoneOffset(effectiveTimezone, referenceDate); + : timezoneList.browserTz + const selectedOffset = getTimezoneOffset(effectiveTimezone, referenceDate) const handleOpen = (event: MouseEvent) => { - setAnchorEl(event.currentTarget); - }; + setAnchorEl(event.currentTarget) + } const handleClose = () => { - setAnchorEl(null); - }; + setAnchorEl(null) + } - const open = Boolean(anchorEl); - const { t } = useI18n(); + const open = Boolean(anchorEl) + const { t } = useI18n() return ( <> { - inputRef.current?.focus(); - }, - }, + inputRef.current?.focus() + } + } }} > - ); + ) } export function useTimeZoneList() { return useMemo(() => { - const zones = Object.keys(TIMEZONES.zones).sort(); - const browserTz = resolveTimezone(browserDefaultTimeZone); + const zones = Object.keys(TIMEZONES.zones).sort() + const browserTz = resolveTimezone(browserDefaultTimeZone) - return { zones, browserTz, getTimezoneOffset }; - }, []); + return { zones, browserTz, getTimezoneOffset } + }, []) } diff --git a/src/components/Calendar/handlers/eventHandlers.ts b/src/components/Calendar/handlers/eventHandlers.ts index e9de525..1ff1376 100644 --- a/src/components/Calendar/handlers/eventHandlers.ts +++ b/src/components/Calendar/handlers/eventHandlers.ts @@ -1,47 +1,47 @@ -import { AppDispatch } from "@/app/store"; -import { User } from "@/components/Attendees/PeopleSearch"; -import { formatLocalDateTime } from "@/components/Event/utils/dateTimeFormatters"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { AppDispatch } from '@/app/store' +import { User } from '@/components/Attendees/PeopleSearch' +import { formatLocalDateTime } from '@/components/Event/utils/dateTimeFormatters' +import { Calendar } from '@/features/Calendars/CalendarTypes' import { getEventAsync, putEventAsync, updateEventInstanceAsync, - updateSeriesAsync, -} from "@/features/Calendars/services"; -import { getEvent } from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { updateAttendeesAfterTimeChange } from "@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange"; + updateSeriesAsync +} from '@/features/Calendars/services' +import { getEvent } from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { updateAttendeesAfterTimeChange } from '@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange' import { AttendeeOptions, - createAttendee, -} from "@/features/User/models/attendee.mapper"; -import { getDeltaInMilliseconds } from "@/utils/dateUtils"; + createAttendee +} from '@/features/User/models/attendee.mapper' +import { getDeltaInMilliseconds } from '@/utils/dateUtils' import { CalendarApi, DateSelectArg, EventClickArg, - EventDropArg, -} from "@fullcalendar/core"; -import { EventResizeDoneArg } from "@fullcalendar/interaction"; + EventDropArg +} from '@fullcalendar/core' +import { EventResizeDoneArg } from '@fullcalendar/interaction' export interface EventHandlersProps { - setSelectedRange: (range: DateSelectArg | null) => void; - setAnchorEl: (el: HTMLElement | null) => void; - calendarRef: React.RefObject; - dispatch: AppDispatch; - setOpenEventDisplay: (open: boolean) => void; - setEventDisplayedId: (id: string) => void; - setEventDisplayedCalId: (id: string) => void; - setEventDisplayedTemp: (temp: boolean) => void; - calendars: Record; - setSelectedEvent: (event: CalendarEvent) => void; + setSelectedRange: (range: DateSelectArg | null) => void + setAnchorEl: (el: HTMLElement | null) => void + calendarRef: React.RefObject + dispatch: AppDispatch + setOpenEventDisplay: (open: boolean) => void + setEventDisplayedId: (id: string) => void + setEventDisplayedCalId: (id: string) => void + setEventDisplayedTemp: (temp: boolean) => void + calendars: Record + setSelectedEvent: (event: CalendarEvent) => void setAfterChoiceFunc: ( - func: ((type: "solo" | "all" | undefined) => void) | undefined - ) => void; - setOpenEditModePopup: (open: string) => void; - tempUsers: User[]; - setTempEvent: (event: CalendarEvent) => void; - timezone: string; + func: ((type: 'solo' | 'all' | undefined) => void) | undefined + ) => void + setOpenEditModePopup: (open: string) => void + tempUsers: User[] + setTempEvent: (event: CalendarEvent) => void + timezone: string } export const createEventHandlers = (props: EventHandlersProps) => { @@ -60,55 +60,55 @@ export const createEventHandlers = (props: EventHandlersProps) => { setOpenEditModePopup, tempUsers, setTempEvent, - timezone, - } = props; + timezone + } = props const handleDateSelect = (selectInfo: DateSelectArg) => { - setSelectedRange(selectInfo); + setSelectedRange(selectInfo) if (tempUsers) { const newEvent: CalendarEvent = { start: selectInfo?.start ? formatLocalDateTime(selectInfo?.start, timezone) - : "", + : '', end: selectInfo?.end ? formatLocalDateTime(selectInfo?.end, timezone) - : "", - attendee: tempUsers.map((user) => { + : '', + attendee: tempUsers.map(user => { const attendeeOption: AttendeeOptions = { cal_address: user.email, cn: user.displayName, - rsvp: "TRUE", - }; - - if (user.objectType === "resource") { - attendeeOption.cutype = "RESOURCE"; + rsvp: 'TRUE' } - return createAttendee(attendeeOption); - }), - } as CalendarEvent; - setTempEvent(newEvent); + if (user.objectType === 'resource') { + attendeeOption.cutype = 'RESOURCE' + } + return createAttendee(attendeeOption) + }) + } as CalendarEvent + + setTempEvent(newEvent) } - setAnchorEl(document.body); - }; + setAnchorEl(document.body) + } const handleClosePopover = () => { - calendarRef.current?.unselect(); - setAnchorEl(null); - setSelectedRange(null); - }; + calendarRef.current?.unselect() + setAnchorEl(null) + setSelectedRange(null) + } const handleCloseEventDisplay = () => { - setOpenEventDisplay(false); - }; + setOpenEventDisplay(false) + } const handleEventClick = (info: EventClickArg) => { - info.jsEvent.preventDefault(); + info.jsEvent.preventDefault() if (info.event.url) { - window.open(info.event.url); + window.open(info.event.url) } else { - setOpenEventDisplay(true); + setOpenEventDisplay(true) if ( calendars[info.event.extendedProps.calId] && calendars[info.event.extendedProps.calId].events[ @@ -121,57 +121,57 @@ export const createEventHandlers = (props: EventHandlersProps) => { info.event.extendedProps.uid ] ) - ); + ) } - setEventDisplayedId(info.event.extendedProps.uid); - setEventDisplayedCalId(info.event.extendedProps.calId); - setEventDisplayedTemp(info.event._def.extendedProps.temp); + setEventDisplayedId(info.event.extendedProps.uid) + setEventDisplayedCalId(info.event.extendedProps.calId) + setEventDisplayedTemp(info.event._def.extendedProps.temp) } - }; + } const handleEventAllow = () => { - return true; - }; + return true + } const handleEventDrop = async (arg: EventDropArg) => { if (!arg.event || !arg.event._def || !arg.event._def.extendedProps) { - return; + return } const event = calendars[arg.event._def.extendedProps.calId].events[ arg.event._def.extendedProps.uid - ]; - const calendar = calendars[arg.event._def.extendedProps.calId]; + ] + const calendar = calendars[arg.event._def.extendedProps.calId] - const isRecurring = event.uid.includes("/"); - const totalDeltaMs = getDeltaInMilliseconds(arg.delta); + const isRecurring = event.uid.includes('/') + const totalDeltaMs = getDeltaInMilliseconds(arg.delta) - const originalStart = new Date(event.start); - const computedNewStart = new Date(originalStart.getTime() + totalDeltaMs); - const originalEnd = new Date(event.end ?? ""); - const computedNewEnd = new Date(originalEnd.getTime() + totalDeltaMs); + const originalStart = new Date(event.start) + const computedNewStart = new Date(originalStart.getTime() + totalDeltaMs) + const originalEnd = new Date(event.end ?? '') + const computedNewEnd = new Date(originalEnd.getTime() + totalDeltaMs) const newEvent = updateAttendeesAfterTimeChange( { ...event, start: computedNewStart.toISOString(), end: computedNewEnd.toISOString(), - sequence: (event.sequence ?? 1) + 1, + sequence: (event.sequence ?? 1) + 1 } as CalendarEvent, true - ); + ) if (isRecurring) { - setSelectedEvent(event); - setOpenEditModePopup("edit"); + setSelectedEvent(event) + setOpenEditModePopup('edit') setAfterChoiceFunc( - () => async (typeOfAction: "solo" | "all" | undefined) => { - if (typeOfAction === "solo") { + () => async (typeOfAction: 'solo' | 'all' | undefined) => { + if (typeOfAction === 'solo') { await dispatch( updateEventInstanceAsync({ cal: calendar, event: newEvent }) - ); - } else if (typeOfAction === "all") { - const master = await getEvent(newEvent, true); + ) + } else if (typeOfAction === 'all') { + const master = await getEvent(newEvent, true) await dispatch( updateSeriesAsync({ cal: calendar, @@ -179,61 +179,61 @@ export const createEventHandlers = (props: EventHandlersProps) => { ...master, start: computedNewStart.toISOString(), end: computedNewEnd.toISOString(), - sequence: (master.sequence ?? 1) + 1, - }, + sequence: (master.sequence ?? 1) + 1 + } }) - ); + ) } } - ); + ) } else { await dispatch( putEventAsync({ cal: calendars[newEvent.calId], newEvent }) - ); + ) } - }; + } const handleEventResize = async (arg: EventResizeDoneArg) => { if (!arg.event || !arg.event._def || !arg.event._def.extendedProps) { - return; + return } const event = calendars[arg.event._def.extendedProps.calId].events[ arg.event._def.extendedProps.uid - ]; - const calendar = calendars[arg.event._def.extendedProps.calId]; + ] + const calendar = calendars[arg.event._def.extendedProps.calId] - const isRecurring = event.uid.includes("/"); + const isRecurring = event.uid.includes('/') - const originalStart = new Date(event.start); + const originalStart = new Date(event.start) const computedNewStart = new Date( originalStart.getTime() + getDeltaInMilliseconds(arg.startDelta) - ); - const originalEnd = new Date(event.end ?? ""); + ) + const originalEnd = new Date(event.end ?? '') const computedNewEnd = new Date( originalEnd.getTime() + getDeltaInMilliseconds(arg.endDelta) - ); + ) const newEvent = updateAttendeesAfterTimeChange( { ...event, start: computedNewStart.toISOString(), end: computedNewEnd.toISOString(), - sequence: (event.sequence ?? 1) + 1, + sequence: (event.sequence ?? 1) + 1 } as CalendarEvent, true - ); + ) if (isRecurring) { - setSelectedEvent(event); - setOpenEditModePopup("edit"); + setSelectedEvent(event) + setOpenEditModePopup('edit') setAfterChoiceFunc( - () => async (typeOfAction: "solo" | "all" | undefined) => { - if (typeOfAction === "solo") { + () => async (typeOfAction: 'solo' | 'all' | undefined) => { + if (typeOfAction === 'solo') { await dispatch( updateEventInstanceAsync({ cal: calendar, event: newEvent }) - ); - } else if (typeOfAction === "all") { - const master = await getEvent(newEvent, true); + ) + } else if (typeOfAction === 'all') { + const master = await getEvent(newEvent, true) await dispatch( updateSeriesAsync({ @@ -242,19 +242,19 @@ export const createEventHandlers = (props: EventHandlersProps) => { ...master, start: computedNewStart.toISOString(), end: computedNewEnd.toISOString(), - sequence: (master.sequence ?? 1) + 1, - }, + sequence: (master.sequence ?? 1) + 1 + } }) - ); + ) } } - ); + ) } else { await dispatch( putEventAsync({ cal: calendars[newEvent.calId], newEvent }) - ); + ) } - }; + } return { handleDateSelect, @@ -263,6 +263,6 @@ export const createEventHandlers = (props: EventHandlersProps) => { handleEventClick, handleEventAllow, handleEventDrop, - handleEventResize, - }; -}; + handleEventResize + } +} diff --git a/src/components/Calendar/handlers/mouseHandlers.ts b/src/components/Calendar/handlers/mouseHandlers.ts index 5a2bfab..0943902 100644 --- a/src/components/Calendar/handlers/mouseHandlers.ts +++ b/src/components/Calendar/handlers/mouseHandlers.ts @@ -1,109 +1,108 @@ interface CalendarHTMLElement extends HTMLElement { - __calendarMouseMoveHandler?: (e: MouseEvent) => void; - __calendarMouseLeaveHandler?: () => void; + __calendarMouseMoveHandler?: (e: MouseEvent) => void + __calendarMouseLeaveHandler?: () => void } export interface MouseHandlersProps { - calendarEl: CalendarHTMLElement; + calendarEl: CalendarHTMLElement } export const createMouseHandlers = (props: MouseHandlersProps) => { - const { calendarEl } = props; + const { calendarEl } = props const handleMouseMove = (e: MouseEvent) => { - const timegridEl = calendarEl.querySelector(".fc-timegrid-body"); - if (!timegridEl) return; + const timegridEl = calendarEl.querySelector('.fc-timegrid-body') + if (!timegridEl) return - const allDayTable = calendarEl.querySelector(".fc-scrollgrid-sync-table"); + const allDayTable = calendarEl.querySelector('.fc-scrollgrid-sync-table') if (allDayTable) { - const allDayRect = allDayTable.getBoundingClientRect(); + const allDayRect = allDayTable.getBoundingClientRect() if (e.clientY >= allDayRect.top && e.clientY <= allDayRect.bottom) { timegridEl - .querySelectorAll(".hour-highlight") - .forEach((el: Element) => el.remove()); - return; + .querySelectorAll('.hour-highlight') + .forEach((el: Element) => el.remove()) + return } } - const target = e.target as HTMLElement; - if (target && target.closest(".fc-timegrid-slot-label")) { + const target = e.target as HTMLElement + if (target && target.closest('.fc-timegrid-slot-label')) { timegridEl - .querySelectorAll(".hour-highlight") - .forEach((el: Element) => el.remove()); - return; + .querySelectorAll('.hour-highlight') + .forEach((el: Element) => el.remove()) + return } - const dayColumns = timegridEl.querySelectorAll(".fc-timegrid-col"); - if (dayColumns.length === 0) return; + const dayColumns = timegridEl.querySelectorAll('.fc-timegrid-col') + if (dayColumns.length === 0) return timegridEl - .querySelectorAll(".hour-highlight") - .forEach((el: Element) => el.remove()); + .querySelectorAll('.hour-highlight') + .forEach((el: Element) => el.remove()) - let targetColumn: Element | null = null; + let targetColumn: Element | null = null for (const column of dayColumns) { - const rect = column.getBoundingClientRect(); + const rect = column.getBoundingClientRect() if (e.clientX >= rect.left && e.clientX <= rect.right) { - targetColumn = column; - break; + targetColumn = column + break } } if (targetColumn) { - const rect = targetColumn.getBoundingClientRect(); - const relativeY = e.clientY - rect.top; - const slotHeight = rect.height / 48; - const slotIndex = Math.floor(relativeY / slotHeight); + const rect = targetColumn.getBoundingClientRect() + const relativeY = e.clientY - rect.top + const slotHeight = rect.height / 48 + const slotIndex = Math.floor(relativeY / slotHeight) if (relativeY >= 0 && relativeY <= rect.height) { - const highlight = document.createElement("div"); - highlight.className = "hour-highlight"; - highlight.style.top = `${slotIndex * slotHeight}px`; - highlight.style.height = `${slotHeight}px`; - - (targetColumn as HTMLElement).style.position = "relative"; - targetColumn.appendChild(highlight); + const highlight = document.createElement('div') + highlight.className = 'hour-highlight' + highlight.style.top = `${slotIndex * slotHeight}px` + highlight.style.height = `${slotHeight}px` + ;(targetColumn as HTMLElement).style.position = 'relative' + targetColumn.appendChild(highlight) } } - }; + } const handleMouseLeave = () => { - const timegridEl = calendarEl.querySelector(".fc-timegrid-body"); + const timegridEl = calendarEl.querySelector('.fc-timegrid-body') if (timegridEl) { timegridEl - .querySelectorAll(".hour-highlight") - .forEach((el: Element) => el.remove()); + .querySelectorAll('.hour-highlight') + .forEach((el: Element) => el.remove()) } - }; + } const addMouseEventListeners = () => { - calendarEl.addEventListener("mousemove", handleMouseMove); - calendarEl.addEventListener("mouseleave", handleMouseLeave); + calendarEl.addEventListener('mousemove', handleMouseMove) + calendarEl.addEventListener('mouseleave', handleMouseLeave) - calendarEl.__calendarMouseMoveHandler = handleMouseMove; - calendarEl.__calendarMouseLeaveHandler = handleMouseLeave; - }; + calendarEl.__calendarMouseMoveHandler = handleMouseMove + calendarEl.__calendarMouseLeaveHandler = handleMouseLeave + } const removeMouseEventListeners = () => { if (calendarEl.__calendarMouseMoveHandler) { calendarEl.removeEventListener( - "mousemove", + 'mousemove', calendarEl.__calendarMouseMoveHandler - ); - delete calendarEl.__calendarMouseMoveHandler; + ) + delete calendarEl.__calendarMouseMoveHandler } if (calendarEl.__calendarMouseLeaveHandler) { calendarEl.removeEventListener( - "mouseleave", + 'mouseleave', calendarEl.__calendarMouseLeaveHandler - ); - delete calendarEl.__calendarMouseLeaveHandler; + ) + delete calendarEl.__calendarMouseLeaveHandler } - }; + } return { handleMouseMove, handleMouseLeave, addMouseEventListeners, - removeMouseEventListeners, - }; -}; + removeMouseEventListeners + } +} diff --git a/src/components/Calendar/handlers/viewHandlers.ts b/src/components/Calendar/handlers/viewHandlers.ts index 9c0d7b6..31320ac 100644 --- a/src/components/Calendar/handlers/viewHandlers.ts +++ b/src/components/Calendar/handlers/viewHandlers.ts @@ -1,26 +1,26 @@ -import { EventErrorHandler } from "@/components/Error/EventErrorHandler"; -import { EventChip } from "@/components/Event/EventChip/EventChip"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; -import { userAttendee } from "@/features/User/models/attendee"; +import { EventErrorHandler } from '@/components/Error/EventErrorHandler' +import { EventChip } from '@/components/Event/EventChip/EventChip' +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { userAttendee } from '@/features/User/models/attendee' import { CalendarApi, DayHeaderMountArg, EventContentArg, EventMountArg, NowIndicatorContentArg, - ViewMountArg, -} from "@fullcalendar/core"; -import React from "react"; -import { createMouseHandlers } from "./mouseHandlers"; + ViewMountArg +} from '@fullcalendar/core' +import React from 'react' +import { createMouseHandlers } from './mouseHandlers' export interface ViewHandlersProps { - calendarRef: React.RefObject; - setSelectedDate: (date: Date) => void; - setSelectedMiniDate: (date: Date) => void; - onViewChange?: (view: string) => void; - calendars: Record; - tempcalendars: Record; - errorHandler: EventErrorHandler; + calendarRef: React.RefObject + setSelectedDate: (date: Date) => void + setSelectedMiniDate: (date: Date) => void + onViewChange?: (view: string) => void + calendars: Record + tempcalendars: Record + errorHandler: EventErrorHandler } export const createViewHandlers = (props: ViewHandlersProps) => { @@ -31,126 +31,126 @@ export const createViewHandlers = (props: ViewHandlersProps) => { onViewChange, calendars, tempcalendars, - errorHandler, - } = props; + errorHandler + } = props const handleNowIndicatorContent = (arg: NowIndicatorContentArg) => { if (arg.isAxis) { return React.createElement( - "div", - { style: { display: "flex", alignItems: "center" } }, + 'div', + { style: { display: 'flex', alignItems: 'center' } }, React.createElement( - "div", - { className: "now-time-label" }, + 'div', + { className: 'now-time-label' }, new Date().toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", + hour: '2-digit', + minute: '2-digit', hour12: false, - timeZone: arg.view.dateEnv.timeZone, + timeZone: arg.view.dateEnv.timeZone }) ) - ); + ) } - }; + } const handleDayHeaderDidMount = (arg: DayHeaderMountArg) => { - if (arg.view.type === "timeGridWeek") { - const headerEl = arg.el; + if (arg.view.type === 'timeGridWeek') { + const headerEl = arg.el const handleDayHeaderClick = () => { - calendarRef.current?.changeView("timeGridDay", arg.date); - setSelectedDate(new Date(arg.date)); - setSelectedMiniDate(new Date(arg.date)); + calendarRef.current?.changeView('timeGridDay', arg.date) + setSelectedDate(new Date(arg.date)) + setSelectedMiniDate(new Date(arg.date)) if (onViewChange) { - onViewChange("timeGridDay"); + onViewChange('timeGridDay') } - }; + } - headerEl.addEventListener("click", handleDayHeaderClick); - headerEl.__dayHeaderClickHandler = handleDayHeaderClick; + headerEl.addEventListener('click', handleDayHeaderClick) + headerEl.__dayHeaderClickHandler = handleDayHeaderClick } - }; + } const handleDayHeaderWillUnmount = (arg: DayHeaderMountArg) => { - const headerEl = arg.el; + const headerEl = arg.el if (headerEl.__dayHeaderClickHandler) { - headerEl.removeEventListener("click", headerEl.__dayHeaderClickHandler); - delete headerEl.__dayHeaderClickHandler; + headerEl.removeEventListener('click', headerEl.__dayHeaderClickHandler) + delete headerEl.__dayHeaderClickHandler } - }; + } const handleViewDidMount = (arg: ViewMountArg) => { - if (arg.view.type === "timeGridWeek" || arg.view.type === "timeGridDay") { - const calendarEl = document.querySelector(".fc") as HTMLElement; + if (arg.view.type === 'timeGridWeek' || arg.view.type === 'timeGridDay') { + const calendarEl = document.querySelector('.fc') as HTMLElement if (calendarEl) { - const mouseHandlers = createMouseHandlers({ calendarEl }); - mouseHandlers.addMouseEventListeners(); + const mouseHandlers = createMouseHandlers({ calendarEl }) + mouseHandlers.addMouseEventListeners() } } - }; + } const handleViewWillUnmount = (arg: ViewMountArg) => { if (arg.el.__timeInterval) { - clearInterval(arg.el.__timeInterval); - delete arg.el.__timeInterval; + clearInterval(arg.el.__timeInterval) + delete arg.el.__timeInterval } if (arg.el.__timeObserver) { - arg.el.__timeObserver.disconnect(); - delete arg.el.__timeObserver; + arg.el.__timeObserver.disconnect() + delete arg.el.__timeObserver } - const calendarEl = document.querySelector(".fc") as HTMLElement; + const calendarEl = document.querySelector('.fc') as HTMLElement if (calendarEl) { - const mouseHandlers = createMouseHandlers({ calendarEl }); - mouseHandlers.removeMouseEventListeners(); + const mouseHandlers = createMouseHandlers({ calendarEl }) + mouseHandlers.removeMouseEventListeners() } - }; + } const handleEventContent = (arg: EventContentArg) => { return React.createElement(EventChip, { arg, calendars, tempcalendars, - errorHandler, - }); - }; + errorHandler + }) + } const handleEventDidMount = (arg: EventMountArg) => { - const attendees = arg.event._def.extendedProps.attendee || []; - if (!calendars[arg.event._def.extendedProps.calId]) return; + const attendees = arg.event._def.extendedProps.attendee || [] + if (!calendars[arg.event._def.extendedProps.calId]) return const ownerEmails = new Set( - calendars[arg.event._def.extendedProps.calId].owner?.emails?.map( - (email) => email.toLowerCase() + calendars[arg.event._def.extendedProps.calId].owner?.emails?.map(email => + email.toLowerCase() ) - ); + ) const showSpecialDisplay = attendees.filter((att: userAttendee) => ownerEmails.has(att.cal_address.toLowerCase()) - ); + ) - if (!showSpecialDisplay[0]) return; + if (!showSpecialDisplay[0]) return arg.el.classList.remove( - "declined-event", - "tentative-event", - "needs-action-event" - ); + 'declined-event', + 'tentative-event', + 'needs-action-event' + ) switch (showSpecialDisplay[0].partstat) { - case "DECLINED": - arg.el.classList.add("declined-event"); - break; - case "TENTATIVE": - arg.el.classList.add("tentative-event"); - break; - case "NEEDS-ACTION": - arg.el.classList.add("needs-action-event"); - break; + case 'DECLINED': + arg.el.classList.add('declined-event') + break + case 'TENTATIVE': + arg.el.classList.add('tentative-event') + break + case 'NEEDS-ACTION': + arg.el.classList.add('needs-action-event') + break default: - break; + break } - }; + } return { handleNowIndicatorContent, @@ -159,6 +159,6 @@ export const createViewHandlers = (props: ViewHandlersProps) => { handleViewDidMount, handleViewWillUnmount, handleEventContent, - handleEventDidMount, - }; -}; + handleEventDidMount + } +} diff --git a/src/components/Calendar/hooks/useCalendarEventHandlers.ts b/src/components/Calendar/hooks/useCalendarEventHandlers.ts index b8f2eaa..ec66e99 100644 --- a/src/components/Calendar/hooks/useCalendarEventHandlers.ts +++ b/src/components/Calendar/hooks/useCalendarEventHandlers.ts @@ -1,12 +1,12 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { useCallback } from "react"; +import { useCallback } from 'react' import { createEventHandlers, - EventHandlersProps, -} from "../handlers/eventHandlers"; + EventHandlersProps +} from '../handlers/eventHandlers' export const useCalendarEventHandlers = (props: EventHandlersProps) => { - const eventHandlers = createEventHandlers(props); + const eventHandlers = createEventHandlers(props) return { handleDateSelect: useCallback(eventHandlers.handleDateSelect, [ @@ -14,13 +14,13 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => { props.setAnchorEl, props.setTempEvent, props.tempUsers, - props.timezone, + props.timezone ]), handleClosePopover: useCallback(eventHandlers.handleClosePopover, [ props.calendarRef, props.setAnchorEl, props.setSelectedRange, - props.dispatch, + props.dispatch ]), handleCloseEventDisplay: useCallback( eventHandlers.handleCloseEventDisplay, @@ -32,7 +32,7 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => { props.setEventDisplayedCalId, props.setEventDisplayedTemp, props.calendars, - props.dispatch, + props.dispatch ]), handleEventAllow: useCallback(eventHandlers.handleEventAllow, []), handleEventDrop: useCallback(eventHandlers.handleEventDrop, [ @@ -40,11 +40,11 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => { props.dispatch, props.setSelectedEvent, props.setOpenEditModePopup, - props.setAfterChoiceFunc, + props.setAfterChoiceFunc ]), handleEventResize: useCallback(eventHandlers.handleEventResize, [ props.calendars, - props.dispatch, - ]), - }; -}; + props.dispatch + ]) + } +} diff --git a/src/components/Calendar/hooks/useCalendarViewHandlers.ts b/src/components/Calendar/hooks/useCalendarViewHandlers.ts index 83d76e1..421f9d3 100644 --- a/src/components/Calendar/hooks/useCalendarViewHandlers.ts +++ b/src/components/Calendar/hooks/useCalendarViewHandlers.ts @@ -1,19 +1,16 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { useCallback } from "react"; -import { - createViewHandlers, - ViewHandlersProps, -} from "../handlers/viewHandlers"; +import { useCallback } from 'react' +import { createViewHandlers, ViewHandlersProps } from '../handlers/viewHandlers' export const useCalendarViewHandlers = (props: ViewHandlersProps) => { - const viewHandlers = createViewHandlers(props); + const viewHandlers = createViewHandlers(props) return { handleDayHeaderDidMount: useCallback(viewHandlers.handleDayHeaderDidMount, [ props.calendarRef, props.setSelectedDate, props.setSelectedMiniDate, - props.onViewChange, + props.onViewChange ]), handleDayHeaderWillUnmount: useCallback( viewHandlers.handleDayHeaderWillUnmount, @@ -23,14 +20,14 @@ export const useCalendarViewHandlers = (props: ViewHandlersProps) => { handleViewWillUnmount: useCallback(viewHandlers.handleViewWillUnmount, []), handleEventContent: useCallback(viewHandlers.handleEventContent, [ props.calendars, - props.tempcalendars, + props.tempcalendars ]), handleEventDidMount: useCallback(viewHandlers.handleEventDidMount, [ - props.calendars, + props.calendars ]), handleNowIndicatorContent: useCallback( viewHandlers.handleNowIndicatorContent, [] - ), - }; -}; + ) + } +} diff --git a/src/components/Calendar/utils/calendarUtils.ts b/src/components/Calendar/utils/calendarUtils.ts index 7ccfcde..28a4578 100644 --- a/src/components/Calendar/utils/calendarUtils.ts +++ b/src/components/Calendar/utils/calendarUtils.ts @@ -1,100 +1,100 @@ -import { AppDispatch } from "@/app/store"; -import { Calendar, DelegationAccess } from "@/features/Calendars/CalendarTypes"; -import { getCalendarDetailAsync } from "@/features/Calendars/services"; -import { AclEntry } from "@/features/Calendars/types/CalendarData"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { formatDateToYYYYMMDDTHHMMSS } from "@/utils/dateUtils"; -import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; -import { getEffectiveEmail } from "@/utils/getEffectiveEmail"; -import { isEventOrganiser } from "@/utils/isEventOrganiser"; -import { convertEventDateTimeToISO } from "@/utils/timezone"; -import { EventInput, SlotLabelContentArg } from "@fullcalendar/core"; -import moment from "moment-timezone"; -import { useI18n } from "twake-i18n"; +import { AppDispatch } from '@/app/store' +import { Calendar, DelegationAccess } from '@/features/Calendars/CalendarTypes' +import { getCalendarDetailAsync } from '@/features/Calendars/services' +import { AclEntry } from '@/features/Calendars/types/CalendarData' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { formatDateToYYYYMMDDTHHMMSS } from '@/utils/dateUtils' +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' +import { getEffectiveEmail } from '@/utils/getEffectiveEmail' +import { isEventOrganiser } from '@/utils/isEventOrganiser' +import { convertEventDateTimeToISO } from '@/utils/timezone' +import { EventInput, SlotLabelContentArg } from '@fullcalendar/core' +import moment from 'moment-timezone' +import { useI18n } from 'twake-i18n' export const updateSlotLabelVisibility = ( currentTime: Date, slotLabel: SlotLabelContentArg, timezone: string ) => { - const isCurrentWeekOrDay = checkIfCurrentWeekOrDay(); + const isCurrentWeekOrDay = checkIfCurrentWeekOrDay() if (!isCurrentWeekOrDay) { - return "fc-timegrid-slot-label"; + return 'fc-timegrid-slot-label' } - const current = moment.tz(currentTime, timezone); - const currentMinutes = current.hours() * 60 + current.minutes(); - const timeText = slotLabel?.text?.trim(); + const current = moment.tz(currentTime, timezone) + const currentMinutes = current.hours() * 60 + current.minutes() + const timeText = slotLabel?.text?.trim() if (timeText && timeText.match(/^\d{1,2}:\d{2}$/)) { - const [hours, minutes] = timeText.split(":").map(Number); - const labelMinutes = hours * 60 + minutes; + const [hours, minutes] = timeText.split(':').map(Number) + const labelMinutes = hours * 60 + minutes - let timeDiff = Math.abs(currentMinutes - labelMinutes); + let timeDiff = Math.abs(currentMinutes - labelMinutes) if (timeDiff > 12 * 60) { - timeDiff = 24 * 60 - timeDiff; + timeDiff = 24 * 60 - timeDiff } if (timeDiff <= 15) { - return "timegrid-slot-label-hidden"; + return 'timegrid-slot-label-hidden' } } - return "fc-timegrid-slot-label"; -}; + return 'fc-timegrid-slot-label' +} export const checkIfCurrentWeekOrDay = (): boolean => { - const todayColumn = document.querySelector(".fc-day-today"); + const todayColumn = document.querySelector('.fc-day-today') if (!todayColumn) { - return false; + return false } const nowIndicator = document.querySelector( - ".fc-timegrid-now-indicator-arrow" - ); - return !!nowIndicator; -}; + '.fc-timegrid-now-indicator-arrow' + ) + return !!nowIndicator +} export function formatEventChipTitle( e: CalendarEvent, t: (key: string) => string ) { if (!e.title) { - return t("event.untitled"); + return t('event.untitled') } - return e.title === "Busy" && e.class === "PRIVATE" - ? t("event.form.busy") - : e.title; + return e.title === 'Busy' && e.class === 'PRIVATE' + ? t('event.form.busy') + : e.title } type ConvertedEvent = CalendarEvent & { - colors: Record | undefined; - editable: boolean; - priority: number; -}; + colors: Record | undefined + editable: boolean + priority: number +} function applyTimezoneToEvent( event: CalendarEvent, convertedEvent: ConvertedEvent ): void { - const eventTimezone = event.timezone || "Etc/UTC"; - const isAllDay = event.allday ?? false; + const eventTimezone = event.timezone || 'Etc/UTC' + const isAllDay = event.allday ?? false if (!isAllDay && event.start) { const startISO = convertEventDateTimeToISO(event.start, eventTimezone, { - isAllDay, - }); - if (startISO) convertedEvent.start = startISO; + isAllDay + }) + if (startISO) convertedEvent.start = startISO } if (!isAllDay && event.end && eventTimezone) { const endISO = convertEventDateTimeToISO(event.end, eventTimezone, { - isAllDay, - }); - if (endISO) convertedEvent.end = endISO; + isAllDay + }) + if (endISO) convertedEvent.end = endISO } } @@ -109,28 +109,28 @@ function buildConvertedEvent( const isWriteDelegated = (calendar?.delegated && calendar.access?.write && - (!event.class || event.class === "PUBLIC")) ?? - false; + (!event.class || event.class === 'PUBLIC')) ?? + false const effectiveEmail = getEffectiveEmail( calendar, isWriteDelegated, userAddress - ); - const isOrganiser = isEventOrganiser(event, effectiveEmail); - const isPersonalEvent = extractEventBaseUuid(event.calId) === userId; + ) + const isOrganiser = isEventOrganiser(event, effectiveEmail) + const isPersonalEvent = extractEventBaseUuid(event.calId) === userId const convertedEvent: ConvertedEvent = { ...event, title: formatEventChipTitle(event, t), colors: event.color, editable: (isPersonalEvent || isWriteDelegated) && isOrganiser && !pending, - priority: isPersonalEvent ? 1 : 0, - }; + priority: isPersonalEvent ? 1 : 0 + } - applyTimezoneToEvent(event, convertedEvent); + applyTimezoneToEvent(event, convertedEvent) - return convertedEvent; + return convertedEvent } export const eventToFullCalendarFormat = ( @@ -142,10 +142,10 @@ export const eventToFullCalendarFormat = ( calendars: Record ): EventInput[] => { // eslint-disable-next-line react-hooks/rules-of-hooks - const { t } = useI18n(); + const { t } = useI18n() return filteredEvents - .concat(filteredTempEvents.map((event) => ({ ...event, temp: true }))) - .map((event) => + .concat(filteredTempEvents.map(event => ({ ...event, temp: true }))) + .map(event => buildConvertedEvent( event, calendars[event.calId], @@ -154,8 +154,8 @@ export const eventToFullCalendarFormat = ( pending, t ) - ) as EventInput[]; -}; + ) as EventInput[] +} export const extractEvents = ( selectedCalendars: string[], @@ -163,29 +163,29 @@ export const extractEvents = ( userAddress?: string, hideDeclinedEvents?: boolean | null ) => { - const allEvents: CalendarEvent[] = []; + const allEvents: CalendarEvent[] = [] - selectedCalendars.forEach((id) => { - const calendar = calendars[id]; + selectedCalendars.forEach(id => { + const calendar = calendars[id] if (calendar?.events) { - allEvents.push(...Object.values(calendar.events)); + allEvents.push(...Object.values(calendar.events)) } - }); + }) return allEvents - .filter((event) => event.status !== "CANCELLED") + .filter(event => event.status !== 'CANCELLED') .filter( - (event) => + event => !( hideDeclinedEvents && event.attendee?.some( - (a) => + a => calendars[event.calId].owner.emails.includes(a.cal_address) && - a.partstat === "DECLINED" + a.partstat === 'DECLINED' ) ) - ); -}; + ) +} export const updateCalsDetails = ( selectedCalendars: string[], @@ -195,141 +195,141 @@ export const updateCalsDetails = ( previousRangeKey: string, dispatch: AppDispatch, calendarRange: { start: Date; end: Date }, - calType?: "temp", + calType?: 'temp', controllers?: Map ) => { - if (pending || !rangeKey) return; + if (pending || !rangeKey) return const newCalendars = selectedCalendars.filter( - (id) => !previousSelectedCalendars.includes(id) - ); + id => !previousSelectedCalendars.includes(id) + ) - newCalendars.forEach((id) => { + newCalendars.forEach(id => { if (controllers) { - const controller = new AbortController(); - controllers.set(id, controller); + const controller = new AbortController() + controllers.set(id, controller) dispatch( getCalendarDetailAsync({ calId: id, match: { start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start), - end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), + end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end) }, calType, - signal: controller.signal, + signal: controller.signal }) - ); + ) } else { dispatch( getCalendarDetailAsync({ calId: id, match: { start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start), - end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), + end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end) }, - calType, + calType }) - ); + ) } - }); + }) if (rangeKey !== previousRangeKey) { - selectedCalendars?.forEach((id) => { + selectedCalendars?.forEach(id => { if (id) { if (controllers) { - const controller = new AbortController(); - controllers.set(id, controller); + const controller = new AbortController() + controllers.set(id, controller) dispatch( getCalendarDetailAsync({ calId: id, match: { start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start), - end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), + end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end) }, calType, - signal: controller.signal, + signal: controller.signal }) - ); + ) } else { dispatch( getCalendarDetailAsync({ calId: id, match: { start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start), - end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), + end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end) }, - calType, + calType }) - ); + ) } } - }); + }) } -}; +} -export function getCalendarVisibility(acl: AclEntry[]): "private" | "public" { - let hasRead = false; +export function getCalendarVisibility(acl: AclEntry[]): 'private' | 'public' { + let hasRead = false if (acl) { for (const entry of acl) { - if (entry.principal !== "{DAV:}authenticated") continue; + if (entry.principal !== '{DAV:}authenticated') continue - if (entry.privilege === "{DAV:}read") { - hasRead = true; - break; // highest visibility, can stop + if (entry.privilege === '{DAV:}read') { + hasRead = true + break // highest visibility, can stop } } } - if (hasRead) return "public"; - return "private"; + if (hasRead) return 'public' + return 'private' } export function getCalendarDelegationAccess( acl: AclEntry[], userId: string ): DelegationAccess { - const userPrincipal = `principals/users/${userId}`; + const userPrincipal = `principals/users/${userId}` const access: DelegationAccess = { freebusy: false, read: false, write: false, - "write-properties": false, - all: false, - }; - - for (const entry of acl ?? []) { - if (entry.principal !== userPrincipal) continue; - privilegeToAccess(entry.privilege, access); + 'write-properties': false, + all: false } - return access; + for (const entry of acl ?? []) { + if (entry.principal !== userPrincipal) continue + privilegeToAccess(entry.privilege, access) + } + + return access } function privilegeToAccess(privilege: string, currentAccess: DelegationAccess) { switch (privilege) { - case "{urn:ietf:params:xml:ns:caldav}read-free-busy": - currentAccess["freebusy"] = true; - break; - case "{DAV:}read": - currentAccess["read"] = true; - currentAccess["freebusy"] = true; // read implies read-free-busy - break; - case "{DAV:}write-properties": - currentAccess["write-properties"] = true; - break; - case "{DAV:}write": - currentAccess["write-properties"] = true; // write implies write-properties - currentAccess["write"] = true; - break; - case "{DAV:}all": - currentAccess["freebusy"] = true; - currentAccess["read"] = true; - currentAccess["write-properties"] = true; - currentAccess["write"] = true; - currentAccess["all"] = true; - break; + case '{urn:ietf:params:xml:ns:caldav}read-free-busy': + currentAccess['freebusy'] = true + break + case '{DAV:}read': + currentAccess['read'] = true + currentAccess['freebusy'] = true // read implies read-free-busy + break + case '{DAV:}write-properties': + currentAccess['write-properties'] = true + break + case '{DAV:}write': + currentAccess['write-properties'] = true // write implies write-properties + currentAccess['write'] = true + break + case '{DAV:}all': + currentAccess['freebusy'] = true + currentAccess['read'] = true + currentAccess['write-properties'] = true + currentAccess['write'] = true + currentAccess['all'] = true + break default: - break; + break } } diff --git a/src/components/Dialog/README.md b/src/components/Dialog/README.md index 3639d05..08c2284 100644 --- a/src/components/Dialog/README.md +++ b/src/components/Dialog/README.md @@ -18,12 +18,12 @@ A highly reusable dialog component that supports both normal and expanded (fulls ## Basic Usage ```tsx -import { ResponsiveDialog } from "@/components/Dialog"; -import { useState } from "react"; +import { ResponsiveDialog } from '@/components/Dialog' +import { useState } from 'react' function MyComponent() { - const [open, setOpen] = useState(false); - const [showMore, setShowMore] = useState(false); + const [open, setOpen] = useState(false) + const [showMore, setShowMore] = useState(false) const actions = ( <> @@ -32,7 +32,7 @@ function MyComponent() { )} - ); + ) return ( {/* Wrapped in Stack with spacing={2} for both normal and expanded modes */} - ); + ) } ``` @@ -84,12 +84,12 @@ function MyComponent() { title="Custom Styled Dialog" isExpanded={showMore} contentSx={{ - backgroundColor: "#f5f5f5", - padding: 4, + backgroundColor: '#f5f5f5', + padding: 4 }} titleSx={{ - backgroundColor: "primary.main", - color: "white", + backgroundColor: 'primary.main', + color: 'white' }} > diff --git a/src/components/Dialog/ResponsiveDialog.tsx b/src/components/Dialog/ResponsiveDialog.tsx index 8da49aa..83959c0 100644 --- a/src/components/Dialog/ResponsiveDialog.tsx +++ b/src/components/Dialog/ResponsiveDialog.tsx @@ -10,13 +10,13 @@ import { IconButton, Stack, SxProps, - Theme, -} from "@linagora/twake-mui"; -import ArrowBackIcon from "@mui/icons-material/ArrowBack"; -import CloseIcon from "@mui/icons-material/Close"; -import OpenInFullIcon from "@mui/icons-material/OpenInFull"; -import CozyBridge from "cozy-external-bridge"; -import React, { ReactNode, useMemo } from "react"; + Theme +} from '@linagora/twake-mui' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import CloseIcon from '@mui/icons-material/Close' +import OpenInFullIcon from '@mui/icons-material/OpenInFull' +import CozyBridge from 'cozy-external-bridge' +import React, { ReactNode, useMemo } from 'react' /** * ResponsiveDialog - A reusable dialog component that can switch between normal and expanded modes @@ -43,52 +43,48 @@ import React, { ReactNode, useMemo } from "react"; */ interface ResponsiveDialogProps extends Omit< DialogProps, - "maxWidth" | "title" + 'maxWidth' | 'title' > { /** Whether the dialog is open */ - open: boolean; + open: boolean /** Callback fired when the dialog should be closed */ - onClose: () => void; + onClose: () => void /** Dialog title - can be string or custom ReactNode */ - title: string | ReactNode; + title: string | ReactNode /** Dialog content - form fields, text, etc. */ - children: ReactNode; + children: ReactNode /** Optional actions rendered in DialogActions (buttons, etc.) */ - actions?: ReactNode; + actions?: ReactNode /** Toggle between normal and expanded (fullscreen) mode */ - isExpanded?: boolean; + isExpanded?: boolean /** Callback when expand/collapse button is clicked (required if using isExpanded) */ - onExpandToggle?: () => void; + onExpandToggle?: () => void /** Max width in normal mode (default: "570px") */ - normalMaxWidth?: string; + normalMaxWidth?: string /** Max width of content container in expanded mode (default: "990px") */ - expandedContentMaxWidth?: string; + expandedContentMaxWidth?: string /** Height of app header to preserve visibility (default: "90px") */ - headerHeight?: string; + headerHeight?: string /** Spacing between children in normal mode (default: 2 = 16px) */ - normalSpacing?: number; + normalSpacing?: number /** Spacing between children in expanded mode (default: 2 = 16px) */ - expandedSpacing?: number; + expandedSpacing?: number /** Custom styles for DialogContent - merged with base styles */ - contentSx?: SxProps; + contentSx?: SxProps /** Custom styles for DialogTitle */ - titleSx?: SxProps; + titleSx?: SxProps /** Additional props for DialogContent (excluding sx) */ - dialogContentProps?: Omit; + dialogContentProps?: Omit /** Additional props for DialogTitle (excluding sx) */ - dialogTitleProps?: Omit; + dialogTitleProps?: Omit /** Whether to display dividers between title/content/actions */ - dividers?: boolean; + dividers?: boolean /** Whether to show header action icons (expand/close) in normal mode (default: true) */ - showHeaderActions?: boolean; + showHeaderActions?: boolean /** Whether to add border-top to DialogActions */ - actionsBorderTop?: boolean; + actionsBorderTop?: boolean /** Justify content alignment for DialogActions */ - actionsJustifyContent?: - | "flex-start" - | "center" - | "flex-end" - | "space-between"; + actionsJustifyContent?: 'flex-start' | 'center' | 'flex-end' | 'space-between' } function ResponsiveDialog({ @@ -99,9 +95,9 @@ function ResponsiveDialog({ actions, isExpanded = false, onExpandToggle, - normalMaxWidth = "570px", - expandedContentMaxWidth = "990px", - headerHeight = "70px", + normalMaxWidth = '570px', + expandedContentMaxWidth = '990px', + headerHeight = '70px', normalSpacing = 2, expandedSpacing = 2, contentSx, @@ -111,68 +107,68 @@ function ResponsiveDialog({ dividers = false, showHeaderActions = true, actionsBorderTop = false, - actionsJustifyContent = "flex-end", + actionsJustifyContent = 'flex-end', sx, ...otherDialogProps }: ResponsiveDialogProps) { - const isInIframe = useMemo(() => new CozyBridge().isInIframe(), []); + const isInIframe = useMemo(() => new CozyBridge().isInIframe(), []) const baseSx: SxProps = { - "& .MuiBackdrop-root": { - backgroundColor: "rgba(0, 0, 0, 0.1)", - opacity: isExpanded ? "0 !important" : undefined, - transition: isExpanded ? "none !important" : undefined, - pointerEvents: isExpanded ? "none" : undefined, + '& .MuiBackdrop-root': { + backgroundColor: 'rgba(0, 0, 0, 0.1)', + opacity: isExpanded ? '0 !important' : undefined, + transition: isExpanded ? 'none !important' : undefined, + pointerEvents: isExpanded ? 'none' : undefined }, - "& .MuiDialog-paper": { - maxWidth: isExpanded ? "100%" : normalMaxWidth, - width: "100%", + '& .MuiDialog-paper': { + maxWidth: isExpanded ? '100%' : normalMaxWidth, + width: '100%', height: isExpanded - ? `calc(100vh - ${isInIframe ? "0px" : headerHeight})` + ? `calc(100vh - ${isInIframe ? '0px' : headerHeight})` : undefined, - maxHeight: isExpanded && isInIframe ? "100%" : undefined, - margin: isExpanded ? `${isInIframe ? 0 : headerHeight} 0 0 0` : "32px", - boxShadow: isExpanded ? "none !important" : undefined, - transition: isExpanded ? "none !important" : undefined, - zIndex: isExpanded ? 1200 : 1300, + maxHeight: isExpanded && isInIframe ? '100%' : undefined, + margin: isExpanded ? `${isInIframe ? 0 : headerHeight} 0 0 0` : '32px', + boxShadow: isExpanded ? 'none !important' : undefined, + transition: isExpanded ? 'none !important' : undefined, + zIndex: isExpanded ? 1200 : 1300 }, - "& .MuiDialogActions-root .MuiBox-root": { + '& .MuiDialogActions-root .MuiBox-root': { maxWidth: isExpanded ? expandedContentMaxWidth : undefined, - margin: isExpanded ? "0 auto" : undefined, - padding: "0", - width: isExpanded ? "100%" : undefined, - justifyContent: isExpanded ? "flex-end" : undefined, - }, - }; + margin: isExpanded ? '0 auto' : undefined, + padding: '0', + width: isExpanded ? '100%' : undefined, + justifyContent: isExpanded ? 'flex-end' : undefined + } + } const baseContentSx: SxProps = { - width: "100%", - }; + width: '100%' + } const contentWrapperSx: SxProps = { - maxWidth: isExpanded ? expandedContentMaxWidth : "100%", - margin: isExpanded ? "0 auto" : "0", - width: "100%", - }; + maxWidth: isExpanded ? expandedContentMaxWidth : '100%', + margin: isExpanded ? '0 auto' : '0', + width: '100%' + } React.useEffect(() => { if (isExpanded) { - document.body.classList.add("fullscreen-view"); + document.body.classList.add('fullscreen-view') } else { - document.body.classList.remove("fullscreen-view"); + document.body.classList.remove('fullscreen-view') } - }, [isExpanded]); + }, [isExpanded]) const handleClose = ( event: unknown, - reason: "backdropClick" | "escapeKeyDown" + reason: 'backdropClick' | 'escapeKeyDown' ) => { - if (isExpanded && reason === "backdropClick") { - return; + if (isExpanded && reason === 'backdropClick') { + return } - onClose(); - }; + onClose() + } - const currentSpacing = isExpanded ? expandedSpacing : normalSpacing; + const currentSpacing = isExpanded ? expandedSpacing : normalSpacing return ( - + ) : showHeaderActions ? ( - + )} @@ -226,7 +222,7 @@ function ResponsiveDialog({ dividers={dividers} sx={[ baseContentSx, - ...(Array.isArray(contentSx) ? contentSx : [contentSx]), + ...(Array.isArray(contentSx) ? contentSx : [contentSx]) ]} {...dialogContentProps} > @@ -242,16 +238,16 @@ function ResponsiveDialog({ `1px solid ${theme.palette.divider}` + ? theme => `1px solid ${theme.palette.divider}` : undefined, - justifyContent: actionsJustifyContent, + justifyContent: actionsJustifyContent }} > {actions} )} - ); + ) } -export default ResponsiveDialog; +export default ResponsiveDialog diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts index 31a5b06..0f912bd 100644 --- a/src/components/Dialog/index.ts +++ b/src/components/Dialog/index.ts @@ -1 +1 @@ -export { default as ResponsiveDialog } from "./ResponsiveDialog"; +export { default as ResponsiveDialog } from './ResponsiveDialog' diff --git a/src/components/Error/Error.tsx b/src/components/Error/Error.tsx index 7583b1c..73e6581 100644 --- a/src/components/Error/Error.tsx +++ b/src/components/Error/Error.tsx @@ -1,43 +1,43 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; +import { useAppDispatch, useAppSelector } from '@/app/hooks' import { Box, Button, Fade, Paper, Stack, - Typography, -} from "@linagora/twake-mui"; -import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; -import ReplayIcon from "@mui/icons-material/Replay"; -import { useEffect, useRef } from "react"; -import { push } from "redux-first-history"; -import { useI18n } from "twake-i18n"; + Typography +} from '@linagora/twake-mui' +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' +import ReplayIcon from '@mui/icons-material/Replay' +import { useEffect, useRef } from 'react' +import { push } from 'redux-first-history' +import { useI18n } from 'twake-i18n' export function Error() { - const { t } = useI18n(); - const dispatch = useAppDispatch(); - const userError = useAppSelector((state) => state.user.error); - const calendarError = useAppSelector((state) => state.calendars.error); - const initialUserError = useRef(userError); + const { t } = useI18n() + const dispatch = useAppDispatch() + const userError = useAppSelector(state => state.user.error) + const calendarError = useAppSelector(state => state.calendars.error) + const initialUserError = useRef(userError) useEffect(() => { if (!initialUserError.current) { - dispatch(push("/")); + dispatch(push('/')) } - }, [dispatch]); + }, [dispatch]) - const errorMessage = userError || calendarError || t("error.unknown"); + const errorMessage = userError || calendarError || t('error.unknown') return ( - {t("error.title")} + {t('error.title')} @@ -79,19 +79,19 @@ export function Error() { startIcon={} onClick={() => window.location.reload()} sx={{ - textTransform: "none", + textTransform: 'none', fontWeight: 600, borderRadius: 2, px: 3, py: 1, - boxShadow: "none", + boxShadow: 'none' }} > - {t("error.retry")} + {t('error.retry')} - ); + ) } diff --git a/src/components/Error/ErrorSnackbar.tsx b/src/components/Error/ErrorSnackbar.tsx index c48c515..31ad6c8 100644 --- a/src/components/Error/ErrorSnackbar.tsx +++ b/src/components/Error/ErrorSnackbar.tsx @@ -1,109 +1,109 @@ -import { useAppDispatch } from "@/app/hooks"; -import { clearError as calendarClearError } from "@/features/Calendars/CalendarSlice"; -import { clearError as userClearError } from "@/features/User/userSlice"; -import { Alert, Button, Snackbar } from "@linagora/twake-mui"; -import { useI18n } from "twake-i18n"; +import { useAppDispatch } from '@/app/hooks' +import { clearError as calendarClearError } from '@/features/Calendars/CalendarSlice' +import { clearError as userClearError } from '@/features/User/userSlice' +import { Alert, Button, Snackbar } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' export function ErrorSnackbar({ error, - type, + type }: { - error: string | null; - type: "user" | "calendar"; + error: string | null + type: 'user' | 'calendar' }) { - const { t } = useI18n(); - const dispatch = useAppDispatch(); + const { t } = useI18n() + const dispatch = useAppDispatch() const handleCloseSnackbar = () => { - dispatch(type === "calendar" ? calendarClearError() : userClearError()); - }; + dispatch(type === 'calendar' ? calendarClearError() : userClearError()) + } const getErrorMessage = () => { - if (!error) return t("error.unknown"); + if (!error) return t('error.unknown') // Check if error message is a translation key with params // Format: TRANSLATION:key|param1=value1|param2=value2 - if (error.startsWith("TRANSLATION:")) { - const parts = error.substring(12).split("|"); - const translationKey = parts[0]; - const params: Record = {}; + if (error.startsWith('TRANSLATION:')) { + const parts = error.substring(12).split('|') + const translationKey = parts[0] + const params: Record = {} for (let i = 1; i < parts.length; i++) { - const equalIndex = parts[i].indexOf("="); - if (equalIndex === -1) continue; - const key = parts[i].substring(0, equalIndex); - const value = parts[i].substring(equalIndex + 1); + const equalIndex = parts[i].indexOf('=') + if (equalIndex === -1) continue + const key = parts[i].substring(0, equalIndex) + const value = parts[i].substring(equalIndex + 1) if (key && value) { try { - params[key] = decodeURIComponent(value); + params[key] = decodeURIComponent(value) } catch { - params[key] = value; + params[key] = value } } } - return t(translationKey, params); + return t(translationKey, params) } - return error; - }; + return error + } return ( - {t("common.ok")} + {t('common.ok')} } > {getErrorMessage()} - ); + ) } export function EventErrorSnackbar({ messages, - onClose, + onClose }: { - messages: string[]; - onClose: () => void; + messages: string[] + onClose: () => void }) { - const { t } = useI18n(); - const open = messages.length > 0; + const { t } = useI18n() + const open = messages.length > 0 const summary = messages.length === 1 ? messages[0] - : t("error.multipleEvents", { count: messages.length }); + : t('error.multipleEvents', { count: messages.length }) return ( - {t("common.ok")} + {t('common.ok')} } > {summary} - ); + ) } diff --git a/src/components/Error/EventErrorHandler.ts b/src/components/Error/EventErrorHandler.ts index fd47839..d27d672 100644 --- a/src/components/Error/EventErrorHandler.ts +++ b/src/components/Error/EventErrorHandler.ts @@ -1,27 +1,27 @@ export class EventErrorHandler { - private errors = new Map(); - private reportedEvents = new Set(); - private onErrorCallback?: (messages: string[]) => void; + private errors = new Map() + private reportedEvents = new Set() + private onErrorCallback?: (messages: string[]) => void setErrorCallback(callback: (messages: string[]) => void) { - this.onErrorCallback = callback; + this.onErrorCallback = callback } reportError(eventId: string, message: string) { if (!this.reportedEvents.has(eventId)) { - this.reportedEvents.add(eventId); - this.errors.set(eventId, message); - console.warn(`[EventErrorHandler] ${eventId}: ${message}`); - this.emit(); + this.reportedEvents.add(eventId) + this.errors.set(eventId, message) + console.warn(`[EventErrorHandler] ${eventId}: ${message}`) + this.emit() } } clearAll() { - this.errors.clear(); - this.emit(); + this.errors.clear() + this.emit() } private emit() { - this.onErrorCallback?.(Array.from(this.errors.values())); + this.onErrorCallback?.(Array.from(this.errors.values())) } } diff --git a/src/components/Event/AddDescButton.tsx b/src/components/Event/AddDescButton.tsx index d09513d..fcace27 100644 --- a/src/components/Event/AddDescButton.tsx +++ b/src/components/Event/AddDescButton.tsx @@ -1,35 +1,35 @@ -import { TextField } from "@linagora/twake-mui"; -import { Notes as NotesIcon } from "@mui/icons-material"; -import React from "react"; -import { useI18n } from "twake-i18n"; -import { FieldWithLabel } from "./components/FieldWithLabel"; -import { SectionPreviewRow } from "./components/SectionPreviewRow"; +import { TextField } from '@linagora/twake-mui' +import { Notes as NotesIcon } from '@mui/icons-material' +import React from 'react' +import { useI18n } from 'twake-i18n' +import { FieldWithLabel } from './components/FieldWithLabel' +import { SectionPreviewRow } from './components/SectionPreviewRow' export function AddDescButton({ showDescription, setShowDescription, showMore, description, - setDescription, + setDescription }: { - showDescription: boolean; - setShowDescription: (b: boolean) => void; - showMore: boolean; - description: string; - setDescription: (d: string) => void; + showDescription: boolean + setShowDescription: (b: boolean) => void + showMore: boolean + description: string + setDescription: (d: string) => void }) { - const { t } = useI18n(); - const descriptionInputRef = React.useRef(null); + const { t } = useI18n() + const descriptionInputRef = React.useRef(null) React.useEffect(() => { if (showDescription) { - descriptionInputRef.current?.focus(); + descriptionInputRef.current?.focus() } - }, [showDescription]); + }, [showDescription]) const descriptionField = ( @@ -37,31 +37,31 @@ export function AddDescButton({ fullWidth label="" inputRef={descriptionInputRef} - inputProps={{ "aria-label": t("event.form.description") }} - placeholder={t("event.form.descriptionPlaceholder")} + inputProps={{ 'aria-label': t('event.form.description') }} + placeholder={t('event.form.descriptionPlaceholder')} value={description} - onChange={(e) => setDescription(e.target.value)} + onChange={e => setDescription(e.target.value)} size="small" margin="dense" multiline minRows={2} maxRows={10} sx={{ - "& .MuiInputBase-root": { - maxHeight: "33%", - overflowY: "auto", - padding: 0, - }, - "& textarea": { - resize: "vertical", + '& .MuiInputBase-root': { + maxHeight: '33%', + overflowY: 'auto', + padding: 0 }, + '& textarea': { + resize: 'vertical' + } }} /> - ); + ) if (showMore) { - return descriptionField; + return descriptionField } return ( @@ -72,11 +72,11 @@ export function AddDescButton({ icon={} onClick={() => setShowDescription(true)} > - {t("event.form.addDescription")} + {t('event.form.addDescription')} )} {showDescription && descriptionField} - ); + ) } diff --git a/src/components/Event/EditModeDialog.tsx b/src/components/Event/EditModeDialog.tsx index 08aebad..d87c827 100644 --- a/src/components/Event/EditModeDialog.tsx +++ b/src/components/Event/EditModeDialog.tsx @@ -7,64 +7,64 @@ import { DialogTitle, FormControlLabel, Radio, - RadioGroup, -} from "@linagora/twake-mui"; -import { useState } from "react"; -import { useI18n } from "twake-i18n"; + RadioGroup +} from '@linagora/twake-mui' +import { useState } from 'react' +import { useI18n } from 'twake-i18n' export function EditModeDialog({ type, setOpen, - eventAction, + eventAction }: { - type: string | null; - setOpen: (e: string | null) => void; - eventAction: (type: "solo" | "all" | undefined) => void; + type: string | null + setOpen: (e: string | null) => void + eventAction: (type: 'solo' | 'all' | undefined) => void }) { - const { t } = useI18n(); - const [typeOfAction, setTypeOfAction] = useState<"solo" | "all" | undefined>( - "solo" - ); - const handleEvent = async () => { - eventAction(typeOfAction); - handleClose(); - }; + const { t } = useI18n() + const [typeOfAction, setTypeOfAction] = useState<'solo' | 'all' | undefined>( + 'solo' + ) + const handleEvent = () => { + eventAction(typeOfAction) + handleClose() + } const handleClose = () => { - setOpen(null); - setTypeOfAction("solo"); - }; + setOpen(null) + setTypeOfAction('solo') + } return ( - {type === "edit" && t("editModeDialog.updateRecurrentEvent")} - {type === "delete" && t("editModeDialog.deleteRecurrentEvent")} - {type === "attendance" && t("editModeDialog.updateParticipationStatus")} + {type === 'edit' && t('editModeDialog.updateRecurrentEvent')} + {type === 'delete' && t('editModeDialog.deleteRecurrentEvent')} + {type === 'attendance' && t('editModeDialog.updateParticipationStatus')} - setTypeOfAction(e.target.value as "solo" | "all" | undefined) + onChange={e => + setTypeOfAction(e.target.value as 'solo' | 'all' | undefined) } > } - label={t("editModeDialog.thisEvent")} + label={t('editModeDialog.thisEvent')} /> } - label={t("editModeDialog.allEvents")} + label={t('editModeDialog.allEvents')} /> - - + + - ); + ) } diff --git a/src/components/Event/EventChip/ErrorEventChip.tsx b/src/components/Event/EventChip/ErrorEventChip.tsx index 4ff2d6f..2893f03 100644 --- a/src/components/Event/EventChip/ErrorEventChip.tsx +++ b/src/components/Event/EventChip/ErrorEventChip.tsx @@ -1,44 +1,44 @@ -import { EventErrorHandler } from "@/components/Error/EventErrorHandler"; -import { EventContentArg } from "@fullcalendar/core"; -import { Card, Typography } from "@linagora/twake-mui"; -import { useEffect, useRef } from "react"; +import { EventErrorHandler } from '@/components/Error/EventErrorHandler' +import { EventContentArg } from '@fullcalendar/core' +import { Card, Typography } from '@linagora/twake-mui' +import { useEffect, useRef } from 'react' export function ErrorEventChip({ event, errorHandler, - error, + error }: { - event: EventContentArg["event"]; - errorHandler: EventErrorHandler; - error: unknown; + event: EventContentArg['event'] + errorHandler: EventErrorHandler + error: unknown }) { - const hasReported = useRef(false); + const hasReported = useRef(false) useEffect(() => { if (!hasReported.current) { - let message: string; + let message: string if (error instanceof Error) { - message = error.message; + message = error.message } else if ( - typeof error === "object" && + typeof error === 'object' && error !== null && - "class" in error + 'class' in error ) { message = `${String( (error as { class: string }).class - )} error during rendering ${event._def.extendedProps.uid || event.id}`; + )} error during rendering ${event._def.extendedProps.uid || event.id}` } else { - message = `Unknown error during rendering ${event._def.extendedProps.uid || event.id}`; + message = `Unknown error during rendering ${event._def.extendedProps.uid || event.id}` } errorHandler.reportError( event._def.extendedProps.uid || event.id, message - ); - hasReported.current = true; + ) + hasReported.current = true } - }, [event, errorHandler, error]); + }, [event, errorHandler, error]) return ( {event.title} - ); + ) } diff --git a/src/components/Event/EventChip/EventChip.tsx b/src/components/Event/EventChip/EventChip.tsx index de1b3bc..f314fd6 100644 --- a/src/components/Event/EventChip/EventChip.tsx +++ b/src/components/Event/EventChip/EventChip.tsx @@ -4,11 +4,11 @@ import { Card, CardContent, CardHeader, - Typography, -} from "@linagora/twake-mui"; -import { useRef } from "react"; -import { stringAvatar } from "../utils/eventUtils"; -import { ErrorEventChip } from "./ErrorEventChip"; + Typography +} from '@linagora/twake-mui' +import { useRef } from 'react' +import { stringAvatar } from '../utils/eventUtils' +import { ErrorEventChip } from './ErrorEventChip' import { DisplayedIcons, EventChipProps, @@ -19,76 +19,71 @@ import { getOwnerAttendee, getTitleStyle, IconDisplayConfig, - useCompactMode, -} from "./EventChipUtils"; + useCompactMode +} from './EventChipUtils' -const PRIVATE_CLASSIFICATIONS = ["PRIVATE", "CONFIDENTIAL"]; +const PRIVATE_CLASSIFICATIONS = ['PRIVATE', 'CONFIDENTIAL'] export const EVENT_DURATION = { SHORT: 15, MEDIUM: 30, - LONG: 60, -} as const; + LONG: 60 +} as const export function EventChip({ arg, calendars, tempcalendars, - errorHandler, + errorHandler }: EventChipProps) { - const cardRef = useRef(null); - const showCompact = useCompactMode(cardRef); + const cardRef = useRef(null) + const showCompact = useCompactMode(cardRef) - const event = arg.event; - const props = event._def.extendedProps; - const { - calId, - temp, - attendee: attendees = [], - class: classification, - } = props; + const event = arg.event + const props = event._def.extendedProps + const { calId, temp, attendee: attendees = [], class: classification } = props try { // Calendar validation - const calendarsSource = temp ? tempcalendars : calendars; - const calendar = calendarsSource[calId]; - if (!calendar) return null; + const calendarsSource = temp ? tempcalendars : calendars + const calendar = calendarsSource[calId] + if (!calendar) return null // Event properties - const isPrivate = PRIVATE_CLASSIFICATIONS.includes(classification); + const isPrivate = PRIVATE_CLASSIFICATIONS.includes(classification) const ownerEmails = new Set( - calendar.owner?.emails?.map((e) => e.toLowerCase()) - ); + calendar.owner?.emails?.map(e => e.toLowerCase()) + ) // const delegated = calendar.delegated; // Determine owner attendee - const ownerAttendee = getOwnerAttendee(attendees, ownerEmails); + const ownerAttendee = getOwnerAttendee(attendees, ownerEmails) // Color and contrast logic const bestColor = getBestColor( (calendar.color as { light: string; dark: string }) ?? { - light: "#fff", - dark: "#000", + light: '#fff', + dark: '#000' } - ); + ) // Icon display configuration const IconDisplayed: IconDisplayConfig = { - declined: ownerAttendee?.partstat === "DECLINED", - tentative: ownerAttendee?.partstat === "TENTATIVE", - needAction: ownerAttendee?.partstat === "NEEDS-ACTION", - private: isPrivate, - }; + declined: ownerAttendee?.partstat === 'DECLINED', + tentative: ownerAttendee?.partstat === 'TENTATIVE', + needAction: ownerAttendee?.partstat === 'NEEDS-ACTION', + private: isPrivate + } // View and time calculations - const isMonthView = arg.view.type === "dayGridMonth"; - const timeZone = arg.view.calendar?.getOption("timeZone") || "UTC"; - const { startTime, endTime } = getEventTimes(event, timeZone); - const eventLength = getEventDuration(event); + const isMonthView = arg.view.type === 'dayGridMonth' + const timeZone = arg.view.calendar?.getOption('timeZone') || 'UTC' + const { startTime, endTime } = getEventTimes(event, timeZone) + const eventLength = getEventDuration(event) - const isMoreThan15 = eventLength > EVENT_DURATION.SHORT; - const isMoreThan30 = eventLength > EVENT_DURATION.MEDIUM; - const isMoreThan60 = eventLength > EVENT_DURATION.LONG; + const isMoreThan15 = eventLength > EVENT_DURATION.SHORT + const isMoreThan30 = eventLength > EVENT_DURATION.MEDIUM + const isMoreThan60 = eventLength > EVENT_DURATION.LONG // Style calculation const titleStyle = getTitleStyle( @@ -96,7 +91,7 @@ export function EventChip({ ownerAttendee?.partstat, calendar, isPrivate - ); + ) const cardStyle = getCardStyle( bestColor, @@ -104,7 +99,7 @@ export function EventChip({ ownerAttendee?.partstat, calendar, isPrivate - ); + ) // Organizer avatar const OrganizerAvatar = event._def.extendedProps.organizer @@ -112,28 +107,28 @@ export function EventChip({ event._def.extendedProps.organizer?.cn ?? event._def.extendedProps.organizer?.cal_address ) - : { color: undefined, children: null }; + : { color: undefined, children: null } return ( {(!isMoreThan30 || isMonthView) && !event._def.extendedProps.allday && ( @@ -159,12 +154,12 @@ export function EventChip({ className="compactStartTime" style={{ ...titleStyle, - textDecoration: "none", - overflow: "visible", - opacity: "70%", - letterSpacing: "0%", - fontSize: "10px", - marginRight: "4px", + textDecoration: 'none', + overflow: 'visible', + opacity: '70%', + letterSpacing: '0%', + fontSize: '10px', + marginRight: '4px' }} > {startTime} @@ -185,13 +180,13 @@ export function EventChip({ {startTime} {!showCompact && ` - ${endTime}`} @@ -206,50 +201,50 @@ export function EventChip({ {event._def.extendedProps.location && ( {event._def.extendedProps.location} )} - + {event._def.extendedProps.description && ( {event._def.extendedProps.description} @@ -265,23 +260,24 @@ export function EventChip({ !showCompact && window.displayOrgAvatar && ( + > + {OrganizerAvatar.children} + )} - ); + ) } catch (e) { return ( - ); + ) } } diff --git a/src/components/Event/EventChip/EventChipUtils.tsx b/src/components/Event/EventChip/EventChipUtils.tsx index 404fb8d..44e6384 100644 --- a/src/components/Event/EventChip/EventChipUtils.tsx +++ b/src/components/Event/EventChip/EventChipUtils.tsx @@ -1,56 +1,54 @@ -import { EventErrorHandler } from "@/components/Error/EventErrorHandler"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; -import { userAttendee } from "@/features/User/models/attendee"; -import { EventContentArg } from "@fullcalendar/core"; -import { getContrastRatio } from "@linagora/twake-mui"; -import CancelIcon from "@mui/icons-material/Cancel"; -import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; -import LockOutlineIcon from "@mui/icons-material/LockOutline"; -import moment from "moment"; -import React, { useLayoutEffect, useState } from "react"; -import { EVENT_DURATION } from "./EventChip"; +import { EventErrorHandler } from '@/components/Error/EventErrorHandler' +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { userAttendee } from '@/features/User/models/attendee' +import { EventContentArg } from '@fullcalendar/core' +import { getContrastRatio } from '@linagora/twake-mui' +import CancelIcon from '@mui/icons-material/Cancel' +import HelpOutlineIcon from '@mui/icons-material/HelpOutline' +import LockOutlineIcon from '@mui/icons-material/LockOutline' +import moment from 'moment' +import React, { useLayoutEffect, useState } from 'react' +import { EVENT_DURATION } from './EventChip' -const COMPACT_WIDTH_THRESHOLD = 100; +const COMPACT_WIDTH_THRESHOLD = 100 export interface EventChipProps { - arg: EventContentArg["event"]; - calendars: Record; - tempcalendars: Record; - errorHandler: EventErrorHandler; + arg: EventContentArg['event'] + calendars: Record + tempcalendars: Record + errorHandler: EventErrorHandler } export interface IconDisplayConfig { - declined: boolean; - tentative: boolean; - needAction: boolean; - private: boolean; + declined: boolean + tentative: boolean + needAction: boolean + private: boolean } -export function getEventDuration(event: EventContentArg["event"]): number { +export function getEventDuration(event: EventContentArg['event']): number { return moment(event._instance?.range.end).diff( moment(event._instance?.range.start), - "minutes" - ); + 'minutes' + ) } export function getBestColor(colors: { light: string; dark: string }): string { - const contrastToDark = getContrastRatio(colors?.dark, "#fff"); - const contrastToLight = getContrastRatio(colors?.light, "#fff"); - return contrastToDark > contrastToLight ? colors?.dark : colors?.light; + const contrastToDark = getContrastRatio(colors?.dark, '#fff') + const contrastToLight = getContrastRatio(colors?.light, '#fff') + return contrastToDark > contrastToLight ? colors?.dark : colors?.light } export function getEventTimes( - event: EventContentArg["event"], + event: EventContentArg['event'], timeZone: string ) { return { - startTime: moment.tz(event.start, timeZone).format("HH:mm"), - endTime: moment.tz(event.end, timeZone).format("HH:mm"), - }; + startTime: moment.tz(event.start, timeZone).format('HH:mm'), + endTime: moment.tz(event.end, timeZone).format('HH:mm') + } } export function getOwnerAttendee( attendees: userAttendee[], ownerEmails: Set ): userAttendee | undefined { - return attendees.find((att) => - ownerEmails.has(att.cal_address.toLowerCase()) - ); + return attendees.find(att => ownerEmails.has(att.cal_address.toLowerCase())) } export function getTitleStyle( @@ -60,41 +58,41 @@ export function getTitleStyle( isPrivate?: boolean ): React.CSSProperties { const baseStyle: React.CSSProperties = { - fontFamily: "Roboto", - fontWeight: "500", - fontStyle: "Medium", - fontSize: "12px", - lineHeight: "16px", - letterSpacing: "0.5px", - verticalAlign: "middle", - overflow: "hidden", - textOverflow: "ellipsis", - font: "Roboto", - whiteSpace: "nowrap", - color: bestColor, - }; + fontFamily: 'Roboto', + fontWeight: '500', + fontStyle: 'Medium', + fontSize: '12px', + lineHeight: '16px', + letterSpacing: '0.5px', + verticalAlign: 'middle', + overflow: 'hidden', + textOverflow: 'ellipsis', + font: 'Roboto', + whiteSpace: 'nowrap', + color: bestColor + } switch (partstat) { - case "DECLINED": - return { ...baseStyle, textDecoration: "line-through" }; - case "TENTATIVE": - case "ACCEPTED": - return { ...baseStyle, color: calendar?.color?.dark }; - case "NEEDS-ACTION": - return baseStyle; + case 'DECLINED': + return { ...baseStyle, textDecoration: 'line-through' } + case 'TENTATIVE': + case 'ACCEPTED': + return { ...baseStyle, color: calendar?.color?.dark } + case 'NEEDS-ACTION': + return baseStyle default: if (isPrivate) { - return { ...baseStyle, color: calendar?.color?.dark }; + return { ...baseStyle, color: calendar?.color?.dark } } - return baseStyle; + return baseStyle } } function getEventVariant(duration: number) { - if (duration <= EVENT_DURATION.SHORT) return "short"; - if (duration <= EVENT_DURATION.MEDIUM) return "medium"; - if (duration <= EVENT_DURATION.LONG) return "long"; - return "extraLong"; + if (duration <= EVENT_DURATION.SHORT) return 'short' + if (duration <= EVENT_DURATION.MEDIUM) return 'medium' + if (duration <= EVENT_DURATION.LONG) return 'long' + return 'extraLong' } function getCardVariantStyle( @@ -102,27 +100,27 @@ function getCardVariantStyle( baseColor: string ): React.CSSProperties { const shared: React.CSSProperties = { - width: "100%", - height: "100%", - borderRadius: "8px", - boxShadow: "none", + width: '100%', + height: '100%', + borderRadius: '8px', + boxShadow: 'none', border: `1px solid ${baseColor}`, color: baseColor, - display: "flex", - flexDirection: "column", - justifyContent: "flex-start", - }; + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start' + } switch (variant) { - case "short": - return { ...shared, padding: "0px 6px", justifyContent: "center" }; - case "medium": - return { ...shared, padding: "4px 6px", justifyContent: "center" }; + case 'short': + return { ...shared, padding: '0px 6px', justifyContent: 'center' } + case 'medium': + return { ...shared, padding: '4px 6px', justifyContent: 'center' } default: return { ...shared, - padding: "4px 6px", - }; + padding: '4px 6px' + } } } @@ -136,40 +134,40 @@ export function getCardStyle( const baseStyle: React.CSSProperties = getCardVariantStyle( getEventVariant(eventLength), bestColor - ); + ) switch (partstat) { - case "DECLINED": - return baseStyle; - case "TENTATIVE": + case 'DECLINED': + return baseStyle + case 'TENTATIVE': return { ...baseStyle, backgroundColor: calendar?.color?.light, color: calendar?.color?.dark, - border: "1px solid white", - }; - case "NEEDS-ACTION": + border: '1px solid white' + } + case 'NEEDS-ACTION': return { ...baseStyle, - backgroundColor: "#fff", - }; - case "ACCEPTED": + backgroundColor: '#fff' + } + case 'ACCEPTED': return { ...baseStyle, backgroundColor: calendar?.color?.light, color: calendar?.color?.dark, - border: "1px solid white", - }; + border: '1px solid white' + } default: if (isPrivate) { return { ...baseStyle, backgroundColor: calendar?.color?.light, color: calendar?.color?.dark, - border: "1px solid white", - }; + border: '1px solid white' + } } - return baseStyle; + return baseStyle } } @@ -177,26 +175,26 @@ export function DisplayedIcons( IconDisplayed: IconDisplayConfig, iconColor?: string ) { - if (!Object.values(IconDisplayed).find((b) => b === true)) return; + if (!Object.values(IconDisplayed).find(b => b === true)) return const iconStyle: React.CSSProperties = { - fontSize: "15px", - color: iconColor || "inherit", - marginRight: 2, - }; + fontSize: '15px', + color: iconColor || 'inherit', + marginRight: 2 + } return ( {IconDisplayed.needAction && } @@ -204,34 +202,34 @@ export function DisplayedIcons( {IconDisplayed.tentative && } {IconDisplayed.private && } - ); + ) } export function useCompactMode( cardRef: React.RefObject ): boolean { - const [showCompact, setShowCompact] = useState(false); + const [showCompact, setShowCompact] = useState(false) useLayoutEffect(() => { - if (!cardRef.current) return; + if (!cardRef.current) return const checkWidth = () => { - const width = cardRef.current?.offsetWidth ?? 0; - const newCompact = width < COMPACT_WIDTH_THRESHOLD; + const width = cardRef.current?.offsetWidth ?? 0 + const newCompact = width < COMPACT_WIDTH_THRESHOLD - setShowCompact((prev) => (prev !== newCompact ? newCompact : prev)); - }; + setShowCompact(prev => (prev !== newCompact ? newCompact : prev)) + } - checkWidth(); + checkWidth() const resizeObserver = new ResizeObserver(() => { - requestAnimationFrame(checkWidth); - }); + requestAnimationFrame(checkWidth) + }) - resizeObserver.observe(cardRef.current); + resizeObserver.observe(cardRef.current) - return () => resizeObserver.disconnect(); - }, [cardRef]); + return () => resizeObserver.disconnect() + }, [cardRef]) - return showCompact; + return showCompact } diff --git a/src/components/Event/EventChip/SimpleEventChip.tsx b/src/components/Event/EventChip/SimpleEventChip.tsx index fe2bf0e..2349d69 100644 --- a/src/components/Event/EventChip/SimpleEventChip.tsx +++ b/src/components/Event/EventChip/SimpleEventChip.tsx @@ -1,27 +1,27 @@ -import { Card, Typography } from "@linagora/twake-mui"; +import { Card, Typography } from '@linagora/twake-mui' export function SimpleEventChip({ title }: { title: string }) { return ( {title} - ); + ) } diff --git a/src/components/Event/EventDuplicate.tsx b/src/components/Event/EventDuplicate.tsx index 44fd9ae..1b495da 100644 --- a/src/components/Event/EventDuplicate.tsx +++ b/src/components/Event/EventDuplicate.tsx @@ -1,19 +1,19 @@ -import { MenuItem } from "@linagora/twake-mui"; -import { useI18n } from "twake-i18n"; +import { MenuItem } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' export default function EventDuplication({ - onOpenDuplicate, + onOpenDuplicate }: { - onOpenDuplicate?: () => void; + onOpenDuplicate?: () => void }) { - const { t } = useI18n(); + const { t } = useI18n() return ( { - onOpenDuplicate?.(); + onOpenDuplicate?.() }} > - {t("eventDuplication.duplicateEvent")} + {t('eventDuplication.duplicateEvent')} - ); + ) } diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index 08cc530..c70bd4f 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -1,15 +1,15 @@ -import { Calendar } from "@/features/Calendars/CalendarTypes"; -import { RepetitionObject } from "@/features/Events/EventsTypes"; -import { userAttendee } from "@/features/User/models/attendee"; -import iconCamera from "@/static/images/icon-camera.svg"; -import { defaultColors } from "@/utils/defaultColors"; -import { makeDisplayName } from "@/utils/makeDisplayName"; -import { renameDefault } from "@/utils/renameDefault"; +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { RepetitionObject } from '@/features/Events/EventsTypes' +import { userAttendee } from '@/features/User/models/attendee' +import iconCamera from '@/static/images/icon-camera.svg' +import { defaultColors } from '@/utils/defaultColors' +import { makeDisplayName } from '@/utils/makeDisplayName' +import { renameDefault } from '@/utils/renameDefault' import { addVideoConferenceToDescription, generateMeetingLink, - removeVideoConferenceFromDescription, -} from "@/utils/videoConferenceUtils"; + removeVideoConferenceFromDescription +} from '@/utils/videoConferenceUtils' import { Box, Button, @@ -24,101 +24,101 @@ import { TextField, ToggleButton, ToggleButtonGroup, - Typography, -} from "@linagora/twake-mui"; + Typography +} from '@linagora/twake-mui' import { Close as DeleteIcon, ContentCopy as CopyIcon, LocationOn as LocationIcon, - Public as PublicIcon, -} from "@mui/icons-material"; -import LockOutlineIcon from "@mui/icons-material/LockOutline"; -import SquareRoundedIcon from "@mui/icons-material/SquareRounded"; -import React from "react"; -import { useI18n } from "twake-i18n"; -import AttendeeSelector from "../Attendees/AttendeeSearch"; -import { CalendarItemList } from "../Calendar/CalendarItemList"; -import { OwnerCaption } from "../Calendar/OwnerCaption"; -import { SnackbarAlert } from "../Loading/SnackBarAlert"; -import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete"; -import { AddDescButton } from "./AddDescButton"; -import { DateTimeFields } from "./components/DateTimeFields"; -import { DateTimeSummary } from "./components/DateTimeSummary"; -import { FieldWithLabel } from "./components/FieldWithLabel"; -import { SectionPreviewRow } from "./components/SectionPreviewRow"; -import RepeatEvent from "./EventRepeat"; -import { useAllDayToggle } from "./hooks/useAllDayToggle"; -import { combineDateTime, splitDateTime } from "./utils/dateTimeHelpers"; -import { validateEventForm } from "./utils/formValidation"; -import { Resource, ResourceSearch } from "../Attendees/ResourceSearch"; + Public as PublicIcon +} from '@mui/icons-material' +import LockOutlineIcon from '@mui/icons-material/LockOutline' +import SquareRoundedIcon from '@mui/icons-material/SquareRounded' +import React from 'react' +import { useI18n } from 'twake-i18n' +import AttendeeSelector from '../Attendees/AttendeeSearch' +import { CalendarItemList } from '../Calendar/CalendarItemList' +import { OwnerCaption } from '../Calendar/OwnerCaption' +import { SnackbarAlert } from '../Loading/SnackBarAlert' +import { TimezoneAutocomplete } from '../Timezone/TimezoneAutocomplete' +import { AddDescButton } from './AddDescButton' +import { DateTimeFields } from './components/DateTimeFields' +import { DateTimeSummary } from './components/DateTimeSummary' +import { FieldWithLabel } from './components/FieldWithLabel' +import { SectionPreviewRow } from './components/SectionPreviewRow' +import RepeatEvent from './EventRepeat' +import { useAllDayToggle } from './hooks/useAllDayToggle' +import { combineDateTime, splitDateTime } from './utils/dateTimeHelpers' +import { validateEventForm } from './utils/formValidation' +import { Resource, ResourceSearch } from '../Attendees/ResourceSearch' interface EventFormFieldsProps { // Form state - title: string; - setTitle: (title: string) => void; - description: string; - setDescription: (description: string) => void; - location: string; - setLocation: (location: string) => void; - start: string; - setStart: (start: string) => void; - end: string; - setEnd: (end: string) => void; - allday: boolean; - setAllDay: (allday: boolean) => void; - repetition: RepetitionObject; - setRepetition: (repetition: RepetitionObject) => void; - typeOfAction?: "solo" | "all"; - attendees: userAttendee[]; - setAttendees: (attendees: userAttendee[]) => void; - alarm: string; - setAlarm: (alarm: string) => void; - busy: string; - setBusy: (busy: string) => void; - eventClass: string; - setEventClass: (eventClass: string) => void; - timezone: string; - setTimezone: (timezone: string) => void; - calendarid: string; - setCalendarid: (calendarid: string) => void; - hasVideoConference: boolean; - setHasVideoConference: (hasVideoConference: boolean) => void; - meetingLink: string | null; - setMeetingLink: (meetingLink: string | null) => void; - selectedResources: Resource[]; - setSelectedResources: (resources: Resource[]) => void; + title: string + setTitle: (title: string) => void + description: string + setDescription: (description: string) => void + location: string + setLocation: (location: string) => void + start: string + setStart: (start: string) => void + end: string + setEnd: (end: string) => void + allday: boolean + setAllDay: (allday: boolean) => void + repetition: RepetitionObject + setRepetition: (repetition: RepetitionObject) => void + typeOfAction?: 'solo' | 'all' + attendees: userAttendee[] + setAttendees: (attendees: userAttendee[]) => void + alarm: string + setAlarm: (alarm: string) => void + busy: string + setBusy: (busy: string) => void + eventClass: string + setEventClass: (eventClass: string) => void + timezone: string + setTimezone: (timezone: string) => void + calendarid: string + setCalendarid: (calendarid: string) => void + hasVideoConference: boolean + setHasVideoConference: (hasVideoConference: boolean) => void + meetingLink: string | null + setMeetingLink: (meetingLink: string | null) => void + selectedResources: Resource[] + setSelectedResources: (resources: Resource[]) => void // UI state - showMore: boolean; - showDescription: boolean; - setShowDescription: (showDescription: boolean) => void; - showRepeat: boolean; - setShowRepeat: (showRepeat: boolean) => void; - isOpen?: boolean; + showMore: boolean + showDescription: boolean + setShowDescription: (showDescription: boolean) => void + showRepeat: boolean + setShowRepeat: (showRepeat: boolean) => void + isOpen?: boolean // Data - userPersonalCalendars: Calendar[]; + userPersonalCalendars: Calendar[] timezoneList: { - zones: string[]; - browserTz: string; - getTimezoneOffset: (tzName: string, date: Date) => string; - }; - eventId?: string | null; + zones: string[] + browserTz: string + getTimezoneOffset: (tzName: string, date: Date) => string + } + eventId?: string | null // Event handlers - onStartChange?: (newStart: string) => void; - onEndChange?: (newEnd: string) => void; + onStartChange?: (newStart: string) => void + onEndChange?: (newEnd: string) => void onAllDayChange?: ( newAllDay: boolean, newStart: string, newEnd: string - ) => void; - onCalendarChange?: (newCalendarId: string) => void; + ) => void + onCalendarChange?: (newCalendarId: string) => void // Validation - onValidationChange?: (isValid: boolean) => void; - showValidationErrors?: boolean; - onHasEndDateChangedChange?: (has: boolean) => void; + onValidationChange?: (isValid: boolean) => void + showValidationErrors?: boolean + onHasEndDateChangedChange?: (has: boolean) => void } export default function EventFormFields({ @@ -170,64 +170,60 @@ export default function EventFormFields({ showValidationErrors = false, onHasEndDateChangedChange, selectedResources, - setSelectedResources, + setSelectedResources }: EventFormFieldsProps) { - const { t } = useI18n(); + const { t } = useI18n() // Internal state for 4 separate fields - const [startDate, setStartDate] = React.useState(""); - const [startTime, setStartTime] = React.useState(""); - const [endDate, setEndDate] = React.useState(""); - const [endTime, setEndTime] = React.useState(""); + const [startDate, setStartDate] = React.useState('') + const [startTime, setStartTime] = React.useState('') + const [endDate, setEndDate] = React.useState('') + const [endTime, setEndTime] = React.useState('') // Track if user has manually changed end date in extended mode - const [hasEndDateChanged, setHasEndDateChanged] = React.useState(false); + const [hasEndDateChanged, setHasEndDateChanged] = React.useState(false) // Track if user has clicked on datetime clickable section in normal mode // Once clicked, normal mode will always show full fields until modal closes const [hasClickedDateTimeSection, setHasClickedDateTimeSection] = - React.useState(false); + React.useState(false) // Track if user has clicked on location section in normal mode const [hasClickedLocationSection, setHasClickedLocationSection] = - React.useState(false); + React.useState(false) // Track if user has clicked on calendar section in normal mode const [hasClickedCalendarSection, setHasClickedCalendarSection] = - React.useState(false); + React.useState(false) // Track whether the calendar Select dropdown is open - const [calendarSelectOpen, setCalendarSelectOpen] = React.useState(false); + const [calendarSelectOpen, setCalendarSelectOpen] = React.useState(false) - const delegatedCalendars = userPersonalCalendars.filter( - (cal) => cal.delegated - ); - const personalCalendars = userPersonalCalendars.filter( - (cal) => !cal.delegated - ); + const delegatedCalendars = userPersonalCalendars.filter(cal => cal.delegated) + const personalCalendars = userPersonalCalendars.filter(cal => !cal.delegated) const selectedCalendar = userPersonalCalendars.find( - (cal) => cal.id === calendarid - ); + cal => cal.id === calendarid + ) const selectedOwnerDisplayName = selectedCalendar - ? (makeDisplayName(selectedCalendar) ?? "") - : ""; - const isSelectedDelegated = !!selectedCalendar?.delegated; + ? (makeDisplayName(selectedCalendar) ?? '') + : '' + const isSelectedDelegated = !!selectedCalendar?.delegated // Reset all click-tracking state when modal closes React.useEffect(() => { if (!isOpen) { - setHasEndDateChanged(false); - setHasClickedDateTimeSection(false); - setHasClickedLocationSection(false); - setHasClickedCalendarSection(false); - setCalendarSelectOpen(false); + setHasEndDateChanged(false) + setHasClickedDateTimeSection(false) + setHasClickedLocationSection(false) + setHasClickedCalendarSection(false) + setCalendarSelectOpen(false) } - }, [isOpen]); + }, [isOpen]) // Notify parent about end date change visibility state React.useEffect(() => { - onHasEndDateChangedChange?.(hasEndDateChanged); - }, [hasEndDateChanged, onHasEndDateChangedChange]); + onHasEndDateChangedChange?.(hasEndDateChanged) + }, [hasEndDateChanged, onHasEndDateChangedChange]) // Reset hasEndDateChanged when startDate === endDate in normal mode React.useEffect(() => { @@ -238,16 +234,16 @@ export default function EventFormFields({ endDate && startDate === endDate ) { - setHasEndDateChanged(false); + setHasEndDateChanged(false) } - }, [showMore, hasEndDateChanged, startDate, endDate]); + }, [showMore, hasEndDateChanged, startDate, endDate]) // Auto-open the calendar Select once it is mounted after clicking the preview row React.useEffect(() => { if (hasClickedCalendarSection) { - setCalendarSelectOpen(true); + setCalendarSelectOpen(true) } - }, [hasClickedCalendarSection]); + }, [hasClickedCalendarSection]) // Use all-day toggle hook const { handleAllDayToggle } = useAllDayToggle({ @@ -263,35 +259,35 @@ export default function EventFormFields({ setStart, setEnd, setAllDay, - onAllDayChange, - }); + onAllDayChange + }) // Ref for title input field to enable auto-focus - const titleInputRef = React.useRef(null); - const locationInputRef = React.useRef(null); + const titleInputRef = React.useRef(null) + const locationInputRef = React.useRef(null) // Track previous showMore state to detect changes - const prevShowMoreRef = React.useRef(undefined); + const prevShowMoreRef = React.useRef(undefined) // Focus location field when user clicks the location preview row React.useEffect(() => { - if (hasClickedLocationSection && process.env.NODE_ENV !== "test") { - locationInputRef.current?.focus(); + if (hasClickedLocationSection && process.env.NODE_ENV !== 'test') { + locationInputRef.current?.focus() } - }, [hasClickedLocationSection]); + }, [hasClickedLocationSection]) // Auto-focus title field when modal opens (skip in test environment) React.useEffect(() => { if (isOpen) { - if (titleInputRef.current && process.env.NODE_ENV !== "test") { + if (titleInputRef.current && process.env.NODE_ENV !== 'test') { // Use setTimeout to ensure the dialog is fully rendered const timer = setTimeout(() => { - titleInputRef.current?.focus(); - }, 100); - return () => clearTimeout(timer); + titleInputRef.current?.focus() + }, 100) + return () => clearTimeout(timer) } } - }, [isOpen]); + }, [isOpen]) // Auto-focus title field when toggling between normal and extended mode React.useEffect(() => { @@ -299,104 +295,104 @@ export default function EventFormFields({ if ( prevShowMoreRef.current !== undefined && isOpen && - process.env.NODE_ENV !== "test" + process.env.NODE_ENV !== 'test' ) { - const hasChanged = prevShowMoreRef.current !== showMore; + const hasChanged = prevShowMoreRef.current !== showMore if (hasChanged) { // Simple setTimeout approach with sufficient delay for layout changes const timer = setTimeout(() => { if (titleInputRef.current) { - titleInputRef.current.focus(); + titleInputRef.current.focus() } - }, 150); + }, 150) // Update previous value before returning cleanup - prevShowMoreRef.current = showMore; - return () => clearTimeout(timer); + prevShowMoreRef.current = showMore + return () => clearTimeout(timer) } } // Always update previous value - prevShowMoreRef.current = showMore; - }, [showMore, isOpen]); + prevShowMoreRef.current = showMore + }, [showMore, isOpen]) // Sync start prop to startDate/startTime React.useEffect(() => { if (start) { - const { date, time } = splitDateTime(start); - setStartDate(date); - setStartTime(time); + const { date, time } = splitDateTime(start) + setStartDate(date) + setStartTime(time) } - }, [start]); + }, [start]) // Sync end prop to endDate/endTime React.useEffect(() => { if (end) { - const { date, time } = splitDateTime(end); - setEndDate(date); - setEndTime(time); + const { date, time } = splitDateTime(end) + setEndDate(date) + setEndTime(time) } - }, [end]); + }, [end]) // Change handlers for 4 separate fields const handleStartDateChange = React.useCallback( (newDate: string, newTime?: string) => { - setStartDate(newDate); - const newStart = combineDateTime(newDate, newTime ? newTime : startTime); + setStartDate(newDate) + const newStart = combineDateTime(newDate, newTime ? newTime : startTime) if (onStartChange) { - onStartChange(newStart); + onStartChange(newStart) } else { - setStart(newStart); + setStart(newStart) } }, [startTime, onStartChange, setStart] - ); + ) const handleStartTimeChange = React.useCallback( (newTime: string, newDate?: string) => { - setStartTime(newTime); - const newStart = combineDateTime(newDate ? newDate : startDate, newTime); + setStartTime(newTime) + const newStart = combineDateTime(newDate ? newDate : startDate, newTime) if (onStartChange) { - onStartChange(newStart); + onStartChange(newStart) } else { - setStart(newStart); + setStart(newStart) } }, [startDate, onStartChange, setStart] - ); + ) const handleEndDateChange = React.useCallback( (newDate: string, newTime?: string) => { - setEndDate(newDate); + setEndDate(newDate) // Track if user changed end date in extended mode if (showMore) { - setHasEndDateChanged(true); + setHasEndDateChanged(true) } - const newEnd = combineDateTime(newDate, newTime ? newTime : endTime); + const newEnd = combineDateTime(newDate, newTime ? newTime : endTime) if (onEndChange) { - onEndChange(newEnd); + onEndChange(newEnd) } else { - setEnd(newEnd); + setEnd(newEnd) } }, [endTime, onEndChange, setEnd, showMore] - ); + ) const handleEndTimeChange = React.useCallback( (newTime: string, newDate?: string) => { - setEndTime(newTime); + setEndTime(newTime) - const newEnd = combineDateTime(newDate ? newDate : endDate, newTime); + const newEnd = combineDateTime(newDate ? newDate : endDate, newTime) if (onEndChange) { - onEndChange(newEnd); + onEndChange(newEnd) } else { - setEnd(newEnd); + setEnd(newEnd) } }, [endDate, onEndChange, setEnd] - ); + ) // Validation logic const validateForm = React.useCallback(() => { @@ -408,8 +404,8 @@ export default function EventFormFields({ allday, showValidationErrors, hasEndDateChanged, - showMore, - }); + showMore + }) }, [ startDate, startTime, @@ -418,89 +414,89 @@ export default function EventFormFields({ allday, showValidationErrors, hasEndDateChanged, - showMore, - ]); + showMore + ]) // Notify parent about validation changes React.useEffect(() => { - const validation = validateForm(); - onValidationChange?.(validation.isValid); - }, [validateForm, onValidationChange]); + const validation = validateForm() + onValidationChange?.(validation.isValid) + }, [validateForm, onValidationChange]) // Auto-calculate end time from start time (+ 1 hour) if not already set React.useEffect(() => { if (startTime && !endTime && !allday) { - const [hours, minutes] = startTime.split(":"); - const endHour = (parseInt(hours) + 1) % 24; - const calculatedEndTime = `${endHour.toString().padStart(2, "0")}:${minutes}`; - setEndTime(calculatedEndTime); - const newEnd = combineDateTime(endDate || startDate, calculatedEndTime); + const [hours, minutes] = startTime.split(':') + const endHour = (parseInt(hours) + 1) % 24 + const calculatedEndTime = `${endHour.toString().padStart(2, '0')}:${minutes}` + setEndTime(calculatedEndTime) + const newEnd = combineDateTime(endDate || startDate, calculatedEndTime) if (onEndChange) { - onEndChange(newEnd); + onEndChange(newEnd) } else { - setEnd(newEnd); + setEnd(newEnd) } } - }, [startTime, endTime, allday, endDate, startDate, onEndChange, setEnd]); + }, [startTime, endTime, allday, endDate, startDate, onEndChange, setEnd]) - const validation = validateForm(); + const validation = validateForm() const handleAddVideoConference = () => { - const newMeetingLink = generateMeetingLink(); + const newMeetingLink = generateMeetingLink() const updatedDescription = addVideoConferenceToDescription( description, newMeetingLink - ); - setDescription(updatedDescription); - setHasVideoConference(true); - setMeetingLink(newMeetingLink); + ) + setDescription(updatedDescription) + setHasVideoConference(true) + setMeetingLink(newMeetingLink) if (showMore) { - setShowDescription(true); + setShowDescription(true) } - }; + } - const [openToast, setOpenToast] = React.useState(false); + const [openToast, setOpenToast] = React.useState(false) const handleCopyMeetingLink = async () => { if (meetingLink) { try { - await navigator.clipboard.writeText(meetingLink); - setOpenToast(true); + await navigator.clipboard.writeText(meetingLink) + setOpenToast(true) } catch (err) { - console.error("Failed to copy link:", err); + console.error('Failed to copy link:', err) } } - }; + } const handleDeleteVideoConference = () => { - setDescription(removeVideoConferenceFromDescription(description)); - setHasVideoConference(false); - setMeetingLink(null); - }; + setDescription(removeVideoConferenceFromDescription(description)) + setHasVideoConference(false) + setMeetingLink(null) + } const handleCalendarChange = (newCalendarId: string) => { - setCalendarid(newCalendarId); - onCalendarChange?.(newCalendarId); - }; + setCalendarid(newCalendarId) + onCalendarChange?.(newCalendarId) + } const handleResourceChange = (resources: Resource[]) => { - setSelectedResources(resources); - }; + setSelectedResources(resources) + } return ( <> { - setTitle(e.target.value); + onChange={e => { + setTitle(e.target.value) }} size="small" margin="dense" @@ -511,8 +507,8 @@ export default function EventFormFields({ @@ -565,7 +561,7 @@ export default function EventFormFields({ } label={ - {t("event.form.allDay")} + {t('event.form.allDay')} } /> { - const newShowRepeat = !showRepeat; - setShowRepeat(newShowRepeat); + const newShowRepeat = !showRepeat + setShowRepeat(newShowRepeat) if (newShowRepeat) { - const days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; - const eventStartDate = new Date(start); - const jsDay = eventStartDate.getDay(); - const icsDay = days[(jsDay + 6) % 7]; + const days = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] + const eventStartDate = new Date(start) + const jsDay = eventStartDate.getDay() + const icsDay = days[(jsDay + 6) % 7] setRepetition({ - freq: "weekly", + freq: 'weekly', interval: 1, occurrences: 0, - endDate: "", - byday: [icsDay], - } as RepetitionObject); + endDate: '', + byday: [icsDay] + } as RepetitionObject) } else { setRepetition({ - freq: "", + freq: '', interval: 1, occurrences: 0, - endDate: "", - byday: null, - } as RepetitionObject); + endDate: '', + byday: null + } as RepetitionObject) } }} /> } label={ - {t("event.form.repeat")} + {t('event.form.repeat')} } /> @@ -626,19 +622,19 @@ export default function EventFormFields({ )} {!(!showMore && !hasClickedDateTimeSection) && - (showRepeat || (typeOfAction === "solo" && repetition?.freq)) && ( + (showRepeat || (typeOfAction === 'solo' && repetition?.freq)) && ( )} } + placeholder={t('event.form.addGuestsPlaceholder')} + inputSlot={params => } /> {!showMore ? ( @@ -665,30 +661,30 @@ export default function EventFormFields({ camera } onClick={() => - window.open(meetingLink, "_blank", "noopener,noreferrer") + window.open(meetingLink, '_blank', 'noopener,noreferrer') } size="medium" variant="contained" color="primary" - sx={{ borderRadius: "4px", mr: 1 }} + sx={{ borderRadius: '4px', mr: 1 }} > - {t("event.form.joinVisioConference")} + {t('event.form.joinVisioConference')} @@ -700,7 +696,7 @@ export default function EventFormFields({ } onClick={handleAddVideoConference} > - {t("event.form.addVisioConference")} + {t('event.form.addVisioConference')} ) ) : ( @@ -714,11 +710,11 @@ export default function EventFormFields({ variant="contained" color="secondary" sx={{ - borderRadius: "4px", - display: hasVideoConference ? "none" : "flex", + borderRadius: '4px', + display: hasVideoConference ? 'none' : 'flex' }} > - {t("event.form.addVisioConference")} + {t('event.form.addVisioConference')} {hasVideoConference && meetingLink && ( @@ -728,33 +724,33 @@ export default function EventFormFields({ camera } onClick={() => - window.open(meetingLink, "_blank", "noopener,noreferrer") + window.open(meetingLink, '_blank', 'noopener,noreferrer') } size="medium" variant="contained" color="primary" sx={{ - borderRadius: "4px", - mr: 1, + borderRadius: '4px', + mr: 1 }} > - {t("event.form.joinVisioConference")} + {t('event.form.joinVisioConference')} @@ -774,7 +770,7 @@ export default function EventFormFields({ @@ -783,17 +779,17 @@ export default function EventFormFields({ icon={} onClick={() => setHasClickedLocationSection(true)} > - {location || t("event.form.locationPlaceholder")} + {location || t('event.form.locationPlaceholder')} ) : ( setLocation(e.target.value)} + onChange={e => setLocation(e.target.value)} size="small" margin="dense" /> @@ -802,7 +798,7 @@ export default function EventFormFields({ @@ -812,18 +808,18 @@ export default function EventFormFields({ cal.id === calendarid) + userPersonalCalendars.find(cal => cal.id === calendarid) ?.color?.light ?? defaultColors[0].light, width: 24, - height: 24, + height: 24 }} /> } onClick={() => setHasClickedCalendarSection(true)} > {selectedCalendar?.name ? ( - - + + {renameDefault( selectedCalendar.name, selectedOwnerDisplayName, @@ -833,21 +829,21 @@ export default function EventFormFields({ ) : ( - t("event.form.calendar") + t('event.form.calendar') )} ) : ( @@ -868,19 +864,16 @@ export default function EventFormFields({ {showMore && ( <> } - onChange={async ( - _event: React.SyntheticEvent, - value: Resource[] - ) => { - handleResourceChange(value); + inputSlot={params => } + onChange={(_event: React.SyntheticEvent, value: Resource[]) => { + handleResourceChange(value) }} hideLabel={true} /> @@ -888,7 +881,7 @@ export default function EventFormFields({ @@ -898,49 +891,49 @@ export default function EventFormFields({ displayEmpty onChange={(e: SelectChangeEvent) => setAlarm(e.target.value)} > - {t("event.form.notifications.")} + {t('event.form.notifications.')} - {t("event.form.notifications.-PT1M")} + {t('event.form.notifications.-PT1M')} - {t("event.form.notifications.-PT5M")} + {t('event.form.notifications.-PT5M')} - {t("event.form.notifications.-PT10M")} + {t('event.form.notifications.-PT10M')} - {t("event.form.notifications.-PT15M")} + {t('event.form.notifications.-PT15M')} - {t("event.form.notifications.-PT30M")} + {t('event.form.notifications.-PT30M')} - {t("event.form.notifications.-PT1H")} + {t('event.form.notifications.-PT1H')} - {t("event.form.notifications.-PT2H")} + {t('event.form.notifications.-PT2H')} - {t("event.form.notifications.-PT5H")} + {t('event.form.notifications.-PT5H')} - {t("event.form.notifications.-PT12H")} + {t('event.form.notifications.-PT12H')} - {t("event.form.notifications.-PT1D")} + {t('event.form.notifications.-PT1D')} - {t("event.form.notifications.-PT2D")} + {t('event.form.notifications.-PT2D')} - {t("event.form.notifications.-PT1W")} + {t('event.form.notifications.-PT1W')} @@ -949,16 +942,14 @@ export default function EventFormFields({ value={busy} onChange={(e: SelectChangeEvent) => setBusy(e.target.value)} > - - {t("event.form.free")} - - {t("event.form.busy")} + {t('event.form.free')} + {t('event.form.busy')} { if (newValue !== null) { - setEventClass(newValue); + setEventClass(newValue) } }} size="medium" > - + - {t("event.form.visibleAll")} + {t('event.form.visibleAll')} - + - {t("event.form.visibleParticipants")} + {t('event.form.visibleParticipants')} @@ -986,8 +977,8 @@ export default function EventFormFields({ - ); + ) } diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx index d72b40e..153a0ac 100644 --- a/src/components/Event/EventRepeat.tsx +++ b/src/components/Event/EventRepeat.tsx @@ -1,4 +1,4 @@ -import { RepetitionObject } from "@/features/Events/EventsTypes"; +import { RepetitionObject } from '@/features/Events/EventsTypes' import { Box, FormControl, @@ -10,134 +10,134 @@ import { SelectChangeEvent, Stack, TextField, - Typography, -} from "@linagora/twake-mui"; -import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; -import { DatePicker } from "@mui/x-date-pickers/DatePicker"; -import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; -import dayjs from "dayjs"; -import "dayjs/locale/en"; -import "dayjs/locale/fr"; -import "dayjs/locale/ru"; -import "dayjs/locale/vi"; -import { useI18n } from "twake-i18n"; -import { ReadOnlyDateField } from "./components/ReadOnlyPickerField"; -import { LONG_DATE_FORMAT } from "./utils/dateTimeFormatters"; -import { FC_DAYS, WeekDaySelector } from "./WeekDaySelector"; + Typography +} from '@linagora/twake-mui' +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' +import { DatePicker } from '@mui/x-date-pickers/DatePicker' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import dayjs from 'dayjs' +import 'dayjs/locale/en' +import 'dayjs/locale/fr' +import 'dayjs/locale/ru' +import 'dayjs/locale/vi' +import { useI18n } from 'twake-i18n' +import { ReadOnlyDateField } from './components/ReadOnlyPickerField' +import { LONG_DATE_FORMAT } from './utils/dateTimeFormatters' +import { FC_DAYS, WeekDaySelector } from './WeekDaySelector' export default function RepeatEvent({ repetition, eventStart, setRepetition, - isOwn = true, + isOwn = true }: { - repetition: RepetitionObject; - eventStart: Date; - setRepetition: (repetition: RepetitionObject) => void; - isOwn?: boolean; + repetition: RepetitionObject + eventStart: Date + setRepetition: (repetition: RepetitionObject) => void + isOwn?: boolean }) { - const { t } = useI18n(); - const days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; - const day = new Date(eventStart); + const { t } = useI18n() + const days = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] + const day = new Date(eventStart) const dateCalendarLayoutSx = { - "& .MuiDateCalendar-root.MuiDateCalendar-root": { - width: "260px", - maxWidth: "260px", - padding: "0 15px", - }, - }; + '& .MuiDateCalendar-root.MuiDateCalendar-root': { + width: '260px', + maxWidth: '260px', + padding: '0 15px' + } + } // Fully derived — occurrences takes priority over endDate const getEndOption = () => { - if (repetition.occurrences) return "after"; - if (repetition.endDate) return "on"; - return "never"; - }; + if (repetition.occurrences) return 'after' + if (repetition.endDate) return 'on' + return 'never' + } - const endOption = getEndOption(); + const endOption = getEndOption() - const defaultEndDate = dayjs(eventStart).add(1, "day").format("YYYY-MM-DD"); + const defaultEndDate = dayjs(eventStart).add(1, 'day').format('YYYY-MM-DD') return ( {/* Interval */} - {t("event.repeat.every")} + {t('event.repeat.every')} + onChange={e => setRepetition({ ...repetition, - interval: Number(e.target.value), + interval: Number(e.target.value) }) } size="small" style={{ width: 80 }} inputProps={{ min: 1, - "data-testid": "repeat-interval", + 'data-testid': 'repeat-interval', style: { - textAlign: "center", - paddingRight: 5, - }, + textAlign: 'center', + paddingRight: 5 + } }} /> {/* Weekly selection */} - {repetition.freq === "weekly" && ( + {repetition.freq === 'weekly' && ( FC_DAYS.find((d) => d.ics === ics)?.fc ?? -1) - .filter((d) => d !== -1)} - onChange={(fcDays) => { + .map(ics => FC_DAYS.find(d => d.ics === ics)?.fc ?? -1) + .filter(d => d !== -1)} + onChange={fcDays => { const icsDays = fcDays - .map((fc) => FC_DAYS.find((d) => d.fc === fc)?.ics ?? "") - .filter(Boolean); + .map(fc => FC_DAYS.find(d => d.fc === fc)?.ics ?? '') + .filter(Boolean) setRepetition({ ...repetition, - byday: icsDays.length > 0 ? icsDays : null, - }); + byday: icsDays.length > 0 ? icsDays : null + }) }} disabled={!isOwn} /> @@ -147,37 +147,37 @@ export default function RepeatEvent({ {/* End options */} - {t("event.repeat.end.label")} + {t('event.repeat.end.label')} { - const value = e.target.value; - if (value === endOption) return; + onChange={e => { + const value = e.target.value + if (value === endOption) return - if (value === "never") { + if (value === 'never') { setRepetition({ ...repetition, occurrences: null, - endDate: null, - }); + endDate: null + }) } - if (value === "after") { + if (value === 'after') { setRepetition({ ...repetition, occurrences: repetition.occurrences && repetition.occurrences > 0 ? repetition.occurrences : 1, - endDate: null, - }); + endDate: null + }) } - if (value === "on") { + if (value === 'on') { setRepetition({ ...repetition, occurrences: null, - endDate: repetition.endDate || defaultEndDate, - }); + endDate: repetition.endDate || defaultEndDate + }) } }} > @@ -187,7 +187,7 @@ export default function RepeatEvent({ control={} label={ - {t("event.repeat.end.never")} + {t('event.repeat.end.never')} } /> @@ -200,21 +200,21 @@ export default function RepeatEvent({ label={ - {t("event.repeat.end.on")} + {t('event.repeat.end.on')} { - if (!value || !value.isValid()) return; - const newDateStr = value.format("YYYY-MM-DD"); + onChange={value => { + if (!value || !value.isValid()) return + const newDateStr = value.format('YYYY-MM-DD') setRepetition({ ...repetition, occurrences: null, - endDate: newDateStr, - }); + endDate: newDateStr + }) }} onOpen={() => { - if (!isOwn || endOption === "on") return; + if (!isOwn || endOption === 'on') return setRepetition({ ...repetition, occurrences: null, - endDate: repetition.endDate || defaultEndDate, - }); + endDate: repetition.endDate || defaultEndDate + }) }} slots={{ field: ReadOnlyDateField }} slotProps={{ field: {}, - layout: { sx: dateCalendarLayoutSx }, + layout: { sx: dateCalendarLayoutSx } }} disabled={!isOwn} /> @@ -263,38 +263,38 @@ export default function RepeatEvent({ alignItems="center" gap={1} onClick={() => { - if (!isOwn || endOption === "after") return; + if (!isOwn || endOption === 'after') return setRepetition({ ...repetition, endDate: null, occurrences: repetition.occurrences && repetition.occurrences > 0 ? repetition.occurrences - : 1, - }); + : 1 + }) }} > - {t("event.repeat.end.after")} + {t('event.repeat.end.after')} { - const value = Number(e.target.value); + onChange={e => { + const value = Number(e.target.value) setRepetition({ ...repetition, endDate: null, - occurrences: value > 0 ? value : 1, - }); + occurrences: value > 0 ? value : 1 + }) }} style={{ width: 100 }} disabled={!isOwn} /> - {t("event.repeat.end.occurrences")} + {t('event.repeat.end.occurrences')} } @@ -303,5 +303,5 @@ export default function RepeatEvent({ - ); + ) } diff --git a/src/components/Event/InfoRow.tsx b/src/components/Event/InfoRow.tsx index ebac6b6..7985b18 100644 --- a/src/components/Event/InfoRow.tsx +++ b/src/components/Event/InfoRow.tsx @@ -1,23 +1,23 @@ -import { Box, Link, Typography } from "@linagora/twake-mui"; -import React from "react"; +import { Box, Link, Typography } from '@linagora/twake-mui' +import React from 'react' type InfoRowProps = { - icon: React.ReactNode; - text?: string; - error?: boolean; - data?: string; // optional link target - content?: React.ReactNode; // if provided, overrides text rendering - style?: React.CSSProperties; - alignItems?: React.CSSProperties["alignItems"]; - flexWrap?: React.CSSProperties["flexWrap"]; -}; + icon: React.ReactNode + text?: string + error?: boolean + data?: string // optional link target + content?: React.ReactNode // if provided, overrides text rendering + style?: React.CSSProperties + alignItems?: React.CSSProperties['alignItems'] + flexWrap?: React.CSSProperties['flexWrap'] +} function detectUrls(text: string) { // Simple regex that captures whole URLs without splitting them apart - const urlRegex = /(https?:\/\/[^\s]+|www\.[^\s]+)/gi; + const urlRegex = /(https?:\/\/[^\s]+|www\.[^\s]+)/gi - const parts = []; - let lastIndex = 0; + const parts = [] + let lastIndex = 0 text.replace(urlRegex, (match, _, offset) => { // Push the text before the match @@ -26,11 +26,11 @@ function detectUrls(text: string) { {text.slice(lastIndex, offset)} - ); + ) } // Normalize href - const href = match.startsWith("http") ? match : `https://${match}`; + const href = match.startsWith('http') ? match : `https://${match}` parts.push( {match} - ); + ) - lastIndex = offset + match.length; - return match; - }); + lastIndex = offset + match.length + return match + }) // Push remaining text after last URL if (lastIndex < text.length) { parts.push( {text.slice(lastIndex)} - ); + ) } - return parts; + return parts } export function InfoRow({ @@ -64,17 +64,17 @@ export function InfoRow({ data, content, style, - alignItems = "center", - flexWrap = "nowrap", + alignItems = 'center', + flexWrap = 'nowrap' }: InfoRowProps) { return ( {icon} @@ -83,14 +83,14 @@ export function InfoRow({ ) : ( {data ? ( @@ -108,5 +108,5 @@ export function InfoRow({ )} - ); + ) } diff --git a/src/components/Event/WeekDaySelector.tsx b/src/components/Event/WeekDaySelector.tsx index 66714c2..31bc1f7 100644 --- a/src/components/Event/WeekDaySelector.tsx +++ b/src/components/Event/WeekDaySelector.tsx @@ -1,55 +1,55 @@ -import { Box } from "@linagora/twake-mui"; -import { useI18n } from "twake-i18n"; +import { Box } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' interface WeekDaySelectorProps { - selectedDays: number[]; // FullCalendar format: 0=Sun, 1=Mon... - onChange: (days: number[]) => void; - disabled?: boolean; + selectedDays: number[] // FullCalendar format: 0=Sun, 1=Mon... + onChange: (days: number[]) => void + disabled?: boolean } export const FC_DAYS = [ - { fc: 1, ics: "MO" }, - { fc: 2, ics: "TU" }, - { fc: 3, ics: "WE" }, - { fc: 4, ics: "TH" }, - { fc: 5, ics: "FR" }, - { fc: 6, ics: "SA" }, - { fc: 0, ics: "SU" }, -]; + { fc: 1, ics: 'MO' }, + { fc: 2, ics: 'TU' }, + { fc: 3, ics: 'WE' }, + { fc: 4, ics: 'TH' }, + { fc: 5, ics: 'FR' }, + { fc: 6, ics: 'SA' }, + { fc: 0, ics: 'SU' } +] export function WeekDaySelector({ selectedDays, onChange, - disabled, + disabled }: WeekDaySelectorProps) { - const { t } = useI18n(); + const { t } = useI18n() const getDayLabel = (ics: string) => { const dayMap: Record = { - MO: t("event.repeat.days.monday"), - TU: t("event.repeat.days.tuesday"), - WE: t("event.repeat.days.wednesday"), - TH: t("event.repeat.days.thursday"), - FR: t("event.repeat.days.friday"), - SA: t("event.repeat.days.saturday"), - SU: t("event.repeat.days.sunday"), - }; - return dayMap[ics] || ics; - }; + MO: t('event.repeat.days.monday'), + TU: t('event.repeat.days.tuesday'), + WE: t('event.repeat.days.wednesday'), + TH: t('event.repeat.days.thursday'), + FR: t('event.repeat.days.friday'), + SA: t('event.repeat.days.saturday'), + SU: t('event.repeat.days.sunday') + } + return dayMap[ics] || ics + } const handleToggle = (fcDay: number) => { - if (disabled) return; + if (disabled) return const updated = selectedDays.includes(fcDay) - ? selectedDays.filter((d) => d !== fcDay) - : [...selectedDays, fcDay]; - onChange(updated); - }; + ? selectedDays.filter(d => d !== fcDay) + : [...selectedDays, fcDay] + onChange(updated) + } return ( {FC_DAYS.map(({ fc, ics }) => { - const isSelected = selectedDays.includes(fc); - const fullLabel = getDayLabel(ics); + const isSelected = selectedDays.includes(fc) + const fullLabel = getDayLabel(ics) return ( {fullLabel.charAt(0)} - ); + ) })} - ); + ) } diff --git a/src/components/Event/components/ClickableField.tsx b/src/components/Event/components/ClickableField.tsx index bd70cdb..148f57a 100644 --- a/src/components/Event/components/ClickableField.tsx +++ b/src/components/Event/components/ClickableField.tsx @@ -1,14 +1,14 @@ -import { Box, Typography, useTheme } from "@linagora/twake-mui"; -import { alpha } from "@mui/material/styles"; -import React from "react"; +import { Box, Typography, useTheme } from '@linagora/twake-mui' +import { alpha } from '@mui/material/styles' +import React from 'react' interface ClickableFieldProps { - icon: React.ReactNode; - text?: string; - onClick: () => void; - iconColor?: string; - children?: React.ReactNode; - ariaLabel?: string; + icon: React.ReactNode + text?: string + onClick: () => void + iconColor?: string + children?: React.ReactNode + ariaLabel?: string } export const ClickableField: React.FC = ({ @@ -17,9 +17,9 @@ export const ClickableField: React.FC = ({ onClick, iconColor, children, - ariaLabel, + ariaLabel }) => { - const theme = useTheme(); + const theme = useTheme() return ( = ({ role="button" tabIndex={0} aria-label={ariaLabel ?? text} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick(); + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() } }} sx={{ - display: "flex", - alignItems: children ? "flex-start" : "center", - cursor: "pointer", - padding: "8px 12px", - borderRadius: "4px", - "&:hover": { - backgroundColor: "action.hover", + display: 'flex', + alignItems: children ? 'flex-start' : 'center', + cursor: 'pointer', + padding: '8px 12px', + borderRadius: '4px', + '&:hover': { + backgroundColor: 'action.hover' }, - "&:focus-visible": { + '&:focus-visible': { outline: `2px solid ${theme.palette.primary.main}`, - outlineOffset: "2px", - }, + outlineOffset: '2px' + } }} > {icon} @@ -74,14 +74,14 @@ export const ClickableField: React.FC = ({ ) : ( {text} )} - ); -}; + ) +} diff --git a/src/components/Event/components/DateTimeFields.tsx b/src/components/Event/components/DateTimeFields.tsx index c0e5b6a..071d3b1 100644 --- a/src/components/Event/components/DateTimeFields.tsx +++ b/src/components/Event/components/DateTimeFields.tsx @@ -1,73 +1,73 @@ -import { Box, TextFieldProps, Typography } from "@linagora/twake-mui"; -import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { Box, TextFieldProps, Typography } from '@linagora/twake-mui' +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' import { DatePicker, - DatePickerFieldProps, -} from "@mui/x-date-pickers/DatePicker"; -import { PickerValue } from "@mui/x-date-pickers/internals"; -import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; + DatePickerFieldProps +} from '@mui/x-date-pickers/DatePicker' +import { PickerValue } from '@mui/x-date-pickers/internals' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' import { TimePicker, - TimePickerFieldProps, -} from "@mui/x-date-pickers/TimePicker"; -import dayjs, { Dayjs } from "dayjs"; -import "dayjs/locale/en"; -import "dayjs/locale/fr"; -import "dayjs/locale/ru"; -import "dayjs/locale/vi"; -import customParseFormat from "dayjs/plugin/customParseFormat"; -import React, { useMemo } from "react"; -import { useI18n } from "twake-i18n"; -import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters"; -import { dtDate, dtTime, toDateTime } from "../utils/dateTimeHelpers"; -import { EditableTimeField } from "./EditableTimeField"; -import { ReadOnlyDateField } from "./ReadOnlyPickerField"; + TimePickerFieldProps +} from '@mui/x-date-pickers/TimePicker' +import dayjs, { Dayjs } from 'dayjs' +import 'dayjs/locale/en' +import 'dayjs/locale/fr' +import 'dayjs/locale/ru' +import 'dayjs/locale/vi' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import React, { useMemo } from 'react' +import { useI18n } from 'twake-i18n' +import { LONG_DATE_FORMAT } from '../utils/dateTimeFormatters' +import { dtDate, dtTime, toDateTime } from '../utils/dateTimeHelpers' +import { EditableTimeField } from './EditableTimeField' +import { ReadOnlyDateField } from './ReadOnlyPickerField' -dayjs.extend(customParseFormat); +dayjs.extend(customParseFormat) const timePickerPopperSx = { - "& .MuiPaper-root": { - width: "110px", - minWidth: "110px", + '& .MuiPaper-root': { + width: '110px', + minWidth: '110px' }, - "& .MuiMultiSectionDigitalClockSection-item": { - justifyContent: "flex-start", - width: "100%", - textAlign: "left", - }, -}; + '& .MuiMultiSectionDigitalClockSection-item': { + justifyContent: 'flex-start', + width: '100%', + textAlign: 'left' + } +} // twake-mui datePickerOverrides also uses this selector. Repeating ensures our override wins. const dateCalendarLayoutSx = { - "& .MuiDateCalendar-root.MuiDateCalendar-root": { - width: "260px", - maxWidth: "260px", - padding: "0 15px", - }, -}; + '& .MuiDateCalendar-root.MuiDateCalendar-root': { + width: '260px', + maxWidth: '260px', + padding: '0 15px' + } +} /** * Props for DateTimeFields component */ export interface DateTimeFieldsProps { - startDate: string; - startTime: string; - endDate: string; - endTime: string; - allday: boolean; - showMore: boolean; - hasEndDateChanged: boolean; - showEndDate: boolean; - onToggleEndDate: () => void; + startDate: string + startTime: string + endDate: string + endTime: string + allday: boolean + showMore: boolean + hasEndDateChanged: boolean + showEndDate: boolean + onToggleEndDate: () => void validation: { errors: { - dateTime: string; - }; - }; - onStartDateChange: (date: string) => void; - onStartTimeChange: (time: string) => void; - onEndDateChange: (date: string, time?: string) => void; - onEndTimeChange: (time: string) => void; + dateTime: string + } + } + onStartDateChange: (date: string) => void + onStartTimeChange: (time: string) => void + onEndDateChange: (date: string, time?: string) => void + onEndTimeChange: (time: string) => void } /** @@ -87,39 +87,39 @@ export const DateTimeFields: React.FC = ({ onStartDateChange, onStartTimeChange, onEndDateChange, - onEndTimeChange, + onEndTimeChange }) => { - const { t, lang } = useI18n(); + const { t, lang } = useI18n() - const initialDurationRef = React.useRef(null); - const isUserActionRef = React.useRef(false); + const initialDurationRef = React.useRef(null) + const isUserActionRef = React.useRef(false) const getCurrentDuration = React.useCallback((): number => { - const start = toDateTime(startDate, startTime); - const end = toDateTime(endDate, endTime); + const start = toDateTime(startDate, startTime) + const end = toDateTime(endDate, endTime) if (allday) { - return Math.max(end.startOf("day").diff(start.startOf("day"), "day"), 0); + return Math.max(end.startOf('day').diff(start.startOf('day'), 'day'), 0) } else { - return Math.max(end.diff(start, "minute"), 0); + return Math.max(end.diff(start, 'minute'), 0) } - }, [startDate, startTime, endDate, endTime, allday]); + }, [startDate, startTime, endDate, endTime, allday]) React.useEffect(() => { if (!isUserActionRef.current && startDate && endDate) { - initialDurationRef.current = getCurrentDuration(); + initialDurationRef.current = getCurrentDuration() } queueMicrotask(() => { - isUserActionRef.current = false; - }); - }, [startDate, endDate, startTime, endTime, getCurrentDuration]); + isUserActionRef.current = false + }) + }, [startDate, endDate, startTime, endTime, getCurrentDuration]) const spansMultipleDays = React.useMemo(() => { - return startDate !== endDate; - }, [startDate, endDate]); + return startDate !== endDate + }, [startDate, endDate]) - const isExpanded = showMore; - const shouldShowEndDateNormal = allday || showEndDate; + const isExpanded = showMore + const shouldShowEndDateNormal = allday || showEndDate // Show full 4 fields when: // 1. Non-allday with hasEndDateChanged // 2. Multiple days with hasEndDateChanged (supports drag from week/month view grid with allday checked) @@ -127,144 +127,144 @@ export const DateTimeFields: React.FC = ({ const shouldShowFullFieldsInNormal = (!allday && hasEndDateChanged) || (hasEndDateChanged && spansMultipleDays) || - (spansMultipleDays && !allday); + (spansMultipleDays && !allday) const showSingleDateField = - !isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal; + !isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal // When shouldShowFullFieldsInNormal is true, show time fields even if allday is true // This supports the case: drag from week/month view grid with multiple days - const shouldShowTimeFields = !allday || shouldShowFullFieldsInNormal; + const shouldShowTimeFields = !allday || shouldShowFullFieldsInNormal const startDateLabel = showSingleDateField - ? t("dateTimeFields.date") - : t("dateTimeFields.startDate"); + ? t('dateTimeFields.date') + : t('dateTimeFields.startDate') const handleStartDateChange = (value: PickerValue) => { - if (!value || !value.isValid()) return; + if (!value || !value.isValid()) return - isUserActionRef.current = true; - const newDateStr = dtDate(value as Dayjs); - const duration = initialDurationRef.current ?? getCurrentDuration(); + isUserActionRef.current = true + const newDateStr = dtDate(value as Dayjs) + const duration = initialDurationRef.current ?? getCurrentDuration() - onStartDateChange(newDateStr); + onStartDateChange(newDateStr) // Preserve duration by adjusting end - const newStart = toDateTime(newDateStr, startTime); - let newEnd: Dayjs; + const newStart = toDateTime(newDateStr, startTime) + let newEnd: Dayjs if (allday) { - newEnd = newStart.add(duration, "day"); + newEnd = newStart.add(duration, 'day') } else { - newEnd = newStart.add(duration, "minute"); + newEnd = newStart.add(duration, 'minute') } - const newEndDate = dtDate(newEnd); - const newEndTime = dtTime(newEnd); + const newEndDate = dtDate(newEnd) + const newEndTime = dtTime(newEnd) if (newEndDate !== endDate) { - onEndDateChange(newEndDate); + onEndDateChange(newEndDate) } if (!allday && newEndTime !== endTime) { - onEndTimeChange(newEndTime); + onEndTimeChange(newEndTime) } - }; + } const handleStartTimeChange = (value: PickerValue) => { - if (!value || !value.isValid()) return; + if (!value || !value.isValid()) return - isUserActionRef.current = true; - const newTimeStr = dtTime(value as Dayjs); - const duration = initialDurationRef.current ?? getCurrentDuration(); + isUserActionRef.current = true + const newTimeStr = dtTime(value as Dayjs) + const duration = initialDurationRef.current ?? getCurrentDuration() - onStartTimeChange(newTimeStr); + onStartTimeChange(newTimeStr) - const newStart = toDateTime(startDate, newTimeStr); - const newEnd = newStart.add(duration, "minute"); + const newStart = toDateTime(startDate, newTimeStr) + const newEnd = newStart.add(duration, 'minute') - const newEndDate = dtDate(newEnd); - const newEndTime = dtTime(newEnd); + const newEndDate = dtDate(newEnd) + const newEndTime = dtTime(newEnd) if (newEndDate !== endDate && newEndTime !== endTime) { - onEndDateChange(newEndDate, newEndTime); + onEndDateChange(newEndDate, newEndTime) } else { if (newEndDate !== endDate) { - onEndDateChange(newEndDate); + onEndDateChange(newEndDate) } if (!allday && newEndTime !== endTime) { - onEndTimeChange(newEndTime); + onEndTimeChange(newEndTime) } } - }; + } const handleEndDateChange = (value: PickerValue) => { - if (!value || !value.isValid()) return; + if (!value || !value.isValid()) return - isUserActionRef.current = true; - const newDateStr = dtDate(value as Dayjs); - const newEnd = toDateTime(newDateStr, endTime); - const currentStart = toDateTime(startDate, startTime); + isUserActionRef.current = true + const newDateStr = dtDate(value as Dayjs) + const newEnd = toDateTime(newDateStr, endTime) + const currentStart = toDateTime(startDate, startTime) if (newEnd.isBefore(currentStart)) { - const duration = initialDurationRef.current ?? getCurrentDuration(); - let newStart: Dayjs; + const duration = initialDurationRef.current ?? getCurrentDuration() + let newStart: Dayjs if (allday) { - newStart = newEnd.subtract(duration, "day"); + newStart = newEnd.subtract(duration, 'day') } else { - newStart = newEnd.subtract(duration, "minute"); + newStart = newEnd.subtract(duration, 'minute') } - const newStartDate = dtDate(newStart); - const newStartTime = dtTime(newStart); + const newStartDate = dtDate(newStart) + const newStartTime = dtTime(newStart) if (newStartDate !== startDate) { - onStartDateChange(newStartDate); + onStartDateChange(newStartDate) } if (!allday && newStartTime !== startTime) { - onStartTimeChange(newStartTime); + onStartTimeChange(newStartTime) } } else { initialDurationRef.current = newEnd.diff( currentStart, - allday ? "day" : "minute" - ); + allday ? 'day' : 'minute' + ) } - onEndDateChange(newDateStr); - }; + onEndDateChange(newDateStr) + } const handleEndTimeChange = (value: PickerValue) => { - if (!value || !value.isValid()) return; + if (!value || !value.isValid()) return - isUserActionRef.current = true; - const newTimeStr = dtTime(value as Dayjs); - const newEnd = toDateTime(endDate, newTimeStr); - const currentStart = toDateTime(startDate, startTime); + isUserActionRef.current = true + const newTimeStr = dtTime(value as Dayjs) + const newEnd = toDateTime(endDate, newTimeStr) + const currentStart = toDateTime(startDate, startTime) // Always update duration ref to reflect current state, even if invalid (end < start) // This prevents stale duration from being used when user edits start time - initialDurationRef.current = newEnd.diff(currentStart, "minute"); + initialDurationRef.current = newEnd.diff(currentStart, 'minute') - onEndTimeChange(newTimeStr); - }; + onEndTimeChange(newTimeStr) + } // Memoize parsed date/time values const startDateValue = useMemo( () => (startDate ? dayjs(startDate) : null), [startDate] - ); + ) const startTimeValue = useMemo( - () => (startTime ? dayjs(startTime, "HH:mm") : null), + () => (startTime ? dayjs(startTime, 'HH:mm') : null), [startTime] - ); + ) const endDateValue = useMemo( () => (endDate ? dayjs(endDate) : null), [endDate] - ); + ) const endTimeValue = useMemo( - () => (endTime ? dayjs(endTime, "HH:mm") : null), + () => (endTime ? dayjs(endTime, 'HH:mm') : null), [endTime] - ); + ) const getSlotProps = ( testId: string, @@ -272,18 +272,18 @@ export const DateTimeFields: React.FC = ({ testLabel?: string ) => ({ textField: { - size: "small" as const, - margin: "dense" as const, + size: 'small' as const, + margin: 'dense' as const, fullWidth: true, InputLabelProps: { shrink: true }, error: hasError, - sx: { width: "100%" }, + sx: { width: '100%' }, inputProps: { - "data-testid": testId, - ...(testLabel ? { "aria-label": testLabel } : {}), - }, - }, - }); + 'data-testid': testId, + ...(testLabel ? { 'aria-label': testLabel } : {}) + } + } + }) const getFieldSlotProps = ( testId: string, @@ -292,25 +292,25 @@ export const DateTimeFields: React.FC = ({ ): Partial & Pick< TextFieldProps, - | "size" - | "margin" - | "fullWidth" - | "InputLabelProps" - | "error" - | "sx" - | "inputProps" + | 'size' + | 'margin' + | 'fullWidth' + | 'InputLabelProps' + | 'error' + | 'sx' + | 'inputProps' > => ({ - size: "small" as const, - margin: "dense" as const, + size: 'small' as const, + margin: 'dense' as const, fullWidth: true, InputLabelProps: { shrink: true }, error: hasError, - sx: { width: "100%" }, + sx: { width: '100%' }, inputProps: { - "data-testid": testId, - ...(testLabel ? { "aria-label": testLabel } : {}), - }, - }); + 'data-testid': testId, + ...(testLabel ? { 'aria-label': testLabel } : {}) + } + }) const getTimeFieldSlotProps = ( testId: string, @@ -319,49 +319,49 @@ export const DateTimeFields: React.FC = ({ ): Partial & Pick< TextFieldProps, - | "size" - | "margin" - | "fullWidth" - | "InputLabelProps" - | "error" - | "sx" - | "inputProps" + | 'size' + | 'margin' + | 'fullWidth' + | 'InputLabelProps' + | 'error' + | 'sx' + | 'inputProps' > => ({ - size: "small" as const, - margin: "dense" as const, + size: 'small' as const, + margin: 'dense' as const, fullWidth: true, InputLabelProps: { shrink: true }, error: hasError, - sx: { width: "100%" }, + sx: { width: '100%' }, inputProps: { - "data-testid": testId, - ...(testLabel ? { "aria-label": testLabel } : {}), - }, - }); + 'data-testid': testId, + ...(testLabel ? { 'aria-label': testLabel } : {}) + } + }) return ( {isExpanded || shouldShowFullFieldsInNormal ? ( <> - + = ({ slots={{ field: ReadOnlyDateField }} slotProps={{ ...getSlotProps( - "start-date-input", + 'start-date-input', false, - t("dateTimeFields.startDate") + t('dateTimeFields.startDate') ), field: getFieldSlotProps( - "start-date-input", + 'start-date-input', false, - t("dateTimeFields.startDate") + t('dateTimeFields.startDate') ), - layout: { sx: dateCalendarLayoutSx }, + layout: { sx: dateCalendarLayoutSx } }} /> {shouldShowTimeFields && ( - + = ({ timeSteps={{ minutes: 30 }} slots={{ field: EditableTimeField, - actionBar: () => null, + actionBar: () => null }} slotProps={{ - openPickerButton: { sx: { display: "none" } }, + openPickerButton: { sx: { display: 'none' } }, popper: { sx: timePickerPopperSx }, field: getTimeFieldSlotProps( - "start-time-input", + 'start-time-input', false, - t("dateTimeFields.startTime") - ), + t('dateTimeFields.startTime') + ) }} /> )} - + = ({ slots={{ field: ReadOnlyDateField }} slotProps={{ ...getSlotProps( - "end-date-input", + 'end-date-input', !!validation.errors.dateTime, - t("dateTimeFields.endDate") + t('dateTimeFields.endDate') ), field: getFieldSlotProps( - "end-date-input", + 'end-date-input', !!validation.errors.dateTime, - t("dateTimeFields.endDate") + t('dateTimeFields.endDate') ), - layout: { sx: dateCalendarLayoutSx }, + layout: { sx: dateCalendarLayoutSx } }} /> {shouldShowTimeFields && ( - + = ({ timeSteps={{ minutes: 30 }} slots={{ field: EditableTimeField, - actionBar: () => null, + actionBar: () => null }} slotProps={{ - openPickerButton: { sx: { display: "none" } }, + openPickerButton: { sx: { display: 'none' } }, popper: { sx: timePickerPopperSx }, field: getTimeFieldSlotProps( - "end-time-input", + 'end-time-input', !!validation.errors.dateTime, - t("dateTimeFields.endTime") - ), + t('dateTimeFields.endTime') + ) }} /> @@ -457,7 +457,7 @@ export const DateTimeFields: React.FC = ({ ) : shouldShowEndDateNormal ? ( - + = ({ slots={{ field: ReadOnlyDateField }} slotProps={{ ...getSlotProps( - "start-date-input", + 'start-date-input', false, - t("dateTimeFields.startDate") + t('dateTimeFields.startDate') ), field: getFieldSlotProps( - "start-date-input", + 'start-date-input', false, - t("dateTimeFields.startDate") + t('dateTimeFields.startDate') ), - layout: { sx: dateCalendarLayoutSx }, + layout: { sx: dateCalendarLayoutSx } }} /> - + = ({ slots={{ field: ReadOnlyDateField }} slotProps={{ ...getSlotProps( - "end-date-input", + 'end-date-input', !!validation.errors.dateTime, - t("dateTimeFields.endDate") + t('dateTimeFields.endDate') ), field: getFieldSlotProps( - "end-date-input", + 'end-date-input', !!validation.errors.dateTime, - t("dateTimeFields.endDate") + t('dateTimeFields.endDate') ), - layout: { sx: dateCalendarLayoutSx }, + layout: { sx: dateCalendarLayoutSx } }} /> ) : ( - + - + = ({ timeSteps={{ minutes: 30 }} slots={{ field: EditableTimeField, - actionBar: () => null, + actionBar: () => null }} slotProps={{ - openPickerButton: { sx: { display: "none" } }, + openPickerButton: { sx: { display: 'none' } }, popper: { sx: timePickerPopperSx }, field: getTimeFieldSlotProps( - "start-time-input", + 'start-time-input', false, - t("dateTimeFields.startTime") - ), + t('dateTimeFields.startTime') + ) }} /> {!allday && ( - )} - + = ({ timeSteps={{ minutes: 30 }} slots={{ field: EditableTimeField, - actionBar: () => null, + actionBar: () => null }} slotProps={{ - openPickerButton: { sx: { display: "none" } }, + openPickerButton: { sx: { display: 'none' } }, popper: { sx: timePickerPopperSx }, field: getTimeFieldSlotProps( - "end-time-input", + 'end-time-input', !!validation.errors.dateTime, - t("dateTimeFields.endTime") - ), + t('dateTimeFields.endTime') + ) }} /> @@ -584,11 +584,11 @@ export const DateTimeFields: React.FC = ({ - + {validation.errors.dateTime} @@ -596,5 +596,5 @@ export const DateTimeFields: React.FC = ({ )} - ); -}; + ) +} diff --git a/src/components/Event/components/DateTimeSummary.tsx b/src/components/Event/components/DateTimeSummary.tsx index 69b3b80..db9644e 100644 --- a/src/components/Event/components/DateTimeSummary.tsx +++ b/src/components/Event/components/DateTimeSummary.tsx @@ -1,29 +1,29 @@ -import { Box, Typography, useTheme } from "@linagora/twake-mui"; -import { alpha } from "@mui/material/styles"; -import AccessTimeIcon from "@mui/icons-material/AccessTime"; -import dayjs from "dayjs"; -import "dayjs/locale/en"; -import "dayjs/locale/fr"; -import "dayjs/locale/ru"; -import "dayjs/locale/vi"; -import React from "react"; -import { useI18n } from "twake-i18n"; -import { getTimezoneOffset } from "@/utils/timezone"; -import { RepetitionObject } from "@/features/Events/EventsTypes"; -import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters"; -import { SectionPreviewRow } from "./SectionPreviewRow"; -import { makeRecurrenceString } from "@/features/Events/EventPreview/utils/makeRecurrenceString"; +import { Box, Typography, useTheme } from '@linagora/twake-mui' +import { alpha } from '@mui/material/styles' +import AccessTimeIcon from '@mui/icons-material/AccessTime' +import dayjs from 'dayjs' +import 'dayjs/locale/en' +import 'dayjs/locale/fr' +import 'dayjs/locale/ru' +import 'dayjs/locale/vi' +import React from 'react' +import { useI18n } from 'twake-i18n' +import { getTimezoneOffset } from '@/utils/timezone' +import { RepetitionObject } from '@/features/Events/EventsTypes' +import { LONG_DATE_FORMAT } from '../utils/dateTimeFormatters' +import { SectionPreviewRow } from './SectionPreviewRow' +import { makeRecurrenceString } from '@/features/Events/EventPreview/utils/makeRecurrenceString' interface DateTimeSummaryProps { - startDate: string; - startTime: string; - endDate: string; - endTime: string; - allday: boolean; - timezone: string; - repetition: RepetitionObject; - showEndDate: boolean; - onClick: () => void; + startDate: string + startTime: string + endDate: string + endTime: string + allday: boolean + timezone: string + repetition: RepetitionObject + showEndDate: boolean + onClick: () => void } export const DateTimeSummary: React.FC = ({ @@ -35,111 +35,110 @@ export const DateTimeSummary: React.FC = ({ timezone, repetition, showEndDate, - onClick, + onClick }) => { - const { t, lang } = useI18n(); - const theme = useTheme(); + const { t, lang } = useI18n() + const theme = useTheme() // Format date with current locale. VI: "Thứ 4, 4 Tháng 2, 2026"; FR: "mercredi, 5 février 2026"; RU: first letter capitalized const formatDate = (dateStr: string): string => { - if (!dateStr) return ""; - const date = dayjs(dateStr); - const locale = - lang && ["en", "vi", "fr", "ru"].includes(lang) ? lang : "en"; + if (!dateStr) return '' + const date = dayjs(dateStr) + const locale = lang && ['en', 'vi', 'fr', 'ru'].includes(lang) ? lang : 'en' - if (locale === "vi") { - const dow = date.day(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday - const weekdayLabel = dow === 0 ? "Chủ nhật" : `Thứ ${dow + 1}`; // Mon=Thứ 2, Wed=Thứ 4, ... - const day = date.date(); - const month = date.month() + 1; - const year = date.year(); - return `${weekdayLabel}, ${day} Tháng ${month}, ${year}`; + if (locale === 'vi') { + const dow = date.day() // 0 = Sunday, 1 = Monday, ..., 6 = Saturday + const weekdayLabel = dow === 0 ? 'Chủ nhật' : `Thứ ${dow + 1}` // Mon=Thứ 2, Wed=Thứ 4, ... + const day = date.date() + const month = date.month() + 1 + const year = date.year() + return `${weekdayLabel}, ${day} Tháng ${month}, ${year}` } // French: "5 février" (day before month), not "février 5" - if (locale === "fr") { - const formatted = date.locale("fr").format("dddd, D MMMM YYYY"); - return formatted.charAt(0).toUpperCase() + formatted.slice(1); + if (locale === 'fr') { + const formatted = date.locale('fr').format('dddd, D MMMM YYYY') + return formatted.charAt(0).toUpperCase() + formatted.slice(1) } - const formatted = date.locale(locale).format(LONG_DATE_FORMAT); - if (locale === "ru") { - return formatted.charAt(0).toUpperCase() + formatted.slice(1); + const formatted = date.locale(locale).format(LONG_DATE_FORMAT) + if (locale === 'ru') { + return formatted.charAt(0).toUpperCase() + formatted.slice(1) } - return formatted; - }; + return formatted + } // Format time in 24h: "03:30 - 16:30" const formatTime = (startTimeStr: string, endTimeStr: string): string => { - if (allday || !startTimeStr || !endTimeStr) return ""; + if (allday || !startTimeStr || !endTimeStr) return '' const toHHmm = (timeStr: string): string => { - const [h, m] = timeStr.split(":").map((s) => parseInt(s, 10) || 0); - return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; - }; + const [h, m] = timeStr.split(':').map(s => parseInt(s, 10) || 0) + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` + } - return `${toHHmm(startTimeStr)} - ${toHHmm(endTimeStr)}`; - }; + return `${toHHmm(startTimeStr)} - ${toHHmm(endTimeStr)}` + } // Format timezone: "(UTC+2) Paris". Use event date for offset (DST correctness). const formatTimezone = (tz: string, dateStr?: string): string => { - if (!tz) return ""; + if (!tz) return '' try { - const dateForOffset = dateStr ? dayjs(dateStr).toDate() : new Date(); - const offset = getTimezoneOffset(tz, dateForOffset); - const tzName = tz.replace(/_/g, " "); - return `(${offset}) ${tzName}`; + const dateForOffset = dateStr ? dayjs(dateStr).toDate() : new Date() + const offset = getTimezoneOffset(tz, dateForOffset) + const tzName = tz.replace(/_/g, ' ') + return `(${offset}) ${tzName}` } catch { - return tz.replace(/_/g, " "); + return tz.replace(/_/g, ' ') } - }; + } // Format repeat: "Doesn't repeat" or repeat info const formatRepeat = (rep: RepetitionObject): string => { if (!rep || !rep.freq) { - return t("event.repeat.doesNotRepeat"); + return t('event.repeat.doesNotRepeat') } return ( makeRecurrenceString({ repetition: rep, t, - startText: rep.interval === 1 ? t("event.repeat.every") : "", - joinChar: "", - enableStrForOneTimeInterval: true, - }) || "" - ); - }; + startText: rep.interval === 1 ? t('event.repeat.every') : '', + joinChar: '', + enableStrForOneTimeInterval: true + }) || '' + ) + } // Format date text: show both start and end date if showEndDate is true const formatDateText = (): string => { if (showEndDate && endDate && endDate !== startDate) { - const startDateText = formatDate(startDate); - const endDateText = formatDate(endDate); - return `${startDateText} - ${endDateText}`; + const startDateText = formatDate(startDate) + const endDateText = formatDate(endDate) + return `${startDateText} - ${endDateText}` } - return formatDate(startDate); - }; + return formatDate(startDate) + } - const dateText = formatDateText(); - const timeText = formatTime(startTime, endTime); - const timezoneText = formatTimezone(timezone, startDate); - const repeatText = formatRepeat(repetition); + const dateText = formatDateText() + const timeText = formatTime(startTime, endTime) + const timezoneText = formatTimezone(timezone, startDate) + const repeatText = formatRepeat(repetition) // Don't render if no date if (!startDate) { - return null; + return null } const primaryStyle = { - fontSize: "14px", + fontSize: '14px', fontWeight: 500, - color: alpha(theme.palette.grey[900], 0.9), - }; + color: alpha(theme.palette.grey[900], 0.9) + } return ( } + icon={} onClick={onClick} > @@ -153,14 +152,14 @@ export const DateTimeSummary: React.FC = ({ )} - + {timezoneText} - + {repeatText} - ); -}; + ) +} diff --git a/src/components/Event/components/EditableTimeField.tsx b/src/components/Event/components/EditableTimeField.tsx index 92339d1..764bd3e 100644 --- a/src/components/Event/components/EditableTimeField.tsx +++ b/src/components/Event/components/EditableTimeField.tsx @@ -1,274 +1,294 @@ -import { TextField } from "@linagora/twake-mui"; +// EditableTimeField + +import { TextField } from '@linagora/twake-mui' import { useParsedFormat, usePickerActionsContext, usePickerContext, - useSplitFieldProps, -} from "@mui/x-date-pickers/hooks"; -import { PickerFieldProps } from "@mui/x-date-pickers/models"; -import { TimePickerFieldProps } from "@mui/x-date-pickers/TimePicker"; + useSplitFieldProps +} from '@mui/x-date-pickers/hooks' +import { PickerFieldProps } from '@mui/x-date-pickers/models' +import { TimePickerFieldProps } from '@mui/x-date-pickers/TimePicker' import { PickerFieldAdapter, PickerValidationScope, useValidation, - validateTime, -} from "@mui/x-date-pickers/validation"; -import { Dayjs } from "dayjs"; -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { parseTimeInput } from "../utils/dateTimeHelpers"; + validateTime +} from '@mui/x-date-pickers/validation' +import { Dayjs } from 'dayjs' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { parseTimeInput } from '../utils/dateTimeHelpers' -type FieldType = "date" | "time" | "date-time"; +type FieldType = 'date' | 'time' | 'date-time' type GenericPickerFieldProps = PickerFieldProps & { - fieldType: FieldType; + fieldType: FieldType validator: ( value: Dayjs | null, context: PickerValidationScope, adapter: PickerFieldAdapter - ) => string | null; -}; + ) => string | null +} -const TIME_DISPLAY_FORMAT = "HH:mm"; +const TIME_DISPLAY_FORMAT = 'HH:mm' /** * Editable field for time pickers. Allows free typing with format on blur/enter. * Click anywhere in the field to open picker. */ function EditableTimePickerField(props: GenericPickerFieldProps) { - const { fieldType, validator, ...fieldProps } = props; + const { fieldType, validator, ...fieldProps } = props const { internalProps, forwardedProps } = useSplitFieldProps( fieldProps, fieldType - ); + ) - const pickerContext = usePickerContext(); - const pickerActions = usePickerActionsContext(); - const parsedFormat = useParsedFormat(); - const inputRef = useRef(null); + const { + value: pickerValue, + open, + setOpen, + timezone, + triggerRef, + rootClassName, + rootSx, + rootRef, + name: pickerName + } = usePickerContext() + const pickerActions = usePickerActionsContext() + const parsedFormat = useParsedFormat() + const inputRef = useRef(null) const getFormattedValue = useCallback(() => { - if (pickerContext.value == null) return ""; - if (!pickerContext.value.isValid()) return ""; - return pickerContext.value.format(TIME_DISPLAY_FORMAT); - }, [pickerContext.value]); + if (pickerValue == null) return '' + if (!pickerValue.isValid()) return '' + return pickerValue.format(TIME_DISPLAY_FORMAT) + }, [pickerValue]) - const [inputValue, setInputValue] = useState(getFormattedValue); - const [isFocused, setIsFocused] = useState(false); + const [inputValue, setInputValue] = useState(getFormattedValue) + const [isFocused, setIsFocused] = useState(false) const [pendingCommitValue, setPendingCommitValue] = useState( null - ); - const prevFormattedValueRef = useRef(getFormattedValue()); - const isDispatchingCloseEventRef = useRef(false); - const hasDispatchedInMicrotaskRef = useRef(false); + ) + const prevFormattedValueRef = useRef(getFormattedValue()) + const isDispatchingCloseEventRef = useRef(false) + const hasDispatchedInMicrotaskRef = useRef(false) // Sync input value when picker value changes from dropdown selection or external useEffect(() => { - const newFormattedValue = getFormattedValue(); - const valueChanged = newFormattedValue !== prevFormattedValueRef.current; + const updateInputValue = () => { + const newFormattedValue = getFormattedValue() + const valueChanged = newFormattedValue !== prevFormattedValueRef.current - // Sync when: - // 1. Value changed (from dropdown selection or external) - // 2. Not focused (external change) - if (valueChanged || !isFocused) { - setInputValue(newFormattedValue); + // Sync when: + // 1. Value changed (from dropdown selection or external) + // 2. Not focused (external change) + if (valueChanged || !isFocused) { + setInputValue(newFormattedValue) + } + + // Close dropdown after selection (value changed while dropdown is open) + if (valueChanged && open) { + setOpen(false) + // Clear focus after dropdown selection + setIsFocused(false) + inputRef.current?.blur() + } + + prevFormattedValueRef.current = newFormattedValue } + updateInputValue() + }, [getFormattedValue, isFocused, open, setOpen]) - // Close dropdown after selection (value changed while dropdown is open) - if (valueChanged && pickerContext.open) { - pickerContext.setOpen(false); - // Clear focus after dropdown selection - setIsFocused(false); - inputRef.current?.blur(); - } - - prevFormattedValueRef.current = newFormattedValue; - }, [getFormattedValue, isFocused, pickerContext]); - - const wasOpenRef = useRef(pickerContext.open); + const wasOpenRef = useRef(open) // Handle dropdown open/close useEffect(() => { - const wasOpen = wasOpenRef.current; - const isOpen = pickerContext.open; + const handleDropdown = () => { + const wasOpen = wasOpenRef.current + const isOpen = open - if (isOpen && !wasOpen && inputRef.current) { - // Dropdown just opened - refocus input to allow typing - const timer = setTimeout(() => { - inputRef.current?.focus(); - }, 10); - wasOpenRef.current = isOpen; - return () => clearTimeout(timer); - } - - if (!isOpen && wasOpen) { - // Dropdown just closed - check if we need to commit user input - const current = getFormattedValue(); - if (isFocused && inputValue.trim() && inputValue !== current) { - // Store input value to commit later (after parseAndUpdateTime is defined) - setPendingCommitValue(inputValue); - } else { - setInputValue(current); + if (isOpen && !wasOpen && inputRef.current) { + // Dropdown just opened - refocus input to allow typing + const timer = setTimeout(() => { + inputRef.current?.focus() + }, 10) + wasOpenRef.current = isOpen + return () => clearTimeout(timer) } - setIsFocused(false); - // Blur input to clear DOM focus (removes Mui-focused class) - inputRef.current?.blur(); - } - wasOpenRef.current = isOpen; - }, [pickerContext.open, getFormattedValue, isFocused, inputValue]); + if (!isOpen && wasOpen) { + // Dropdown just closed - check if we need to commit user input + const current = getFormattedValue() + if (isFocused && inputValue.trim() && inputValue !== current) { + // Store input value to commit later (after parseAndUpdateTime is defined) + setPendingCommitValue(inputValue) + } else { + setInputValue(current) + } + setIsFocused(false) + // Blur input to clear DOM focus (removes Mui-focused class) + inputRef.current?.blur() + } + + wasOpenRef.current = isOpen + } + handleDropdown() + }, [open, getFormattedValue, isFocused, inputValue]) const { hasValidationError } = useValidation({ validator, - value: pickerContext.value, - timezone: pickerContext.timezone, - props: internalProps, - }); + value: pickerValue, + timezone: timezone, + props: internalProps + }) const parseAndUpdateTime = useCallback( (value: string) => { - const trimmed = value.trim(); + const trimmed = value.trim() if (!trimmed) { - setInputValue(getFormattedValue()); - return; + setInputValue(getFormattedValue()) + return } - const newValue = parseTimeInput( - trimmed, - pickerContext.value as Dayjs | null - ); + const newValue = parseTimeInput(trimmed, pickerValue as Dayjs | null) if (newValue) { // Update picker internal state and trigger onChange in one call - pickerActions.setValue(newValue, { changeImportance: "accept" }); - setInputValue(newValue.format(TIME_DISPLAY_FORMAT)); + pickerActions.setValue(newValue, { changeImportance: 'accept' }) + setInputValue(newValue.format(TIME_DISPLAY_FORMAT)) } else { // Invalid input - reset to current value - setInputValue(getFormattedValue()); + setInputValue(getFormattedValue()) } }, - [pickerContext, pickerActions, getFormattedValue] - ); + [pickerValue, pickerActions, getFormattedValue] + ) // Commit pending input value when picker closes (after parseAndUpdateTime is defined) useEffect(() => { - if (pendingCommitValue !== null) { - parseAndUpdateTime(pendingCommitValue); - setPendingCommitValue(null); + const updateTimeAndClearPending = () => { + if (pendingCommitValue !== null) { + parseAndUpdateTime(pendingCommitValue) + setPendingCommitValue(null) + } } - }, [pendingCommitValue, parseAndUpdateTime]); + updateTimeAndClearPending() + }, [pendingCommitValue, parseAndUpdateTime]) // Listen for close events from other picker fields useEffect(() => { const handleCloseOtherPickers = () => { // Don't close if this field is the one dispatching the event if (isDispatchingCloseEventRef.current) { - return; + return } // If this picker is open and another field is requesting to close others - if (pickerContext.open) { - pickerContext.setOpen(false); + if (open) { + setOpen(false) } - }; + } - window.addEventListener( - "close-other-time-pickers", - handleCloseOtherPickers - ); + window.addEventListener('close-other-time-pickers', handleCloseOtherPickers) return () => { window.removeEventListener( - "close-other-time-pickers", + 'close-other-time-pickers', handleCloseOtherPickers - ); - }; - }, [pickerContext]); + ) + } + }, [open, setOpen]) const dispatchCloseOtherPickers = useCallback(() => { // Guard: prevent duplicate dispatch in the same microtask if (hasDispatchedInMicrotaskRef.current) { - return; + return } // Mark that we've dispatched in this microtask - hasDispatchedInMicrotaskRef.current = true; + hasDispatchedInMicrotaskRef.current = true // Set flag to prevent this field from closing its own picker - isDispatchingCloseEventRef.current = true; + isDispatchingCloseEventRef.current = true // Notify other pickers to close - window.dispatchEvent(new CustomEvent("close-other-time-pickers")); + window.dispatchEvent(new CustomEvent('close-other-time-pickers')) // Reset flags in next microtask to ensure event is processed first - Promise.resolve().then(() => { - isDispatchingCloseEventRef.current = false; - hasDispatchedInMicrotaskRef.current = false; - }); - }, []); + Promise.resolve() + .then(() => { + isDispatchingCloseEventRef.current = false + hasDispatchedInMicrotaskRef.current = false + return + }) + .catch(error => { + console.error('Error resetting picker close flags:', error) + }) + }, []) const handleClick = (e: React.MouseEvent) => { - if (forwardedProps.disabled || forwardedProps.readOnly) return; + if (forwardedProps.disabled || forwardedProps.readOnly) return // Stop propagation to prevent parent from toggling the picker - e.stopPropagation(); + e.stopPropagation() - dispatchCloseOtherPickers(); + dispatchCloseOtherPickers() // Always keep it open when clicked, or open it if closed - if (!pickerContext.open) { - pickerContext.setOpen(true); + if (!open) { + setOpen(true) } - }; + } const handleFocus = () => { - if (forwardedProps.disabled || forwardedProps.readOnly) return; + if (forwardedProps.disabled || forwardedProps.readOnly) return - setIsFocused(true); - dispatchCloseOtherPickers(); - }; + setIsFocused(true) + dispatchCloseOtherPickers() + } const handleBlur = (_e: React.FocusEvent) => { // If dropdown is open, don't parse input // MUI will handle selection and sync value via useEffect - if (pickerContext.open) { - return; + if (open) { + return } // Dropdown is closed - parse input and update value - setIsFocused(false); - parseAndUpdateTime(inputValue); - }; + setIsFocused(false) + parseAndUpdateTime(inputValue) + } const handleChange = (e: React.ChangeEvent) => { - setInputValue(e.target.value); - }; + setInputValue(e.target.value) + } const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - parseAndUpdateTime(inputValue); - setIsFocused(false); - pickerContext.setOpen(false); - inputRef.current?.blur(); - } else if (e.key === "Escape") { - setInputValue(getFormattedValue()); - setIsFocused(false); - pickerContext.setOpen(false); - inputRef.current?.blur(); + if (e.key === 'Enter') { + e.preventDefault() + parseAndUpdateTime(inputValue) + setIsFocused(false) + setOpen(false) + inputRef.current?.blur() + } else if (e.key === 'Escape') { + setInputValue(getFormattedValue()) + setIsFocused(false) + setOpen(false) + inputRef.current?.blur() } - }; + } const mergedInputProps = { ...forwardedProps.InputProps, - ref: pickerContext.triggerRef, + ref: triggerRef, sx: { - cursor: "text", - ...forwardedProps.InputProps?.sx, - }, - }; + cursor: 'text', + ...forwardedProps.InputProps?.sx + } + } return ( - ); + ) } export function EditableTimeField(props: TimePickerFieldProps) { @@ -297,5 +317,5 @@ export function EditableTimeField(props: TimePickerFieldProps) { fieldType="time" validator={validateTime} /> - ); + ) } diff --git a/src/components/Event/components/FieldWithLabel.tsx b/src/components/Event/components/FieldWithLabel.tsx index a58c872..f893a2d 100644 --- a/src/components/Event/components/FieldWithLabel.tsx +++ b/src/components/Event/components/FieldWithLabel.tsx @@ -1,5 +1,5 @@ -import { Box, SxProps, Theme, Typography } from "@linagora/twake-mui"; -import React from "react"; +import { Box, SxProps, Theme, Typography } from '@linagora/twake-mui' +import React from 'react' /** * Helper component for field with label @@ -10,64 +10,64 @@ export const FieldWithLabel = React.memo( label, isExpanded, children, - sx, + sx }: { - label: string | React.ReactNode; - isExpanded: boolean; - children: React.ReactNode; - sx?: SxProps; + label: string | React.ReactNode + isExpanded: boolean + children: React.ReactNode + sx?: SxProps }) => { if (!isExpanded) { // Normal mode: label on top const isEmptyLabel = label === null || label === undefined || - (typeof label === "string" && label.trim() === ""); + (typeof label === 'string' && label.trim() === '') return ( *:not(:first-of-type)": { - marginTop: isEmptyLabel ? 0 : "6px", + '& > *:not(:first-of-type)': { + marginTop: isEmptyLabel ? 0 : '6px' }, // Only apply margin to direct child MuiTextField-root - "& > .MuiFormControl-root": { - marginTop: "6px", - marginBottom: 0, + '& > .MuiFormControl-root': { + marginTop: '6px', + marginBottom: 0 }, - "& > .MuiTextField-root": { - marginTop: "6px", - marginBottom: 0, + '& > .MuiTextField-root': { + marginTop: '6px', + marginBottom: 0 }, // Reset margin for nested MuiTextField-root - "& .MuiFormControl-root .MuiTextField-root": { + '& .MuiFormControl-root .MuiTextField-root': { marginTop: 0, - marginBottom: 0, + marginBottom: 0 }, - "& .MuiTextField-root .MuiTextField-root": { + '& .MuiTextField-root .MuiTextField-root': { marginTop: 0, - marginBottom: 0, + marginBottom: 0 }, // Reset margin for nested Box children (DateTimeFields structure) - "& > .MuiBox-root > .MuiBox-root": { - marginTop: 0, - }, - "& > .MuiBox-root > .MuiBox-root > .MuiBox-root": { - marginTop: 0, + '& > .MuiBox-root > .MuiBox-root': { + marginTop: 0 }, + '& > .MuiBox-root > .MuiBox-root > .MuiBox-root': { + marginTop: 0 + } }, - ...(Array.isArray(sx) ? sx : sx ? [sx] : []), + ...(Array.isArray(sx) ? sx : sx ? [sx] : []) ]} > {!isEmptyLabel && ( - + {label} )} {children} - ); + ) } // Extended mode: label on left @@ -77,9 +77,9 @@ export const FieldWithLabel = React.memo( component="div" variant="h6" sx={{ - minWidth: "115px", - marginRight: "12px", - flexShrink: 0, + minWidth: '115px', + marginRight: '12px', + flexShrink: 0 }} > {label} @@ -88,23 +88,25 @@ export const FieldWithLabel = React.memo( flexGrow={1} sx={{ // Set margin-top: 8px for second row in DateTimeFields (4 fields layout) - "& > .MuiBox-root > .MuiBox-root:nth-of-type(2)": { - marginTop: "8px", + '& > .MuiBox-root > .MuiBox-root:nth-of-type(2)': { + marginTop: '8px' }, // Remove margin from MuiFormControl-root MuiFormControl-marginDense in extended mode - "& .MuiFormControl-root.MuiFormControl-marginDense": { + '& .MuiFormControl-root.MuiFormControl-marginDense': { marginTop: 0, - marginBottom: 0, + marginBottom: 0 }, - "& .MuiTextField-root.MuiFormControl-marginDense": { + '& .MuiTextField-root.MuiFormControl-marginDense': { marginTop: 0, - marginBottom: 0, - }, + marginBottom: 0 + } }} > {children} - ); + ) } -); +) + +FieldWithLabel.displayName = 'FieldWithLabel' diff --git a/src/components/Event/components/ReadOnlyPickerField.tsx b/src/components/Event/components/ReadOnlyPickerField.tsx index bfdb5de..d597147 100644 --- a/src/components/Event/components/ReadOnlyPickerField.tsx +++ b/src/components/Event/components/ReadOnlyPickerField.tsx @@ -1,88 +1,98 @@ -import { TextField } from "@linagora/twake-mui"; -import { DatePickerFieldProps } from "@mui/x-date-pickers/DatePicker"; +// ReadOnlyPickerField + +import { TextField } from '@linagora/twake-mui' +import { DatePickerFieldProps } from '@mui/x-date-pickers/DatePicker' import { useParsedFormat, usePickerContext, - useSplitFieldProps, -} from "@mui/x-date-pickers/hooks"; -import { PickerFieldProps } from "@mui/x-date-pickers/models"; + useSplitFieldProps +} from '@mui/x-date-pickers/hooks' +import { PickerFieldProps } from '@mui/x-date-pickers/models' import { PickerFieldAdapter, PickerValidationScope, useValidation, - validateDate, -} from "@mui/x-date-pickers/validation"; -import { Dayjs } from "dayjs"; + validateDate +} from '@mui/x-date-pickers/validation' +import { Dayjs } from 'dayjs' -type FieldType = "date" | "time" | "date-time"; +type FieldType = 'date' | 'time' | 'date-time' type GenericPickerFieldProps = PickerFieldProps & { - fieldType: FieldType; + fieldType: FieldType validator: ( value: Dayjs | null, context: PickerValidationScope, adapter: PickerFieldAdapter - ) => string | null; -}; + ) => string | null +} /** * Shared read-only field for date/time pickers. Disables typing, removes icon, * and opens the picker when clicking anywhere in the field. */ function ReadOnlyPickerField(props: GenericPickerFieldProps) { - const { fieldType, validator, ...fieldProps } = props; + const { fieldType, validator, ...fieldProps } = props const { internalProps, forwardedProps } = useSplitFieldProps( fieldProps, fieldType - ); + ) - const pickerContext = usePickerContext(); - const parsedFormat = useParsedFormat(); + const { + value, + timezone, + fieldFormat, + open, + setOpen, + triggerRef, + rootClassName, + rootSx, + rootRef, + name: pickerName + } = usePickerContext() + + const parsedFormat = useParsedFormat() const { hasValidationError } = useValidation({ validator, - value: pickerContext.value, - timezone: pickerContext.timezone, - props: internalProps, - }); + value: value, + timezone: timezone, + props: internalProps + }) const valueToDisplay = - pickerContext.value == null - ? "" - : pickerContext.value.isValid() - ? pickerContext.value.format(pickerContext.fieldFormat) - : ""; + value == null ? '' : value.isValid() ? value.format(fieldFormat) : '' const mergedInputProps = { ...forwardedProps.InputProps, - ref: pickerContext.triggerRef, + ref: triggerRef, readOnly: true, sx: { - cursor: "pointer", - "& *": { cursor: "inherit" }, - ...forwardedProps.InputProps?.sx, - }, - }; + cursor: 'pointer', + '& *': { cursor: 'inherit' }, + ...forwardedProps.InputProps?.sx + } + } return ( pickerContext.setOpen((prev) => !prev)} - className={pickerContext.rootClassName} - sx={pickerContext.rootSx} - ref={pickerContext.rootRef} - name={pickerContext.name} + focused={open} + onClick={() => setOpen((prev: boolean) => !prev)} + className={rootClassName} + sx={rootSx} + ref={rootRef} + name={pickerName} /> - ); + ) } export function ReadOnlyDateField(props: DatePickerFieldProps) { return ( - ); + ) } diff --git a/src/components/Event/components/SectionPreviewRow.tsx b/src/components/Event/components/SectionPreviewRow.tsx index 4732b63..555138f 100644 --- a/src/components/Event/components/SectionPreviewRow.tsx +++ b/src/components/Event/components/SectionPreviewRow.tsx @@ -1,13 +1,13 @@ -import { Box, Typography, useTheme } from "@linagora/twake-mui"; -import { alpha } from "@mui/material/styles"; -import React from "react"; +import { Box, Typography, useTheme } from '@linagora/twake-mui' +import { alpha } from '@mui/material/styles' +import React from 'react' export interface SectionPreviewRowProps { - icon: React.ReactNode; + icon: React.ReactNode /** Primary text or custom content. If string, rendered with fontSize 14px, fontWeight 500. */ - children: React.ReactNode; - onClick: () => void; - iconColor?: string; + children: React.ReactNode + onClick: () => void + iconColor?: string } /** @@ -18,17 +18,17 @@ export const SectionPreviewRow: React.FC = ({ icon, children, onClick, - iconColor, + iconColor }) => { - const theme = useTheme(); - const color = iconColor ?? alpha(theme.palette.grey[900], 0.9); + const theme = useTheme() + const color = iconColor ?? alpha(theme.palette.grey[900], 0.9) const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick(); + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() } - }; + } return ( = ({ onClick={onClick} onKeyDown={handleKeyDown} sx={{ - display: "flex", - alignItems: "center", - cursor: "pointer", - padding: "8px 12px", - borderRadius: "4px", - "&:hover": { - backgroundColor: "action.hover", - }, + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + padding: '8px 12px', + borderRadius: '4px', + '&:hover': { + backgroundColor: 'action.hover' + } }} > {icon} - {typeof children === "string" ? ( + {typeof children === 'string' ? ( {children} @@ -85,5 +85,5 @@ export const SectionPreviewRow: React.FC = ({ )} - ); -}; + ) +} diff --git a/src/components/Event/eventHandlers/eventHandlers.ts b/src/components/Event/eventHandlers/eventHandlers.ts index 217cd9d..0527741 100644 --- a/src/components/Event/eventHandlers/eventHandlers.ts +++ b/src/components/Event/eventHandlers/eventHandlers.ts @@ -1,18 +1,18 @@ -import { AppDispatch } from "@/app/store"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { AppDispatch } from '@/app/store' +import { Calendar } from '@/features/Calendars/CalendarTypes' import { deleteEventAsync, deleteEventInstanceAsync, putEventAsync, - updateEventInstanceAsync, -} from "@/features/Calendars/services"; -import { updateSeriesPartstat } from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { PartStat } from "@/features/User/models/attendee"; -import { createAttendee } from "@/features/User/models/attendee.mapper"; -import { userData } from "@/features/User/userDataTypes"; -import { buildFamilyName } from "@/utils/buildFamilyName"; -import { isEventOrganiser } from "@/utils/isEventOrganiser"; + updateEventInstanceAsync +} from '@/features/Calendars/services' +import { updateSeriesPartstat } from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { PartStat } from '@/features/User/models/attendee' +import { createAttendee } from '@/features/User/models/attendee.mapper' +import { userData } from '@/features/User/userDataTypes' +import { buildFamilyName } from '@/utils/buildFamilyName' +import { isEventOrganiser } from '@/utils/isEventOrganiser' function updateEventAttendees( calendar: Calendar, @@ -22,60 +22,60 @@ function updateEventAttendees( ) { if (calendar.owner?.resource) { const updatedAttendees = - event.attendee?.map((attendeeData) => - attendeeData.cutype === "RESOURCE" && attendeeData.cn === calendar.name + event.attendee?.map(attendeeData => + attendeeData.cutype === 'RESOURCE' && attendeeData.cn === calendar.name ? { ...attendeeData, partstat: rsvp } : attendeeData - ) || []; + ) || [] - return { attendee: updatedAttendees }; + return { attendee: updatedAttendees } } if (!user) { - throw new Error("Cannot update attendees without user data"); + throw new Error('Cannot update attendees without user data') } - const eventHasNoAttendees = !event?.attendee || event.attendee.length === 0; - const isOrganizer = isEventOrganiser(event, user.email); + const eventHasNoAttendees = !event?.attendee || event.attendee.length === 0 + const isOrganizer = isEventOrganiser(event, user.email) if (eventHasNoAttendees) { const userdata = createAttendee({ cal_address: user.email, cn: buildFamilyName(user.given_name, user.family_name, user.email), - role: isOrganizer ? "CHAIR" : "REQ-PARTICIPANT", - partstat: rsvp, - }); + role: isOrganizer ? 'CHAIR' : 'REQ-PARTICIPANT', + partstat: rsvp + }) return { organizer: isOrganizer ? userdata : event.organizer, - attendee: [userdata], - }; + attendee: [userdata] + } } return { attendee: (() => { - const userEmailLower = user.email?.toLowerCase(); + const userEmailLower = user.email?.toLowerCase() const userExists = event.attendee.some( - (attendee) => attendee.cal_address?.toLowerCase() === userEmailLower - ); + attendee => attendee.cal_address?.toLowerCase() === userEmailLower + ) - const updatedAttendees = event.attendee.map((attendeeData) => + const updatedAttendees = event.attendee.map(attendeeData => attendeeData.cal_address?.toLowerCase() === userEmailLower ? { ...attendeeData, partstat: rsvp } : attendeeData - ); + ) if (!userExists) { const newUserAttendee = createAttendee({ cal_address: user.email, cn: buildFamilyName(user.given_name, user.family_name, user.email), - role: "REQ-PARTICIPANT", - partstat: rsvp, - }); - return [...updatedAttendees, newUserAttendee]; + role: 'REQ-PARTICIPANT', + partstat: rsvp + }) + return [...updatedAttendees, newUserAttendee] } - return updatedAttendees; - })(), - }; + return updatedAttendees + })() + } } async function handleSoloRSVP( @@ -83,7 +83,7 @@ async function handleSoloRSVP( calendar: Calendar, event: CalendarEvent ) { - dispatch(updateEventInstanceAsync({ cal: calendar, event })); + await dispatch(updateEventInstanceAsync({ cal: calendar, event })) } async function handleAllRSVP( @@ -91,7 +91,7 @@ async function handleAllRSVP( userEmail: string, rsvp: PartStat ) { - await updateSeriesPartstat(event, userEmail, rsvp); + await updateSeriesPartstat(event, userEmail, rsvp) } async function handleDefaultRSVP( @@ -99,7 +99,7 @@ async function handleDefaultRSVP( calendar: Calendar, newEvent: CalendarEvent ) { - dispatch(putEventAsync({ cal: calendar, newEvent })); + await dispatch(putEventAsync({ cal: calendar, newEvent })) } export async function handleRSVP( @@ -112,42 +112,42 @@ export async function handleRSVP( ) { const newEvent = { ...event, - ...updateEventAttendees(calendar, event, user, rsvp), - }; + ...updateEventAttendees(calendar, event, user, rsvp) + } - if (typeOfAction === "solo") { - await handleSoloRSVP(dispatch, calendar, newEvent); - } else if (typeOfAction === "all") { + if (typeOfAction === 'solo') { + await handleSoloRSVP(dispatch, calendar, newEvent) + } else if (typeOfAction === 'all') { if (!user?.email) { - throw new Error("Cannot update all occurrences without user email"); + throw new Error('Cannot update all occurrences without user email') } - await handleAllRSVP(event, user.email, rsvp); + await handleAllRSVP(event, user.email, rsvp) } else { - await handleDefaultRSVP(dispatch, calendar, newEvent); + await handleDefaultRSVP(dispatch, calendar, newEvent) } } export function handleDelete( isRecurring: boolean, - typeOfAction: "solo" | "all" | undefined, - onClose: (event: unknown, reason: "backdropClick" | "escapeKeyDown") => void, + typeOfAction: 'solo' | 'all' | undefined, + onClose: (event: unknown, reason: 'backdropClick' | 'escapeKeyDown') => void, dispatch: AppDispatch, calendar: Calendar, event: CalendarEvent, calId: string, eventId: string ) { - onClose({}, "backdropClick"); + onClose({}, 'backdropClick') - if (isRecurring && typeOfAction === "solo") { - dispatch(deleteEventInstanceAsync({ cal: calendar, event })); + if (isRecurring && typeOfAction === 'solo') { + dispatch(deleteEventInstanceAsync({ cal: calendar, event })) } else { dispatch( deleteEventAsync({ calId, eventId, - eventURL: event.URL, + eventURL: event.URL }) - ); + ) } } diff --git a/src/components/Event/hooks/useAllDayToggle.ts b/src/components/Event/hooks/useAllDayToggle.ts index 2822b78..cf67d68 100644 --- a/src/components/Event/hooks/useAllDayToggle.ts +++ b/src/components/Event/hooks/useAllDayToggle.ts @@ -1,23 +1,23 @@ -import React from "react"; -import { combineDateTime } from "../utils/dateTimeHelpers"; +import React from 'react' +import { combineDateTime } from '../utils/dateTimeHelpers' /** * Parameters for all-day toggle hook */ export interface AllDayToggleParams { - allday: boolean; - start: string; - end: string; - startDate: string; - startTime: string; - endDate: string; - endTime: string; - setStartTime: (time: string) => void; - setEndTime: (time: string) => void; - setStart: (start: string) => void; - setEnd: (end: string) => void; - setAllDay: (allday: boolean) => void; - onAllDayChange?: (allday: boolean, start: string, end: string) => void; + allday: boolean + start: string + end: string + startDate: string + startTime: string + endDate: string + endTime: string + setStartTime: (time: string) => void + setEndTime: (time: string) => void + setStart: (start: string) => void + setEnd: (end: string) => void + setAllDay: (allday: boolean) => void + onAllDayChange?: (allday: boolean, start: string, end: string) => void } /** @@ -25,12 +25,12 @@ export interface AllDayToggleParams { */ export interface AllDayToggleHandlers { originalTimeRef: React.MutableRefObject<{ - start: string; - end: string; - endDate?: string; - fromAllDaySlot?: boolean; - } | null>; - handleAllDayToggle: () => void; + start: string + end: string + endDate?: string + fromAllDaySlot?: boolean + } | null> + handleAllDayToggle: () => void } /** @@ -53,58 +53,58 @@ export function useAllDayToggle( setStart, setEnd, setAllDay, - onAllDayChange, - } = params; + onAllDayChange + } = params // Store original time before toggling to all-day const originalTimeRef = React.useRef<{ - start: string; - end: string; - endDate?: string; - fromAllDaySlot?: boolean; - } | null>(null); + start: string + end: string + endDate?: string + fromAllDaySlot?: boolean + } | null>(null) const handleAllDayToggle = React.useCallback(() => { - const newAllDay = !allday; - let newStart = start; - let newEnd = end; + const newAllDay = !allday + let newStart = start + let newEnd = end if (!newAllDay) { - const hasTimeParts = start.includes("T") && end.includes("T"); + const hasTimeParts = start.includes('T') && end.includes('T') if (!hasTimeParts && !startTime && !endTime) { - const now = new Date(); - now.setSeconds(0); - now.setMilliseconds(0); - const nextHour = new Date(now); - nextHour.setMinutes(0); - nextHour.setHours(now.getHours() + 1); + const now = new Date() + now.setSeconds(0) + now.setMilliseconds(0) + const nextHour = new Date(now) + nextHour.setMinutes(0) + nextHour.setHours(now.getHours() + 1) - const startHours = String(nextHour.getHours()).padStart(2, "0"); - const startMinutes = String(nextHour.getMinutes()).padStart(2, "0"); - const startTimeStr = `${startHours}:${startMinutes}`; + const startHours = String(nextHour.getHours()).padStart(2, '0') + const startMinutes = String(nextHour.getMinutes()).padStart(2, '0') + const startTimeStr = `${startHours}:${startMinutes}` - const endHourDate = new Date(nextHour); - endHourDate.setHours(endHourDate.getHours() + 1); - const endHours = String(endHourDate.getHours()).padStart(2, "0"); - const endMinutes = String(endHourDate.getMinutes()).padStart(2, "0"); - const endTimeStr = `${endHours}:${endMinutes}`; + const endHourDate = new Date(nextHour) + endHourDate.setHours(endHourDate.getHours() + 1) + const endHours = String(endHourDate.getHours()).padStart(2, '0') + const endMinutes = String(endHourDate.getMinutes()).padStart(2, '0') + const endTimeStr = `${endHours}:${endMinutes}` - const startDateOnly = start.split("T")[0] || startDate; - const endDateOnly = end.split("T")[0] || endDate || startDateOnly; - newStart = combineDateTime(startDateOnly, startTimeStr); - newEnd = combineDateTime(endDateOnly, endTimeStr); + const startDateOnly = start.split('T')[0] || startDate + const endDateOnly = end.split('T')[0] || endDate || startDateOnly + newStart = combineDateTime(startDateOnly, startTimeStr) + newEnd = combineDateTime(endDateOnly, endTimeStr) - setStartTime(startTimeStr); - setEndTime(endTimeStr); + setStartTime(startTimeStr) + setEndTime(endTimeStr) } } if (!onAllDayChange) { - setStart(newStart); - setEnd(newEnd); - setAllDay(newAllDay); + setStart(newStart) + setEnd(newEnd) + setAllDay(newAllDay) } else { - onAllDayChange(newAllDay, newStart, newEnd); + onAllDayChange(newAllDay, newStart, newEnd) } }, [ allday, @@ -119,11 +119,11 @@ export function useAllDayToggle( setStart, setEnd, setAllDay, - onAllDayChange, - ]); + onAllDayChange + ]) return { originalTimeRef, - handleAllDayToggle, - }; + handleAllDayToggle + } } diff --git a/src/components/Event/utils/dateRules.ts b/src/components/Event/utils/dateRules.ts index 4f821c8..4629b6a 100644 --- a/src/components/Event/utils/dateRules.ts +++ b/src/components/Event/utils/dateRules.ts @@ -1,12 +1,12 @@ // Date rules helpers to normalize end date/time behavior -import { combineDateTime } from "./dateTimeHelpers"; +import { combineDateTime } from './dateTimeHelpers' /** Adds a number of days to a YYYY-MM-DD string and returns YYYY-MM-DD */ export function addDays(dateStr: string, days: number): string { - const d = new Date(dateStr); - d.setDate(d.getDate() + days); - return d.toISOString().split("T")[0]; + const d = new Date(dateStr) + d.setDate(d.getDate() + days) + return d.toISOString().split('T')[0] } /** @@ -18,8 +18,8 @@ export function getEndDateForStartChange( startDate: string, isAllDay: boolean ): string { - if (!startDate) return ""; - return isAllDay ? addDays(startDate, 1) : startDate; + if (!startDate) return '' + return isAllDay ? addDays(startDate, 1) : startDate } /** @@ -32,23 +32,23 @@ export function getEndDateForStartChange( * - end restored from originalEndDate if any, else previousEndDate, else start */ export function getEndDateForToggle(params: { - nextAllDay: boolean; - fromAllDaySlot?: boolean; - startDate: string; - previousEndDate: string; - originalEndDate?: string; + nextAllDay: boolean + fromAllDaySlot?: boolean + startDate: string + previousEndDate: string + originalEndDate?: string }): string { const { nextAllDay, fromAllDaySlot, startDate, previousEndDate, - originalEndDate, - } = params; + originalEndDate + } = params if (nextAllDay) { - return fromAllDaySlot ? startDate : addDays(startDate, 1); + return fromAllDaySlot ? startDate : addDays(startDate, 1) } - return originalEndDate || previousEndDate || startDate; + return originalEndDate || previousEndDate || startDate } /** Utility to combine date with a fallback time (HH:mm) safely */ @@ -57,6 +57,6 @@ export function combineWithFallback( timeHHmm: string | undefined, fallbackTime: string ): string { - const time = timeHHmm && timeHHmm.trim() ? timeHHmm : fallbackTime; - return combineDateTime(dateStr, time); + const time = timeHHmm && timeHHmm.trim() ? timeHHmm : fallbackTime + return combineDateTime(dateStr, time) } diff --git a/src/components/Event/utils/dateTimeFormatters.ts b/src/components/Event/utils/dateTimeFormatters.ts index 9fdf706..38ecc8e 100644 --- a/src/components/Event/utils/dateTimeFormatters.ts +++ b/src/components/Event/utils/dateTimeFormatters.ts @@ -11,27 +11,27 @@ export function formatLocalDateTime(date: Date, timeZone?: string): string { // Guard against invalid or undefined dates if (!date || isNaN(date.getTime())) { - return ""; + return '' } if (timeZone) { - const formatter = new Intl.DateTimeFormat("en-CA", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", + const formatter = new Intl.DateTimeFormat('en-CA', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', hour12: false, - timeZone, - }); - const formatted = formatter.format(date); - return formatted.replace(", ", "T"); + timeZone + }) + const formatted = formatter.format(date) + return formatted.replace(', ', 'T') } - const pad = (n: number) => n.toString().padStart(2, "0"); + const pad = (n: number) => n.toString().padStart(2, '0') return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad( date.getDate() - )}T${pad(date.getHours())}:${pad(date.getMinutes())}`; + )}T${pad(date.getHours())}:${pad(date.getMinutes())}` } /** @@ -45,24 +45,24 @@ export function formatDateTimeInTimezone( timezone: string ): string { // Parse the ISO string as UTC - const utcDate = new Date(isoString); + const utcDate = new Date(isoString) // Format the date in the target timezone - const formatter = new Intl.DateTimeFormat("en-CA", { + const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }) - const parts = formatter.formatToParts(utcDate); + const parts = formatter.formatToParts(utcDate) const getValue = (type: string) => - parts.find((p) => p.type === type)?.value || ""; + parts.find(p => p.type === type)?.value || '' - return `${getValue("year")}-${getValue("month")}-${getValue("day")}T${getValue("hour")}:${getValue("minute")}`; + return `${getValue('year')}-${getValue('month')}-${getValue('day')}T${getValue('hour')}:${getValue('minute')}` } /** @@ -70,14 +70,14 @@ export function formatDateTimeInTimezone( * @returns Rounded Date object */ export function getRoundedCurrentTime(): Date { - const now = new Date(); - const minutes = now.getMinutes(); - const roundedMinutes = minutes < 30 ? 0 : 30; - now.setMinutes(roundedMinutes); - now.setSeconds(0); - now.setMilliseconds(0); - return now; + const now = new Date() + const minutes = now.getMinutes() + const roundedMinutes = minutes < 30 ? 0 : 30 + now.setMinutes(roundedMinutes) + now.setSeconds(0) + now.setMilliseconds(0) + return now } /** Long date display format for date pickers */ -export const LONG_DATE_FORMAT = "dddd, MMMM D, YYYY"; +export const LONG_DATE_FORMAT = 'dddd, MMMM D, YYYY' diff --git a/src/components/Event/utils/dateTimeHelpers.ts b/src/components/Event/utils/dateTimeHelpers.ts index 636771a..3e416a5 100644 --- a/src/components/Event/utils/dateTimeHelpers.ts +++ b/src/components/Event/utils/dateTimeHelpers.ts @@ -1,21 +1,21 @@ -import dayjs, { Dayjs } from "dayjs"; -import customParseFormat from "dayjs/plugin/customParseFormat"; -import moment from "moment-timezone"; +import dayjs, { Dayjs } from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import moment from 'moment-timezone' -dayjs.extend(customParseFormat); +dayjs.extend(customParseFormat) /** * Helper functions for date/time string manipulation */ -export const DATETIME_WITH_SECONDS_LENGTH = 19; -export const DATETIME_FORMAT_WITH_SECONDS = "YYYY-MM-DDTHH:mm:ss"; -export const DATETIME_FORMAT_WITHOUT_SECONDS = "YYYY-MM-DDTHH:mm"; +export const DATETIME_WITH_SECONDS_LENGTH = 19 +export const DATETIME_FORMAT_WITH_SECONDS = 'YYYY-MM-DDTHH:mm:ss' +export const DATETIME_FORMAT_WITHOUT_SECONDS = 'YYYY-MM-DDTHH:mm' -export const TIME_PARSE_FORMATS = ["HH:mm", "H:mm", "HHmm", "Hmm", "HH", "H"]; +export const TIME_PARSE_FORMATS = ['HH:mm', 'H:mm', 'HHmm', 'Hmm', 'HH', 'H'] // Strict parsing mode - input must exactly match the provided format(s) -const STRICT_PARSING = true; +const STRICT_PARSING = true /** * Detect datetime format based on string length @@ -25,7 +25,7 @@ const STRICT_PARSING = true; export function detectDateTimeFormat(datetime: string): string { return datetime.length >= DATETIME_WITH_SECONDS_LENGTH ? DATETIME_FORMAT_WITH_SECONDS - : DATETIME_FORMAT_WITHOUT_SECONDS; + : DATETIME_FORMAT_WITHOUT_SECONDS } /** @@ -34,15 +34,15 @@ export function detectDateTimeFormat(datetime: string): string { * @returns Object with date and time strings */ export function splitDateTime(datetime: string): { - date: string; - time: string; + date: string + time: string } { - if (!datetime) return { date: "", time: "" }; - const parts = datetime.split("T"); + if (!datetime) return { date: '', time: '' } + const parts = datetime.split('T') return { - date: parts[0] || "", - time: parts[1]?.slice(0, 5) || "", // HH:mm only - }; + date: parts[0] || '', + time: parts[1]?.slice(0, 5) || '' // HH:mm only + } } /** @@ -52,9 +52,9 @@ export function splitDateTime(datetime: string): { * @returns Combined datetime string or date only if no time */ export function combineDateTime(date: string, time: string): string { - if (!date) return ""; - if (!time) return date; // Date only for all-day - return `${date}T${time}`; + if (!date) return '' + if (!time) return date // Date only for all-day + return `${date}T${time}` } /** @@ -65,32 +65,32 @@ export function convertFormDateTimeToISO( datetime: string, timezone: string ): string { - if (!datetime) return ""; - const tz = timezone || "Etc/UTC"; - const format = detectDateTimeFormat(datetime); - const momentDate = moment.tz(datetime, format, tz); + if (!datetime) return '' + const tz = timezone || 'Etc/UTC' + const format = detectDateTimeFormat(datetime) + const momentDate = moment.tz(datetime, format, tz) if (!momentDate.isValid()) { console.warn( `[convertFormDateTimeToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${tz}"` - ); - return ""; + ) + return '' } - return momentDate.toDate().toISOString(); + return momentDate.toDate().toISOString() } /** Convert date + time strings → Dayjs */ export const toDateTime = (date: string, time: string): Dayjs => { - const d = dayjs(date, "YYYY-MM-DD", STRICT_PARSING); - if (!time) return d.startOf("day"); - const [h, m] = time.split(":").map(Number); - return d.hour(h).minute(m).second(0).millisecond(0); -}; + const d = dayjs(date, 'YYYY-MM-DD', STRICT_PARSING) + if (!time) return d.startOf('day') + const [h, m] = time.split(':').map(Number) + return d.hour(h).minute(m).second(0).millisecond(0) +} /** Extract date “YYYY-MM-DD” */ -export const dtDate = (d: Dayjs) => d.format("YYYY-MM-DD"); +export const dtDate = (d: Dayjs) => d.format('YYYY-MM-DD') /** Extract time “HH:mm” */ -export const dtTime = (d: Dayjs) => d.format("HH:mm"); +export const dtTime = (d: Dayjs) => d.format('HH:mm') /** * Parse flexible time string input into Dayjs object @@ -101,25 +101,25 @@ export function parseTimeInput( value: string, currentDate: Dayjs | null ): Dayjs | null { - let trimmed = value.trim(); + let trimmed = value.trim() if (!trimmed) { - return null; + return null } // Normalize 3-digit input (e.g. "830" → "0830" → 08:30, "123" → "0123" → 01:23) if (/^\d{3}$/.test(trimmed)) { - trimmed = trimmed.padStart(4, "0"); + trimmed = trimmed.padStart(4, '0') } - const parsed = dayjs(trimmed, TIME_PARSE_FORMATS, STRICT_PARSING); + const parsed = dayjs(trimmed, TIME_PARSE_FORMATS, STRICT_PARSING) if (parsed.isValid()) { - const baseDate = currentDate || dayjs(); + const baseDate = currentDate || dayjs() return baseDate .hour(parsed.hour()) .minute(parsed.minute()) .second(0) - .millisecond(0); + .millisecond(0) } - return null; + return null } diff --git a/src/components/Event/utils/eventUtils.tsx b/src/components/Event/utils/eventUtils.tsx index 68bc988..b12581b 100644 --- a/src/components/Event/utils/eventUtils.tsx +++ b/src/components/Event/utils/eventUtils.tsx @@ -1,17 +1,17 @@ -import { AppDispatch } from "@/app/store"; -import { emptyEventsCal } from "@/features/Calendars/CalendarSlice"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { AppDispatch } from '@/app/store' +import { emptyEventsCal } from '@/features/Calendars/CalendarSlice' +import { Calendar } from '@/features/Calendars/CalendarTypes' import { getCalendarDetailAsync, getCalendarsListAsync, - refreshCalendarWithSyncToken, -} from "@/features/Calendars/services"; -import { userAttendee } from "@/features/User/models/attendee"; -import { getInitials, stringToGradient } from "@/utils/avatarUtils"; -import { formatDateToYYYYMMDDTHHMMSS } from "@/utils/dateUtils"; -import { Avatar, Badge, Box, Typography } from "@linagora/twake-mui"; -import CancelIcon from "@mui/icons-material/Cancel"; -import CheckCircleIcon from "@mui/icons-material/CheckCircle"; + refreshCalendarWithSyncToken +} from '@/features/Calendars/services' +import { userAttendee } from '@/features/User/models/attendee' +import { getInitials, stringToGradient } from '@/utils/avatarUtils' +import { formatDateToYYYYMMDDTHHMMSS } from '@/utils/dateUtils' +import { Avatar, Badge, Box, Typography } from '@linagora/twake-mui' +import CancelIcon from '@mui/icons-material/Cancel' +import CheckCircleIcon from '@mui/icons-material/CheckCircle' export function renderAttendeeBadge( a: userAttendee, @@ -22,40 +22,40 @@ export function renderAttendeeBadge( caption?: string ) { const classIcon = - a.partstat === "ACCEPTED" ? ( + a.partstat === 'ACCEPTED' ? ( - ) : a.partstat === "DECLINED" ? ( + ) : a.partstat === 'DECLINED' ? ( - ) : null; + ) : null if (!isFull) { - return ; + return } else { return ( {classIcon} @@ -65,13 +65,13 @@ export function renderAttendeeBadge( > - + {a.cn || a.cal_address} {isOrganizer && ( - {t("event.organizer")} + {t('event.organizer')} )} {caption && ( @@ -81,76 +81,76 @@ export function renderAttendeeBadge( )} - ); + ) } } export function stringToColor(string: string) { - let hash = 0; + let hash = 0 for (let i = 0; i < string.length; i++) { - hash = string.charCodeAt(i) + ((hash << 5) - hash); + hash = string.charCodeAt(i) + ((hash << 5) - hash) } - let color = "#"; + let color = '#' for (let i = 0; i < 3; i++) { - const value = (hash >> (i * 8)) & 0xff; - color += `00${value.toString(16)}`.slice(-2); + const value = (hash >> (i * 8)) & 0xff + color += `00${value.toString(16)}`.slice(-2) } - return color; + return color } export function stringAvatar(name: string) { return { color: stringToGradient(name), - children: getInitials(name), - }; + children: getInitials(name) + } } export async function refreshCalendars( dispatch: AppDispatch, calendars: Calendar[], calendarRange: { - start: Date; - end: Date; + start: Date + end: Date }, - calType?: "temp" + calType?: 'temp' ) { - if (process.env.NODE_ENV === "test") return; + if (process.env.NODE_ENV === 'test') return if (!calType) { - await dispatch(getCalendarsListAsync()); + await dispatch(getCalendarsListAsync()) } const results = await Promise.allSettled( - calendars.map((calendar) => + calendars.map(calendar => dispatch( refreshCalendarWithSyncToken({ calendar, calType, calendarRange }) ).unwrap() ) - ); + ) results.forEach((result, index) => { - if (result.status === "rejected") { + if (result.status === 'rejected') { console.error( `Failed to refresh calendar ${calendars[index].id}:`, result.reason - ); + ) } - }); + }) } export async function refreshSingularCalendar( dispatch: AppDispatch, calendar: Calendar, calendarRange: { start: Date; end: Date }, - calType?: "temp" + calType?: 'temp' ) { - const isTestEnv = process.env.NODE_ENV === "test"; - dispatch(emptyEventsCal({ calId: calendar.id, calType })); + const isTestEnv = process.env.NODE_ENV === 'test' + dispatch(emptyEventsCal({ calId: calendar.id, calType })) if (isTestEnv) { - return; + return } await dispatch( @@ -158,9 +158,9 @@ export async function refreshSingularCalendar( calId: calendar.id, match: { start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start), - end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), + end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end) }, - calType, + calType }) - ); + ) } diff --git a/src/components/Event/utils/formValidation.ts b/src/components/Event/utils/formValidation.ts index 19d9ea5..e2aac58 100644 --- a/src/components/Event/utils/formValidation.ts +++ b/src/components/Event/utils/formValidation.ts @@ -1,27 +1,27 @@ -import { combineDateTime } from "./dateTimeHelpers"; +import { combineDateTime } from './dateTimeHelpers' /** * Validation parameters for event form */ export interface ValidationParams { - startDate: string; - startTime: string; - endDate: string; - endTime: string; - allday: boolean; - showValidationErrors: boolean; - hasEndDateChanged?: boolean; - showMore?: boolean; + startDate: string + startTime: string + endDate: string + endTime: string + allday: boolean + showValidationErrors: boolean + hasEndDateChanged?: boolean + showMore?: boolean } /** * Validation result for event form */ export interface ValidationResult { - isValid: boolean; + isValid: boolean errors: { - dateTime: string; - }; + dateTime: string + } } /** @@ -38,102 +38,102 @@ export function validateEventForm(params: ValidationParams): ValidationResult { allday, showValidationErrors, hasEndDateChanged = false, - showMore = false, - } = params; + showMore = false + } = params - let isDateTimeValid = true; - let dateTimeError = ""; + let isDateTimeValid = true + let dateTimeError = '' // Determine which fields are visible based on UI mode const showFullFields = showMore || allday || hasEndDateChanged || - (!showMore && !allday && startDate !== endDate); - const showTimeOnly = !allday && !showFullFields; + (!showMore && !allday && startDate !== endDate) + const showTimeOnly = !allday && !showFullFields // Validate start date - if (!startDate || startDate.trim() === "") { - isDateTimeValid = false; - dateTimeError = "Start date is required"; + if (!startDate || startDate.trim() === '') { + isDateTimeValid = false + dateTimeError = 'Start date is required' } // Validate start time (if not all-day) - else if (!allday && (!startTime || startTime.trim() === "")) { - isDateTimeValid = false; - dateTimeError = "Start time is required"; + else if (!allday && (!startTime || startTime.trim() === '')) { + isDateTimeValid = false + dateTimeError = 'Start time is required' } // Validate end fields based on UI mode else if (showFullFields) { // 4 fields mode: validate both end date and end time - if (!endDate || endDate.trim() === "") { - isDateTimeValid = false; - dateTimeError = "End date is required"; - } else if (!allday && (!endTime || endTime.trim() === "")) { - isDateTimeValid = false; - dateTimeError = "End time is required"; + if (!endDate || endDate.trim() === '') { + isDateTimeValid = false + dateTimeError = 'End date is required' + } else if (!allday && (!endTime || endTime.trim() === '')) { + isDateTimeValid = false + dateTimeError = 'End time is required' } else { // Validate total datetime if (allday) { const toLocalDate = (ymd: string) => { - const [y, m, d] = ymd.split("-").map((v) => parseInt(v, 10)); - if (!y || !m || !d) return new Date(NaN); - return new Date(y, m - 1, d); - }; + const [y, m, d] = ymd.split('-').map(v => parseInt(v, 10)) + if (!y || !m || !d) return new Date(NaN) + return new Date(y, m - 1, d) + } - const startOnly = toLocalDate(startDate); - const endOnly = toLocalDate(endDate); + const startOnly = toLocalDate(startDate) + const endOnly = toLocalDate(endDate) if (isNaN(startOnly.getTime()) || isNaN(endOnly.getTime())) { - isDateTimeValid = false; - dateTimeError = "Invalid date"; + isDateTimeValid = false + dateTimeError = 'Invalid date' } else if (endOnly < startOnly) { - isDateTimeValid = false; - dateTimeError = "End date must be on or after start date"; + isDateTimeValid = false + dateTimeError = 'End date must be on or after start date' } } else { - const startDateTime = new Date(combineDateTime(startDate, startTime)); - const endDateTime = new Date(combineDateTime(endDate, endTime)); + const startDateTime = new Date(combineDateTime(startDate, startTime)) + const endDateTime = new Date(combineDateTime(endDate, endTime)) if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) { - isDateTimeValid = false; - dateTimeError = "Invalid date/time"; + isDateTimeValid = false + dateTimeError = 'Invalid date/time' } else if (endDateTime <= startDateTime) { - isDateTimeValid = false; - dateTimeError = "End date/time must be after start date/time"; + isDateTimeValid = false + dateTimeError = 'End date/time must be after start date/time' } } } } else if (showTimeOnly) { // 3 fields mode: validate time only (end time > start time, same day) - if (!endTime || endTime.trim() === "") { - isDateTimeValid = false; - dateTimeError = "End time is required"; + if (!endTime || endTime.trim() === '') { + isDateTimeValid = false + dateTimeError = 'End time is required' } else { // Compare times only (same day is assumed) - const startTimeParts = startTime.split(":"); - const endTimeParts = endTime.split(":"); + const startTimeParts = startTime.split(':') + const endTimeParts = endTime.split(':') if (startTimeParts.length === 2 && endTimeParts.length === 2) { const startMinutes = - parseInt(startTimeParts[0]) * 60 + parseInt(startTimeParts[1]); + parseInt(startTimeParts[0]) * 60 + parseInt(startTimeParts[1]) const endMinutes = - parseInt(endTimeParts[0]) * 60 + parseInt(endTimeParts[1]); + parseInt(endTimeParts[0]) * 60 + parseInt(endTimeParts[1]) if (endMinutes <= startMinutes) { - isDateTimeValid = false; - dateTimeError = "End time must be after start time"; + isDateTimeValid = false + dateTimeError = 'End time must be after start time' } } else { - isDateTimeValid = false; - dateTimeError = "Invalid time format"; + isDateTimeValid = false + dateTimeError = 'Invalid time format' } } } - const isValid = isDateTimeValid; + const isValid = isDateTimeValid return { isValid, errors: { - dateTime: showValidationErrors ? dateTimeError : "", - }, - }; + dateTime: showValidationErrors ? dateTimeError : '' + } + } } diff --git a/src/components/Loading/Loading.tsx b/src/components/Loading/Loading.tsx index bec86cd..a46a76a 100644 --- a/src/components/Loading/Loading.tsx +++ b/src/components/Loading/Loading.tsx @@ -1,41 +1,41 @@ -import React from "react"; -import { DotLottieReact } from "@lottiefiles/dotlottie-react"; -import { Box } from "@linagora/twake-mui"; -import twakeLogo from "../../static/twake-workplace.svg"; +import React from 'react' +import { DotLottieReact } from '@lottiefiles/dotlottie-react' +import { Box } from '@linagora/twake-mui' +import twakeLogo from '../../static/twake-workplace.svg' export function Loading() { return ( - ); + ) } diff --git a/src/components/Loading/SnackBarAlert.tsx b/src/components/Loading/SnackBarAlert.tsx index 4681dcb..3d1f856 100644 --- a/src/components/Loading/SnackBarAlert.tsx +++ b/src/components/Loading/SnackBarAlert.tsx @@ -1,31 +1,31 @@ -import type { AlertColor } from "@linagora/twake-mui"; -import { Alert, Snackbar } from "@linagora/twake-mui"; +import type { AlertColor } from '@linagora/twake-mui' +import { Alert, Snackbar } from '@linagora/twake-mui' export function SnackbarAlert({ open, setOpen, message, - severity = "success", + severity = 'success' }: { - open: boolean; - setOpen: (o: boolean) => void; - message: string; - severity?: AlertColor; + open: boolean + setOpen: (o: boolean) => void + message: string + severity?: AlertColor }) { return ( setOpen(false)} - anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setOpen(false)} - sx={{ width: "100%" }} + sx={{ width: '100%' }} > {message} - ); + ) } diff --git a/src/components/Menubar/EventSearchBar.tsx b/src/components/Menubar/EventSearchBar.tsx index aa01dea..d0f48ff 100644 --- a/src/components/Menubar/EventSearchBar.tsx +++ b/src/components/Menubar/EventSearchBar.tsx @@ -1,10 +1,10 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { selectCalendars } from "@/app/selectors/selectCalendars"; -import { searchEventsAsync } from "@/features/Search/SearchSlice"; -import { setView } from "@/features/Settings/SettingsSlice"; -import { userAttendee } from "@/features/User/models/attendee"; -import { createAttendee } from "@/features/User/models/attendee.mapper"; -import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { selectCalendars } from '@/app/selectors/selectCalendars' +import { searchEventsAsync } from '@/features/Search/SearchSlice' +import { setView } from '@/features/Settings/SettingsSlice' +import { userAttendee } from '@/features/User/models/attendee' +import { createAttendee } from '@/features/User/models/attendee.mapper' +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' import { Box, Button, @@ -21,199 +21,199 @@ import { Stack, TextField, Typography, - type AutocompleteRenderInputParams, -} from "@linagora/twake-mui"; -import HighlightOffIcon from "@mui/icons-material/HighlightOff"; -import SearchIcon from "@mui/icons-material/Search"; -import TuneIcon from "@mui/icons-material/Tune"; -import { useEffect, useRef, useState } from "react"; -import { useI18n } from "twake-i18n"; -import UserSearch from "../Attendees/AttendeeSearch"; -import { PeopleSearch, User } from "../Attendees/PeopleSearch"; -import { CalendarItemList } from "../Calendar/CalendarItemList"; + type AutocompleteRenderInputParams +} from '@linagora/twake-mui' +import HighlightOffIcon from '@mui/icons-material/HighlightOff' +import SearchIcon from '@mui/icons-material/Search' +import TuneIcon from '@mui/icons-material/Tune' +import { useEffect, useRef, useState } from 'react' +import { useI18n } from 'twake-i18n' +import UserSearch from '../Attendees/AttendeeSearch' +import { PeopleSearch, User } from '../Attendees/PeopleSearch' +import { CalendarItemList } from '../Calendar/CalendarItemList' -const SEARCH_OBJECT_TYPES = ["user", "contact"]; +const SEARCH_OBJECT_TYPES = ['user', 'contact'] export default function SearchBar() { - const { t } = useI18n(); - const dispatch = useAppDispatch(); - const calendars = useAppSelector(selectCalendars); - const userId = useAppSelector((state) => state.user.userData?.openpaasId); + const { t } = useI18n() + const dispatch = useAppDispatch() + const calendars = useAppSelector(selectCalendars) + const userId = useAppSelector(state => state.user.userData?.openpaasId) const personnalCalendars = userId - ? calendars.filter((c) => extractEventBaseUuid(c.id) === userId) - : []; + ? calendars.filter(c => extractEventBaseUuid(c.id) === userId) + : [] - const [search, setSearch] = useState(""); - const [selectedContacts, setSelectedContacts] = useState([]); - const [extended, setExtended] = useState(false); + const [search, setSearch] = useState('') + const [selectedContacts, setSelectedContacts] = useState([]) + const [extended, setExtended] = useState(false) const [filters, setFilters] = useState({ - searchIn: "my-calendars", - keywords: "", + searchIn: 'my-calendars', + keywords: '', organizers: [] as userAttendee[], - attendees: [] as userAttendee[], - }); + attendees: [] as userAttendee[] + }) - const [anchorEl, setAnchorEl] = useState(null); - const filterOpen = Boolean(anchorEl); - const [filterError, setFilterError] = useState(false); + const [anchorEl, setAnchorEl] = useState(null) + const filterOpen = Boolean(anchorEl) + const [filterError, setFilterError] = useState(false) const searchWidth = { - xs: "10vw", - sm: "20vw", - md: "35vw", - xl: "35vw", - "@media (min-width: 2000px)": { - width: "55vw", - }, - }; + xs: '10vw', + sm: '20vw', + md: '35vw', + xl: '35vw', + '@media (min-width: 2000px)': { + width: '55vw' + } + } - const inputRef = useRef(null); - const containerRef = useRef(null); - const shouldCollapseRef = useRef(false); + const inputRef = useRef(null) + const containerRef = useRef(null) + const shouldCollapseRef = useRef(false) - type FilterField = "searchIn" | "keywords" | "organizers" | "attendees"; + type FilterField = 'searchIn' | 'keywords' | 'organizers' | 'attendees' const handleFilterChange = ( field: FilterField, value: string | userAttendee[] ) => { - setFilters((prev) => ({ ...prev, [field]: value })); - if (field === "organizers") { + setFilters(prev => ({ ...prev, [field]: value })) + if (field === 'organizers') { setSelectedContacts( (value as userAttendee[]).map((a: userAttendee) => ({ displayName: a.cn ?? a.cal_address, - email: a.cal_address || "", + email: a.cal_address || '' })) - ); + ) } - }; + } function buildQuery( searchQuery: string, filters: { - searchIn: string; - keywords: string; - organizers: userAttendee[]; - attendees: userAttendee[]; + searchIn: string + keywords: string + organizers: userAttendee[] + attendees: userAttendee[] } ) { - const trimmedSearch = searchQuery.trim(); - const trimmedKeywords = filters.keywords.trim(); + const trimmedSearch = searchQuery.trim() + const trimmedKeywords = filters.keywords.trim() // Block search if all search criteria are empty const hasSearchCriteria = trimmedSearch || trimmedKeywords || filters.organizers.length > 0 || - filters.attendees.length > 0; + filters.attendees.length > 0 if (!hasSearchCriteria) { - return; + return } - let searchInCalendars: string[]; + let searchInCalendars: string[] - if (filters.searchIn === "" || !filters.searchIn) { - searchInCalendars = calendars.map((c) => c.id); - } else if (filters.searchIn === "my-calendars") { - searchInCalendars = personnalCalendars.map((c) => c.id); + if (filters.searchIn === '' || !filters.searchIn) { + searchInCalendars = calendars.map(c => c.id) + } else if (filters.searchIn === 'my-calendars') { + searchInCalendars = personnalCalendars.map(c => c.id) } else { - searchInCalendars = [filters.searchIn]; + searchInCalendars = [filters.searchIn] } const cleanedFilters = { keywords: trimmedKeywords, - organizers: filters.organizers.map((u) => u.cal_address), - attendees: filters.attendees.map((u) => u.cal_address), - searchIn: searchInCalendars, - }; + organizers: filters.organizers.map(u => u.cal_address), + attendees: filters.attendees.map(u => u.cal_address), + searchIn: searchInCalendars + } return { search: trimmedSearch, - filters: cleanedFilters, - }; + filters: cleanedFilters + } } const handleClearFilters = () => { setFilters({ - searchIn: "my-calendars", - keywords: "", + searchIn: 'my-calendars', + keywords: '', organizers: [] as userAttendee[], - attendees: [] as userAttendee[], - }); - setAnchorEl(null); - setFilterError(false); - }; + attendees: [] as userAttendee[] + }) + setAnchorEl(null) + setFilterError(false) + } const handleContactSelect = (contacts: User[]) => { - setSelectedContacts(contacts); - setSearch(""); + setSelectedContacts(contacts) + setSearch('') if (contacts.length > 0) { - handleSearch("", { + handleSearch('', { ...filters, - organizers: contacts.map((contact) => + organizers: contacts.map(contact => createAttendee({ cal_address: contact.email, - cn: contact.displayName, + cn: contact.displayName }) - ), - }); + ) + }) } - }; + } const handleSearch = async ( searchQuery: string, filters: { - searchIn: string; - keywords: string; - organizers: userAttendee[]; - attendees: userAttendee[]; + searchIn: string + keywords: string + organizers: userAttendee[] + attendees: userAttendee[] } ) => { - const cleanedQuery = buildQuery(searchQuery, filters); + const cleanedQuery = buildQuery(searchQuery, filters) if (cleanedQuery) { - dispatch(searchEventsAsync(cleanedQuery)); - dispatch(setView("search")); - setAnchorEl(null); + await dispatch(searchEventsAsync(cleanedQuery)) + dispatch(setView('search')) + setAnchorEl(null) } else { if (filterOpen) { - setFilterError(true); + setFilterError(true) } } - }; + } useEffect(() => { function handleClickOutside(event: MouseEvent) { - const target = event.target as Node; + const target = event.target as Node if (filterOpen) { - return; + return } if ( containerRef.current?.contains(target) || inputRef.current?.contains(target) || - (target as HTMLElement).closest(".MuiAutocomplete-popper") + (target as HTMLElement).closest('.MuiAutocomplete-popper') ) { - return; + return } if (!search.trim() && selectedContacts.length === 0) { - setExtended(false); + setExtended(false) } } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [filterOpen, search, selectedContacts]); + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [filterOpen, search, selectedContacts]) return ( <> {!extended && ( @@ -226,29 +226,29 @@ export default function SearchBar() { { - handleContactSelect(users); + handleContactSelect(users) }} objectTypes={SEARCH_OBJECT_TYPES} onToggleEventPreview={() => {}} customSlotProps={{ popper: { anchorEl: containerRef.current, - placement: "bottom-start", + placement: 'bottom-start', sx: { minWidth: searchWidth, - "& .MuiPaper-root": { - width: "100%", - }, + '& .MuiPaper-root': { + width: '100%' + } }, modifiers: [ { - name: "offset", + name: 'offset', options: { - offset: [0, 8], - }, - }, - ], - }, + offset: [0, 8] + } + } + ] + } }} customRenderInput={( params: AutocompleteRenderInputParams, @@ -259,51 +259,51 @@ export default function SearchBar() { {...params} fullWidth autoFocus - placeholder={t("common.search")} + placeholder={t('common.search')} value={query} - inputRef={(el) => { - inputRef.current = el; - const ref = params.InputProps.ref; - if (typeof ref === "function") { - ref(el); - } else if (ref && "current" in ref) { - ( + inputRef={el => { + inputRef.current = el + const ref = params.InputProps.ref + if (typeof ref === 'function') { + ref(el) + } else if (ref && 'current' in ref) { + ;( ref as React.MutableRefObject - ).current = el; + ).current = el } }} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSearch(query, filters); + onKeyDown={e => { + if (e.key === 'Enter') { + handleSearch(query, filters) } }} - onChange={(e) => { - const value = e.target.value; - setQuery(value); - setSearch(value); + onChange={e => { + const value = e.target.value + setQuery(value) + setSearch(value) }} variant="outlined" sx={{ - borderRadius: "999px", - "& .MuiInputBase-input": { padding: "12px 10px" }, - animation: "scaleIn 0.25s ease-out", - "@keyframes scaleIn": { - from: { transform: "scaleX(0)", opacity: 0 }, - to: { transform: "scaleX(1)", opacity: 1 }, + borderRadius: '999px', + '& .MuiInputBase-input': { padding: '12px 10px' }, + animation: 'scaleIn 0.25s ease-out', + '@keyframes scaleIn': { + from: { transform: 'scaleX(0)', opacity: 0 }, + to: { transform: 'scaleX(1)', opacity: 1 } }, - transformOrigin: "right", - "& .MuiOutlinedInput-root": { - borderRadius: "999px", + transformOrigin: 'right', + '& .MuiOutlinedInput-root': { + borderRadius: '999px', height: 40, - padding: "0 10px", - }, + padding: '0 10px' + } }} InputProps={{ ...params.InputProps, startAdornment: ( <> - + {params.InputProps.startAdornment} @@ -312,38 +312,38 @@ export default function SearchBar() { {(query || selectedContacts.length > 0) && ( { - setQuery(""); - setSearch(""); - handleFilterChange("keywords", ""); - setSelectedContacts([]); + setQuery('') + setSearch('') + handleFilterChange('keywords', '') + setSelectedContacts([]) }} > )} e.preventDefault()} + aria-label={t('search.filter.filters')} + onMouseDown={e => e.preventDefault()} onClick={() => { - setAnchorEl(containerRef.current); - handleFilterChange("keywords", query); + setAnchorEl(containerRef.current) + handleFilterChange('keywords', query) handleFilterChange( - "organizers", + 'organizers', selectedContacts.map((attendee: User) => createAttendee({ cal_address: attendee.email, - cn: attendee.displayName, + cn: attendee.displayName }) ) - ); + ) }} > - ), + ) }} /> )} @@ -355,11 +355,11 @@ export default function SearchBar() { open={filterOpen} anchorEl={anchorEl} onClose={handleClearFilters} - anchorOrigin={{ vertical: "bottom", horizontal: "right" }} - transformOrigin={{ vertical: "top", horizontal: "right" }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} slotProps={{ paper: { - sx: { mt: 1.2, width: extended ? searchWidth : "auto" }, + sx: { mt: 1.2, width: extended ? searchWidth : 'auto' } }, transition: { onExited: () => { @@ -368,11 +368,11 @@ export default function SearchBar() { selectedContacts.length === 0 && shouldCollapseRef.current ) { - setExtended(false); + setExtended(false) } - shouldCollapseRef.current = false; - }, - }, + shouldCollapseRef.current = false + } + } }} > @@ -380,56 +380,54 @@ export default function SearchBar() { - {t("search.searchIn")} + {t('search.searchIn')} @@ -437,25 +435,25 @@ export default function SearchBar() { - {t("search.keywords")} + {t('search.keywords')} { - handleFilterChange("keywords", e.target.value); + onChange={e => { + handleFilterChange('keywords', e.target.value) if (e.target.value.trim()) { - setFilterError(false); + setFilterError(false) } }} size="small" @@ -464,21 +462,21 @@ export default function SearchBar() { - {t("search.organizers")} + {t('search.organizers')} { - handleFilterChange("organizers", users); + handleFilterChange('organizers', users) if (users.length > 0) { - setFilterError(false); + setFilterError(false) } }} /> @@ -486,21 +484,21 @@ export default function SearchBar() { - {t("search.participants")} + {t('search.participants')} { - handleFilterChange("attendees", users); + handleFilterChange('attendees', users) if (users.length > 0) { - setFilterError(false); + setFilterError(false) } }} /> @@ -508,27 +506,27 @@ export default function SearchBar() { - + - ); + ) } diff --git a/src/components/Menubar/Menubar.tsx b/src/components/Menubar/Menubar.tsx index a179f14..d62abed 100644 --- a/src/components/Menubar/Menubar.tsx +++ b/src/components/Menubar/Menubar.tsx @@ -1,11 +1,11 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { setView } from "@/features/Settings/SettingsSlice"; -import { Logout } from "@/features/User/oidcAuth"; -import logo from "@/static/header-logo.svg"; -import { getInitials, stringToGradient } from "@/utils/avatarUtils"; -import { getUserDisplayName } from "@/utils/userUtils"; -import { redirectTo } from "@/utils/navigation"; -import { CalendarApi } from "@fullcalendar/core"; +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { setView } from '@/features/Settings/SettingsSlice' +import { Logout } from '@/features/User/oidcAuth' +import logo from '@/static/header-logo.svg' +import { getInitials, stringToGradient } from '@/utils/avatarUtils' +import { getUserDisplayName } from '@/utils/userUtils' +import { redirectTo } from '@/utils/navigation' +import { CalendarApi } from '@fullcalendar/core' import { Avatar, Box, @@ -18,36 +18,36 @@ import { MenuItem, Popover, Select, - Typography, -} from "@linagora/twake-mui"; -import WidgetsOutlinedIcon from "@mui/icons-material/WidgetsOutlined"; -import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; -import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -import LogoutIcon from "@mui/icons-material/Logout"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; -import React, { useEffect, useState } from "react"; -import { push } from "redux-first-history"; -import { useI18n } from "twake-i18n"; -import SearchBar from "./EventSearchBar"; -import "./Menubar.styl"; + Typography +} from '@linagora/twake-mui' +import WidgetsOutlinedIcon from '@mui/icons-material/WidgetsOutlined' +import HelpOutlineIcon from '@mui/icons-material/HelpOutline' +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft' +import ChevronRightIcon from '@mui/icons-material/ChevronRight' +import LogoutIcon from '@mui/icons-material/Logout' +import RefreshIcon from '@mui/icons-material/Refresh' +import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined' +import React, { useEffect, useState } from 'react' +import { push } from 'redux-first-history' +import { useI18n } from 'twake-i18n' +import SearchBar from './EventSearchBar' +import './Menubar.styl' export type AppIconProps = { - name: string; - link: string; - icon: string; -}; + name: string + link: string + icon: string +} export type MenubarProps = { - calendarRef: React.RefObject; - onRefresh: () => void; - currentDate: Date; - onDateChange?: (date: Date) => void; - currentView: string; - onViewChange?: (view: string) => void; - isIframe?: boolean; -}; + calendarRef: React.RefObject + onRefresh: () => void + currentDate: Date + onDateChange?: (date: Date) => void + currentView: string + onViewChange?: (view: string) => void + isIframe?: boolean +} export function Menubar({ calendarRef, @@ -56,103 +56,103 @@ export function Menubar({ onDateChange, currentView, onViewChange, - isIframe, + isIframe }: MenubarProps) { - const { t } = useI18n(); // deliberately NOT using f() - const user = useAppSelector((state) => state.user.userData); - const applist: AppIconProps[] = window.appList ?? []; - const supportLink = window.SUPPORT_URL; - const [anchorEl, setAnchorEl] = useState(null); + const { t } = useI18n() // deliberately NOT using f() + const user = useAppSelector(state => state.user.userData) + const applist: AppIconProps[] = window.appList ?? [] + const supportLink = window.SUPPORT_URL + const [anchorEl, setAnchorEl] = useState(null) const [userMenuAnchorEl, setUserMenuAnchorEl] = useState( null - ); - const dispatch = useAppDispatch(); + ) + const dispatch = useAppDispatch() useEffect(() => { if (!user) { - dispatch(push("/")); + dispatch(push('/')) } - }, [dispatch, user]); + }, [dispatch, user]) if (!user) { - return null; + return null } const handleOpen = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; + setAnchorEl(event.currentTarget) + } const handleClose = () => { - setAnchorEl(null); - }; + setAnchorEl(null) + } - const handleNavigation = async (action: "prev" | "next" | "today") => { - if (!calendarRef.current) return; - await dispatch(setView("calendar")); + const handleNavigation = async (action: 'prev' | 'next' | 'today') => { + if (!calendarRef.current) return + await dispatch(setView('calendar')) switch (action) { - case "prev": - calendarRef.current.prev(); - break; - case "next": - calendarRef.current.next(); - break; - case "today": - calendarRef.current.today(); - break; + case 'prev': + calendarRef.current.prev() + break + case 'next': + calendarRef.current.next() + break + case 'today': + calendarRef.current.today() + break } // Notify parent about date change after navigation if (onDateChange) { - const newDate = calendarRef.current.getDate(); - onDateChange(newDate); + const newDate = calendarRef.current.getDate() + onDateChange(newDate) } - }; + } const handleViewChange = async (view: string) => { - if (!calendarRef.current) return; - await dispatch(setView("calendar")); + if (!calendarRef.current) return + await dispatch(setView('calendar')) - calendarRef.current.changeView(view); + calendarRef.current.changeView(view) // Notify parent about view change if (onViewChange) { - onViewChange(view); + onViewChange(view) } // Notify parent about date change after view change if (onDateChange) { - const newDate = calendarRef.current.getDate(); - onDateChange(newDate); + const newDate = calendarRef.current.getDate() + onDateChange(newDate) } - }; + } - const open = Boolean(anchorEl); - const userMenuOpen = Boolean(userMenuAnchorEl); + const open = Boolean(anchorEl) + const userMenuOpen = Boolean(userMenuAnchorEl) const handleUserMenuClick = (event: React.MouseEvent) => { - setUserMenuAnchorEl(event.currentTarget); - }; + setUserMenuAnchorEl(event.currentTarget) + } const handleUserMenuClose = () => { - setUserMenuAnchorEl(null); - }; + setUserMenuAnchorEl(null) + } const handleSettingsClick = () => { - dispatch(setView("settings")); - handleUserMenuClose(); - }; + dispatch(setView('settings')) + handleUserMenuClose() + } const handleLogoutClick = async () => { - const logoutUrl = await Logout(); - sessionStorage.removeItem("tokenSet"); - redirectTo(logoutUrl.href); - handleUserMenuClose(); - }; + const logoutUrl = await Logout() + sessionStorage.removeItem('tokenSet') + redirectTo(logoutUrl.href) + handleUserMenuClose() + } // Use i18n for month names instead of date-fns - const monthIndex = currentDate.getMonth(); - const year = currentDate.getFullYear(); - const monthName = t(`months.standalone.${monthIndex}`); - const dateLabel = `${monthName} ${year}`; + const monthIndex = currentDate.getMonth() + const year = currentDate.getFullYear() + const monthName = t(`months.standalone.${monthIndex}`) + const dateLabel = `${monthName} ${year}` return ( <> @@ -174,26 +174,26 @@ export function Menubar({ variant="outlined" size="medium" sx={{ - "& button:first-of-type": { - borderRadius: "12px 0 0 12px", - }, - "& button:last-of-type": { - borderRadius: "0 12px 12px 0", + '& button:first-of-type': { + borderRadius: '12px 0 0 12px' }, + '& button:last-of-type': { + borderRadius: '0 12px 12px 0' + } }} > - @@ -214,8 +214,8 @@ export function Menubar({ @@ -229,23 +229,23 @@ export function Menubar({ > @@ -260,8 +260,8 @@ export function Menubar({ target="_blank" rel="noopener noreferrer" style={{ marginRight: 8 }} - aria-label={t("menubar.help")} - title={t("menubar.help")} + aria-label={t('menubar.help')} + title={t('menubar.help')} > @@ -273,8 +273,8 @@ export function Menubar({ @@ -286,13 +286,13 @@ export function Menubar({
{!isIframe ? ( {getInitials(getUserDisplayName(user))} @@ -308,20 +308,20 @@ export function Menubar({ anchorEl={anchorEl} onClose={handleClose} anchorOrigin={{ - vertical: "bottom", - horizontal: "right", + vertical: 'bottom', + horizontal: 'right' }} transformOrigin={{ - vertical: "top", - horizontal: "right", + vertical: 'top', + horizontal: 'right' }} PaperProps={{ sx: { minWidth: 230, mt: 2, - p: "14px 8px", - borderRadius: "14px", - }, + p: '14px 8px', + borderRadius: '14px' + } }} >
@@ -336,43 +336,43 @@ export function Menubar({ anchorEl={userMenuAnchorEl} onClose={handleUserMenuClose} anchorOrigin={{ - vertical: "bottom", - horizontal: "right", + vertical: 'bottom', + horizontal: 'right' }} transformOrigin={{ - vertical: "top", - horizontal: "right", + vertical: 'top', + horizontal: 'right' }} PaperProps={{ sx: { minWidth: 280, mt: 1, - padding: "0 !important", - borderRadius: "14px", - }, + padding: '0 !important', + borderRadius: '14px' + } }} > {getInitials(getUserDisplayName(user))} {getUserDisplayName(user)} @@ -380,7 +380,7 @@ export function Menubar({ {user?.email} @@ -390,74 +390,74 @@ export function Menubar({ - {t("menubar.settings") || "Settings"} + {t('menubar.settings') || 'Settings'} - {t("menubar.logout") || "Logout"} + {t('menubar.logout') || 'Logout'} - ); + ) } export type MainTitleProps = { - calendarRef: React.RefObject; - currentView: string; - onViewChange?: (view: string) => void; - onDateChange?: (date: Date) => void; -}; + calendarRef: React.RefObject + currentView: string + onViewChange?: (view: string) => void + onDateChange?: (date: Date) => void +} export function MainTitle({ calendarRef, currentView, onViewChange, - onDateChange, + onDateChange }: MainTitleProps) { - const { t } = useI18n(); - const dispatch = useAppDispatch(); + const { t } = useI18n() + const dispatch = useAppDispatch() const handleLogoClick = async () => { - if (!calendarRef.current) return; + if (!calendarRef.current) return - await dispatch(setView("calendar")); + await dispatch(setView('calendar')) - if (currentView !== "timeGridWeek") { - calendarRef.current.changeView("timeGridWeek"); + if (currentView !== 'timeGridWeek') { + calendarRef.current.changeView('timeGridWeek') if (onViewChange) { - onViewChange("timeGridWeek"); + onViewChange('timeGridWeek') } } - calendarRef.current.today(); + calendarRef.current.today() if (onDateChange) { - const newDate = calendarRef.current.getDate(); - onDateChange(newDate); + const newDate = calendarRef.current.getDate() + onDateChange(newDate) } - }; + } return (
{t("menubar.logoAlt")}
- ); + ) } function AppIcon({ prop }: { prop: AppIconProps }) { @@ -468,16 +468,16 @@ function AppIcon({ prop }: { prop: AppIconProps }) { target="_blank" rel="noreferrer" sx={{ - textDecoration: "none", - color: "inherit", - display: "flex", - flexDirection: "column", - alignItems: "center", - p: "8px 12px 5px", - borderRadius: "14px", - "&:hover": { - backgroundColor: "action.hover", - }, + textDecoration: 'none', + color: 'inherit', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + p: '8px 12px 5px', + borderRadius: '14px', + '&:hover': { + backgroundColor: 'action.hover' + } }} > - + {prop.name} - ); + ) } diff --git a/src/components/Timezone/TimezoneAutocomplete.tsx b/src/components/Timezone/TimezoneAutocomplete.tsx index aa17098..94f4a5c 100644 --- a/src/components/Timezone/TimezoneAutocomplete.tsx +++ b/src/components/Timezone/TimezoneAutocomplete.tsx @@ -1,29 +1,29 @@ -import { Autocomplete, TextField } from "@linagora/twake-mui"; -import { PublicOutlined as TimezoneIcon } from "@mui/icons-material"; -import { useMemo } from "react"; +import { Autocomplete, TextField } from '@linagora/twake-mui' +import { PublicOutlined as TimezoneIcon } from '@mui/icons-material' +import { useMemo } from 'react' interface TimezoneOption { - value: string; - label: string; - offset: string; + value: string + label: string + offset: string } interface TimezoneAutocompleteProps { - value: string; - onChange: (timezone: string) => void; - zones: string[]; - getTimezoneOffset: (tzName: string) => string; - showIcon?: boolean; - inputRef?: React.Ref; - width?: number | string; - size?: "small" | "medium"; - placeholder?: string; - inputFontSize?: string; - inputPadding?: string; - onClose?: () => void; - disableClearable?: boolean; - hideBorder?: boolean; - openOnFocus?: boolean; + value: string + onChange: (timezone: string) => void + zones: string[] + getTimezoneOffset: (tzName: string) => string + showIcon?: boolean + inputRef?: React.Ref + width?: number | string + size?: 'small' | 'medium' + placeholder?: string + inputFontSize?: string + inputPadding?: string + onClose?: () => void + disableClearable?: boolean + hideBorder?: boolean + openOnFocus?: boolean } export function TimezoneAutocomplete({ @@ -34,24 +34,24 @@ export function TimezoneAutocomplete({ showIcon = false, inputRef, width, - size = "small", - placeholder = "Select timezone", + size = 'small', + placeholder = 'Select timezone', inputFontSize, inputPadding, onClose, disableClearable = false, hideBorder = false, - openOnFocus = false, + openOnFocus = false }: TimezoneAutocompleteProps) { const options = useMemo(() => { - return zones.map((tz) => ({ + return zones.map(tz => ({ value: tz, - label: tz.replace(/_/g, " "), - offset: getTimezoneOffset(tz), - })); - }, [zones, getTimezoneOffset]); + label: tz.replace(/_/g, ' '), + offset: getTimezoneOffset(tz) + })) + }, [zones, getTimezoneOffset]) - const selectedOption = options.find((opt) => opt.value === value) || null; + const selectedOption = options.find(opt => opt.value === value) || null return ( { if (newValue) { - onChange(newValue.value); - onClose?.(); + onChange(newValue.value) + onClose?.() } }} options={options} - getOptionLabel={(option) => `${option.label} (${option.offset})`} + getOptionLabel={option => `${option.label} (${option.offset})`} size={size} sx={width ? { width } : undefined} disableClearable={disableClearable} - renderInput={(params) => ( + renderInput={params => ( e.target.select()} + onFocus={e => e.target.select()} variant="outlined" autoComplete="off" inputRef={inputRef} sx={ hideBorder ? { - "& .MuiOutlinedInput-notchedOutline": { - border: "none", + '& .MuiOutlinedInput-notchedOutline': { + border: 'none' }, - "&:hover .MuiOutlinedInput-notchedOutline": { - border: "none", - }, - "&.Mui-focused .MuiOutlinedInput-notchedOutline": { - border: "none", + '&:hover .MuiOutlinedInput-notchedOutline': { + border: 'none' }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { + border: 'none' + } } : undefined } @@ -99,7 +99,7 @@ export function TimezoneAutocomplete({ {params.InputProps.startAdornment} @@ -111,18 +111,18 @@ export function TimezoneAutocomplete({ ? { style: { ...(inputFontSize ? { fontSize: inputFontSize } : {}), - ...(inputPadding ? { padding: inputPadding } : {}), - }, + ...(inputPadding ? { padding: inputPadding } : {}) + } } - : {}), - }, + : {}) + } }} inputProps={{ ...params.inputProps, - autoComplete: "new-password", + autoComplete: 'new-password' }} /> )} /> - ); + ) } diff --git a/src/features/Calendars/CalendarApi.ts b/src/features/Calendars/CalendarApi.ts index 744d0b1..22ea7c9 100644 --- a/src/features/Calendars/CalendarApi.ts +++ b/src/features/Calendars/CalendarApi.ts @@ -1,20 +1,20 @@ -import { api } from "@/utils/apiUtils"; -import { CalendarInput, CalendarList } from "./types/CalendarData"; +import { api } from '@/utils/apiUtils' +import { CalendarInput, CalendarList } from './types/CalendarData' export async function getCalendars( userId: string, - scope: string = "personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true", + scope: string = 'personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true', signal?: AbortSignal ): Promise { const calendars = await api .get(`dav/calendars/${userId}.json?${scope}`, { headers: { - Accept: "application/calendar+json", + Accept: 'application/calendar+json' }, - signal, + signal }) - .json(); - return calendars as CalendarList; + .json() + return calendars as CalendarList } export async function getCalendar( @@ -23,17 +23,17 @@ export async function getCalendar( signal?: AbortSignal ) { const response = await api(`dav/calendars/${id}.json`, { - method: "REPORT", + method: 'REPORT', headers: { - Accept: "application/json, text/plain, */*", + Accept: 'application/json, text/plain, */*' }, body: JSON.stringify({ - match, + match }), - signal, - }); - const calendar = await response.json(); - return calendar; + signal + }) + const calendar = await response.json() + return calendar } export async function postCalendar( @@ -45,16 +45,16 @@ export async function postCalendar( ) { const response = await api.post(`dav/calendars/${userId}.json`, { headers: { - Accept: "application/json, text/plain, */*", + Accept: 'application/json, text/plain, */*' }, body: JSON.stringify({ id: calId, - "dav:name": name, - "apple:color": color.light, - "caldav:description": desc, - }), - }); - return response; + 'dav:name': name, + 'apple:color': color.light, + 'caldav:description': desc + }) + }) + return response } export async function addSharedCalendar( @@ -64,24 +64,24 @@ export async function addSharedCalendar( ) { const response = await api.post(`dav/calendars/${userId}.json`, { headers: { - Accept: "application/json, text/plain, */*", + Accept: 'application/json, text/plain, */*' }, body: JSON.stringify({ id: calId, ...cal.cal, - "calendarserver:source": { + 'calendarserver:source': { acl: cal.cal.acl, calendarHomeId: cal.cal.id, - color: cal.cal["apple:color"], - description: cal.cal["caldav:description"], + color: cal.cal['apple:color'], + description: cal.cal['caldav:description'], href: cal.cal._links.self.href, id: cal.cal.id, invite: cal.cal.invite, - name: cal.cal["dav:name"], - }, - }), - }); - return response; + name: cal.cal['dav:name'] + } + }) + }) + return response } export async function proppatchCalendar( @@ -89,58 +89,58 @@ export async function proppatchCalendar( patch: { name: string; desc: string; color: Record } ) { const response = await api(`dav${calLink}`, { - method: "PROPPATCH", + method: 'PROPPATCH', headers: { - Accept: "application/json, text/plain, */*", + Accept: 'application/json, text/plain, */*' }, body: JSON.stringify({ - "dav:name": patch.name, - "caldav:description": patch.desc, - "apple:color": patch.color.light, - }), - }); - return response; + 'dav:name': patch.name, + 'caldav:description': patch.desc, + 'apple:color': patch.color.light + }) + }) + return response } export async function removeCalendar(calLink: string) { const response = await api(`dav${calLink}`, { - method: "DELETE", + method: 'DELETE', headers: { - Accept: "application/json, text/plain, */*", - }, - }); - return response; + Accept: 'application/json, text/plain, */*' + } + }) + return response } export async function updateAclCalendar(calLink: string, request: string) { const response = await api(`dav${calLink}`, { - method: "ACL", + method: 'ACL', headers: { - Accept: "application/json", + Accept: 'application/json' }, - body: JSON.stringify({ public_right: request }), - }); - return response; + body: JSON.stringify({ public_right: request }) + }) + return response } export async function getSecretLink(calLink: string, reset: boolean) { const response = await api .get(`calendar/api${calLink}/secret-link?shouldResetLink=${reset}`, { headers: { - Accept: "application/json, text/plain, */*", - }, + Accept: 'application/json, text/plain, */*' + } }) - .json(); - return response as { secretLink: string }; + .json() + return response as { secretLink: string } } export async function exportCalendar(calLink: string) { const response = await api .get(`dav${calLink}?export`, { headers: { - Accept: "application/calendar", - }, + Accept: 'application/calendar' + } }) - .text(); - return response; + .text() + return response } diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 79cdcfe..f300344 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -1,9 +1,9 @@ -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 { 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, createCalendarAsync, @@ -21,48 +21,48 @@ import { refreshCalendarWithSyncToken, removeCalendarAsync, updateEventInstanceAsync, - updateSeriesAsync, -} from "./services"; + updateSeriesAsync +} from './services' const CalendarSlice = createSlice({ - name: "calendars", + name: 'calendars', initialState: { list: {} as Record, templist: {} as Record, pending: true, - error: null as string | null, + error: null as string | null } as { - list: Record; - templist: Record; - pending: boolean; - error: string | null; + list: Record + templist: Record + pending: boolean + error: string | null }, reducers: { createCalendar: ( state, action: PayloadAction>> ) => { - const id = Date.now().toString(36); - state.list[id] = {} as Calendar; - state.list[id].name = action.payload.name as string; - state.list[id].color = action.payload.color as Record; - state.list[id].description = action.payload.description as string; - state.list[id].events = {}; + const id = Date.now().toString(36) + state.list[id] = {} as Calendar + state.list[id].name = action.payload.name as string + state.list[id].color = action.payload.color as Record + state.list[id].description = action.payload.description as string + state.list[id].events = {} }, addEvent: ( state, action: PayloadAction<{ calendarUid: string; event: CalendarEvent }> ) => { if (!state.list[action.payload.calendarUid].events) { - state.list[action.payload.calendarUid].events = {}; + state.list[action.payload.calendarUid].events = {} } state.list[action.payload.calendarUid].events[action.payload.event.uid] = - action.payload.event; + action.payload.event state.list[action.payload.calendarUid].events[ action.payload.event.uid ].URL = `/calendars/${action.payload.calendarUid}/${extractEventBaseUuid( action.payload.event.uid - )}.ics`; + )}.ics` }, removeEvent: ( state, @@ -70,50 +70,48 @@ const CalendarSlice = createSlice({ ) => { delete state.list[action.payload.calendarUid].events[ action.payload.eventUid - ]; + ] }, removeTempCal: (state, action: PayloadAction) => { - delete state.templist[action.payload]; + delete state.templist[action.payload] }, emptyEventsCal: ( state, - action: PayloadAction<{ calId?: string; calType?: "temp" }> + action: PayloadAction<{ calId?: string; calType?: 'temp' }> ) => { const cals = - action.payload.calType === "temp" ? state.templist : state.list; + action.payload.calType === 'temp' ? state.templist : state.list if (action.payload.calId) { - cals[action.payload.calId].events = {}; + cals[action.payload.calId].events = {} } else { - Object.keys(state.templist).forEach( - (calId) => (cals[calId].events = {}) - ); + Object.keys(state.templist).forEach(calId => (cals[calId].events = {})) } }, updateEventLocal: ( state, action: PayloadAction<{ calId: string; event: CalendarEvent }> ) => { - const { calId, event } = action.payload; - state.list[calId].events[event.uid] = event; + const { calId, event } = action.payload + state.list[calId].events[event.uid] = event }, clearFetchCache: (state, action: PayloadAction) => { - if (!state.list[action.payload]) return; - state.list[action.payload].lastCacheCleared = Date.now(); + if (!state.list[action.payload]) return + state.list[action.payload].lastCacheCleared = Date.now() }, - clearError: (state) => { - state.error = null; + clearError: state => { + state.error = null }, updateCalColor: ( state, action: PayloadAction<{ - id: string; - color: { light: string; dark: string }; + id: string + color: { light: string; dark: string } }> ) => { - state.list[action.payload.id].color = action.payload.color; - }, + state.list[action.payload.id].color = action.payload.color + } }, - extraReducers: (builder) => { + extraReducers: builder => { builder // Fulfilled cases .addCase( @@ -121,39 +119,39 @@ const CalendarSlice = createSlice({ ( state, action: PayloadAction<{ - importedCalendars: Record; - errors: string; + importedCalendars: Record + errors: string }> ) => { - state.pending = false; + state.pending = false state.error = action.payload.errors.length ? action.payload.errors - : null; + : null Object.entries(action.payload.importedCalendars).forEach( ([id, cal]) => { state.list[id] = { ...cal, - events: state.list[id]?.events || {}, - }; + events: state.list[id]?.events || {} + } } - ); + ) // Remove calendars that no longer exist - Object.keys(state.list).forEach((id) => { + Object.keys(state.list).forEach(id => { if (!action.payload.importedCalendars[id]) { - delete state.list[id]; + delete state.list[id] } - }); + }) } ) .addCase( getTempCalendarsListAsync.fulfilled, (state, action: PayloadAction>) => { - state.pending = false; + state.pending = false Object.keys(action.payload).forEach( - (id) => (state.templist[id] = action.payload[id]) - ); + id => (state.templist[id] = action.payload[id]) + ) } ) .addCase( @@ -161,40 +159,37 @@ const CalendarSlice = createSlice({ ( state, action: PayloadAction<{ - calId: string; - events: CalendarEvent[]; - calType?: string; - syncToken?: string; + calId: string + events: CalendarEvent[] + calType?: string + syncToken?: string }> ) => { - state.pending = false; - const type = action.payload.calType === "temp" ? "templist" : "list"; + state.pending = false + const type = action.payload.calType === 'temp' ? 'templist' : 'list' if (!state[type][action.payload.calId]) { - return; + return } - state[type][action.payload.calId].syncToken = - action.payload.syncToken; - action.payload.events.forEach((event) => { - state[type][action.payload.calId].events[event.uid] = event; - }); - Object.keys(state[type][action.payload.calId].events).forEach( - (id) => { - state[type][action.payload.calId].events[id].color = - state[type][action.payload.calId].color; - state[type][action.payload.calId].events[id].calId = - action.payload.calId; - if (!state[type][action.payload.calId].events[id].timezone) { - state[type][action.payload.calId].events[id].timezone = - browserDefaultTimeZone; - } + state[type][action.payload.calId].syncToken = action.payload.syncToken + action.payload.events.forEach(event => { + state[type][action.payload.calId].events[event.uid] = event + }) + Object.keys(state[type][action.payload.calId].events).forEach(id => { + state[type][action.payload.calId].events[id].color = + state[type][action.payload.calId].color + state[type][action.payload.calId].events[id].calId = + action.payload.calId + if (!state[type][action.payload.calId].events[id].timezone) { + state[type][action.payload.calId].events[id].timezone = + browserDefaultTimeZone } - ); + }) } ) - .addCase(putEventAsync.fulfilled, (state) => { - state.pending = false; - state.error = null; + .addCase(putEventAsync.fulfilled, state => { + state.pending = false + state.error = null }) .addCase( getEventAsync.fulfilled, @@ -202,64 +197,64 @@ const CalendarSlice = createSlice({ state, action: PayloadAction<{ calId: string; event: CalendarEvent }> ) => { - state.pending = false; + state.pending = false if (!state.list[action.payload.calId]) { state.list[action.payload.calId] = { id: action.payload.calId, - events: {}, - } as Calendar; + events: {} + } as Calendar } if ( Object.keys(state.list[action.payload.calId].events).find( (eventId: string) => { - const eventIdBase = extractEventBaseUuid(eventId); - return eventIdBase === action.payload.event.uid; + const eventIdBase = extractEventBaseUuid(eventId) + return eventIdBase === action.payload.event.uid } ) ) { Object.keys(state.list[action.payload.calId].events) - .filter((eventKey) => { - const baseUid = extractEventBaseUuid(eventKey); - return baseUid === action.payload.event.uid; + .filter(eventKey => { + const baseUid = extractEventBaseUuid(eventKey) + return baseUid === action.payload.event.uid }) - .forEach((eventKey) => { + .forEach(eventKey => { state.list[action.payload.calId].events[eventKey] = { ...state.list[action.payload.calId].events[eventKey], repetition: action.payload.event.repetition, - timezone: action.payload.event.timezone, - }; - }); + timezone: action.payload.event.timezone + } + }) } else { state.list[action.payload.calId].events[action.payload.event.uid] = - action.payload.event; + action.payload.event } } ) - .addCase(moveEventAsync.fulfilled, (state) => { - state.pending = false; - state.error = null; + .addCase(moveEventAsync.fulfilled, state => { + state.pending = false + state.error = null }) - .addCase(deleteEventAsync.fulfilled, (state) => { - state.pending = false; - state.error = null; + .addCase(deleteEventAsync.fulfilled, state => { + state.pending = false + state.error = null }) .addCase(deleteEventInstanceAsync.fulfilled, (state, action) => { - state.pending = false; - delete state.list[action.payload.calId].events[action.payload.eventId]; - state.error = null; + state.pending = false + delete state.list[action.payload.calId].events[action.payload.eventId] + state.error = null }) .addCase(updateEventInstanceAsync.fulfilled, (state, action) => { - state.pending = false; + state.pending = false state.list[action.payload.calId].events[action.payload.event.uid] = - action.payload.event; - state.error = null; + action.payload.event + state.error = null }) - .addCase(updateSeriesAsync.fulfilled, (state) => { - state.pending = false; - state.error = null; + .addCase(updateSeriesAsync.fulfilled, state => { + state.pending = false + state.error = null }) .addCase(createCalendarAsync.fulfilled, (state, action) => { - state.pending = false; + state.pending = false state.list[`${action.payload.userId}/${action.payload.calId}`] = { color: action.payload.color, id: `${action.payload.userId}/${action.payload.calId}`, @@ -267,16 +262,16 @@ const CalendarSlice = createSlice({ description: action.payload.desc, name: action.payload.name, owner: action.payload.owner, - events: {}, - } as Calendar; - state.error = null; + events: {} + } as Calendar + state.error = null }) - .addCase(patchCalendarAsync.fulfilled, (state) => { - state.pending = false; - state.error = null; + .addCase(patchCalendarAsync.fulfilled, state => { + state.pending = false + state.error = null }) .addCase(addSharedCalendarAsync.fulfilled, (state, action) => { - state.pending = false; + state.pending = false state.list[action.payload.calId] = { color: action.payload.color, id: action.payload.calId, @@ -284,12 +279,12 @@ const CalendarSlice = createSlice({ description: action.payload.desc, name: action.payload.name, events: {}, - owner: action.payload.owner, - } as Calendar; - state.error = null; + owner: action.payload.owner + } as Calendar + state.error = null }) .addCase(addCalendarResourceAsync.fulfilled, (state, action) => { - state.pending = false; + state.pending = false state.list[action.payload.calId] = { color: action.payload.color, id: action.payload.calId, @@ -297,27 +292,27 @@ const CalendarSlice = createSlice({ description: action.payload.desc, name: action.payload.name, events: {}, - owner: action.payload.owner, - } as Calendar; - state.error = null; + owner: action.payload.owner + } as Calendar + state.error = null }) - .addCase(removeCalendarAsync.fulfilled, (state) => { - state.pending = false; - state.error = null; + .addCase(removeCalendarAsync.fulfilled, state => { + state.pending = false + state.error = null }) .addCase(patchACLCalendarAsync.fulfilled, (state, action) => { - state.pending = false; + state.pending = false state.list[action.payload.calId].visibility = - action.payload.request !== "" ? "public" : "private"; - state.error = null; + action.payload.request !== '' ? 'public' : 'private' + state.error = null }) - .addCase(importEventFromFileAsync.fulfilled, (state) => { - state.pending = false; - state.error = null; + .addCase(importEventFromFileAsync.fulfilled, state => { + state.pending = false + state.error = null }) .addCase(refreshCalendarWithSyncToken.fulfilled, (state, action) => { - state.pending = false; - state.error = null; + state.pending = false + state.error = null const { calId, @@ -325,230 +320,230 @@ const CalendarSlice = createSlice({ createdOrUpdatedEvents, calType, syncToken, - syncStatus, - } = action.payload; + syncStatus + } = action.payload const target = - calType === "temp" ? state.templist[calId] : state.list[calId]; + calType === 'temp' ? state.templist[calId] : state.list[calId] if (!target) { - return; + return } - if (syncStatus === "SUCCESS") { - const deletedSet = new Set(deletedEvents); // working with a Set for deletion avoids O(nxm) complexity + if (syncStatus === 'SUCCESS') { + const deletedSet = new Set(deletedEvents) // working with a Set for deletion avoids O(nxm) complexity Object.values(target.events) - .filter((event) => { - return deletedSet.has(event.URL); + .filter(event => { + return deletedSet.has(event.URL) + }) + .forEach(event => { + delete target.events[event.uid] }) - .forEach((event) => { - delete target.events[event.uid]; - }); for (const event of createdOrUpdatedEvents) { - target.events[event.uid] = event; + target.events[event.uid] = event } - target.syncToken = syncToken; + target.syncToken = syncToken } }) // Pending cases - .addCase(getCalendarDetailAsync.pending, (state) => { - state.pending = true; + .addCase(getCalendarDetailAsync.pending, state => { + state.pending = true }) - .addCase(getEventAsync.pending, (state) => { - state.pending = true; + .addCase(getEventAsync.pending, state => { + state.pending = true }) - .addCase(getCalendarsListAsync.pending, (state) => { - state.pending = true; + .addCase(getCalendarsListAsync.pending, state => { + state.pending = true }) - .addCase(putEventAsync.pending, (state) => { - state.pending = true; + .addCase(putEventAsync.pending, state => { + state.pending = true }) - .addCase(moveEventAsync.pending, (state) => { - state.pending = true; + .addCase(moveEventAsync.pending, state => { + state.pending = true }) - .addCase(deleteEventAsync.pending, (state) => { - state.pending = true; + .addCase(deleteEventAsync.pending, state => { + state.pending = true }) - .addCase(deleteEventInstanceAsync.pending, (state) => { - state.pending = true; + .addCase(deleteEventInstanceAsync.pending, state => { + state.pending = true }) - .addCase(updateEventInstanceAsync.pending, (state) => { - state.pending = true; + .addCase(updateEventInstanceAsync.pending, state => { + state.pending = true }) - .addCase(updateSeriesAsync.pending, (state) => { - state.pending = true; + .addCase(updateSeriesAsync.pending, state => { + state.pending = true }) - .addCase(patchCalendarAsync.pending, (state) => { - state.pending = true; + .addCase(patchCalendarAsync.pending, state => { + state.pending = true }) - .addCase(createCalendarAsync.pending, (state) => { - state.pending = true; + .addCase(createCalendarAsync.pending, state => { + state.pending = true }) - .addCase(getTempCalendarsListAsync.pending, (state) => { - state.pending = true; + .addCase(getTempCalendarsListAsync.pending, state => { + state.pending = true }) - .addCase(addSharedCalendarAsync.pending, (state) => { - state.pending = true; + .addCase(addSharedCalendarAsync.pending, state => { + state.pending = true }) - .addCase(addCalendarResourceAsync.pending, (state) => { - state.pending = true; + .addCase(addCalendarResourceAsync.pending, state => { + state.pending = true }) - .addCase(removeCalendarAsync.pending, (state) => { - state.pending = true; + .addCase(removeCalendarAsync.pending, state => { + state.pending = true }) - .addCase(patchACLCalendarAsync.pending, (state) => { - state.pending = true; + .addCase(patchACLCalendarAsync.pending, state => { + state.pending = true }) - .addCase(importEventFromFileAsync.pending, (state) => { - state.pending = true; + .addCase(importEventFromFileAsync.pending, state => { + state.pending = true }) - .addCase(refreshCalendarWithSyncToken.pending, (state) => { - state.pending = true; + .addCase(refreshCalendarWithSyncToken.pending, state => { + state.pending = true }) // Rejected cases .addCase(getCalendarsListAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false if (action.payload?.status !== 401) { state.error = action.payload?.message || action.error.message || - "Failed to load calendars"; + 'Failed to load calendars' } }) .addCase(getTempCalendarsListAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false if ( - action.payload?.message.includes("aborted") || - action.error.name === "AbortError" + action.payload?.message.includes('aborted') || + action.error.name === 'AbortError' ) { - return; + return } state.error = action.payload?.message || action.error.message || - "Failed to load temporary calendars"; + 'Failed to load temporary calendars' }) .addCase(getCalendarDetailAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false if ( - action.payload?.message.includes("aborted") || - action.error.name === "AbortError" + action.payload?.message.includes('aborted') || + action.error.name === 'AbortError' ) { - return; + return } state.error = action.payload?.message || action.error.message || - "Failed to load calendar details"; + 'Failed to load calendar details' }) .addCase(putEventAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to create event"; + 'Failed to create event' }) .addCase(getEventAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to load event"; + 'Failed to load event' }) .addCase(moveEventAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to move event"; + 'Failed to move event' }) .addCase(deleteEventAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to delete event"; + 'Failed to delete event' }) .addCase(deleteEventInstanceAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to delete event instance"; + 'Failed to delete event instance' }) .addCase(updateEventInstanceAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to update event instance"; + 'Failed to update event instance' }) .addCase(updateSeriesAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to update event series"; + 'Failed to update event series' }) .addCase(patchCalendarAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to update calendar"; + 'Failed to update calendar' }) .addCase(createCalendarAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to create calendar"; + 'Failed to create calendar' }) .addCase(addSharedCalendarAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to add shared calendar"; + 'Failed to add shared calendar' }) .addCase(addCalendarResourceAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to add calendar resource"; + 'Failed to add calendar resource' }) .addCase(removeCalendarAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to remove calendar"; + 'Failed to remove calendar' }) .addCase(patchACLCalendarAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to update calendar permissions"; + 'Failed to update calendar permissions' }) .addCase(importEventFromFileAsync.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to import event from file"; + 'Failed to import event from file' }) .addCase(refreshCalendarWithSyncToken.rejected, (state, action) => { - state.pending = false; + state.pending = false state.error = action.payload?.message || action.error.message || - "Failed to refresh calendar"; - }); - }, -}); + 'Failed to refresh calendar' + }) + } +}) export const { addEvent, @@ -559,6 +554,6 @@ export const { emptyEventsCal, clearFetchCache, clearError, - updateCalColor, -} = CalendarSlice.actions; -export default CalendarSlice.reducer; + updateCalColor +} = CalendarSlice.actions +export default CalendarSlice.reducer diff --git a/src/features/Calendars/CalendarTypes.ts b/src/features/Calendars/CalendarTypes.ts index 49ca7c9..cc5dbeb 100644 --- a/src/features/Calendars/CalendarTypes.ts +++ b/src/features/Calendars/CalendarTypes.ts @@ -1,38 +1,38 @@ -import { CalendarEvent } from "../Events/EventsTypes"; -import { OpenPaasUserData } from "../User/type/OpenPaasUserData"; +import { CalendarEvent } from '../Events/EventsTypes' +import { OpenPaasUserData } from '../User/type/OpenPaasUserData' export interface Calendar { - id: string; - link: string; - name: string; - delegated?: boolean; - prodid?: string; - color?: Record; - owner: OpenPaasUserData; - description?: string; - calscale?: string; - version?: string; - events: Record; - visibility: "private" | "public"; - access?: DelegationAccess; - lastCacheCleared?: number; - syncToken?: string; - invite?: CalendarInvite[]; + id: string + link: string + name: string + delegated?: boolean + prodid?: string + color?: Record + owner: OpenPaasUserData + description?: string + calscale?: string + version?: string + events: Record + visibility: 'private' | 'public' + access?: DelegationAccess + lastCacheCleared?: number + syncToken?: string + invite?: CalendarInvite[] } export interface DelegationAccess { - freebusy: boolean; - read: boolean; - write: boolean; - "write-properties": boolean; - all: boolean; + freebusy: boolean + read: boolean + write: boolean + 'write-properties': boolean + all: boolean } export type CalendarInvite = { - href: string; - principal: string; - access: AccessRight; - inviteStatus: number; -}; + href: string + principal: string + access: AccessRight + inviteStatus: number +} -export type AccessRight = 2 | 3 | 5; // VIEW = 2, EDITOR = 3, ADMIN = 5 +export type AccessRight = 2 | 3 | 5 // VIEW = 2, EDITOR = 3, ADMIN = 5 diff --git a/src/features/Calendars/api/addCalendarResourceAsync.ts b/src/features/Calendars/api/addCalendarResourceAsync.ts index 0144bbd..1c486c3 100644 --- a/src/features/Calendars/api/addCalendarResourceAsync.ts +++ b/src/features/Calendars/api/addCalendarResourceAsync.ts @@ -1,69 +1,69 @@ -import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData"; -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"; -import { fetchOwnerOfResource } from "../services/helpers"; +import { OpenPaasUserData } from '@/features/User/type/OpenPaasUserData' +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' +import { fetchOwnerOfResource } from '../services/helpers' export const addCalendarResourceAsync = createAsyncThunk< { - calId: string; - color: Record; - link: string; - name: string; - desc: string; - owner: OpenPaasUserData; + calId: string + color: Record + link: string + name: string + desc: string + owner: OpenPaasUserData }, { - userId: string; - calId: string; - cal: Omit & { - owner?: Omit & { email?: string }; - }; + userId: string + calId: string + cal: Omit & { + owner?: Omit & { email?: string } + } }, { rejectValue: RejectedError } >( - "calendars/addCalendarResource", + 'calendars/addCalendarResource', async ({ userId, calId, cal }, { rejectWithValue }) => { const resourceId = cal.cal._links.self?.href - ?.replace("/calendars/", "") - ?.replace(".json", "") - ?.split("/")[0]; + ?.replace('/calendars/', '') + ?.replace('.json', '') + ?.split('/')[0] let owner: OpenPaasUserData = { - firstname: "", - lastname: cal.cal["dav:name"] ?? "", + firstname: '', + lastname: cal.cal['dav:name'] ?? '', emails: [], - resource: true, - }; + resource: true + } try { - await addSharedCalendar(userId, calId, cal); + await addSharedCalendar(userId, calId, cal) if (resourceId) { try { owner = { ...(await fetchOwnerOfResource(resourceId)), - resource: true, - }; + resource: true + } } catch (e) { - console.error("Error while fetching owner of resource: ", e); + console.error('Error while fetching owner of resource: ', e) } } return { calId: cal.cal._links.self?.href - ?.replace("/calendars/", "") - .replace(".json", ""), + ?.replace('/calendars/', '') + .replace('.json', ''), color: cal.color, link: `/calendars/${userId}/${calId}.json`, - desc: cal.cal["caldav:description"] ?? "", - name: cal.cal["dav:name"] ?? "", - owner, - }; + desc: cal.cal['caldav:description'] ?? '', + name: cal.cal['dav:name'] ?? '', + owner + } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/api/fetchSyncTokenChanges.ts b/src/features/Calendars/api/fetchSyncTokenChanges.ts index a51a3ea..143f59d 100644 --- a/src/features/Calendars/api/fetchSyncTokenChanges.ts +++ b/src/features/Calendars/api/fetchSyncTokenChanges.ts @@ -1,19 +1,19 @@ -import { api } from "@/utils/apiUtils"; -import { Calendar } from "../CalendarTypes"; -import { DavSyncResponse } from "./types"; +import { api } from '@/utils/apiUtils' +import { Calendar } from '../CalendarTypes' +import { DavSyncResponse } from './types' export async function fetchSyncTokenChanges( calendar: Calendar ): Promise { const response = await api(`dav/calendars/${calendar.id}.json`, { - method: "REPORT", + method: 'REPORT', headers: { - Accept: "application/json, text/plain, */*", + Accept: 'application/json, text/plain, */*' }, body: JSON.stringify({ - "sync-token": calendar.syncToken, - }), - }); - const update: DavSyncResponse = await response.json(); - return update; + 'sync-token': calendar.syncToken + }) + }) + const update: DavSyncResponse = await response.json() + return update } diff --git a/src/features/Calendars/api/types.ts b/src/features/Calendars/api/types.ts index d41d9dd..90971b6 100644 --- a/src/features/Calendars/api/types.ts +++ b/src/features/Calendars/api/types.ts @@ -1,22 +1,22 @@ export interface DavSyncItem { - status: number; - _links: CalDavLink; + status: number + _links: CalDavLink } export type CalDavLink = { self?: { - href?: string; - }; -}; + href?: string + } +} export type CalDavItem = { - data?: unknown[]; - _links?: CalDavLink; -}; + data?: unknown[] + _links?: CalDavLink +} export interface DavSyncResponse { _embedded?: { - "dav:item"?: DavSyncItem[]; - }; - "sync-token"?: string; + 'dav:item'?: DavSyncItem[] + } + 'sync-token'?: string } diff --git a/src/features/Calendars/api/updateDelegationCalendar.ts b/src/features/Calendars/api/updateDelegationCalendar.ts index 4ca0bb4..df0b51c 100644 --- a/src/features/Calendars/api/updateDelegationCalendar.ts +++ b/src/features/Calendars/api/updateDelegationCalendar.ts @@ -1,17 +1,17 @@ -import { api } from "@/utils/apiUtils"; +import { api } from '@/utils/apiUtils' export async function updateDelegationCalendar( calLink: string, share: { - set: { [x: string]: string | boolean; "dav:href": string }[]; - remove: { [x: string]: string | boolean; "dav:href": string }[]; + set: { [x: string]: string | boolean; 'dav:href': string }[] + remove: { [x: string]: string | boolean; 'dav:href': string }[] } ) { const response = await api.post(`dav${calLink}`, { headers: { - Accept: "application/json, text/plain, */*", + Accept: 'application/json, text/plain, */*' }, - body: JSON.stringify({ share }), - }); - return response; + body: JSON.stringify({ share }) + }) + return response } diff --git a/src/features/Calendars/services/addSharedCalendarAsync.ts b/src/features/Calendars/services/addSharedCalendarAsync.ts index cdd4e6e..8b2999a 100644 --- a/src/features/Calendars/services/addSharedCalendarAsync.ts +++ b/src/features/Calendars/services/addSharedCalendarAsync.ts @@ -1,46 +1,46 @@ -import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData"; -import { getUserDetails } 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 { OpenPaasUserData } from '@/features/User/type/OpenPaasUserData' +import { getUserDetails } 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' export const addSharedCalendarAsync = createAsyncThunk< { - calId: string; - color: Record; - link: string; - name: string; - desc: string; - owner: OpenPaasUserData; + calId: string + color: Record + link: string + name: string + desc: string + owner: OpenPaasUserData }, { userId: string; calId: string; cal: CalendarInput }, { rejectValue: RejectedError } >( - "calendars/addSharedCalendar", + 'calendars/addSharedCalendar', async ({ userId, calId, cal }, { rejectWithValue }) => { try { - await addSharedCalendar(userId, calId, cal); + await addSharedCalendar(userId, calId, cal) const ownerData = await getUserDetails( cal.cal._links.self.href - .replace("/calendars/", "") - .replace(".json", "") - .split("/")[0] - ); + .replace('/calendars/', '') + .replace('.json', '') + .split('/')[0] + ) return { calId: cal.cal._links.self?.href - ?.replace("/calendars/", "") - .replace(".json", ""), + ?.replace('/calendars/', '') + .replace('.json', ''), color: cal.color, link: `/calendars/${userId}/${calId}.json`, - desc: cal.cal["caldav:description"] ?? "", - name: cal.cal["dav:name"] ?? "", - owner: ownerData, - }; + desc: cal.cal['caldav:description'] ?? '', + name: cal.cal['dav:name'] ?? '', + owner: ownerData + } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/services/createCalendarAsync.ts b/src/features/Calendars/services/createCalendarAsync.ts index 0976507..f27cb3b 100644 --- a/src/features/Calendars/services/createCalendarAsync.ts +++ b/src/features/Calendars/services/createCalendarAsync.ts @@ -1,36 +1,36 @@ -import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData"; -import { userData } from "@/features/User/userDataTypes"; -import { toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { postCalendar } from "../CalendarApi"; -import { RejectedError } from "../types/RejectedError"; +import { OpenPaasUserData } from '@/features/User/type/OpenPaasUserData' +import { userData } from '@/features/User/userDataTypes' +import { toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { postCalendar } from '../CalendarApi' +import { RejectedError } from '../types/RejectedError' export const createCalendarAsync = createAsyncThunk< { - userId: string; - calId: string; - color: Record; - name: string; - desc: string; - owner: OpenPaasUserData; + userId: string + calId: string + color: Record + name: string + desc: string + owner: OpenPaasUserData }, { - userData: userData; - calId: string; - color: Record; - name: string; - desc: string; + userData: userData + calId: string + color: Record + name: string + desc: string }, { rejectValue: RejectedError } >( - "calendars/createCalendar", + 'calendars/createCalendar', async ({ userData, calId, color, name, desc }, { rejectWithValue }) => { try { if (!userData.openpaasId) { - throw new Error("No openpaasId"); + throw new Error('No openpaasId') } - await postCalendar(userData.openpaasId, calId, color, name, desc); + await postCalendar(userData.openpaasId, calId, color, name, desc) return { userId: userData.openpaasId, @@ -43,11 +43,11 @@ export const createCalendarAsync = createAsyncThunk< lastname: userData.family_name, id: userData.openpaasId, preferredEmail: userData.email, - emails: userData.email ? [userData.email] : [], - }, - }; + emails: userData.email ? [userData.email] : [] + } + } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/services/deleteEventAsync.ts b/src/features/Calendars/services/deleteEventAsync.ts index 0169338..7ec62ef 100644 --- a/src/features/Calendars/services/deleteEventAsync.ts +++ b/src/features/Calendars/services/deleteEventAsync.ts @@ -1,20 +1,20 @@ -import { deleteEvent } from "@/features/Events/EventApi"; -import { toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { RejectedError } from "../types/RejectedError"; +import { deleteEvent } from '@/features/Events/EventApi' +import { toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { RejectedError } from '../types/RejectedError' export const deleteEventAsync = createAsyncThunk< { calId: string; eventId: string }, { calId: string; eventId: string; eventURL: string }, { rejectValue: RejectedError } >( - "calendars/delEvent", + 'calendars/delEvent', async ({ calId, eventId, eventURL }, { rejectWithValue }) => { try { - await deleteEvent(eventURL); - return { calId, eventId }; + await deleteEvent(eventURL) + return { calId, eventId } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/services/deleteEventInstanceAsync.ts b/src/features/Calendars/services/deleteEventInstanceAsync.ts index f188b4b..62722a1 100644 --- a/src/features/Calendars/services/deleteEventInstanceAsync.ts +++ b/src/features/Calendars/services/deleteEventInstanceAsync.ts @@ -1,23 +1,23 @@ -import { deleteEventInstance } from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { formatReduxError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { RejectedError } from "../types/RejectedError"; -import { Calendar } from "../CalendarTypes"; +import { deleteEventInstance } from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { formatReduxError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { RejectedError } from '../types/RejectedError' +import { Calendar } from '../CalendarTypes' export const deleteEventInstanceAsync = createAsyncThunk< { calId: string; eventId: string }, { cal: Calendar; event: CalendarEvent }, { rejectValue: RejectedError } ->("calendars/delEventInstance", async ({ cal, event }, { rejectWithValue }) => { +>('calendars/delEventInstance', async ({ cal, event }, { rejectWithValue }) => { try { - await deleteEventInstance(event); - return { calId: cal.id, eventId: event.uid }; + await deleteEventInstance(event) + return { calId: cal.id, eventId: event.uid } } catch (err) { - const error = err as { response?: { status?: number } }; + const error = err as { response?: { status?: number } } return rejectWithValue({ message: formatReduxError(err), - status: error.response?.status, - }); + status: error.response?.status + }) } -}); +}) diff --git a/src/features/Calendars/services/getCalendarDetailAsync.ts b/src/features/Calendars/services/getCalendarDetailAsync.ts index 6f0cc04..a61d720 100644 --- a/src/features/Calendars/services/getCalendarDetailAsync.ts +++ b/src/features/Calendars/services/getCalendarDetailAsync.ts @@ -1,61 +1,57 @@ -import { CalendarEvent } from "@/features/Events/EventsTypes"; +import { CalendarEvent } from '@/features/Events/EventsTypes' import { CalendarData, - CalendarItem, -} from "@/features/Calendars/types/CalendarData"; -import { toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { getCalendar } from "../CalendarApi"; -import { RejectedError } from "../types/RejectedError"; -import { extractCalendarEvents } from "../utils/extractCalendarEvents"; -import { type RootState } from "@/app/store"; + CalendarItem +} from '@/features/Calendars/types/CalendarData' +import { toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { getCalendar } from '../CalendarApi' +import { RejectedError } from '../types/RejectedError' +import { extractCalendarEvents } from '../utils/extractCalendarEvents' +import { type RootState } from '@/app/store' export const getCalendarDetailAsync = createAsyncThunk< { - calId: string; - events: CalendarEvent[]; - calType?: string; - syncToken?: string; + calId: string + events: CalendarEvent[] + calType?: string + syncToken?: string }, { - calId: string; - match: { start: string; end: string }; - calType?: string; - signal?: AbortSignal; + calId: string + match: { start: string; end: string } + calType?: string + signal?: AbortSignal }, { rejectValue: RejectedError } >( - "calendars/getCalendarDetails", + 'calendars/getCalendarDetails', async ({ calId, match, calType, signal }, { rejectWithValue, getState }) => { try { - const state = getState() as RootState; + const state = getState() as RootState const calendarStored = - state.calendars[calType === "temp" ? "templist" : "list"][calId]; + state.calendars[calType === 'temp' ? 'templist' : 'list'][calId] if (!calendarStored) { return rejectWithValue( toRejectedError(new Error(`Calendar ${calId} not found in store`)) - ); + ) } - const calendar = (await getCalendar( - calId, - match, - signal - )) as CalendarData; - const syncToken = calendar._embedded?.["sync-token"]; + const calendar = (await getCalendar(calId, match, signal)) as CalendarData + const syncToken = calendar._embedded?.['sync-token'] - const items = calendar._embedded?.["dav:item"]; + const items = calendar._embedded?.['dav:item'] const events: CalendarEvent[] = Array.isArray(items) ? items.flatMap((item: CalendarItem) => extractCalendarEvents(item, { cal: calendarStored, - color: calendarStored.color, + color: calendarStored.color }) ) - : []; + : [] - return { calId, events, calType, syncToken }; + return { calId, events, calType, syncToken } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/services/getCalendarsListAsync.ts b/src/features/Calendars/services/getCalendarsListAsync.ts index b1363a6..c0e60cb 100644 --- a/src/features/Calendars/services/getCalendarsListAsync.ts +++ b/src/features/Calendars/services/getCalendarsListAsync.ts @@ -1,67 +1,67 @@ -import { RootState } from "@/app/store"; -import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData"; -import { getOpenPaasUser } from "@/features/User/userAPI"; -import { defaultColors } from "@/utils/defaultColors"; -import { formatReduxError, toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { getCalendars } from "../CalendarApi"; -import { Calendar, CalendarInvite } from "../CalendarTypes"; -import { CalendarData } from "../types/CalendarData"; -import { RejectedError } from "../types/RejectedError"; -import { normalizeCalendar } from "../utils/normalizeCalendar"; -import { fetchOwnerData } from "./helpers"; -import { createTheme } from "@mui/material/styles"; -import { getAccessiblePair } from "@/utils/getAccessiblePair"; +import { RootState } from '@/app/store' +import { OpenPaasUserData } from '@/features/User/type/OpenPaasUserData' +import { getOpenPaasUser } from '@/features/User/userAPI' +import { defaultColors } from '@/utils/defaultColors' +import { formatReduxError, toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { getCalendars } from '../CalendarApi' +import { Calendar, CalendarInvite } from '../CalendarTypes' +import { CalendarData } from '../types/CalendarData' +import { RejectedError } from '../types/RejectedError' +import { normalizeCalendar } from '../utils/normalizeCalendar' +import { fetchOwnerData } from './helpers' +import { createTheme } from '@mui/material/styles' +import { getAccessiblePair } from '@/utils/getAccessiblePair' -const theme = createTheme(); +const theme = createTheme() export const getCalendarsListAsync = createAsyncThunk< { importedCalendars: Record; errors: string }, void, { rejectValue: RejectedError; state: RootState } ->("calendars/getCalendars", async (_, { rejectWithValue, getState }) => { - const state = getState(); - const existingCalendars = state.calendars.list || {}; - const existingUser = { id: state.user?.userData?.openpaasId || undefined }; +>('calendars/getCalendars', async (_, { rejectWithValue, getState }) => { + const state = getState() + const existingCalendars = state.calendars.list || {} + const existingUser = { id: state.user?.userData?.openpaasId || undefined } try { - const fetchedCalendars: Record = {}; + const fetchedCalendars: Record = {} const user = existingUser.id ? existingUser - : ((await getOpenPaasUser()) as OpenPaasUserData); - const calendars = await getCalendars(user.id); - const rawCalendars = calendars._embedded["dav:calendar"]; + : ((await getOpenPaasUser()) as OpenPaasUserData) + const calendars = await getCalendars(user.id) + const rawCalendars = calendars._embedded['dav:calendar'] - const errors: string[] = []; + const errors: string[] = [] const normalizedCalendars = rawCalendars.map((cal: CalendarData) => normalizeCalendar(cal, user.id) - ); + ) const uniqueOwnerIds = Array.from( new Set(normalizedCalendars.map(({ ownerId }) => ownerId).filter(Boolean)) - ); + ) - const ownerDataMap = new Map(); - const OWNER_BATCH_SIZE = 20; + const ownerDataMap = new Map() + const OWNER_BATCH_SIZE = 20 const mapOwnerData = async (ownerId: string) => { try { - const data = await fetchOwnerData(ownerId); - ownerDataMap.set(ownerId, data); + const data = await fetchOwnerData(ownerId) + ownerDataMap.set(ownerId, data) } catch (error) { - console.error(`Failed to fetch user details for ${ownerId}:`, error); + console.error(`Failed to fetch user details for ${ownerId}:`, error) ownerDataMap.set(ownerId, { - firstname: "", - lastname: "Unknown User", - emails: [], - }); - errors.push(formatReduxError(error)); + firstname: '', + lastname: 'Unknown User', + emails: [] + }) + errors.push(formatReduxError(error)) } - }; + } for (let i = 0; i < uniqueOwnerIds.length; i += OWNER_BATCH_SIZE) { - const chunk = uniqueOwnerIds.slice(i, i + OWNER_BATCH_SIZE); - await Promise.all(chunk.map((ownerId) => mapOwnerData(ownerId))); + const chunk = uniqueOwnerIds.slice(i, i + OWNER_BATCH_SIZE) + await Promise.all(chunk.map(ownerId => mapOwnerData(ownerId))) } normalizedCalendars.forEach( @@ -73,36 +73,34 @@ export const getCalendarsListAsync = createAsyncThunk< id, ownerId, visibility, - access, + access }) => { const ownerData = ownerDataMap.get(ownerId) || { - firstname: "", - lastname: "Unknown User", - emails: [], - }; + firstname: '', + lastname: 'Unknown User', + emails: [] + } - const rawColor = cal["apple:color"]; + const rawColor = cal['apple:color'] const color = rawColor ? { light: rawColor, - dark: getAccessiblePair(rawColor, theme), + dark: getAccessiblePair(rawColor, theme) } - : defaultColors[0]; + : defaultColors[0] const invite: CalendarInvite[] = ( (cal.invite ?? []) as Array<{ - href: string; - principal: string; - access: number; - inviteStatus: number; + href: string + principal: string + access: number + inviteStatus: number }> - ).filter((inv): inv is CalendarInvite => - [2, 3, 5].includes(inv.access) - ); + ).filter((inv): inv is CalendarInvite => [2, 3, 5].includes(inv.access)) fetchedCalendars[id] = { id, - name: cal["dav:name"] ?? "", + name: cal['dav:name'] ?? '', link, owner: ownerData, description, @@ -111,42 +109,42 @@ export const getCalendarsListAsync = createAsyncThunk< visibility, access, events: {}, - invite, - }; + invite + } } - ); + ) - const importedCalendars: Record = {}; + const importedCalendars: Record = {} - const fetchedIds = new Set(Object.keys(fetchedCalendars)); - const existingIds = new Set(Object.keys(existingCalendars)); + const fetchedIds = new Set(Object.keys(fetchedCalendars)) + const existingIds = new Set(Object.keys(existingCalendars)) - const added = [...fetchedIds].filter((id) => !existingIds.has(id)); + const added = [...fetchedIds].filter(id => !existingIds.has(id)) - existingIds.forEach((id) => { + existingIds.forEach(id => { if (fetchedIds.has(id)) { - const existingCal = existingCalendars[id]; - const fetchedCal = fetchedCalendars[id]; + const existingCal = existingCalendars[id] + const fetchedCal = fetchedCalendars[id] if (fetchedCal) { importedCalendars[id] = { ...fetchedCal, - events: existingCal.events || {}, - }; + events: existingCal.events || {} + } } } - }); + }) // Add new calendars - added.forEach((id) => { - importedCalendars[id] = fetchedCalendars[id]; - }); + added.forEach(id => { + importedCalendars[id] = fetchedCalendars[id] + }) return { importedCalendars, - errors: errors.join("\n"), - }; + errors: errors.join('\n') + } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } -}); +}) diff --git a/src/features/Calendars/services/getEventAsync.ts b/src/features/Calendars/services/getEventAsync.ts index a115684..15f24aa 100644 --- a/src/features/Calendars/services/getEventAsync.ts +++ b/src/features/Calendars/services/getEventAsync.ts @@ -1,25 +1,25 @@ -import { getEvent } from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { formatReduxError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { RejectedError } from "../types/RejectedError"; +import { getEvent } from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { formatReduxError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { RejectedError } from '../types/RejectedError' export const getEventAsync = createAsyncThunk< { calId: string; event: CalendarEvent }, CalendarEvent, { rejectValue: RejectedError } ->("calendars/getEvent", async (event, { rejectWithValue }) => { +>('calendars/getEvent', async (event, { rejectWithValue }) => { try { - const response: CalendarEvent = await getEvent(event); + const response: CalendarEvent = await getEvent(event) return { calId: event.calId, - event: response, - }; + event: response + } } catch (err) { - const error = err as { response?: { status?: number } }; + const error = err as { response?: { status?: number } } return rejectWithValue({ message: formatReduxError(err), - status: error.response?.status, - }); + status: error.response?.status + }) } -}); +}) diff --git a/src/features/Calendars/services/getTempCalendarsListAsync.ts b/src/features/Calendars/services/getTempCalendarsListAsync.ts index 4c1a44d..f1733db 100644 --- a/src/features/Calendars/services/getTempCalendarsListAsync.ts +++ b/src/features/Calendars/services/getTempCalendarsListAsync.ts @@ -1,55 +1,55 @@ -import { User } from "@/components/Attendees/PeopleSearch"; -import { getCalendarVisibility } from "@/components/Calendar/utils/calendarUtils"; -import { formatReduxError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { getCalendars } from "../CalendarApi"; -import { Calendar } from "../CalendarTypes"; -import { RejectedError } from "../types/RejectedError"; -import { fetchOwnerData } from "./helpers"; +import { User } from '@/components/Attendees/PeopleSearch' +import { getCalendarVisibility } from '@/components/Calendar/utils/calendarUtils' +import { formatReduxError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { getCalendars } from '../CalendarApi' +import { Calendar } from '../CalendarTypes' +import { RejectedError } from '../types/RejectedError' +import { fetchOwnerData } from './helpers' export const getTempCalendarsListAsync = createAsyncThunk< Record, User, { rejectValue: RejectedError } ->("calendars/getTempCalendars", async (tempUser, { rejectWithValue }) => { +>('calendars/getTempCalendars', async (tempUser, { rejectWithValue }) => { try { - const importedCalendars: Record = {}; + const importedCalendars: Record = {} if (!tempUser.openpaasId) { - const username = tempUser.displayName || tempUser.email || "User"; + const username = tempUser.displayName || tempUser.email || 'User' throw new Error( `TRANSLATION:calendar.userDoesNotHaveValidId|name=${encodeURIComponent(username)}` - ); + ) } const calendars = await getCalendars( tempUser.openpaasId, - "sharedPublic=true&" - ); + 'sharedPublic=true&' + ) - const rawCalendars = calendars._embedded?.["dav:calendar"]; + const rawCalendars = calendars._embedded?.['dav:calendar'] if (!rawCalendars || rawCalendars.length === 0) { - const userName = tempUser.displayName || tempUser.email || "User"; + const userName = tempUser.displayName || tempUser.email || 'User' // Format: TRANSLATION:key|param1=value1 - const encodedName = encodeURIComponent(userName); + const encodedName = encodeURIComponent(userName) throw new Error( `TRANSLATION:calendar.userDoesNotHavePublicCalendars|name=${encodedName}` - ); + ) } for (const cal of rawCalendars) { - const name = cal["dav:name"] ?? ""; - const description = cal["caldav:description"] ?? ""; - const delegated = cal["calendarserver:delegatedsource"] ? true : false; - const source = cal["calendarserver:source"] - ? cal["calendarserver:source"]._links.self?.href - : cal._links.self?.href; + const name = cal['dav:name'] ?? '' + const description = cal['caldav:description'] ?? '' + const delegated = cal['calendarserver:delegatedsource'] ? true : false + const source = cal['calendarserver:source'] + ? cal['calendarserver:source']._links.self?.href + : cal._links.self?.href if (!source) { - throw new Error("No source for calendar"); + throw new Error('No source for calendar') } - const link = cal._links.self?.href ?? ""; + const link = cal._links.self?.href ?? '' - const id = source.replace("/calendars/", "").replace(".json", ""); - const visibility = getCalendarVisibility(cal["acl"] ?? []); - const ownerData = await fetchOwnerData(id.split("/")[0]); + const id = source.replace('/calendars/', '').replace('.json', '') + const visibility = getCalendarVisibility(cal['acl'] ?? []) + const ownerData = await fetchOwnerData(id.split('/')[0]) importedCalendars[id] = { id, @@ -59,20 +59,20 @@ export const getTempCalendarsListAsync = createAsyncThunk< description, delegated, color: { - light: tempUser.color?.light ?? "#a8a8a8ff", - dark: tempUser.color?.dark ?? "#a8a8a8ff", + light: tempUser.color?.light ?? '#a8a8a8ff', + dark: tempUser.color?.dark ?? '#a8a8a8ff' }, visibility, - events: {}, - }; + events: {} + } } - return importedCalendars; + return importedCalendars } catch (err) { - const error = err as { response?: { status?: number } }; + const error = err as { response?: { status?: number } } return rejectWithValue({ message: formatReduxError(err), - status: error.response?.status, - }); + status: error.response?.status + }) } -}); +}) diff --git a/src/features/Calendars/services/helpers.ts b/src/features/Calendars/services/helpers.ts index cf91102..1e422c8 100644 --- a/src/features/Calendars/services/helpers.ts +++ b/src/features/Calendars/services/helpers.ts @@ -1,12 +1,12 @@ -import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData"; -import { getResourceDetails, getUserDetails } from "@/features/User/userAPI"; +import { OpenPaasUserData } from '@/features/User/type/OpenPaasUserData' +import { getResourceDetails, getUserDetails } from '@/features/User/userAPI' export const fetchOwnerOfResource = async ( resourceId: string ): Promise => { try { - const data = await getResourceDetails(resourceId); - const ownerData = await getUserDetails(data.creator); + const data = await getResourceDetails(resourceId) + const ownerData = await getUserDetails(data.creator) return { ...ownerData, administrators: data.administrators, @@ -14,33 +14,33 @@ export const fetchOwnerOfResource = async ( resourceIcon: window.CALENDAR_BASE_URL && data.icon ? `${window.CALENDAR_BASE_URL}/images/icon/${data.icon}.svg` - : undefined, - }; + : undefined + } } catch (error) { - console.error(`Failed to fetch resource details for ${resourceId}:`, error); - throw error; + console.error(`Failed to fetch resource details for ${resourceId}:`, error) + throw error } -}; +} export const fetchOwnerData = async ( ownerId: string ): Promise => { try { - const owner = await getUserDetails(ownerId); - return owner; + const owner = await getUserDetails(ownerId) + return owner } catch (error) { const status = (error as { response?: { status?: number } }).response - ?.status; + ?.status if (status === 404) { - const owner = await fetchOwnerOfResource(ownerId); + const owner = await fetchOwnerOfResource(ownerId) return { ...owner, - resource: true, - }; + resource: true + } } - console.error(`Failed to fetch user details for ${ownerId}:`, error); - throw error; + console.error(`Failed to fetch user details for ${ownerId}:`, error) + throw error } -}; +} diff --git a/src/features/Calendars/services/importEventFromFileAsync.ts b/src/features/Calendars/services/importEventFromFileAsync.ts index a5d1289..289c219 100644 --- a/src/features/Calendars/services/importEventFromFileAsync.ts +++ b/src/features/Calendars/services/importEventFromFileAsync.ts @@ -1,32 +1,32 @@ -import { importEventFromFile } from "@/features/Events/EventApi"; -import { importFile } from "@/utils/apiUtils"; -import { formatReduxError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { RejectedError } from "../types/RejectedError"; +import { importEventFromFile } from '@/features/Events/EventApi' +import { importFile } from '@/utils/apiUtils' +import { formatReduxError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { RejectedError } from '../types/RejectedError' export const importEventFromFileAsync = createAsyncThunk< void, { - calLink: string; - file: File; + calLink: string + file: File }, { rejectValue: RejectedError } ->("calendars/importEvent", async ({ calLink, file }, { rejectWithValue }) => { +>('calendars/importEvent', async ({ calLink, file }, { rejectWithValue }) => { try { - const response = await importFile(file); - const id = response?._id; + const response = await importFile(file) + const id = response?._id if (!id) { return rejectWithValue({ - message: "Failed to upload file: missing file ID", - status: undefined, - }); + message: 'Failed to upload file: missing file ID', + status: undefined + }) } - await importEventFromFile(id, calLink); + await importEventFromFile(id, calLink) } catch (err) { - const error = err as { response?: { status?: number } }; + const error = err as { response?: { status?: number } } return rejectWithValue({ message: formatReduxError(err), - status: error.response?.status, - }); + status: error.response?.status + }) } -}); +}) diff --git a/src/features/Calendars/services/index.ts b/src/features/Calendars/services/index.ts index 4b5f45d..e7ab71c 100644 --- a/src/features/Calendars/services/index.ts +++ b/src/features/Calendars/services/index.ts @@ -1,17 +1,17 @@ -export { addSharedCalendarAsync } from "./addSharedCalendarAsync"; -export { createCalendarAsync } from "./createCalendarAsync"; -export { deleteEventAsync } from "./deleteEventAsync"; -export { deleteEventInstanceAsync } from "./deleteEventInstanceAsync"; -export { getCalendarDetailAsync } from "./getCalendarDetailAsync"; -export { getCalendarsListAsync } from "./getCalendarsListAsync"; -export { getEventAsync } from "./getEventAsync"; -export { getTempCalendarsListAsync } from "./getTempCalendarsListAsync"; -export { importEventFromFileAsync } from "./importEventFromFileAsync"; -export { moveEventAsync } from "./moveEventAsync"; -export { patchACLCalendarAsync } from "./patchACLCalendarAsync"; -export { patchCalendarAsync } from "./patchCalendarAsync"; -export { putEventAsync } from "./putEventAsync"; -export { refreshCalendarWithSyncToken } from "./refreshCalendar"; -export { removeCalendarAsync } from "./removeCalendarAsync"; -export { updateEventInstanceAsync } from "./updateEventInstanceAsync"; -export { updateSeriesAsync } from "./updateSeriesAsync"; +export { addSharedCalendarAsync } from './addSharedCalendarAsync' +export { createCalendarAsync } from './createCalendarAsync' +export { deleteEventAsync } from './deleteEventAsync' +export { deleteEventInstanceAsync } from './deleteEventInstanceAsync' +export { getCalendarDetailAsync } from './getCalendarDetailAsync' +export { getCalendarsListAsync } from './getCalendarsListAsync' +export { getEventAsync } from './getEventAsync' +export { getTempCalendarsListAsync } from './getTempCalendarsListAsync' +export { importEventFromFileAsync } from './importEventFromFileAsync' +export { moveEventAsync } from './moveEventAsync' +export { patchACLCalendarAsync } from './patchACLCalendarAsync' +export { patchCalendarAsync } from './patchCalendarAsync' +export { putEventAsync } from './putEventAsync' +export { refreshCalendarWithSyncToken } from './refreshCalendar' +export { removeCalendarAsync } from './removeCalendarAsync' +export { updateEventInstanceAsync } from './updateEventInstanceAsync' +export { updateSeriesAsync } from './updateSeriesAsync' diff --git a/src/features/Calendars/services/moveEventAsync.ts b/src/features/Calendars/services/moveEventAsync.ts index ac0ae13..253b7cf 100644 --- a/src/features/Calendars/services/moveEventAsync.ts +++ b/src/features/Calendars/services/moveEventAsync.ts @@ -1,25 +1,25 @@ -import { moveEvent } from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { Calendar } from "../CalendarTypes"; -import { RejectedError } from "../types/RejectedError"; +import { moveEvent } from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { Calendar } from '../CalendarTypes' +import { RejectedError } from '../types/RejectedError' export const moveEventAsync = createAsyncThunk< { calId: string }, { cal: Calendar; newEvent: CalendarEvent; newURL: string }, { rejectValue: RejectedError } >( - "calendars/moveEvent", + 'calendars/moveEvent', async ({ cal, newEvent, newURL }, { rejectWithValue }) => { try { - await moveEvent(newEvent, newURL); + await moveEvent(newEvent, newURL) return { - calId: cal.id, - }; + calId: cal.id + } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/services/patchACLCalendarAsync.ts b/src/features/Calendars/services/patchACLCalendarAsync.ts index f94aca5..632c012 100644 --- a/src/features/Calendars/services/patchACLCalendarAsync.ts +++ b/src/features/Calendars/services/patchACLCalendarAsync.ts @@ -1,32 +1,32 @@ -import { toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { updateAclCalendar } from "../CalendarApi"; -import { RejectedError } from "../types/RejectedError"; +import { toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { updateAclCalendar } from '../CalendarApi' +import { RejectedError } from '../types/RejectedError' export const patchACLCalendarAsync = createAsyncThunk< { - calId: string; - calLink: string; - request: string; + calId: string + calLink: string + request: string }, { - calId: string; - calLink: string; - request: string; + calId: string + calLink: string + request: string }, { rejectValue: RejectedError } >( - "calendars/requestACLCalendar", + 'calendars/requestACLCalendar', async ({ calId, calLink, request }, { rejectWithValue }) => { try { - await updateAclCalendar(calLink, request); + await updateAclCalendar(calLink, request) return { calId, calLink, - request, - }; + request + } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/services/patchCalendarAsync.ts b/src/features/Calendars/services/patchCalendarAsync.ts index ce7cb59..d1b1b62 100644 --- a/src/features/Calendars/services/patchCalendarAsync.ts +++ b/src/features/Calendars/services/patchCalendarAsync.ts @@ -1,32 +1,32 @@ -import { toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { proppatchCalendar } from "../CalendarApi"; -import { RejectedError } from "../types/RejectedError"; +import { toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { proppatchCalendar } from '../CalendarApi' +import { RejectedError } from '../types/RejectedError' export const patchCalendarAsync = createAsyncThunk< { - calId: string; - calLink: string; - patch: { name: string; desc: string; color: Record }; + calId: string + calLink: string + patch: { name: string; desc: string; color: Record } }, { - calId: string; - calLink: string; - patch: { name: string; desc: string; color: Record }; + calId: string + calLink: string + patch: { name: string; desc: string; color: Record } }, { rejectValue: RejectedError } >( - "calendars/patchCalendar", + 'calendars/patchCalendar', async ({ calId, calLink, patch }, { rejectWithValue }) => { try { - await proppatchCalendar(calLink, patch); + await proppatchCalendar(calLink, patch) return { calId, calLink, - patch, - }; + patch + } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/services/putEventAsync.ts b/src/features/Calendars/services/putEventAsync.ts index 501d88b..43b9252 100644 --- a/src/features/Calendars/services/putEventAsync.ts +++ b/src/features/Calendars/services/putEventAsync.ts @@ -1,29 +1,29 @@ -import { putEvent } from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { Calendar } from "../CalendarTypes"; -import { RejectedError } from "../types/RejectedError"; +import { putEvent } from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { Calendar } from '../CalendarTypes' +import { RejectedError } from '../types/RejectedError' export const putEventAsync = createAsyncThunk< - { calId: string; calType?: "temp" }, - { cal: Calendar; newEvent: CalendarEvent; calType?: "temp" }, + { calId: string; calType?: 'temp' }, + { cal: Calendar; newEvent: CalendarEvent; calType?: 'temp' }, { rejectValue: RejectedError } >( - "calendars/putEvent", + 'calendars/putEvent', async ({ cal, newEvent, calType }, { rejectWithValue }) => { try { await putEvent( newEvent, cal.owner?.emails ? cal.owner.emails[0] : undefined - ); + ) return { calId: cal.id, - calType, - }; + calType + } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/services/refreshCalendar.ts b/src/features/Calendars/services/refreshCalendar.ts index 325ce60..30ce9d5 100644 --- a/src/features/Calendars/services/refreshCalendar.ts +++ b/src/features/Calendars/services/refreshCalendar.ts @@ -1,39 +1,39 @@ -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { buildDelegatedEventURL } from "@/features/Events/utils"; -import { toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import pMap from "p-map"; -import { fetchSyncTokenChanges } from "../api/fetchSyncTokenChanges"; -import { Calendar } from "../CalendarTypes"; -import { RejectedError } from "../types/RejectedError"; -import { expandEventFunction } from "../utils/expandEventFunction"; -import { processSyncUpdates } from "../utils/processSyncTokenUpdates"; +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { buildDelegatedEventURL } from '@/features/Events/utils' +import { toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import pMap from 'p-map' +import { fetchSyncTokenChanges } from '../api/fetchSyncTokenChanges' +import { Calendar } from '../CalendarTypes' +import { RejectedError } from '../types/RejectedError' +import { expandEventFunction } from '../utils/expandEventFunction' +import { processSyncUpdates } from '../utils/processSyncTokenUpdates' export interface SyncTokenUpdates { - calId: string; - deletedEvents: string[]; - createdOrUpdatedEvents: CalendarEvent[]; - calType?: "temp"; - syncToken?: string; - syncStatus?: string; + calId: string + deletedEvents: string[] + createdOrUpdatedEvents: CalendarEvent[] + calType?: 'temp' + syncToken?: string + syncStatus?: string } export const refreshCalendarWithSyncToken = createAsyncThunk< SyncTokenUpdates, { - calendar: Calendar; - calType?: "temp"; + calendar: Calendar + calType?: 'temp' calendarRange: { - start: Date; - end: Date; - }; - maxConcurrency?: number; + start: Date + end: Date + } + maxConcurrency?: number }, { - rejectValue: RejectedError; + rejectValue: RejectedError } >( - "calendars/refreshWithSyncToken", + 'calendars/refreshWithSyncToken', async ( { calendar, maxConcurrency = 8, calendarRange, calType }, { rejectWithValue } @@ -45,25 +45,25 @@ export const refreshCalendarWithSyncToken = createAsyncThunk< deletedEvents: [], createdOrUpdatedEvents: [], calType, - syncStatus: "NO_SYNC_TOKEN", - }; + syncStatus: 'NO_SYNC_TOKEN' + } } - const response = await fetchSyncTokenChanges(calendar); - const newSyncToken = response["sync-token"]; - const updates = response?._embedded?.["dav:item"] ?? []; + const response = await fetchSyncTokenChanges(calendar) + const newSyncToken = response['sync-token'] + const updates = response?._embedded?.['dav:item'] ?? [] - const { toDelete, toExpand } = processSyncUpdates(updates); + const { toDelete, toExpand } = processSyncUpdates(updates) const createdOrUpdatedEvents = await pMap( toExpand, expandEventFunction(calendarRange, calendar), { concurrency: maxConcurrency } - ); + ) return { calId: calendar.id, - deletedEvents: toDelete.map((eventURL) => + deletedEvents: toDelete.map(eventURL => calendar.delegated ? buildDelegatedEventURL(calendar, eventURL) : eventURL @@ -73,10 +73,10 @@ export const refreshCalendarWithSyncToken = createAsyncThunk< .filter(Boolean) as CalendarEvent[], calType, syncToken: newSyncToken, - syncStatus: newSyncToken ? "SUCCESS" : "NO_NEW_SYNC_TOKEN", - }; + syncStatus: newSyncToken ? 'SUCCESS' : 'NO_NEW_SYNC_TOKEN' + } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/services/removeCalendarAsync.ts b/src/features/Calendars/services/removeCalendarAsync.ts index 077e074..7cf3f5a 100644 --- a/src/features/Calendars/services/removeCalendarAsync.ts +++ b/src/features/Calendars/services/removeCalendarAsync.ts @@ -1,27 +1,27 @@ -import { toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { removeCalendar } from "../CalendarApi"; -import { RejectedError } from "../types/RejectedError"; +import { toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { removeCalendar } from '../CalendarApi' +import { RejectedError } from '../types/RejectedError' export const removeCalendarAsync = createAsyncThunk< { - calId: string; + calId: string }, { - calId: string; - calLink: string; + calId: string + calLink: string }, { rejectValue: RejectedError } >( - "calendars/removeCalendar", + 'calendars/removeCalendar', async ({ calId, calLink }, { rejectWithValue }) => { try { - await removeCalendar(calLink); + await removeCalendar(calLink) return { - calId, - }; + calId + } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/services/updateDelegationCalendarAsync.ts b/src/features/Calendars/services/updateDelegationCalendarAsync.ts index b179a61..3ae8bb8 100644 --- a/src/features/Calendars/services/updateDelegationCalendarAsync.ts +++ b/src/features/Calendars/services/updateDelegationCalendarAsync.ts @@ -1,33 +1,33 @@ -import { toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { updateDelegationCalendar } from "../api/updateDelegationCalendar"; -import { RejectedError } from "../types/RejectedError"; +import { toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { updateDelegationCalendar } from '../api/updateDelegationCalendar' +import { RejectedError } from '../types/RejectedError' export const updateDelegationCalendarAsync = createAsyncThunk< { - calId: string; - calLink: string; + calId: string + calLink: string }, { - calId: string; - calLink: string; + calId: string + calLink: string share: { - set: { [x: string]: string | boolean; "dav:href": string }[]; - remove: { [x: string]: string | boolean; "dav:href": string }[]; - }; + set: { [x: string]: string | boolean; 'dav:href': string }[] + remove: { [x: string]: string | boolean; 'dav:href': string }[] + } }, { rejectValue: RejectedError } >( - "calendars/patchDelegationCalendar", + 'calendars/patchDelegationCalendar', async ({ calId, calLink, share }, { rejectWithValue }) => { try { - await updateDelegationCalendar(calLink, share); + await updateDelegationCalendar(calLink, share) return { calId, - calLink, - }; + calLink + } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/services/updateEventInstanceAsync.ts b/src/features/Calendars/services/updateEventInstanceAsync.ts index 9df8c49..cc48ea8 100644 --- a/src/features/Calendars/services/updateEventInstanceAsync.ts +++ b/src/features/Calendars/services/updateEventInstanceAsync.ts @@ -1,22 +1,22 @@ -import { putEventWithOverrides } from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { RejectedError } from "../types/RejectedError"; -import { Calendar } from "../CalendarTypes"; +import { putEventWithOverrides } from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { RejectedError } from '../types/RejectedError' +import { Calendar } from '../CalendarTypes' export const updateEventInstanceAsync = createAsyncThunk< { calId: string; event: CalendarEvent }, { cal: Calendar; event: CalendarEvent }, { rejectValue: RejectedError } >( - "calendars/updateEventInstance", + 'calendars/updateEventInstance', async ({ cal, event }, { rejectWithValue }) => { try { - await putEventWithOverrides(event, cal.owner?.emails?.[0]); - return { calId: cal.id, event }; + await putEventWithOverrides(event, cal.owner?.emails?.[0]) + return { calId: cal.id, event } } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/services/updateSeriesAsync.ts b/src/features/Calendars/services/updateSeriesAsync.ts index f817b55..0e19f48 100644 --- a/src/features/Calendars/services/updateSeriesAsync.ts +++ b/src/features/Calendars/services/updateSeriesAsync.ts @@ -1,21 +1,21 @@ -import { updateSeries } from "@/features/Events/api/updateSeries"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { toRejectedError } from "@/utils/errorUtils"; -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { Calendar } from "../CalendarTypes"; -import { RejectedError } from "../types/RejectedError"; +import { updateSeries } from '@/features/Events/api/updateSeries' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { toRejectedError } from '@/utils/errorUtils' +import { createAsyncThunk } from '@reduxjs/toolkit' +import { Calendar } from '../CalendarTypes' +import { RejectedError } from '../types/RejectedError' export const updateSeriesAsync = createAsyncThunk< void, { cal: Calendar; event: CalendarEvent; removeOverrides?: boolean }, { rejectValue: RejectedError } >( - "calendars/updateSeries", + 'calendars/updateSeries', async ({ cal, event, removeOverrides = true }, { rejectWithValue }) => { try { - await updateSeries(event, cal.owner?.emails?.[0], removeOverrides); + await updateSeries(event, cal.owner?.emails?.[0], removeOverrides) } catch (err) { - return rejectWithValue(toRejectedError(err)); + return rejectWithValue(toRejectedError(err)) } } -); +) diff --git a/src/features/Calendars/types/AsyncThunkResult.ts b/src/features/Calendars/types/AsyncThunkResult.ts index 3768c26..b46ec3e 100644 --- a/src/features/Calendars/types/AsyncThunkResult.ts +++ b/src/features/Calendars/types/AsyncThunkResult.ts @@ -1,7 +1,7 @@ // Type for async thunk results export interface AsyncThunkResult { - type: string; - error?: { message?: string }; - payload?: { message?: string }; - unwrap?: () => Promise; + type: string + error?: { message?: string } + payload?: { message?: string } + unwrap?: () => Promise } diff --git a/src/features/Calendars/types/CalendarData.ts b/src/features/Calendars/types/CalendarData.ts index 28eafe8..fd26444 100644 --- a/src/features/Calendars/types/CalendarData.ts +++ b/src/features/Calendars/types/CalendarData.ts @@ -1,11 +1,11 @@ -import { User } from "@/components/Attendees/PeopleSearch"; -import { CalDavLink } from "../api/types"; +import { User } from '@/components/Attendees/PeopleSearch' +import { CalDavLink } from '../api/types' // Access control entry export interface AclEntry { - privilege: string; - principal: string; - protected: boolean; + privilege: string + principal: string + protected: boolean } // VObject property value can be various types @@ -17,64 +17,64 @@ export type VObjectValue = | Record | null | RepetitionRule - | undefined; + | undefined // VObject property tuple export type VObjectProperty = [ string, Record, string, - VObjectValue, -]; + VObjectValue +] -export type VCalComponent = [string, VObjectProperty[], VCalComponent[]?]; +export type VCalComponent = [string, VObjectProperty[], VCalComponent[]?] export interface Organizer { - cn?: string; - email: string; + cn?: string + email: string } // The `dav:item` object in _embedded export interface CalendarItem { - _links: CalDavLink; - etag: string; - status: number; - data: VCalComponent; + _links: CalDavLink + etag: string + status: number + data: VCalComponent } // Main calendar data export interface CalendarData { - _links: CalDavLink; - "caldav:description"?: string; - "dav:name"?: string; - "apple:color"?: string; - id?: string; - acl?: AclEntry[]; - invite?: unknown; + _links: CalDavLink + 'caldav:description'?: string + 'dav:name'?: string + 'apple:color'?: string + id?: string + acl?: AclEntry[] + invite?: unknown _embedded: { - "sync-token": string; - "dav:item": CalendarItem[]; - }; - "calendarserver:source"?: { _links: CalDavLink }; - "calendarserver:delegatedsource"?: string; + 'sync-token': string + 'dav:item': CalendarItem[] + } + 'calendarserver:source'?: { _links: CalDavLink } + 'calendarserver:delegatedsource'?: string } export interface CalendarList { - _embedded: { "dav:calendar": CalendarData[] }; + _embedded: { 'dav:calendar': CalendarData[] } } // Calendar input for forms or UI export interface CalendarInput { - cal: CalendarData; - color: Record; - owner?: User; + cal: CalendarData + color: Record + owner?: User } // Vevent repetition rule export interface RepetitionRule { - freq: string; - interval?: number; - count?: number; - until?: string; - byday?: string | string[]; + freq: string + interval?: number + count?: number + until?: string + byday?: string | string[] } diff --git a/src/features/Calendars/types/RejectedError.ts b/src/features/Calendars/types/RejectedError.ts index 83d15e2..b97eed5 100644 --- a/src/features/Calendars/types/RejectedError.ts +++ b/src/features/Calendars/types/RejectedError.ts @@ -1,6 +1,6 @@ // Define error type for rejected actions export interface RejectedError { - message: string; - status?: number; + message: string + status?: number } diff --git a/src/features/Calendars/useCalendarLoader.ts b/src/features/Calendars/useCalendarLoader.ts index 3086a7a..c562a49 100644 --- a/src/features/Calendars/useCalendarLoader.ts +++ b/src/features/Calendars/useCalendarLoader.ts @@ -1,36 +1,36 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { getCalendarDetailAsync } from "@/features/Calendars/services"; +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { getCalendarDetailAsync } from '@/features/Calendars/services' import { formatDateToYYYYMMDDTHHMMSS, getAdjacentWeekRange, - getViewRange, -} from "@/utils/dateUtils"; -import { useEffect, useMemo, useRef } from "react"; + getViewRange +} from '@/utils/dateUtils' +import { useEffect, useMemo, useRef } from 'react' export interface Interval { - start: number; - end: number; + start: number + end: number } export function mergeInterval( intervals: Interval[], next: Interval ): Interval[] { - const result: Interval[] = []; - let merged = { ...next }; + const result: Interval[] = [] + let merged = { ...next } for (const iv of intervals) { - if (iv.end < merged.start) result.push(iv); + if (iv.end < merged.start) result.push(iv) else if (iv.start > merged.end) { - result.push(merged); - merged = iv; + result.push(merged) + merged = iv } else merged = { start: Math.min(iv.start, merged.start), - end: Math.max(iv.end, merged.end), - }; + end: Math.max(iv.end, merged.end) + } } - result.push(merged); - return result; + result.push(merged) + return result } export function subtractIntervals( @@ -38,29 +38,29 @@ export function subtractIntervals( end: number, fetched: Interval[] ): Interval[] { - let remaining: Interval[] = [{ start, end }]; + let remaining: Interval[] = [{ start, end }] for (const iv of fetched) { - const next: Interval[] = []; + const next: Interval[] = [] for (const r of remaining) { - if (iv.end <= r.start || iv.start >= r.end) next.push(r); + if (iv.end <= r.start || iv.start >= r.end) next.push(r) else { - if (iv.start > r.start) next.push({ start: r.start, end: iv.start }); - if (iv.end < r.end) next.push({ start: iv.end, end: r.end }); + if (iv.start > r.start) next.push({ start: r.start, end: iv.start }) + if (iv.end < r.end) next.push({ start: iv.end, end: r.end }) } } - remaining = next; + remaining = next } - return remaining; + return remaining } interface UseCalendarDataLoaderParams { - selectedDate: Date; - currentView: string; - selectedCalendars: string[]; - sortedSelectedCalendars: string[]; - calendarIds: string[]; - calendarIdsString: string; - tempCalendarIds: string[]; + selectedDate: Date + currentView: string + selectedCalendars: string[] + sortedSelectedCalendars: string[] + calendarIds: string[] + calendarIdsString: string + tempCalendarIds: string[] } export function useCalendarDataLoader({ @@ -70,59 +70,59 @@ export function useCalendarDataLoader({ sortedSelectedCalendars, calendarIds, calendarIdsString, - tempCalendarIds, + tempCalendarIds }: UseCalendarDataLoaderParams) { - const dispatch = useAppDispatch(); - const calendars = useAppSelector((state) => state.calendars.list); + const dispatch = useAppDispatch() + const calendars = useAppSelector(state => state.calendars.list) const { visibleStart, visibleEnd, prefetchEnd } = useMemo(() => { - const { start, end } = getViewRange(selectedDate, currentView); + const { start, end } = getViewRange(selectedDate, currentView) const prefetchEnd = - currentView === "dayGridMonth" + currentView === 'dayGridMonth' ? end.getTime() - : getAdjacentWeekRange(selectedDate).end.getTime(); + : getAdjacentWeekRange(selectedDate).end.getTime() return { visibleStart: start.getTime(), visibleEnd: end.getTime(), - prefetchEnd, - }; - }, [selectedDate, currentView]); + prefetchEnd + } + }, [selectedDate, currentView]) - const fetchedIntervalsRef = useRef>({}); - const inFlightRef = useRef>({}); - const tempFetchedIntervalsRef = useRef>({}); - const processedCacheClearRef = useRef>({}); + const fetchedIntervalsRef = useRef>({}) + const inFlightRef = useRef>({}) + const tempFetchedIntervalsRef = useRef>({}) + const processedCacheClearRef = useRef>({}) useEffect(() => { - let cancelled = false; - const BATCH_SIZE = 5; - const toApiDate = (ms: number) => formatDateToYYYYMMDDTHHMMSS(new Date(ms)); + let cancelled = false + const BATCH_SIZE = 5 + const toApiDate = (ms: number) => formatDateToYYYYMMDDTHHMMSS(new Date(ms)) const run = async () => { // Active load: selected calendars, visible range, gaps only // Exclude intervals already fetched OR currently in-flight to avoid duplicates. - const activeUnits = sortedSelectedCalendars.flatMap((id) => { - const fetched = fetchedIntervalsRef.current[id] ?? []; - const inFlight = inFlightRef.current[id] ?? []; + const activeUnits = sortedSelectedCalendars.flatMap(id => { + const fetched = fetchedIntervalsRef.current[id] ?? [] + const inFlight = inFlightRef.current[id] ?? [] // Build the union of fetched + inFlight intervals to subtract against. const covered = inFlight.reduce( (acc, iv) => mergeInterval(acc, iv), fetched - ); + ) return subtractIntervals(visibleStart, visibleEnd, covered).map( - (gap) => ({ id, gap }) - ); - }); + gap => ({ id, gap }) + ) + }) activeUnits.forEach(({ id, gap }) => { inFlightRef.current[id] = mergeInterval( inFlightRef.current[id] ?? [], gap - ); - }); + ) + }) for (let i = 0; i < activeUnits.length; i += BATCH_SIZE) { - if (cancelled) return; + if (cancelled) return await Promise.all( activeUnits.slice(i, i + BATCH_SIZE).map(async ({ id, gap }) => { try { @@ -131,83 +131,83 @@ export function useCalendarDataLoader({ calId: id, match: { start: toApiDate(gap.start), - end: toApiDate(gap.end), - }, + end: toApiDate(gap.end) + } }) - ).unwrap(); + ).unwrap() if (!cancelled) { // Promote gap from in-flight → fetched. fetchedIntervalsRef.current[id] = mergeInterval( fetchedIntervalsRef.current[id] ?? [], gap - ); + ) } } catch { // Remove gap from in-flight so it can be retried. inFlightRef.current[id] = (inFlightRef.current[id] ?? []).flatMap( - (iv) => { - if (iv.end <= gap.start || iv.start >= gap.end) return [iv]; - const pieces: Interval[] = []; + iv => { + if (iv.end <= gap.start || iv.start >= gap.end) return [iv] + const pieces: Interval[] = [] if (iv.start < gap.start) - pieces.push({ start: iv.start, end: gap.start }); + pieces.push({ start: iv.start, end: gap.start }) if (iv.end > gap.end) - pieces.push({ start: gap.end, end: iv.end }); - return pieces; + pieces.push({ start: gap.end, end: iv.end }) + return pieces } - ); + ) } }) - ); + ) } - if (cancelled) return; + if (cancelled) return // Prefetch: hidden calendars (visible range) + adjacent week for selected const hiddenCalendars = calendarIds.filter( - (id) => !selectedCalendars.includes(id) - ); + id => !selectedCalendars.includes(id) + ) const prefetchUnits = [ - ...hiddenCalendars.flatMap((id) => + ...hiddenCalendars.flatMap(id => subtractIntervals( visibleStart, visibleEnd, fetchedIntervalsRef.current[id] ?? [] - ).map((gap) => ({ id, gap })) + ).map(gap => ({ id, gap })) ), - ...sortedSelectedCalendars.flatMap((id) => + ...sortedSelectedCalendars.flatMap(id => subtractIntervals( visibleEnd, prefetchEnd, fetchedIntervalsRef.current[id] ?? [] ) - .filter((gap) => gap.start < gap.end) - .map((gap) => ({ id, gap })) - ), - ]; + .filter(gap => gap.start < gap.end) + .map(gap => ({ id, gap })) + ) + ] - const savedGaps = new Map(); + const savedGaps = new Map() prefetchUnits.forEach(({ gap }, index) => { - savedGaps.set(index, { ...gap }); - }); + savedGaps.set(index, { ...gap }) + }) // Optimistically mark before firing to avoid duplicate kicks prefetchUnits.forEach(({ id, gap }) => { fetchedIntervalsRef.current[id] = mergeInterval( fetchedIntervalsRef.current[id] ?? [], gap - ); - }); + ) + }) prefetchUnits.forEach(({ id }, index) => { - const originalGap = savedGaps.get(index)!; + const originalGap = savedGaps.get(index) as Interval void dispatch( getCalendarDetailAsync({ calId: id, match: { start: toApiDate(originalGap.start), - end: toApiDate(originalGap.end), - }, + end: toApiDate(originalGap.end) + } }) ) .unwrap() @@ -215,25 +215,25 @@ export function useCalendarDataLoader({ // Roll back by subtracting the original gap fetchedIntervalsRef.current[id] = ( fetchedIntervalsRef.current[id] ?? [] - ).flatMap((iv) => { + ).flatMap(iv => { if (iv.end <= originalGap.start || iv.start >= originalGap.end) { - return [iv]; // no overlap, keep as-is + return [iv] // no overlap, keep as-is } - const pieces: Interval[] = []; + const pieces: Interval[] = [] if (iv.start < originalGap.start) - pieces.push({ start: iv.start, end: originalGap.start }); + pieces.push({ start: iv.start, end: originalGap.start }) if (iv.end > originalGap.end) - pieces.push({ start: originalGap.end, end: iv.end }); - return pieces; - }); - }); - }); - }; + pieces.push({ start: originalGap.end, end: iv.end }) + return pieces + }) + }) + }) + } - run(); + run() return () => { - cancelled = true; - }; + cancelled = true + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ dispatch, @@ -242,90 +242,91 @@ export function useCalendarDataLoader({ sortedSelectedCalendars, calendarIdsString, selectedCalendars, - prefetchEnd, - ]); + prefetchEnd + ]) // Cache-clear const calendarsWithClearedCache = useMemo( () => selectedCalendars - .map((id) => { - const cleared = calendars[id]?.lastCacheCleared; - return cleared ? { id, cleared } : null; + .map(id => { + const cleared = calendars[id]?.lastCacheCleared + return cleared ? { id, cleared } : null }) .filter(Boolean) as { id: string; cleared: number }[], [selectedCalendars, calendars] - ); + ) useEffect(() => { const nonSelectedCalendars = Object.keys( fetchedIntervalsRef.current - ).filter((calId) => !selectedCalendars.includes(calId)); + ).filter(calId => !selectedCalendars.includes(calId)) if (nonSelectedCalendars?.length) { - nonSelectedCalendars.forEach((calId) => { + 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]; - }); + delete fetchedIntervalsRef.current[calId] + delete inFlightRef.current[calId] + }) } - }, [selectedCalendars]); + }, [selectedCalendars]) useEffect(() => { - const toApiDate = (ms: number) => formatDateToYYYYMMDDTHHMMSS(new Date(ms)); + const toApiDate = (ms: number) => formatDateToYYYYMMDDTHHMMSS(new Date(ms)) calendarsWithClearedCache.forEach(({ id, cleared }) => { - if (processedCacheClearRef.current[id] === cleared) return; - delete fetchedIntervalsRef.current[id]; + if (processedCacheClearRef.current[id] === cleared) return + delete fetchedIntervalsRef.current[id] void dispatch( getCalendarDetailAsync({ calId: id, match: { start: toApiDate(visibleStart), - end: toApiDate(visibleEnd), - }, + end: toApiDate(visibleEnd) + } }) ) .unwrap() .then(() => { - processedCacheClearRef.current[id] = cleared; + processedCacheClearRef.current[id] = cleared fetchedIntervalsRef.current[id] = mergeInterval( fetchedIntervalsRef.current[id] ?? [], { start: visibleStart, end: visibleEnd } - ); + ) + return }) .catch(() => { /* leave unrecorded for retry */ - }); - }); - }, [calendarsWithClearedCache, dispatch, visibleStart, visibleEnd]); + }) + }) + }, [calendarsWithClearedCache, dispatch, visibleStart, visibleEnd]) // Temp calendars cleanup useEffect(() => { - const currentIds = new Set(tempCalendarIds); - Object.keys(tempFetchedIntervalsRef.current).forEach((id) => { - if (!currentIds.has(id)) delete tempFetchedIntervalsRef.current[id]; - }); - }, [tempCalendarIds]); + const currentIds = new Set(tempCalendarIds) + Object.keys(tempFetchedIntervalsRef.current).forEach(id => { + if (!currentIds.has(id)) delete tempFetchedIntervalsRef.current[id] + }) + }, [tempCalendarIds]) // Temp calendars load useEffect(() => { - if (tempCalendarIds.length === 0) return; + if (tempCalendarIds.length === 0) return - let cancelled = false; - const BATCH_SIZE = 5; - const toApiDate = (ms: number) => formatDateToYYYYMMDDTHHMMSS(new Date(ms)); + let cancelled = false + const BATCH_SIZE = 5 + const toApiDate = (ms: number) => formatDateToYYYYMMDDTHHMMSS(new Date(ms)) const run = async () => { - const fetchUnits = tempCalendarIds.flatMap((id) => + const fetchUnits = tempCalendarIds.flatMap(id => subtractIntervals( visibleStart, visibleEnd, tempFetchedIntervalsRef.current[id] ?? [] - ).map((gap) => ({ id, gap })) - ); + ).map(gap => ({ id, gap })) + ) for (let i = 0; i < fetchUnits.length; i += BATCH_SIZE) { - if (cancelled) return; + if (cancelled) return await Promise.all( fetchUnits.slice(i, i + BATCH_SIZE).map(async ({ id, gap }) => { try { @@ -334,28 +335,28 @@ export function useCalendarDataLoader({ calId: id, match: { start: toApiDate(gap.start), - end: toApiDate(gap.end), + end: toApiDate(gap.end) }, - calType: "temp", + calType: 'temp' }) - ).unwrap(); + ).unwrap() if (!cancelled) { tempFetchedIntervalsRef.current[id] = mergeInterval( tempFetchedIntervalsRef.current[id] ?? [], gap - ); + ) } } catch { /* leave unrecorded for retry */ } }) - ); + ) } - }; + } - run(); + run() return () => { - cancelled = true; - }; - }, [dispatch, visibleStart, visibleEnd, tempCalendarIds]); + cancelled = true + } + }, [dispatch, visibleStart, visibleEnd, tempCalendarIds]) } diff --git a/src/features/Calendars/utils/expandEventFunction.ts b/src/features/Calendars/utils/expandEventFunction.ts index 5ab8364..8c8aff1 100644 --- a/src/features/Calendars/utils/expandEventFunction.ts +++ b/src/features/Calendars/utils/expandEventFunction.ts @@ -1,27 +1,27 @@ -import { reportEvent } from "@/features/Events/EventApi"; -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { formatDateToYYYYMMDDTHHMMSS } from "@/utils/dateUtils"; -import { Calendar } from "../CalendarTypes"; -import { extractCalendarEvents } from "./extractCalendarEvents"; +import { reportEvent } from '@/features/Events/EventApi' +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { formatDateToYYYYMMDDTHHMMSS } from '@/utils/dateUtils' +import { Calendar } from '../CalendarTypes' +import { extractCalendarEvents } from './extractCalendarEvents' export function expandEventFunction( calendarRange: { start: Date; end: Date }, calendar: Calendar ): (item: string) => Promise { - return async (eventUrl) => { + return async eventUrl => { try { const item = await reportEvent({ URL: eventUrl } as CalendarEvent, { start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start), - end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), - }); + end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end) + }) const events: CalendarEvent[] = extractCalendarEvents(item, { cal: calendar, - color: calendar.color, - }); - return events; + color: calendar.color + }) + return events } catch (err) { - console.error("Failed to fetch event", eventUrl, err); - return undefined; + console.error('Failed to fetch event', eventUrl, err) + return undefined } - }; + } } diff --git a/src/features/Calendars/utils/extractCalendarEvents.ts b/src/features/Calendars/utils/extractCalendarEvents.ts index d7c12df..1d758a1 100644 --- a/src/features/Calendars/utils/extractCalendarEvents.ts +++ b/src/features/Calendars/utils/extractCalendarEvents.ts @@ -1,45 +1,45 @@ -import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { parseCalendarEvent } from "@/features/Events/utils"; -import { defaultColors } from "@/utils/defaultColors"; -import { CalDavItem } from "../api/types"; -import { Calendar } from "../CalendarTypes"; -import { VCalComponent } from "../types/CalendarData"; +import { CalendarEvent } from '@/features/Events/EventsTypes' +import { parseCalendarEvent } from '@/features/Events/utils' +import { defaultColors } from '@/utils/defaultColors' +import { CalDavItem } from '../api/types' +import { Calendar } from '../CalendarTypes' +import { VCalComponent } from '../types/CalendarData' export function extractCalendarEvents( item: CalDavItem, options: { - cal: Calendar; - color?: Record; + cal: Calendar + color?: Record } ): CalendarEvent[] { - const data = item.data; + const data = item.data if (!Array.isArray(data)) { - return []; + return [] } // VEVENTS are at index 2 - const vevents = data[2]; + const vevents = data[2] if (!Array.isArray(vevents)) { - return []; + return [] } - const eventURL = item._links?.self?.href; + const eventURL = item._links?.self?.href if (!eventURL) { - return []; + return [] } return vevents - .map((vevent) => { + .map(vevent => { if (!Array.isArray(vevent)) { - return null; + return null } - const eventProps = vevent[1]; + const eventProps = vevent[1] if (!Array.isArray(eventProps)) { - return null; + return null } - const valarm = extractValarm(vevent); + const valarm = extractValarm(vevent) return parseCalendarEvent( eventProps, @@ -47,20 +47,20 @@ export function extractCalendarEvents( options.cal, eventURL, valarm - ); + ) }) - .filter(Boolean) as CalendarEvent[]; + .filter(Boolean) as CalendarEvent[] } function extractValarm(vevent: VCalComponent) { - const subComponents = vevent[2]; + const subComponents = vevent[2] if (!Array.isArray(subComponents)) { - return undefined; + return undefined } const valarmComponent = subComponents.find( - (component) => Array.isArray(component) && component[0] === "valarm" - ); + component => Array.isArray(component) && component[0] === 'valarm' + ) - return valarmComponent; + return valarmComponent } diff --git a/src/features/Calendars/utils/normalizeCalendar.ts b/src/features/Calendars/utils/normalizeCalendar.ts index 96dd305..6f80995 100644 --- a/src/features/Calendars/utils/normalizeCalendar.ts +++ b/src/features/Calendars/utils/normalizeCalendar.ts @@ -1,27 +1,27 @@ import { getCalendarDelegationAccess, - getCalendarVisibility, -} from "@/components/Calendar/utils/calendarUtils"; -import { CalendarData } from "../types/CalendarData"; + getCalendarVisibility +} from '@/components/Calendar/utils/calendarUtils' +import { CalendarData } from '../types/CalendarData' export function normalizeCalendar(rawCalendar: CalendarData, userId: string) { - const description = rawCalendar["caldav:description"]; - let delegated = false; - let source = rawCalendar["calendarserver:source"] - ? rawCalendar["calendarserver:source"]._links.self?.href - : rawCalendar._links.self?.href; - const link = rawCalendar._links.self?.href; - if (rawCalendar["calendarserver:delegatedsource"]) { - source = rawCalendar["calendarserver:delegatedsource"]; - delegated = true; + const description = rawCalendar['caldav:description'] + let delegated = false + let source = rawCalendar['calendarserver:source'] + ? rawCalendar['calendarserver:source']._links.self?.href + : rawCalendar._links.self?.href + const link = rawCalendar._links.self?.href + if (rawCalendar['calendarserver:delegatedsource']) { + source = rawCalendar['calendarserver:delegatedsource'] + delegated = true } if (!source) { - throw new Error("No source for calendar"); + throw new Error('No source for calendar') } - const id = source.replace("/calendars/", "").replace(".json", ""); - const ownerId = id.split("/")[0]; - const visibility = getCalendarVisibility(rawCalendar["acl"] ?? []); - const access = getCalendarDelegationAccess(rawCalendar["acl"] ?? [], userId); + const id = source.replace('/calendars/', '').replace('.json', '') + const ownerId = id.split('/')[0] + const visibility = getCalendarVisibility(rawCalendar['acl'] ?? []) + const access = getCalendarDelegationAccess(rawCalendar['acl'] ?? [], userId) return { cal: rawCalendar, @@ -32,6 +32,6 @@ export function normalizeCalendar(rawCalendar: CalendarData, userId: string) { id, ownerId, visibility, - access, - }; + access + } } diff --git a/src/features/Calendars/utils/processSyncTokenUpdates.ts b/src/features/Calendars/utils/processSyncTokenUpdates.ts index a00746f..3edee5d 100644 --- a/src/features/Calendars/utils/processSyncTokenUpdates.ts +++ b/src/features/Calendars/utils/processSyncTokenUpdates.ts @@ -1,28 +1,28 @@ -import { DavSyncItem } from "../api/types"; +import { DavSyncItem } from '../api/types' export interface ProcessedSyncUpdates { - toDelete: string[]; - toExpand: string[]; + toDelete: string[] + toExpand: string[] } export function processSyncUpdates( updates: DavSyncItem[] ): ProcessedSyncUpdates { - const toDelete: string[] = []; - const toExpand: string[] = []; + const toDelete: string[] = [] + const toExpand: string[] = [] for (const update of updates) { - const href = update?._links?.self?.href; - if (!href) continue; + const href = update?._links?.self?.href + if (!href) continue if (update.status === 404) { - toDelete.push(href); + toDelete.push(href) } else if (update.status === 200) { - toExpand.push(href); - toDelete.push(href); // we delete the old version of the event to replace it by the new when it's updated + toExpand.push(href) + toDelete.push(href) // we delete the old version of the event to replace it by the new when it's updated } else if (update.status === 410) { - throw new Error("SYNC_TOKEN_INVALID"); + throw new Error('SYNC_TOKEN_INVALID') } } - return { toDelete, toExpand }; + return { toDelete, toExpand } } diff --git a/src/features/Events/AttendanceValidation/AttendanceValidation.tsx b/src/features/Events/AttendanceValidation/AttendanceValidation.tsx index 63f6f98..b94779b 100644 --- a/src/features/Events/AttendanceValidation/AttendanceValidation.tsx +++ b/src/features/Events/AttendanceValidation/AttendanceValidation.tsx @@ -1,61 +1,59 @@ -import { PartStat } from "@/features/User/models/attendee"; -import { userData } from "@/features/User/userDataTypes"; -import { Box, Typography } from "@linagora/twake-mui"; -import { Dispatch, SetStateAction, useState } from "react"; -import { useI18n } from "twake-i18n"; -import { ContextualizedEvent } from "../EventsTypes"; -import { EventCounterModal } from "./EventCounterModal"; -import { RSVPButton } from "./RSVPButton"; +import { PartStat } from '@/features/User/models/attendee' +import { userData } from '@/features/User/userDataTypes' +import { Box, Typography } from '@linagora/twake-mui' +import { Dispatch, SetStateAction, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { ContextualizedEvent } from '../EventsTypes' +import { EventCounterModal } from './EventCounterModal' +import { RSVPButton } from './RSVPButton' interface AttendanceValidationProps { - contextualizedEvent: ContextualizedEvent; - user: userData | undefined; + contextualizedEvent: ContextualizedEvent + user: userData | undefined setAfterChoiceFunc: ( - func: ((type: "solo" | "all" | undefined) => void) | undefined - ) => void; - setOpenEditModePopup: Dispatch>; + func: ((type: 'solo' | 'all' | undefined) => void) | undefined + ) => void + setOpenEditModePopup: Dispatch> } export function AttendanceValidation({ contextualizedEvent, user, setAfterChoiceFunc, - setOpenEditModePopup, + setOpenEditModePopup }: AttendanceValidationProps) { - const { currentUserAttendee, isOwn, calendar } = contextualizedEvent; - const { t } = useI18n(); - const [isLoading, setIsLoading] = useState(false); - const [loadingValue, setLoadingValue] = useState(null); - const [openCounterModal, setOpenCounterModal] = useState(false); + const { currentUserAttendee, isOwn, calendar } = contextualizedEvent + const { t } = useI18n() + const [isLoading, setIsLoading] = useState(false) + const [loadingValue, setLoadingValue] = useState(null) + const [openCounterModal, setOpenCounterModal] = useState(false) const hasNoAttendeesOrOrganizer = !(contextualizedEvent.event?.attendee?.length > 0) && - !contextualizedEvent.event?.organizer; + !contextualizedEvent.event?.organizer - const createByTheUser = currentUserAttendee || hasNoAttendeesOrOrganizer; - const editRightInSelfCalendar = createByTheUser && isOwn; + const createByTheUser = currentUserAttendee || hasNoAttendeesOrOrganizer + const editRightInSelfCalendar = createByTheUser && isOwn const isDelegatedPublicEvent = contextualizedEvent.calendar.delegated && (!contextualizedEvent.event.class || - contextualizedEvent.event.class === "PUBLIC"); + contextualizedEvent.event.class === 'PUBLIC') - const { owner: resourceOwner } = calendar; + const { owner: resourceOwner } = calendar const isAdminOfResource = resourceOwner?.resource && - resourceOwner?.administrators?.some( - (admin) => admin.id === user?.openpaasId - ); + resourceOwner?.administrators?.some(admin => admin.id === user?.openpaasId) if ( !(editRightInSelfCalendar || isDelegatedPublicEvent || isAdminOfResource) ) { - return null; + return null } const handleLoadingChange = (loading: boolean, value?: PartStat) => { - setIsLoading(loading); - setLoadingValue(loading && value ? value : null); - }; + setIsLoading(loading) + setLoadingValue(loading && value ? value : null) + } const commonButtonProps = { contextualizedEvent, @@ -64,15 +62,15 @@ export function AttendanceValidation({ setOpenEditModePopup, isLoading, onLoadingChange: handleLoadingChange, - loadingValue, - }; + loadingValue + } return ( <> {calendar.owner?.resource - ? t("eventPreview.authorizeQuestion") - : t("eventPreview.attendingQuestion")} + ? t('eventPreview.authorizeQuestion') + : t('eventPreview.attendingQuestion')} @@ -83,9 +81,9 @@ export function AttendanceValidation({ setOpenCounterModal(!openCounterModal)} - sx={{ marginLeft: 1, cursor: "pointer" }} + sx={{ marginLeft: 1, cursor: 'pointer' }} > - {t("eventPreview.proposeNewTime")} + {t('eventPreview.proposeNewTime')} )} - ); + ) } diff --git a/src/features/Events/AttendanceValidation/EventCounterModal.tsx b/src/features/Events/AttendanceValidation/EventCounterModal.tsx index 16e3007..9dd1676 100644 --- a/src/features/Events/AttendanceValidation/EventCounterModal.tsx +++ b/src/features/Events/AttendanceValidation/EventCounterModal.tsx @@ -1,122 +1,122 @@ -import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils"; -import { ResponsiveDialog } from "@/components/Dialog"; -import { DateTimeFields } from "@/components/Event/components/DateTimeFields"; -import { FieldWithLabel } from "@/components/Event/components/FieldWithLabel"; -import { splitDateTime } from "@/components/Event/utils/dateTimeHelpers"; -import { Box, Button, TextField, Typography } from "@linagora/twake-mui"; -import { SnackbarAlert } from "@/components/Loading/SnackBarAlert"; -import moment from "moment-timezone"; -import { useEffect, useState } from "react"; -import { useI18n } from "twake-i18n"; -import { postCounterProposal } from "../api/sendCounterProposal"; -import { EventTimeSubtitle } from "../EventPreview/EventTimeSubtitle"; -import { ContextualizedEvent } from "../EventsTypes"; +import { formatEventChipTitle } from '@/components/Calendar/utils/calendarUtils' +import { ResponsiveDialog } from '@/components/Dialog' +import { DateTimeFields } from '@/components/Event/components/DateTimeFields' +import { FieldWithLabel } from '@/components/Event/components/FieldWithLabel' +import { splitDateTime } from '@/components/Event/utils/dateTimeHelpers' +import { Box, Button, TextField, Typography } from '@linagora/twake-mui' +import { SnackbarAlert } from '@/components/Loading/SnackBarAlert' +import moment from 'moment-timezone' +import { useEffect, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { postCounterProposal } from '../api/sendCounterProposal' +import { EventTimeSubtitle } from '../EventPreview/EventTimeSubtitle' +import { ContextualizedEvent } from '../EventsTypes' export function EventCounterModal({ open, setOpen, - contextualizedEvent, + contextualizedEvent }: { - open: boolean; - setOpen: (b: boolean) => void; - contextualizedEvent: ContextualizedEvent; + open: boolean + setOpen: (b: boolean) => void + contextualizedEvent: ContextualizedEvent }) { - const { t } = useI18n(); - const [isSubmitting, setIsSubmitting] = useState(false); - const [showSuccessToast, setShowSuccessToast] = useState(false); + const { t } = useI18n() + const [isSubmitting, setIsSubmitting] = useState(false) + const [showSuccessToast, setShowSuccessToast] = useState(false) - const allday = contextualizedEvent.event.allday ?? false; + const allday = contextualizedEvent.event.allday ?? false - const timezone = contextualizedEvent.event.timezone; + const timezone = contextualizedEvent.event.timezone const startSplit = splitDateTime( moment .tz(contextualizedEvent.event.start, timezone) - .format("YYYY-MM-DDTHH:mm") - ); + .format('YYYY-MM-DDTHH:mm') + ) const endSplit = splitDateTime( moment .tz( contextualizedEvent.event.end ?? contextualizedEvent.event.start, timezone ) - .format("YYYY-MM-DDTHH:mm") - ); + .format('YYYY-MM-DDTHH:mm') + ) - const [startDate, setStartDate] = useState(startSplit.date); - const [startTime, setStartTime] = useState(startSplit.time); - const [endDate, setEndDate] = useState(endSplit.date); - const [endTime, setEndTime] = useState(endSplit.time); - const [showMore, setShowMore] = useState(false); - const [hasEndDateChanged, setHasEndDateChanged] = useState(false); - const [message, setMessage] = useState(""); + const [startDate, setStartDate] = useState(startSplit.date) + const [startTime, setStartTime] = useState(startSplit.time) + const [endDate, setEndDate] = useState(endSplit.date) + const [endTime, setEndTime] = useState(endSplit.time) + const [showMore, setShowMore] = useState(false) + const [hasEndDateChanged, setHasEndDateChanged] = useState(false) + const [message, setMessage] = useState('') const [validation, setValidation] = useState<{ - errors: { dateTime: string }; + errors: { dateTime: string } }>({ - errors: { dateTime: "" }, - }); + errors: { dateTime: '' } + }) const handleStartDateChange = (value: string) => { - setStartDate(value); + setStartDate(value) if (value > endDate) { - setEndDate(value); - setHasEndDateChanged(true); + setEndDate(value) + setHasEndDateChanged(true) } - setValidation({ errors: { dateTime: "" } }); - }; + setValidation({ errors: { dateTime: '' } }) + } const handleStartTimeChange = (value: string) => { - setStartTime(value); - setValidation({ errors: { dateTime: "" } }); - }; + setStartTime(value) + setValidation({ errors: { dateTime: '' } }) + } const handleEndDateChange = (value: string) => { - setEndDate(value); - setHasEndDateChanged(true); - setValidation({ errors: { dateTime: "" } }); - }; + setEndDate(value) + setHasEndDateChanged(true) + setValidation({ errors: { dateTime: '' } }) + } const handleEndTimeChange = (value: string) => { - setEndTime(value); - setValidation({ errors: { dateTime: "" } }); - }; + setEndTime(value) + setValidation({ errors: { dateTime: '' } }) + } const validate = (): boolean => { if (!startDate || !endDate) { setValidation({ - errors: { dateTime: t("event.validation.startRequired") }, - }); - return false; + errors: { dateTime: t('event.validation.startRequired') } + }) + return false } if (!allday && (!startTime || !endTime)) { setValidation({ - errors: { dateTime: t("event.validation.startRequired") }, - }); - return false; + errors: { dateTime: t('event.validation.startRequired') } + }) + return false } if ( endDate < startDate || (!allday && endDate === startDate && endTime <= startTime) ) { setValidation({ - errors: { dateTime: t("event.validation.endAfterStart") }, - }); - return false; + errors: { dateTime: t('event.validation.endAfterStart') } + }) + return false } - setValidation({ errors: { dateTime: "" } }); - return true; - }; + setValidation({ errors: { dateTime: '' } }) + return true + } const handleSubmit = async () => { - if (!validate()) return; + if (!validate()) return if ( !contextualizedEvent.currentUserAttendee?.cal_address || !contextualizedEvent.event.organizer?.cal_address ) { - setValidation({ errors: { dateTime: t("error.unknown") } }); - return; + setValidation({ errors: { dateTime: t('error.unknown') } }) + return } - setIsSubmitting(true); + setIsSubmitting(true) try { await postCounterProposal({ event: contextualizedEvent.event, @@ -128,48 +128,48 @@ export function EventCounterModal({ proposedEnd: contextualizedEvent.event.allday ? endDate : `${endDate}T${endTime}`, - message, - }); - setShowSuccessToast(true); - setOpen(false); + message + }) + setShowSuccessToast(true) + setOpen(false) } catch (error) { - console.error(error); - setValidation({ errors: { dateTime: t("error.unknown") } }); + console.error(error) + setValidation({ errors: { dateTime: t('error.unknown') } }) } finally { - setIsSubmitting(false); + setIsSubmitting(false) } - }; + } useEffect(() => { - if (!open) return; - setStartDate(startSplit.date); - setStartTime(startSplit.time); - setEndDate(endSplit.date); - setEndTime(endSplit.time); - setShowMore(false); - setHasEndDateChanged(false); - setMessage(""); - setValidation({ errors: { dateTime: "" } }); - }, [open, startSplit.date, startSplit.time, endSplit.date, endSplit.time]); + if (!open) return + setStartDate(startSplit.date) + setStartTime(startSplit.time) + setEndDate(endSplit.date) + setEndTime(endSplit.time) + setShowMore(false) + setHasEndDateChanged(false) + setMessage('') + setValidation({ errors: { dateTime: '' } }) + }, [open, startSplit.date, startSplit.time, endSplit.date, endSplit.time]) return ( <> setOpen(false)} - title={t("eventPreview.proposeNewTime")} + title={t('eventPreview.proposeNewTime')} > {/* Event title */} {formatEventChipTitle(contextualizedEvent.event, t)} @@ -186,7 +186,7 @@ export function EventCounterModal({ {/* Your proposal label */} setShowMore((prev) => !prev)} + onToggleEndDate={() => setShowMore(prev => !prev)} /> {/* Optional message */} @@ -220,18 +220,18 @@ export function EventCounterModal({ minRows={2} maxRows={10} fullWidth - placeholder={t("eventPreview.optionalMessage")} + placeholder={t('eventPreview.optionalMessage')} value={message} - onChange={(e) => setMessage(e.target.value)} + onChange={e => setMessage(e.target.value)} sx={{ mt: 2, - "& .MuiInputBase-root": { - overflowY: "auto", - padding: 0, - }, - "& textarea": { - resize: "vertical", + '& .MuiInputBase-root': { + overflowY: 'auto', + padding: 0 }, + '& textarea': { + resize: 'vertical' + } }} /> @@ -239,7 +239,7 @@ export function EventCounterModal({ {/* Actions */} - ); + ) } diff --git a/src/features/Events/AttendanceValidation/RSVPButton.tsx b/src/features/Events/AttendanceValidation/RSVPButton.tsx index fbecda5..472be16 100644 --- a/src/features/Events/AttendanceValidation/RSVPButton.tsx +++ b/src/features/Events/AttendanceValidation/RSVPButton.tsx @@ -1,31 +1,31 @@ -import { useAppDispatch } from "@/app/hooks"; -import { PartStat } from "@/features/User/models/attendee"; -import { userData } from "@/features/User/userDataTypes"; -import { Box, Button, CircularProgress, Theme } from "@linagora/twake-mui"; -import { Dispatch, SetStateAction, useEffect, useRef } from "react"; -import { useI18n } from "twake-i18n"; -import { ContextualizedEvent } from "../EventsTypes"; -import { handleRSVPClick } from "./handleRSVPClick"; +import { useAppDispatch } from '@/app/hooks' +import { PartStat } from '@/features/User/models/attendee' +import { userData } from '@/features/User/userDataTypes' +import { Box, Button, CircularProgress, Theme } from '@linagora/twake-mui' +import { Dispatch, SetStateAction, useEffect, useRef } from 'react' +import { useI18n } from 'twake-i18n' +import { ContextualizedEvent } from '../EventsTypes' +import { handleRSVPClick } from './handleRSVPClick' -const rsvpColor: Record = +const rsvpColor: Record = { - ACCEPTED: "success", - DECLINED: "error", - TENTATIVE: "warning", - "NEEDS-ACTION": "primary", - } as const; + ACCEPTED: 'success', + DECLINED: 'error', + TENTATIVE: 'warning', + 'NEEDS-ACTION': 'primary' + } as const interface RSVPButtonProps { - rsvpValue: PartStat; - contextualizedEvent: ContextualizedEvent; - user: userData | undefined; + rsvpValue: PartStat + contextualizedEvent: ContextualizedEvent + user: userData | undefined setAfterChoiceFunc: ( - func: ((type: "solo" | "all" | undefined) => void) | undefined - ) => void; - setOpenEditModePopup: Dispatch>; - isLoading: boolean; - onLoadingChange: (loading: boolean, value?: PartStat) => void; - loadingValue: PartStat | null; + func: ((type: 'solo' | 'all' | undefined) => void) | undefined + ) => void + setOpenEditModePopup: Dispatch> + isLoading: boolean + onLoadingChange: (loading: boolean, value?: PartStat) => void + loadingValue: PartStat | null } export function RSVPButton({ @@ -36,42 +36,42 @@ export function RSVPButton({ setOpenEditModePopup, isLoading, onLoadingChange, - loadingValue, + loadingValue }: RSVPButtonProps) { - const { t } = useI18n(); - const dispatch = useAppDispatch(); - const { currentUserAttendee, calendar } = contextualizedEvent; - const showLoading = isLoading && loadingValue === rsvpValue; + const { t } = useI18n() + const dispatch = useAppDispatch() + const { currentUserAttendee, calendar } = contextualizedEvent + const showLoading = isLoading && loadingValue === rsvpValue const isReadDelegated = - calendar.delegated && calendar.access?.read && !calendar.access?.write; + calendar.delegated && calendar.access?.read && !calendar.access?.write const previousPartstatRef = useRef( currentUserAttendee?.partstat - ); + ) // Detect when attendee status changes (via WebSocket) and clear loading useEffect(() => { - const currentPartstat = currentUserAttendee?.partstat; + const currentPartstat = currentUserAttendee?.partstat if ( isLoading && previousPartstatRef.current !== undefined && currentPartstat !== previousPartstatRef.current ) { - onLoadingChange(false); + onLoadingChange(false) } - previousPartstatRef.current = currentPartstat; - }, [currentUserAttendee?.partstat, isLoading, rsvpValue, onLoadingChange]); + previousPartstatRef.current = currentPartstat + }, [currentUserAttendee?.partstat, isLoading, rsvpValue, onLoadingChange]) const handleClick = async () => { // Store current partstat before making changes - previousPartstatRef.current = currentUserAttendee?.partstat; + previousPartstatRef.current = currentUserAttendee?.partstat if (previousPartstatRef.current === rsvpValue) { - return; + return } // For recurring events, don't set loading yet - wait for modal choice if (!contextualizedEvent.isRecurring) { - onLoadingChange(true, rsvpValue); + onLoadingChange(true, rsvpValue) } try { @@ -83,42 +83,42 @@ export function RSVPButton({ setOpenEditModePopup, dispatch, onLoadingChange - ); + ) } catch (error) { console.error( `[RSVPButton ${rsvpValue}] Error in handleRSVPClick:`, error - ); + ) // Clear loading on error - onLoadingChange(false); + onLoadingChange(false) } - }; + } - const isCurrentlyActive = currentUserAttendee?.partstat === rsvpValue; + const isCurrentlyActive = currentUserAttendee?.partstat === rsvpValue // Show as active (colored) if: // 1. This button is currently loading, OR // 2. This is the active status AND nothing is loading - const shouldShowActive = showLoading || (isCurrentlyActive && !isLoading); + const shouldShowActive = showLoading || (isCurrentlyActive && !isLoading) - const buttonColor = shouldShowActive ? rsvpColor[rsvpValue] : "primary"; + const buttonColor = shouldShowActive ? rsvpColor[rsvpValue] : 'primary' return ( - ); + ) } diff --git a/src/features/Events/AttendanceValidation/handleRSVPClick.tsx b/src/features/Events/AttendanceValidation/handleRSVPClick.tsx index 0709280..1f1cbb2 100644 --- a/src/features/Events/AttendanceValidation/handleRSVPClick.tsx +++ b/src/features/Events/AttendanceValidation/handleRSVPClick.tsx @@ -1,53 +1,53 @@ -import { AppDispatch } from "@/app/store"; -import { handleRSVP } from "@/components/Event/eventHandlers/eventHandlers"; -import { PartStat } from "@/features/User/models/attendee"; -import { userData } from "@/features/User/userDataTypes"; -import { Dispatch, SetStateAction } from "react"; -import { ContextualizedEvent } from "../EventsTypes"; +import { AppDispatch } from '@/app/store' +import { handleRSVP } from '@/components/Event/eventHandlers/eventHandlers' +import { PartStat } from '@/features/User/models/attendee' +import { userData } from '@/features/User/userDataTypes' +import { Dispatch, SetStateAction } from 'react' +import { ContextualizedEvent } from '../EventsTypes' export async function handleRSVPClick( rsvp: PartStat, contextualizedEvent: ContextualizedEvent, user: userData | undefined, setAfterChoiceFunc: ( - func: ((type: "solo" | "all" | undefined) => void) | undefined + func: ((type: 'solo' | 'all' | undefined) => void) | undefined ) => void, setOpenEditModePopup: Dispatch>, dispatch: AppDispatch, onLoadingChange?: (loading: boolean, value?: PartStat) => void ) { - const { isRecurring, calendar, event } = contextualizedEvent; + const { isRecurring, calendar, event } = contextualizedEvent if (isRecurring) { setAfterChoiceFunc(() => async (type: string | null) => { // If user cancelled the modal, don't proceed if (type === null) { - return; + return } // Now start loading since user made a choice if (onLoadingChange) { - onLoadingChange(true, rsvp); + onLoadingChange(true, rsvp) } try { - await handleRSVP(dispatch, calendar, user, event, rsvp, type); + await handleRSVP(dispatch, calendar, user, event, rsvp, type) } catch (error) { - console.error("Error handling RSVP:", error); + console.error('Error handling RSVP:', error) // Clear loading on error if (onLoadingChange) { - onLoadingChange(false); + onLoadingChange(false) } } - }); - setOpenEditModePopup("attendance"); + }) + setOpenEditModePopup('attendance') } else { try { - await handleRSVP(dispatch, calendar, user, event, rsvp); + await handleRSVP(dispatch, calendar, user, event, rsvp) } catch (error) { - console.error("Error handling RSVP:", error); + console.error('Error handling RSVP:', error) if (onLoadingChange) { - onLoadingChange(false); + onLoadingChange(false) } } } diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index 44d8a05..1aae0a4 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -1,106 +1,106 @@ -import { api } from "@/utils/apiUtils"; -import { convertEventDateTimeToISO, resolveTimezoneId } from "@/utils/timezone"; -import { TIMEZONES } from "@/utils/timezone-data"; -import ICAL from "ical.js"; -import { CalDavItem } from "../Calendars/api/types"; -import { Calendar } from "../Calendars/CalendarTypes"; +import { api } from '@/utils/apiUtils' +import { convertEventDateTimeToISO, resolveTimezoneId } from '@/utils/timezone' +import { TIMEZONES } from '@/utils/timezone-data' +import ICAL from 'ical.js' +import { CalDavItem } from '../Calendars/api/types' +import { Calendar } from '../Calendars/CalendarTypes' import { VCalComponent, VObjectProperty, - VObjectValue, -} from "../Calendars/types/CalendarData"; -import { SearchEventsResponse } from "../Search/types/SearchEventsResponse"; -import { CalendarEvent } from "./EventsTypes"; + VObjectValue +} from '../Calendars/types/CalendarData' +import { SearchEventsResponse } from '../Search/types/SearchEventsResponse' +import { CalendarEvent } from './EventsTypes' import { calendarEventToJCal, makeTimezone, makeVevent, - parseCalendarEvent, -} from "./utils"; + parseCalendarEvent +} from './utils' export async function reportEvent( event: CalendarEvent, match: { start: string; end: string } ): Promise { const response = await api(`dav${event.URL}`, { - method: "REPORT", + method: 'REPORT', body: JSON.stringify({ match }), - headers: { Accept: "application/json" }, - }); + headers: { Accept: 'application/json' } + }) if (!response.ok) { - throw new Error(`REPORT request failed with status ${response.status}`); + throw new Error(`REPORT request failed with status ${response.status}`) } - const eventData: CalDavItem = await response.json(); - return eventData; + const eventData: CalDavItem = await response.json() + return eventData } export async function getEvent(event: CalendarEvent, isMaster?: boolean) { - const response = await api.get(`dav${event.URL}`); - const eventData = await response.text(); + const response = await api.get(`dav${event.URL}`) + const eventData = await response.text() - const eventical = ICAL.parse(eventData); + const eventical = ICAL.parse(eventData) const vevents = (eventical[2] || []).filter( - ([name]: [string]) => name.toLowerCase() === "vevent" - ); + ([name]: [string]) => name.toLowerCase() === 'vevent' + ) const vtimezones = (eventical[2] || []).filter( - ([name]: [string]) => name.toLowerCase() === "vtimezone" - ); + ([name]: [string]) => name.toLowerCase() === 'vtimezone' + ) - let targetVevent; + let targetVevent if (isMaster) { targetVevent = vevents.find( ([, props]: VCalComponent) => - !props.find(([k]) => k.toLowerCase() === "recurrence-id") - ); + !props.find(([k]) => k.toLowerCase() === 'recurrence-id') + ) if (!targetVevent) { - targetVevent = vevents[0]; + targetVevent = vevents[0] } } else { - targetVevent = vevents[0]; + targetVevent = vevents[0] } - let timezoneFromVTimezone: string | undefined; + let timezoneFromVTimezone: string | undefined if (vtimezones.length > 0) { - const vtimezone = vtimezones[0]; + const vtimezone = vtimezones[0] const tzidProp = vtimezone[1]?.find( - ([k]: string[]) => k.toLowerCase() === "tzid" - ); + ([k]: string[]) => k.toLowerCase() === 'tzid' + ) if (tzidProp && tzidProp[3]) { - const resolvedTz = resolveTimezoneId(tzidProp[3]); + const resolvedTz = resolveTimezoneId(tzidProp[3]) if (resolvedTz) { - timezoneFromVTimezone = resolvedTz; + timezoneFromVTimezone = resolvedTz } } } - let timezoneFromDTSTART: string | undefined; + let timezoneFromDTSTART: string | undefined const dtstartProp = targetVevent[1]?.find( - ([k]: string[]) => k.toLowerCase() === "dtstart" - ); + ([k]: string[]) => k.toLowerCase() === 'dtstart' + ) if (dtstartProp) { - const dtstartParams = dtstartProp[1]; - const dtstartValue = dtstartProp[3]; + const dtstartParams = dtstartProp[1] + const dtstartValue = dtstartProp[3] if (dtstartParams) { const tzParam = dtstartParams.tzid || dtstartParams.TZID || dtstartParams.Tzid || dtstartParams.tZid || - dtstartParams.tzId; + dtstartParams.tzId if (tzParam) { - const resolvedTz = resolveTimezoneId(tzParam); + const resolvedTz = resolveTimezoneId(tzParam) if (resolvedTz) { - timezoneFromDTSTART = resolvedTz; + timezoneFromDTSTART = resolvedTz } } } if ( !timezoneFromDTSTART && - typeof dtstartValue === "string" && - dtstartValue.endsWith("Z") + typeof dtstartValue === 'string' && + dtstartValue.endsWith('Z') ) { - timezoneFromDTSTART = "Etc/UTC"; + timezoneFromDTSTART = 'Etc/UTC' } } @@ -109,287 +109,287 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) { event.color ?? {}, { id: event?.calId } as Calendar, event.URL - ); + ) const finalTimezone = timezoneFromVTimezone || timezoneFromDTSTART || eventjson.timezone || - "Etc/UTC"; - eventjson.timezone = finalTimezone; + 'Etc/UTC' + eventjson.timezone = finalTimezone if (!eventjson.allday && eventjson.start && finalTimezone) { - const startISO = convertEventDateTimeToISO(eventjson.start, finalTimezone); + const startISO = convertEventDateTimeToISO(eventjson.start, finalTimezone) if (startISO) { - eventjson.start = startISO; + eventjson.start = startISO } } if (!eventjson.allday && eventjson.end && finalTimezone) { - const endISO = convertEventDateTimeToISO(eventjson.end, finalTimezone); + const endISO = convertEventDateTimeToISO(eventjson.end, finalTimezone) if (endISO) { - eventjson.end = endISO; + eventjson.end = endISO } } if (isMaster) { - const merged = { ...event, ...eventjson }; - merged.timezone = finalTimezone; - return merged; + const merged = { ...event, ...eventjson } + merged.timezone = finalTimezone + return merged } - const merged = { ...event, ...eventjson }; - merged.timezone = finalTimezone; - return merged; + const merged = { ...event, ...eventjson } + merged.timezone = finalTimezone + return merged } export async function dlEvent(event: CalendarEvent) { - const response = await api.get(`dav${event.URL}?export=`); - const eventData = await response.text(); + const response = await api.get(`dav${event.URL}?export=`) + const eventData = await response.text() - return eventData; + return eventData } export async function putEvent(event: CalendarEvent, calOwnerEmail?: string) { const response = await api(`dav${event.URL}`, { - method: "PUT", + method: 'PUT', body: JSON.stringify(calendarEventToJCal(event, calOwnerEmail)), headers: { - "content-type": "text/calendar; charset=utf-8", - }, - }); + 'content-type': 'text/calendar; charset=utf-8' + } + }) if (response.status === 201) { - console.info("Event created successfully:", response.url || event.URL); + console.info('Event created successfully:', response.url || event.URL) } - return response; + return response } export async function putEventWithOverrides( updatedEvent: CalendarEvent, calOwnerEmail?: string ) { - const vevents = await getAllRecurrentEvent(updatedEvent); + const vevents = await getAllRecurrentEvent(updatedEvent) const updatedVevent = makeVevent( updatedEvent, updatedEvent.timezone, calOwnerEmail, !updatedEvent.recurrenceId - ); - let replaced = false; + ) + let replaced = false for (let i = 0; i < vevents.length; i++) { - const ve = vevents[i]; - const recurrenceId = ve[1].find(([k]: string[]) => k === "recurrence-id"); + const ve = vevents[i] + const recurrenceId = ve[1].find(([k]: string[]) => k === 'recurrence-id') if (recurrenceId && recurrenceId[3] === updatedEvent.recurrenceId) { - vevents[i] = updatedVevent; // replace - replaced = true; - break; + vevents[i] = updatedVevent // replace + replaced = true + break } } if (!replaced && updatedEvent.recurrenceId) { - vevents.push(updatedVevent); // add new override + vevents.push(updatedVevent) // add new override } - const timezoneData = TIMEZONES.zones[updatedEvent.timezone]; - const vtimezone = makeTimezone(timezoneData, updatedEvent); + const timezoneData = TIMEZONES.zones[updatedEvent.timezone] + const vtimezone = makeTimezone(timezoneData, updatedEvent) - const newJCal = ["vcalendar", [], [...vevents, vtimezone.component.jCal]]; + const newJCal = ['vcalendar', [], [...vevents, vtimezone.component.jCal]] return api(`dav${updatedEvent.URL}`, { - method: "PUT", + method: 'PUT', body: JSON.stringify(newJCal), headers: { - "content-type": "text/calendar; charset=utf-8", - }, - }); + 'content-type': 'text/calendar; charset=utf-8' + } + }) } export const deleteEventInstance = async (event: CalendarEvent) => { // Get all VEVENTs (master + overrides) from the series - const vevents = await getAllRecurrentEvent(event); + const vevents = await getAllRecurrentEvent(event) // Find the master VEVENT const masterIndex = vevents.findIndex( ([, props]: VCalComponent) => - !props.find(([k]) => k.toLowerCase() === "recurrence-id") - ); + !props.find(([k]) => k.toLowerCase() === 'recurrence-id') + ) if (masterIndex === -1) { - throw new Error("No master VEVENT found for this series"); + throw new Error('No master VEVENT found for this series') } - const exdateValue = event.recurrenceId || event.start; + const exdateValue = event.recurrenceId || event.start const seriesEvent = parseCalendarEvent( vevents[masterIndex][1], {}, { id: event.calId } as Calendar, - "" - ); - const masterProps = vevents[masterIndex][1]; + '' + ) + const masterProps = vevents[masterIndex][1] // Check if this date is already in EXDATE (avoid duplicates) const normalizeRecurrenceId = (id: VObjectValue) => - String(id ?? "").replace(/Z$/, ""); + String(id ?? '').replace(/Z$/, '') const isDuplicate = masterProps.some((prop: VObjectProperty) => { - if (prop[0].toLowerCase() === "exdate" && prop[3]) { + if (prop[0].toLowerCase() === 'exdate' && prop[3]) { return ( normalizeRecurrenceId(prop[3]) === normalizeRecurrenceId(exdateValue) - ); + ) } - return false; - }); + return false + }) if (!isDuplicate) { // Add new EXDATE property as a separate entry - const valueType = seriesEvent.allday ? "date" : "date-time"; - masterProps.push(["exdate", {}, valueType, exdateValue]); + const valueType = seriesEvent.allday ? 'date' : 'date-time' + masterProps.push(['exdate', {}, valueType, exdateValue]) } // Update the master VEVENT with the new properties - vevents[masterIndex][1] = masterProps; + vevents[masterIndex][1] = masterProps // Remove the override instance if it exists (in case it was an override being deleted) const filteredVevents = vevents.filter(([, props]: VCalComponent) => { const recurrenceIdProp = props.find( - ([k]) => k.toLowerCase() === "recurrence-id" - ); - if (!recurrenceIdProp) return true; // Keep master + ([k]) => k.toLowerCase() === 'recurrence-id' + ) + if (!recurrenceIdProp) return true // Keep master return ( normalizeRecurrenceId(recurrenceIdProp[3]) !== - normalizeRecurrenceId(event.recurrenceId ?? "") - ); // Remove matching override - }); + normalizeRecurrenceId(event.recurrenceId ?? '') + ) // Remove matching override + }) // Build the updated jCal with all VEVENTs and timezone - const timezoneData = TIMEZONES.zones[seriesEvent.timezone]; - const vtimezone = makeTimezone(timezoneData, seriesEvent); + const timezoneData = TIMEZONES.zones[seriesEvent.timezone] + const vtimezone = makeTimezone(timezoneData, seriesEvent) const newJCal = [ - "vcalendar", + 'vcalendar', [], - [...filteredVevents, vtimezone.component.jCal], - ]; + [...filteredVevents, vtimezone.component.jCal] + ] return api(`dav${event.URL}`, { - method: "PUT", + method: 'PUT', body: JSON.stringify(newJCal), headers: { - "content-type": "text/calendar; charset=utf-8", - }, - }); -}; + 'content-type': 'text/calendar; charset=utf-8' + } + }) +} export const updateSeriesPartstat = async ( event: CalendarEvent, attendeeEmail: string, partstat: string ) => { - const vevents = await getAllRecurrentEvent(event); + const vevents = await getAllRecurrentEvent(event) // Update PARTSTAT in ALL VEVENTs (master + exceptions) const updatedVevents = vevents.map((vevent: VCalComponent) => { - const properties = vevent[1]; + const properties = vevent[1] const updatedProperties = properties.map((prop: VObjectProperty) => { // Find ATTENDEE properties - if (prop[0] === "attendee") { - const calAddress = prop[3] as string; + if (prop[0] === 'attendee') { + const calAddress = prop[3] as string // Check if this is the target attendee if (calAddress.toLowerCase().includes(attendeeEmail.toLowerCase())) { // Update PARTSTAT parameter - const params = { ...prop[1], partstat: partstat }; - return [prop[0], params, prop[2], prop[3]]; + const params = { ...prop[1], partstat: partstat } + return [prop[0], params, prop[2], prop[3]] } } - return prop; - }); - return [vevent[0], updatedProperties, vevent[2]]; - }); + return prop + }) + return [vevent[0], updatedProperties, vevent[2]] + }) - const timezoneData = TIMEZONES.zones[event.timezone]; - const vtimezone = makeTimezone(timezoneData, event); + const timezoneData = TIMEZONES.zones[event.timezone] + const vtimezone = makeTimezone(timezoneData, event) const newJCal = [ - "vcalendar", + 'vcalendar', [], - [...updatedVevents, vtimezone.component.jCal], - ]; + [...updatedVevents, vtimezone.component.jCal] + ] return api(`dav${event.URL}`, { - method: "PUT", + method: 'PUT', body: JSON.stringify(newJCal), headers: { - "content-type": "text/calendar; charset=utf-8", - }, - }); -}; + 'content-type': 'text/calendar; charset=utf-8' + } + }) +} export async function getAllRecurrentEvent(event: CalendarEvent) { - const response = await api.get(`dav${event.URL}`); - const eventData = await response.text(); - const jcal = ICAL.parse(eventData); - const vevents = jcal[2].filter(([name]: string[]) => name === "vevent"); - return vevents; + const response = await api.get(`dav${event.URL}`) + const eventData = await response.text() + const jcal = ICAL.parse(eventData) + const vevents = jcal[2].filter(([name]: string[]) => name === 'vevent') + return vevents } export async function moveEvent(event: CalendarEvent, newUrl: string) { const response = await api(`dav${event.URL}`, { - method: "MOVE", + method: 'MOVE', headers: { - destination: newUrl, - }, - }); - return response; + destination: newUrl + } + }) + return response } export async function deleteEvent(eventURL: string) { const response = await api(`dav${eventURL}`, { - method: "DELETE", - }); - return response; + method: 'DELETE' + }) + return response } export async function importEventFromFile(id: string, calLink: string) { const response = await api.post(`api/import`, { - body: JSON.stringify({ fileId: id, target: calLink }), - }); - return response; + body: JSON.stringify({ fileId: id, target: calLink }) + }) + return response } export async function searchEvent( query: string, filters: { - searchIn: string[]; - keywords: string; - organizers: string[]; - attendees: string[]; + searchIn: string[] + keywords: string + organizers: string[] + attendees: string[] } ): Promise { - const { keywords, searchIn, organizers, attendees } = filters; + const { keywords, searchIn, organizers, attendees } = filters const reqParam: { - query: string; - calendars: { calendarId: string; userId: string }[]; - organizers?: string[]; - attendees?: string[]; + query: string + calendars: { calendarId: string; userId: string }[] + organizers?: string[] + attendees?: string[] } = { query: keywords || query, - calendars: searchIn.map((calId) => { - const [userId, calendarId] = calId.split("/"); - return { calendarId, userId }; - }), - }; + calendars: searchIn.map(calId => { + const [userId, calendarId] = calId.split('/') + return { calendarId, userId } + }) + } if (organizers.length) { - reqParam.organizers = organizers; + reqParam.organizers = organizers } if (attendees.length) { - reqParam.attendees = attendees; + reqParam.attendees = attendees } const response = await api - .post("calendar/api/events/search?limit=30&offset=0", { - body: JSON.stringify(reqParam), + .post('calendar/api/events/search?limit=30&offset=0', { + body: JSON.stringify(reqParam) }) - .json(); + .json() - return response as SearchEventsResponse; + return response as SearchEventsResponse } diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index f6b46b9..0eae511 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -1,13 +1,13 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { ResponsiveDialog } from "@/components/Dialog"; -import EventFormFields from "@/components/Event/EventFormFields"; -import { addDays } from "@/components/Event/utils/dateRules"; +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { ResponsiveDialog } from '@/components/Dialog' +import EventFormFields from '@/components/Event/EventFormFields' +import { addDays } from '@/components/Event/utils/dateRules' import { formatDateTimeInTimezone, - formatLocalDateTime, -} from "@/components/Event/utils/dateTimeFormatters"; -import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers"; -import { assertThunkSuccess } from "@/utils/assertThunkSuccess"; + formatLocalDateTime +} from '@/components/Event/utils/dateTimeFormatters' +import { convertFormDateTimeToISO } from '@/components/Event/utils/dateTimeHelpers' +import { assertThunkSuccess } from '@/utils/assertThunkSuccess' import { buildEventFormTempData, clearEventFormTempData, @@ -15,35 +15,35 @@ import { restoreEventFormDataFromTemp as restoreEventFormDataFromStorage, restoreFormDataFromTemp, saveEventFormDataToTemp, - showErrorNotification, -} from "@/utils/eventFormTempStorage"; -import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars"; + showErrorNotification +} from '@/utils/eventFormTempStorage' +import { useSelectedCalendars } from '@/utils/storage/useSelectedCalendars' import { browserDefaultTimeZone, getTimezoneOffset, - resolveTimezone, -} from "@/utils/timezone"; -import { TIMEZONES } from "@/utils/timezone-data"; -import { addVideoConferenceToDescription } from "@/utils/videoConferenceUtils"; -import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; -import { Box, Button } from "@linagora/twake-mui"; -import AddIcon from "@mui/icons-material/Add"; + resolveTimezone +} from '@/utils/timezone' +import { TIMEZONES } from '@/utils/timezone-data' +import { addVideoConferenceToDescription } from '@/utils/videoConferenceUtils' +import { CalendarApi, DateSelectArg } from '@fullcalendar/core' +import { Box, Button } from '@linagora/twake-mui' +import AddIcon from '@mui/icons-material/Add' import React, { startTransition, useCallback, useEffect, useMemo, useRef, - useState, -} from "react"; -import { useI18n } from "twake-i18n"; -import { Calendar } from "../Calendars/CalendarTypes"; -import { putEventAsync } from "../Calendars/services"; -import { userAttendee } from "../User/models/attendee"; -import { CalendarEvent, RepetitionObject } from "./EventsTypes"; -import { useEventOrganizer } from "./useEventOrganizer"; -import { buildDelegatedEventURL } from "./utils/buildDelegatedEventURL"; -import { Resource } from "@/components/Attendees/ResourceSearch"; + useState +} from 'react' +import { useI18n } from 'twake-i18n' +import { Calendar } from '../Calendars/CalendarTypes' +import { putEventAsync } from '../Calendars/services' +import { userAttendee } from '../User/models/attendee' +import { CalendarEvent, RepetitionObject } from './EventsTypes' +import { useEventOrganizer } from './useEventOrganizer' +import { buildDelegatedEventURL } from './utils/buildDelegatedEventURL' +import { Resource } from '@/components/Attendees/ResourceSearch' function EventPopover({ open, @@ -51,100 +51,97 @@ function EventPopover({ selectedRange, setSelectedRange, calendarRef, - event, + event }: { - anchorEl: HTMLElement | null; - open: boolean; - onClose: (refresh?: boolean) => void; - selectedRange: DateSelectArg | null; - setSelectedRange: React.Dispatch>; - calendarRef: React.RefObject; - event?: CalendarEvent; + anchorEl: HTMLElement | null + open: boolean + onClose: (refresh?: boolean) => void + selectedRange: DateSelectArg | null + setSelectedRange: React.Dispatch> + calendarRef: React.RefObject + event?: CalendarEvent }) { - const dispatch = useAppDispatch(); - const { t } = useI18n(); - const selectedCalendarIds = useSelectedCalendars(); + const dispatch = useAppDispatch() + const { t } = useI18n() + const selectedCalendarIds = useSelectedCalendars() - const userOrganizer = useAppSelector((state) => state.user.organiserData); - const userId = - useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; - const calList = useAppSelector((state) => state.calendars.list); + const userOrganizer = useAppSelector(state => state.user.organiserData) + const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? '' + const calList = useAppSelector(state => state.calendars.list) const userPersonalCalendars: Calendar[] = Object.values(calList || {}).filter( - (cal) => - cal.id?.split("/")[0] === userId || (cal.delegated && cal.access?.write) - ); + cal => + cal.id?.split('/')[0] === userId || (cal.delegated && cal.access?.write) + ) const timezoneList = useMemo(() => { - const zones = Object.keys(TIMEZONES.zones).sort(); - const browserTz = resolveTimezone(browserDefaultTimeZone); + const zones = Object.keys(TIMEZONES.zones).sort() + const browserTz = resolveTimezone(browserDefaultTimeZone) - return { zones, browserTz, getTimezoneOffset }; - }, []); + return { zones, browserTz, getTimezoneOffset } + }, []) - const calendarTimezone = useAppSelector((state) => state.settings.timeZone); + const calendarTimezone = useAppSelector(state => state.settings.timeZone) const resolvedCalendarTimezone = useMemo(() => { - const tz = calendarTimezone || browserDefaultTimeZone; - return resolveTimezone(tz); - }, [calendarTimezone]); + const tz = calendarTimezone || browserDefaultTimeZone + return resolveTimezone(tz) + }, [calendarTimezone]) const resources: Resource[] = useMemo(() => { const resourcesInEvent = - event?.attendee?.filter((attendee) => attendee.cutype === "RESOURCE") ?? - []; - return resourcesInEvent.map((resource) => ({ + event?.attendee?.filter(attendee => attendee.cutype === 'RESOURCE') ?? [] + return resourcesInEvent.map(resource => ({ email: resource.cal_address, - displayName: resource.cn, - })); - }, [event?.attendee]); + displayName: resource.cn + })) + }, [event?.attendee]) const eventAttendees = useMemo( () => - event?.attendee?.filter((attendee) => attendee.cutype !== "RESOURCE") ?? - [], + event?.attendee?.filter(attendee => attendee.cutype !== 'RESOURCE') ?? [], [event?.attendee] - ); + ) - const [showMore, setShowMore] = useState(false); + const [showMore, setShowMore] = useState(false) const [showDescription, setShowDescription] = useState( event?.description ? true : false - ); + ) const [showRepeat, setShowRepeat] = useState( event?.repetition?.freq ? true : false - ); + ) - const [title, setTitle] = useState(event?.title ?? ""); + const [title, setTitle] = useState(event?.title ?? '') - const [description, setDescription] = useState(event?.description ?? ""); - const [location, setLocation] = useState(event?.location ?? ""); - const [start, setStart] = useState(event?.start ? event.start : ""); - const [end, setEnd] = useState(event?.end ? event.end : ""); + const [description, setDescription] = useState(event?.description ?? '') + const [location, setLocation] = useState(event?.location ?? '') + const [start, setStart] = useState(event?.start ? event.start : '') + const [end, setEnd] = useState(event?.end ? event.end : '') const defaultCalendarId = useMemo(() => { const selectedAndWritable = Object.values(calList ?? {}) - .filter((cal) => selectedCalendarIds.includes(cal.id)) + .filter(cal => selectedCalendarIds.includes(cal.id)) .filter( - (cal) => + cal => (cal.delegated && cal.access?.write) || - userId === cal.id?.split("/")[0] - ); + userId === cal.id?.split('/')[0] + ) if (selectedAndWritable.length > 0) { - return selectedAndWritable[0].id!; + return selectedAndWritable[0].id } const firstPersonal = Object.values(calList ?? {}).find( - (cal) => - cal.id?.split("/")[0] === userId || (cal.delegated && cal.access?.write) - ); - return firstPersonal?.id ?? ""; - }, [selectedCalendarIds, calList, userId]); + cal => + cal.id?.split('/')[0] === userId || (cal.delegated && cal.access?.write) + ) + return firstPersonal?.id ?? '' + }, [selectedCalendarIds, calList, userId]) const [calendarid, setCalendarid] = useState( event?.calId ?? defaultCalendarId - ); - const [allday, setAllDay] = useState(event?.allday ?? false); + ) + const [allday, setAllDay] = useState(event?.allday ?? false) const [repetition, setRepetition] = useState( event?.repetition ?? ({} as RepetitionObject) - ); - const [selectedResources, setSelectedResources] = useState(resources ?? []); + ) + const [selectedResources, setSelectedResources] = useState(resources ?? []) // Derive the effective organizer based on the selected calendar. // When a delegated calendar is selected, the organizer must be the @@ -152,126 +149,124 @@ function EventPopover({ const { organizer } = useEventOrganizer({ calendarid, calList, - userOrganizer, - }); + userOrganizer + }) const [attendees, setAttendees] = useState( eventAttendees - ? eventAttendees.filter((a) => a.cal_address !== organizer?.cal_address) + ? eventAttendees.filter(a => a.cal_address !== organizer?.cal_address) : [] - ); - const [alarm, setAlarm] = useState(event?.alarm?.trigger ?? ""); - const [eventClass, setEventClass] = useState( - event?.class ?? "PUBLIC" - ); - const [busy, setBusy] = useState(event?.transp ?? "OPAQUE"); + ) + const [alarm, setAlarm] = useState(event?.alarm?.trigger ?? '') + const [eventClass, setEventClass] = useState(event?.class ?? 'PUBLIC') + const [busy, setBusy] = useState(event?.transp ?? 'OPAQUE') const [timezone, setTimezone] = useState( event?.timezone ? resolveTimezone(event.timezone) : resolvedCalendarTimezone - ); + ) const [hasVideoConference, setHasVideoConference] = useState( event?.x_openpass_videoconference ? true : false - ); + ) const [meetingLink, setMeetingLink] = useState( event?.x_openpass_videoconference || null - ); - const [isFormValid, setIsFormValid] = useState(false); - const [showValidationErrors, setShowValidationErrors] = useState(false); - const [hasEndDateChanged, setHasEndDateChanged] = useState(false); + ) + const [isFormValid, setIsFormValid] = useState(false) + const [showValidationErrors, setShowValidationErrors] = useState(false) + const [hasEndDateChanged, setHasEndDateChanged] = useState(false) // Use ref to track if we've already initialized to avoid infinite loop - const isInitializedRef = useRef(false); - const userPersonalCalendarsRef = useRef(userPersonalCalendars); + const isInitializedRef = useRef(false) + const userPersonalCalendarsRef = useRef(userPersonalCalendars) // Track when restoring from error to prevent other useEffects from overriding restored data - const isRestoringFromErrorRef = useRef(false); + const isRestoringFromErrorRef = useRef(false) // Track whether the user has explicitly picked a calendar so we don't override their choice - const isCalendarIdUserSelectedRef = useRef(false); + const isCalendarIdUserSelectedRef = useRef(false) // Update ref when userPersonalCalendars changes useEffect(() => { - userPersonalCalendarsRef.current = userPersonalCalendars; - }, [userPersonalCalendars]); + userPersonalCalendarsRef.current = userPersonalCalendars + }, [userPersonalCalendars]) // Sync calendarid whenever defaultCalendarId resolves, unless the user already picked one useEffect(() => { if (!isCalendarIdUserSelectedRef.current && defaultCalendarId) { - setCalendarid(defaultCalendarId); + setCalendarid(defaultCalendarId) } - }, [defaultCalendarId]); + }, [defaultCalendarId]) const resetAllStateToDefault = useCallback(() => { - setShowMore(false); - setShowDescription(false); - setShowRepeat(false); - setTitle(""); - setDescription(""); - setAttendees([]); - setLocation(""); - setStart(""); - setEnd(""); + setShowMore(false) + setShowDescription(false) + setShowRepeat(false) + setTitle('') + setDescription('') + setAttendees([]) + setLocation('') + setStart('') + setEnd('') if (defaultCalendarId) { - setCalendarid(defaultCalendarId); + setCalendarid(defaultCalendarId) } - setAllDay(false); - setRepetition({} as RepetitionObject); - setAlarm(""); - setEventClass("PUBLIC"); - setBusy("OPAQUE"); - setTimezone(resolvedCalendarTimezone); - setHasVideoConference(false); - setMeetingLink(null); - setHasEndDateChanged(false); - setSelectedResources([]); - }, [resolvedCalendarTimezone, defaultCalendarId]); + setAllDay(false) + setRepetition({} as RepetitionObject) + setAlarm('') + setEventClass('PUBLIC') + setBusy('OPAQUE') + setTimezone(resolvedCalendarTimezone) + setHasVideoConference(false) + setMeetingLink(null) + setHasEndDateChanged(false) + setSelectedResources([]) + }, [resolvedCalendarTimezone, defaultCalendarId]) // Track if we should sync from selectedRange (only on initial selection, not on toggle) - const shouldSyncFromRangeRef = useRef(true); - const prevOpenRef = useRef(false); + const shouldSyncFromRangeRef = useRef(true) + const prevOpenRef = useRef(false) // Sync timezone when modal opens or calendarTimezone changes useEffect(() => { // Detect modal opening (transition from closed to open) - const isOpening = open && !prevOpenRef.current; + const isOpening = open && !prevOpenRef.current if (isOpening) { - shouldSyncFromRangeRef.current = true; - setShowValidationErrors(false); + shouldSyncFromRangeRef.current = true + setShowValidationErrors(false) // Set timezone to calendar timezone for new events when opening - const isNewEvent = !event || !event.uid; + const isNewEvent = !event || !event.uid if (isNewEvent) { - setTimezone(resolvedCalendarTimezone); + setTimezone(resolvedCalendarTimezone) } } // Update previous open state - prevOpenRef.current = open; + prevOpenRef.current = open // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, event?.uid, resolvedCalendarTimezone]); + }, [open, event?.uid, resolvedCalendarTimezone]) // Separately sync timezone when calendarTimezone changes while modal is open for new events useEffect(() => { if (open && (!event || !event.uid)) { - setTimezone(resolvedCalendarTimezone); + setTimezone(resolvedCalendarTimezone) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [resolvedCalendarTimezone, open, event?.uid]); + }, [resolvedCalendarTimezone, open, event?.uid]) // Set start/end times when modal opens for new event creation useEffect(() => { // Skip if restoring from error - data already restored if (isRestoringFromErrorRef.current) { - return; + return } // Only run when modal opens and not duplicating an event // Check if event has uid to determine if it's a valid event (not empty object) if (!shouldSyncFromRangeRef.current || !open || (event && event.uid)) { - return; + return } if (selectedRange && selectedRange.start && selectedRange.end) { // Auto-check allday if selectedRange is all-day if (selectedRange.allDay) { - setAllDay(true); + setAllDay(true) } // selectedRange gives us the visual time displayed on calendar @@ -279,21 +274,21 @@ function EventPopover({ if (selectedRange.startStr && selectedRange.endStr) { // Check if they are strings (from FullCalendar) or need conversion const startStr = - typeof selectedRange.startStr === "string" + typeof selectedRange.startStr === 'string' ? selectedRange.startStr - : formatLocalDateTime(selectedRange.start); + : formatLocalDateTime(selectedRange.start) const endStr = - typeof selectedRange.endStr === "string" + typeof selectedRange.endStr === 'string' ? selectedRange.endStr - : formatLocalDateTime(selectedRange.end); + : formatLocalDateTime(selectedRange.end) // Use the string values directly to preserve the displayed time const startValue = selectedRange.allDay - ? startStr.split("T")[0] - : startStr.slice(0, 16); // YYYY-MM-DDTHH:mm + ? startStr.split('T')[0] + : startStr.slice(0, 16) // YYYY-MM-DDTHH:mm let endValue = selectedRange.allDay - ? endStr.split("T")[0] - : endStr.slice(0, 16); + ? endStr.split('T')[0] + : endStr.slice(0, 16) // For all-day slots: detect single click vs drag multiple days // FullCalendar uses exclusive end, so end date = actual end date + 1 @@ -301,53 +296,53 @@ function EventPopover({ // - Single click (end = start + 1): set end = start for UI // - Drag multiple days (end = actual end + 1): set end = end - 1 for UI if (selectedRange.allDay) { - const startDateOnly = startValue.slice(0, 10); - const endDateOnlyFromRange = endValue.slice(0, 10); + const startDateOnly = startValue.slice(0, 10) + const endDateOnlyFromRange = endValue.slice(0, 10) // Calculate days difference - const startDateObj = new Date(startDateOnly); - const endDateObj = new Date(endDateOnlyFromRange); + const startDateObj = new Date(startDateOnly) + const endDateObj = new Date(endDateOnlyFromRange) const daysDiff = Math.floor( (endDateObj.getTime() - startDateObj.getTime()) / (1000 * 60 * 60 * 24) - ); + ) if (daysDiff <= 1) { // Single click: FullCalendar gives end = start + 1, set end = start for UI - endValue = startValue; + endValue = startValue } else { // Drag multiple days: FullCalendar gives end = actual end + 1, subtract 1 for UI - const adjustedEndDate = new Date(endDateObj); - adjustedEndDate.setDate(adjustedEndDate.getDate() - 1); - endValue = adjustedEndDate.toISOString().split("T")[0]; + const adjustedEndDate = new Date(endDateObj) + adjustedEndDate.setDate(adjustedEndDate.getDate() - 1) + endValue = adjustedEndDate.toISOString().split('T')[0] } } - setStart(startValue); - setEnd(endValue); + setStart(startValue) + setEnd(endValue) // Check if multiple days event - const startDateOnly = startValue.slice(0, 10); - const endDateOnly = endValue.slice(0, 10); - const isMultipleDays = startDateOnly !== endDateOnly; + const startDateOnly = startValue.slice(0, 10) + const endDateOnly = endValue.slice(0, 10) + const isMultipleDays = startDateOnly !== endDateOnly if (isMultipleDays) { // Keep normal mode (showMore = false) for multiple days events - setShowMore(false); + setShowMore(false) if (selectedRange.allDay) { // Dragged from allday slot: allday already set to true at line 234 // Will show start date and end date (handled by showEndDate logic) } else { // Dragged from week/month view grid: keep allday=false to show time fields, trigger 4 fields display - setHasEndDateChanged(true); + setHasEndDateChanged(true) } } } else { // Fallback: format Date objects using local time components // Only set if both start and end are valid - const formattedStart = formatLocalDateTime(selectedRange.start); - let formattedEnd = formatLocalDateTime(selectedRange.end); + const formattedStart = formatLocalDateTime(selectedRange.start) + let formattedEnd = formatLocalDateTime(selectedRange.end) // For all-day slots: detect single click vs drag multiple days // FullCalendar uses exclusive end, so end date = actual end date + 1 @@ -355,51 +350,51 @@ function EventPopover({ // - Single click (end = start + 1): set end = start for UI // - Drag multiple days (end = actual end + 1): set end = end - 1 for UI if (selectedRange.allDay && formattedStart && formattedEnd) { - const startDateOnly = formattedStart.slice(0, 10); - const endDateOnly = formattedEnd.slice(0, 10); + const startDateOnly = formattedStart.slice(0, 10) + const endDateOnly = formattedEnd.slice(0, 10) // Calculate days difference - const startDateObj = new Date(startDateOnly); - const endDateObj = new Date(endDateOnly); + const startDateObj = new Date(startDateOnly) + const endDateObj = new Date(endDateOnly) const daysDiff = Math.floor( (endDateObj.getTime() - startDateObj.getTime()) / (1000 * 60 * 60 * 24) - ); + ) if (daysDiff <= 1) { // Single click: FullCalendar gives end = start + 1, set end = start for UI - formattedEnd = formattedStart; + formattedEnd = formattedStart } else { // Drag multiple days: FullCalendar gives end = actual end + 1, subtract 1 for UI - const adjustedEndDate = new Date(endDateObj); - adjustedEndDate.setDate(adjustedEndDate.getDate() - 1); + const adjustedEndDate = new Date(endDateObj) + adjustedEndDate.setDate(adjustedEndDate.getDate() - 1) const adjustedEndDateStr = adjustedEndDate .toISOString() - .split("T")[0]; + .split('T')[0] // Preserve time part if exists, otherwise use date only - formattedEnd = formattedEnd.includes("T") - ? `${adjustedEndDateStr}T${formattedEnd.split("T")[1]}` - : adjustedEndDateStr; + formattedEnd = formattedEnd.includes('T') + ? `${adjustedEndDateStr}T${formattedEnd.split('T')[1]}` + : adjustedEndDateStr } } - if (formattedStart) setStart(formattedStart); - if (formattedEnd) setEnd(formattedEnd); + if (formattedStart) setStart(formattedStart) + if (formattedEnd) setEnd(formattedEnd) if (formattedStart && formattedEnd) { - const startDateOnly = formattedStart.slice(0, 10); - const endDateOnly = formattedEnd.slice(0, 10); - const isMultipleDays = startDateOnly !== endDateOnly; + const startDateOnly = formattedStart.slice(0, 10) + const endDateOnly = formattedEnd.slice(0, 10) + const isMultipleDays = startDateOnly !== endDateOnly if (isMultipleDays) { // Keep normal mode (showMore = false) for multiple days events - setShowMore(false); + setShowMore(false) if (selectedRange.allDay) { // Dragged from allday slot: allday already set to true at line 234 // Will show start date and end date (handled by showEndDate logic) } else { // Dragged from week/month view grid: keep allday=false to show time fields, trigger 4 fields display - setHasEndDateChanged(true); + setHasEndDateChanged(true) } } } @@ -408,117 +403,115 @@ function EventPopover({ // No valid selectedRange - use default times // Start time = current time + 1 hour (rounded up to the hour) // End time = start time + 1 hour - const now = new Date(); - const nextHour = new Date(now); - nextHour.setHours(now.getHours() + 1); - nextHour.setMinutes(0); - nextHour.setSeconds(0); - nextHour.setMilliseconds(0); + const now = new Date() + const nextHour = new Date(now) + nextHour.setHours(now.getHours() + 1) + nextHour.setMinutes(0) + nextHour.setSeconds(0) + nextHour.setMilliseconds(0) - const endTime = new Date(nextHour); - endTime.setHours(nextHour.getHours() + 1); + const endTime = new Date(nextHour) + endTime.setHours(nextHour.getHours() + 1) // Format using local time (browser timezone) - const formattedStart = formatLocalDateTime(nextHour); - const formattedEnd = formatLocalDateTime(endTime); + const formattedStart = formatLocalDateTime(nextHour) + const formattedEnd = formatLocalDateTime(endTime) - if (formattedStart) setStart(formattedStart); - if (formattedEnd) setEnd(formattedEnd); + if (formattedStart) setStart(formattedStart) + if (formattedEnd) setEnd(formattedEnd) // Default to non-all-day when no selectedRange - setAllDay(false); + setAllDay(false) } - shouldSyncFromRangeRef.current = false; - }, [selectedRange, open, event]); + shouldSyncFromRangeRef.current = false + }, [selectedRange, open, event]) // Initialize state when event prop changes (duplicate event or tempEvent with attendees) useEffect(() => { if (event && event.uid) { // Editing existing event - populate fields with event data - setTitle(event.title ?? ""); - setDescription(event.description ?? ""); - setLocation(event.location ?? ""); + setTitle(event.title ?? '') + setDescription(event.description ?? '') + setLocation(event.location ?? '') // Handle all-day events properly - const isAllDay = event.allday ?? false; - setAllDay(isAllDay); + const isAllDay = event.allday ?? false + setAllDay(isAllDay) // Get event's timezone for formatting - prioritize event.timezone from server - let eventTimezone: string; + let eventTimezone: string if (event.timezone) { - eventTimezone = resolveTimezone(event.timezone); + eventTimezone = resolveTimezone(event.timezone) } else { - eventTimezone = resolvedCalendarTimezone; + eventTimezone = resolvedCalendarTimezone } // Format dates based on all-day status and timezone if (event.start) { if (isAllDay) { // For all-day events, use date format (YYYY-MM-DD) - const startDate = new Date(event.start); - setStart(startDate.toISOString().split("T")[0]); + const startDate = new Date(event.start) + setStart(startDate.toISOString().split('T')[0]) } else { // For timed events, format in the event's timezone - setStart(formatDateTimeInTimezone(event.start, eventTimezone)); + setStart(formatDateTimeInTimezone(event.start, eventTimezone)) } } else { - setStart(""); + setStart('') } if (event.end) { if (isAllDay) { // For all-day events, use date format (YYYY-MM-DD) - const endDate = new Date(event.end); - setEnd(endDate.toISOString().split("T")[0]); + const endDate = new Date(event.end) + setEnd(endDate.toISOString().split('T')[0]) } else { // For timed events, format in the event's timezone - setEnd(formatDateTimeInTimezone(event.end, eventTimezone)); + setEnd(formatDateTimeInTimezone(event.end, eventTimezone)) } } else { - setEnd(""); + setEnd('') } if (defaultCalendarId) { - setCalendarid(defaultCalendarId); + setCalendarid(defaultCalendarId) } - setRepetition(event.repetition ?? ({} as RepetitionObject)); - setShowRepeat(event.repetition?.freq ? true : false); + setRepetition(event.repetition ?? ({} as RepetitionObject)) + setShowRepeat(event.repetition?.freq ? true : false) setAttendees( eventAttendees - ? eventAttendees.filter( - (a) => a.cal_address !== organizer?.cal_address - ) + ? eventAttendees.filter(a => a.cal_address !== organizer?.cal_address) : [] - ); - setAlarm(event.alarm?.trigger ?? ""); - setEventClass(event.class ?? "PUBLIC"); - setBusy(event.transp ?? "OPAQUE"); - setTimezone(eventTimezone); - setHasVideoConference(event.x_openpass_videoconference ? true : false); - setMeetingLink(event.x_openpass_videoconference || null); + ) + setAlarm(event.alarm?.trigger ?? '') + setEventClass(event.class ?? 'PUBLIC') + setBusy(event.transp ?? 'OPAQUE') + setTimezone(eventTimezone) + setHasVideoConference(event.x_openpass_videoconference ? true : false) + setMeetingLink(event.x_openpass_videoconference || null) // Update description to include video conference footer if exists if (event.x_openpass_videoconference && event.description) { - const hasVideoFooter = event.description.includes("Visio:"); + const hasVideoFooter = event.description.includes('Visio:') if (!hasVideoFooter) { setDescription( addVideoConferenceToDescription( event.description, event.x_openpass_videoconference ) - ); + ) } else { - setDescription(event.description); + setDescription(event.description) } } - setSelectedResources(resources ?? []); + setSelectedResources(resources ?? []) } else if (event && event.attendee && event.attendee?.length > 0) { // Handle tempEvent case (no uid but has attendees from temp calendar search) setAttendees( - eventAttendees.filter((a) => a.cal_address !== organizer?.cal_address) - ); - setSelectedResources(resources ?? []); + eventAttendees.filter(a => a.cal_address !== organizer?.cal_address) + ) + setSelectedResources(resources ?? []) } }, [ event, @@ -526,51 +519,51 @@ function EventPopover({ resolvedCalendarTimezone, defaultCalendarId, resources, - eventAttendees, - ]); + eventAttendees + ]) // Reset state when creating new event (event is empty object or undefined) useEffect(() => { // Skip if restoring from error - data already restored if (isRestoringFromErrorRef.current) { - return; + return } - const isCreatingNew = !event || !event.uid; - const wasInitialized = isInitializedRef.current; + const isCreatingNew = !event || !event.uid + const wasInitialized = isInitializedRef.current if (isCreatingNew && wasInitialized) { // Creating new event - reset all fields to default // Note: start and end are handled by the selectedRange useEffect // Note: timezone is handled by separate useEffect above - setShowMore(false); - setShowDescription(false); - setShowRepeat(false); - setTitle(""); - setDescription(""); - setAttendees([]); - setLocation(""); + setShowMore(false) + setShowDescription(false) + setShowRepeat(false) + setTitle('') + setDescription('') + setAttendees([]) + setLocation('') if (defaultCalendarId) { - setCalendarid(defaultCalendarId); + setCalendarid(defaultCalendarId) } - setAllDay(false); - setRepetition({} as RepetitionObject); - setAlarm(""); - setEventClass("PUBLIC"); - setBusy("OPAQUE"); - setHasVideoConference(false); - setMeetingLink(null); - setHasEndDateChanged(false); - setSelectedResources([]); + setAllDay(false) + setRepetition({} as RepetitionObject) + setAlarm('') + setEventClass('PUBLIC') + setBusy('OPAQUE') + setHasVideoConference(false) + setMeetingLink(null) + setHasEndDateChanged(false) + setSelectedResources([]) } if (!isCreatingNew) { - isInitializedRef.current = true; + isInitializedRef.current = true } - }, [event, defaultCalendarId]); + }, [event, defaultCalendarId]) const handleStartChange = useCallback( (newStart: string) => { - setStart(newStart); + setStart(newStart) // Defer visual feedback (non-urgent) startTransition(() => { @@ -579,23 +572,23 @@ function EventPopover({ ...prev, start: new Date(newStart), startStr: newStart, - allDay: allday, - } as DateSelectArg; + allDay: allday + } as DateSelectArg setTimeout(() => { - calendarRef.current?.select(newRange); - }, 0); - return newRange; - }); - }); + calendarRef.current?.select(newRange) + }, 0) + return newRange + }) + }) }, // calendarRef and setSelectedRange are stable refs/setters from parent // eslint-disable-next-line react-hooks/exhaustive-deps [allday] - ); + ) const handleEndChange = useCallback( (newEnd: string) => { - setEnd(newEnd); + setEnd(newEnd) // Defer visual feedback (non-urgent) startTransition(() => { @@ -604,72 +597,72 @@ function EventPopover({ ...prev, end: new Date(newEnd), endStr: newEnd, - allDay: allday, - } as DateSelectArg; + allDay: allday + } as DateSelectArg setTimeout(() => { - calendarRef.current?.select(newRange); - }, 0); - return newRange; - }); - }); + calendarRef.current?.select(newRange) + }, 0) + return newRange + }) + }) }, // calendarRef and setSelectedRange are stable refs/setters from parent // eslint-disable-next-line react-hooks/exhaustive-deps [allday] - ); + ) const handleAllDayChange = useCallback( (newAllDay: boolean, newStart: string, newEnd: string) => { // Update critical state immediately (checkbox response) - setAllDay(newAllDay); - setStart(newStart); - setEnd(newEnd); + setAllDay(newAllDay) + setStart(newStart) + setEnd(newEnd) // Defer visual feedback updates (non-urgent) startTransition(() => { setSelectedRange((prev: DateSelectArg | null) => { const newRange = { ...prev, - startStr: newAllDay ? newStart.split("T")[0] : newStart, - endStr: newAllDay ? newEnd.split("T")[0] : newEnd, + startStr: newAllDay ? newStart.split('T')[0] : newStart, + endStr: newAllDay ? newEnd.split('T')[0] : newEnd, start: new Date( - newAllDay ? newStart.split("T")[0] + "T00:00:00" : newStart + newAllDay ? newStart.split('T')[0] + 'T00:00:00' : newStart ), end: new Date( - newAllDay ? newEnd.split("T")[0] + "T00:00:00" : newEnd + newAllDay ? newEnd.split('T')[0] + 'T00:00:00' : newEnd ), - allDay: newAllDay, - } as DateSelectArg; + allDay: newAllDay + } as DateSelectArg setTimeout(() => { - calendarRef.current?.select(newRange); - }, 0); - return newRange; - }); - }); + calendarRef.current?.select(newRange) + }, 0) + return newRange + }) + }) }, // calendarRef and setSelectedRange are stable refs/setters from parent // eslint-disable-next-line react-hooks/exhaustive-deps [] - ); + ) // Wrap setCalendarid so we can track explicit user selections const handleCalendaridChange = useCallback((id: string) => { - isCalendarIdUserSelectedRef.current = true; - setCalendarid(id); - }, []); + isCalendarIdUserSelectedRef.current = true + setCalendarid(id) + }, []) const handleClose = () => { // Clear temp data when user manually closes modal - clearEventFormTempData("create"); - onClose(false); - setShowValidationErrors(false); - resetAllStateToDefault(); - setStart(""); - setEnd(""); - setSelectedResources([]); - shouldSyncFromRangeRef.current = true; // Reset for next time - isCalendarIdUserSelectedRef.current = false; // Reset so next open gets fresh default - }; + clearEventFormTempData('create') + onClose(false) + setShowValidationErrors(false) + resetAllStateToDefault() + setStart('') + setEnd('') + setSelectedResources([]) + shouldSyncFromRangeRef.current = true // Reset for next time + isCalendarIdUserSelectedRef.current = false // Reset so next open gets fresh default + } // Function to save current form data to temp storage const saveCurrentFormData = useCallback(() => { @@ -693,9 +686,9 @@ function EventPopover({ showDescription, showRepeat, hasEndDateChanged, - resources: selectedResources, - }; - return buildEventFormTempData(formState); + resources: selectedResources + } + return buildEventFormTempData(formState) }, [ title, description, @@ -716,19 +709,19 @@ function EventPopover({ showDescription, showRepeat, hasEndDateChanged, - selectedResources, - ]); + selectedResources + ]) // Check for temp data when modal opens useEffect(() => { if (open && !event?.uid) { // Only restore for new events (not duplicating) - const tempData = restoreEventFormDataFromStorage("create"); + const tempData = restoreEventFormDataFromStorage('create') if (tempData && tempData.fromError) { // Mark that we're restoring from error to prevent other useEffects from overriding - isRestoringFromErrorRef.current = true; + isRestoringFromErrorRef.current = true // Prevent selectedRange useEffect from running - shouldSyncFromRangeRef.current = false; + shouldSyncFromRangeRef.current = false // Restore form data from previous failed save restoreFormDataFromTemp(tempData, { @@ -751,197 +744,197 @@ function EventPopover({ setShowDescription, setShowRepeat, setHasEndDateChanged, - setSelectedResources, - }); + setSelectedResources + }) // Clear the error flag but keep data until successful save - const updatedTempData = { ...tempData, fromError: false }; - saveEventFormDataToTemp("create", updatedTempData); + const updatedTempData = { ...tempData, fromError: false } + saveEventFormDataToTemp('create', updatedTempData) // Reset flag after restore completes (use setTimeout to ensure other useEffects have checked the flag) setTimeout(() => { - isRestoringFromErrorRef.current = false; - }, 0); + isRestoringFromErrorRef.current = false + }, 0) } } - }, [open, event?.uid]); + }, [open, event?.uid]) const handleSave = async () => { // Show validation errors when Save is clicked - setShowValidationErrors(true); + setShowValidationErrors(true) // Check if form is valid before saving if (!isFormValid) { - return; + return } - const newEventUID = crypto.randomUUID(); + const newEventUID = crypto.randomUUID() // Resolve target calendar safely const targetCalendar: Calendar | undefined = calList[calendarid] || userPersonalCalendars[0] || - (Object.values(calList)[0] as Calendar | undefined); + (Object.values(calList)[0] as Calendar | undefined) if (!targetCalendar || !targetCalendar.id) { - console.error("No target calendar available to save event"); - return; + console.error('No target calendar available to save event') + return } - const newEventURL = `/calendars/${targetCalendar.id}/${newEventUID}.ics`; + const newEventURL = `/calendars/${targetCalendar.id}/${newEventUID}.ics` const newEvent: CalendarEvent = { calId: targetCalendar.id, title, URL: targetCalendar.delegated ? buildDelegatedEventURL(targetCalendar, newEventURL) : newEventURL, - start: "", + start: '', allday, uid: newEventUID, description, location, - class: eventClass as "PUBLIC" | "PRIVATE" | "CONFIDENTIAL", + class: eventClass as 'PUBLIC' | 'PRIVATE' | 'CONFIDENTIAL', repetition, organizer, timezone, attendee: [ { - cn: organizer?.cn ?? "", - cal_address: organizer?.cal_address ?? "", - partstat: "ACCEPTED", - rsvp: "FALSE", - role: "CHAIR", - cutype: "INDIVIDUAL", - }, + cn: organizer?.cn ?? '', + cal_address: organizer?.cal_address ?? '', + partstat: 'ACCEPTED', + rsvp: 'FALSE', + role: 'CHAIR', + cutype: 'INDIVIDUAL' + } ], transp: busy, sequence: 1, color: targetCalendar?.color, - alarm: { trigger: alarm, action: "EMAIL" }, - x_openpass_videoconference: meetingLink || undefined, - }; + alarm: { trigger: alarm, action: 'EMAIL' }, + x_openpass_videoconference: meetingLink || undefined + } // Map data of resources to attendee before creating event if (selectedResources?.length) { selectedResources.forEach((resource: Resource) => { newEvent.attendee.push({ - cn: resource?.displayName ?? "", - cal_address: resource?.email ?? "", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "RESOURCE", - }); - }); + cn: resource?.displayName ?? '', + cal_address: resource?.email ?? '', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'RESOURCE' + }) + }) } if (allday) { - const startDateOnly = (start || "").split("T")[0]; - const endDateOnlyUI = (end || start || "").split("T")[0]; + const startDateOnly = (start || '').split('T')[0] + const endDateOnlyUI = (end || start || '').split('T')[0] // For all-day events, API needs end date = UI end date + 1 day - const endDateOnlyAPI = addDays(endDateOnlyUI, 1); + const endDateOnlyAPI = addDays(endDateOnlyUI, 1) // Parse date string and create Date at UTC midnight to avoid timezone offset issues const [startYear, startMonth, startDay] = startDateOnly - .split("-") - .map(Number); - const [endYear, endMonth, endDay] = endDateOnlyAPI.split("-").map(Number); + .split('-') + .map(Number) + const [endYear, endMonth, endDay] = endDateOnlyAPI.split('-').map(Number) const startDateObj = new Date( Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0) - ); + ) const endDateObj = new Date( Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0) - ); - newEvent.start = startDateObj.toISOString(); - newEvent.end = endDateObj.toISOString(); + ) + newEvent.start = startDateObj.toISOString() + newEvent.end = endDateObj.toISOString() } else { - newEvent.start = convertFormDateTimeToISO(start, timezone); + newEvent.start = convertFormDateTimeToISO(start, timezone) if (end) { // In normal mode, only override end date when the end date field is not shown and end date is same as start date - const startDateOnly = (start || "").split("T")[0]; - const endDateOnly = (end || "").split("T")[0]; + const startDateOnly = (start || '').split('T')[0] + const endDateOnly = (end || '').split('T')[0] if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) { - const endTimeOnly = end.includes("T") - ? end.split("T")[1]?.slice(0, 5) || "00:00" - : "00:00"; - const endDateTime = `${startDateOnly}T${endTimeOnly}`; - newEvent.end = convertFormDateTimeToISO(endDateTime, timezone); + const endTimeOnly = end.includes('T') + ? end.split('T')[1]?.slice(0, 5) || '00:00' + : '00:00' + const endDateTime = `${startDateOnly}T${endTimeOnly}` + newEvent.end = convertFormDateTimeToISO(endDateTime, timezone) } else { // Extended mode or end date explicitly shown in normal mode or end date differs from start date: use actual end datetime - newEvent.end = convertFormDateTimeToISO(end, timezone); + newEvent.end = convertFormDateTimeToISO(end, timezone) } } } if (attendees.length > 0) { - newEvent.attendee = newEvent.attendee.concat(attendees); + newEvent.attendee = newEvent.attendee.concat(attendees) } // Reset validation state when validation passes - setShowValidationErrors(false); + setShowValidationErrors(false) // Save current form data to temp storage before closing - const formDataToSave = saveCurrentFormData(); - saveEventFormDataToTemp("create", formDataToSave); + const formDataToSave = saveCurrentFormData() + saveEventFormDataToTemp('create', formDataToSave) // Close popup immediately - onClose(true); + onClose(true) // Save to API in background try { const result = await dispatch( putEventAsync({ cal: targetCalendar, - newEvent, + newEvent }) - ); + ) - await assertThunkSuccess(result); + await assertThunkSuccess(result) // Clear temp data on successful save - clearEventFormTempData("create"); + clearEventFormTempData('create') // Reset all state to default values only on successful save - resetAllStateToDefault(); + resetAllStateToDefault() } catch (error) { - const errorObj = error as { message?: string }; + const errorObj = error as { message?: string } // API failed - restore form data and mark as error const errorFormData = { ...formDataToSave, - fromError: true, - }; - saveEventFormDataToTemp("create", errorFormData); + fromError: true + } + saveEventFormDataToTemp('create', errorFormData) // Show error notification showErrorNotification( - errorObj.message || "Failed to create event. Please try again." - ); + errorObj.message || 'Failed to create event. Please try again.' + ) // Try to reopen modal by dispatching custom event // Parent component should listen to this event and reopen modal window.dispatchEvent( - new CustomEvent("eventModalError", { - detail: { type: "create" }, + new CustomEvent('eventModalError', { + detail: { type: 'create' } }) - ); + ) } - }; + } const dialogActions = ( {!showMore && ( )} - + {showMore && ( )} - ); + ) return ( setShowMore(!showMore)} @@ -1005,7 +998,7 @@ function EventPopover({ selectedResources={selectedResources} /> - ); + ) } -export default EventPopover; +export default EventPopover diff --git a/src/features/Events/EventPreview/EventPreviewActionMenu.tsx b/src/features/Events/EventPreview/EventPreviewActionMenu.tsx index 1907da1..1606400 100644 --- a/src/features/Events/EventPreview/EventPreviewActionMenu.tsx +++ b/src/features/Events/EventPreview/EventPreviewActionMenu.tsx @@ -1,17 +1,17 @@ -import EventDuplication from "@/components/Event/EventDuplicate"; -import { Menu, MenuItem } from "@linagora/twake-mui"; -import { useI18n } from "twake-i18n"; -import { CalendarEvent } from "../EventsTypes"; +import EventDuplication from '@/components/Event/EventDuplicate' +import { Menu, MenuItem } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' +import { CalendarEvent } from '../EventsTypes' interface EventPreviewActionMenuProps { - anchorEl: Element | null; - event: CalendarEvent; - userEmail: string; - isOwn: boolean; - isWriteDelegated: boolean; - onClose: () => void; - onDuplicate: () => void; - onDelete: () => void; + anchorEl: Element | null + event: CalendarEvent + userEmail: string + isOwn: boolean + isWriteDelegated: boolean + onClose: () => void + onDuplicate: () => void + onDelete: () => void } export function EventPreviewActionMenu({ @@ -22,15 +22,15 @@ export function EventPreviewActionMenu({ isWriteDelegated, onClose, onDuplicate, - onDelete, + onDelete }: EventPreviewActionMenuProps) { - const { t } = useI18n(); - const mailSpaUrl = window.MAIL_SPA_URL ?? null; + const { t } = useI18n() + const mailSpaUrl = window.MAIL_SPA_URL ?? null - const attendees = event.attendee ?? []; + const attendees = event.attendee ?? [] const otherAttendees = attendees.filter( - (a) => a.cal_address !== userEmail && a.cutype !== "RESOURCE" - ); + a => a.cal_address !== userEmail && a.cutype !== 'RESOURCE' + ) return ( @@ -39,25 +39,25 @@ export function EventPreviewActionMenu({ onClick={() => window.open( `${mailSpaUrl}/mailto/?uri=${encodeURIComponent( - `mailto:${otherAttendees.map((a) => a.cal_address).join(",")}` - )}&subject=${encodeURIComponent(event.title ?? "")}`, - "_blank", - "noopener,noreferrer" + `mailto:${otherAttendees.map(a => a.cal_address).join(',')}` + )}&subject=${encodeURIComponent(event.title ?? '')}`, + '_blank', + 'noopener,noreferrer' ) } > - {t("eventPreview.emailAttendees")} + {t('eventPreview.emailAttendees')} )} { - onClose(); - onDuplicate(); + onClose() + onDuplicate() }} /> {(isOwn || isWriteDelegated) && ( - {t("eventPreview.deleteEvent")} + {t('eventPreview.deleteEvent')} )} - ); + ) } diff --git a/src/features/Events/EventPreview/EventPreviewAttendees.tsx b/src/features/Events/EventPreview/EventPreviewAttendees.tsx index 42b31ca..6bf4b54 100644 --- a/src/features/Events/EventPreview/EventPreviewAttendees.tsx +++ b/src/features/Events/EventPreview/EventPreviewAttendees.tsx @@ -1,26 +1,26 @@ -import { useAppSelector } from "@/app/hooks"; -import { useAttendeesFreeBusy } from "@/components/Attendees/useFreeBusy"; -import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils"; -import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; -import { AvatarGroup, Box, Typography } from "@linagora/twake-mui"; -import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined"; -import { alpha, useTheme } from "@mui/material/styles"; -import { useState } from "react"; -import { useI18n } from "twake-i18n"; -import { makeAttendeePreview } from "."; -import { userAttendee } from "../../User/models/attendee"; +import { useAppSelector } from '@/app/hooks' +import { useAttendeesFreeBusy } from '@/components/Attendees/useFreeBusy' +import { renderAttendeeBadge } from '@/components/Event/utils/eventUtils' +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' +import { AvatarGroup, Box, Typography } from '@linagora/twake-mui' +import PeopleAltOutlinedIcon from '@mui/icons-material/PeopleAltOutlined' +import { alpha, useTheme } from '@mui/material/styles' +import { useState } from 'react' +import { useI18n } from 'twake-i18n' +import { makeAttendeePreview } from '.' +import { userAttendee } from '../../User/models/attendee' interface EventPreviewAttendeesProps { - attendees: userAttendee[]; - organizer: userAttendee | undefined; - allAttendees: userAttendee[]; - start?: string; - end?: string; - timezone?: string; - eventUid?: string | null; + attendees: userAttendee[] + organizer: userAttendee | undefined + allAttendees: userAttendee[] + start?: string + end?: string + timezone?: string + eventUid?: string | null } -const ATTENDEE_DISPLAY_LIMIT = 3; +const ATTENDEE_DISPLAY_LIMIT = 3 export function EventPreviewAttendees({ attendees, @@ -29,43 +29,43 @@ export function EventPreviewAttendees({ start, end, timezone, - eventUid, + eventUid }: EventPreviewAttendeesProps) { - const { t } = useI18n(); - const theme = useTheme(); - const infoIconColor = alpha(theme.palette.grey[900], 0.9); - const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor }; - const userEmail = useAppSelector((state) => state.user.userData.email); - const [showAllAttendees, setShowAllAttendees] = useState(false); + const { t } = useI18n() + const theme = useTheme() + const infoIconColor = alpha(theme.palette.grey[900], 0.9) + const infoIconSx = { minWidth: '25px', marginRight: 2, color: infoIconColor } + const userEmail = useAppSelector(state => state.user.userData.email) + const [showAllAttendees, setShowAllAttendees] = useState(false) - const attendeePreview = makeAttendeePreview(allAttendees, t); + const attendeePreview = makeAttendeePreview(allAttendees, t) const toFreeBusyAttendee = (a: userAttendee) => ({ email: a.cal_address, - userId: null, - }); + userId: null + }) const freeBusyMap = useAttendeesFreeBusy({ existingAttendees: allAttendees .filter( - (attendee) => - attendee.partstat !== "ACCEPTED" && attendee.partstat !== "DECLINED" + attendee => + attendee.partstat !== 'ACCEPTED' && attendee.partstat !== 'DECLINED' ) .map(toFreeBusyAttendee), newAttendees: [], - start: start ?? "", - end: end ?? "", - timezone: timezone ?? "UTC", - eventUid: extractEventBaseUuid(eventUid ?? ""), - enabled: !!(start && end && showAllAttendees), - }); + start: start ?? '', + end: end ?? '', + timezone: timezone ?? 'UTC', + eventUid: extractEventBaseUuid(eventUid ?? ''), + enabled: !!(start && end && showAllAttendees) + }) const busyCaption = (a: userAttendee) => - freeBusyMap[a.cal_address] === "busy" + freeBusyMap[a.cal_address] === 'busy' ? a.cal_address === userEmail - ? t("event.freeBusy.busyCalOwner") - : t("event.freeBusy.busy") - : undefined; + ? t('event.freeBusy.busyCalOwner') + : t('event.freeBusy.busy') + : undefined return ( <> @@ -73,12 +73,12 @@ export function EventPreviewAttendees({ - + - {t("eventPreview.guests", { count: allAttendees.length })} + {t('eventPreview.guests', { count: allAttendees.length })} - + {attendeePreview} @@ -87,7 +87,7 @@ export function EventPreviewAttendees({ {organizer && renderAttendeeBadge( organizer, - "org", + 'org', t, showAllAttendees, true @@ -99,24 +99,24 @@ export function EventPreviewAttendees({ )} setShowAllAttendees((prev) => !prev)} + onClick={() => setShowAllAttendees(prev => !prev)} > {showAllAttendees - ? t("eventPreview.showLess") - : t("eventPreview.showMore")} + ? t('eventPreview.showLess') + : t('eventPreview.showMore')} {showAllAttendees && organizer && - renderAttendeeBadge(organizer, "org", t, showAllAttendees, true)} + renderAttendeeBadge(organizer, 'org', t, showAllAttendees, true)} {showAllAttendees && attendees.map((a, idx) => renderAttendeeBadge( @@ -129,5 +129,5 @@ export function EventPreviewAttendees({ ) )} - ); + ) } diff --git a/src/features/Events/EventPreview/EventPreviewDetails.tsx b/src/features/Events/EventPreview/EventPreviewDetails.tsx index 1a9d928..fca8fad 100644 --- a/src/features/Events/EventPreview/EventPreviewDetails.tsx +++ b/src/features/Events/EventPreview/EventPreviewDetails.tsx @@ -1,26 +1,26 @@ -import { InfoRow } from "@/components/Event/InfoRow"; -import { Box, Button, Link, Typography } from "@linagora/twake-mui"; -import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; -import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined"; -import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; -import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone"; -import RepeatIcon from "@mui/icons-material/Repeat"; -import SubjectIcon from "@mui/icons-material/Subject"; -import VideocamOutlinedIcon from "@mui/icons-material/VideocamOutlined"; -import { alpha, useTheme } from "@mui/material/styles"; -import { useMemo } from "react"; -import { useI18n } from "twake-i18n"; -import { CalendarEvent } from "../EventsTypes"; -import { EventPreviewAttendees } from "./EventPreviewAttendees"; -import { makeRecurrenceString } from "./utils/makeRecurrenceString"; -import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils"; +import { InfoRow } from '@/components/Event/InfoRow' +import { Box, Button, Link, Typography } from '@linagora/twake-mui' +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' +import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined' +import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined' +import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone' +import RepeatIcon from '@mui/icons-material/Repeat' +import SubjectIcon from '@mui/icons-material/Subject' +import VideocamOutlinedIcon from '@mui/icons-material/VideocamOutlined' +import { alpha, useTheme } from '@mui/material/styles' +import { useMemo } from 'react' +import { useI18n } from 'twake-i18n' +import { CalendarEvent } from '../EventsTypes' +import { EventPreviewAttendees } from './EventPreviewAttendees' +import { makeRecurrenceString } from './utils/makeRecurrenceString' +import { renderAttendeeBadge } from '@/components/Event/utils/eventUtils' interface EventPreviewDetailsProps { - event: CalendarEvent; - isOwn: boolean; - isNotPrivate: boolean; - isResourceEventPreview?: boolean; - calendarName?: string; + event: CalendarEvent + isOwn: boolean + isNotPrivate: boolean + isResourceEventPreview?: boolean + calendarName?: string } export function EventPreviewDetails({ @@ -28,63 +28,62 @@ export function EventPreviewDetails({ isOwn, isNotPrivate, isResourceEventPreview, - calendarName, + calendarName }: EventPreviewDetailsProps) { - const { t } = useI18n(); - const theme = useTheme(); - const infoIconColor = alpha(theme.palette.grey[900], 0.9); - const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor }; + const { t } = useI18n() + const theme = useTheme() + const infoIconColor = alpha(theme.palette.grey[900], 0.9) + const infoIconSx = { minWidth: '25px', marginRight: 2, color: infoIconColor } const resources = useMemo( () => event?.attendee?.filter( - (attendee) => - attendee.cutype === "RESOURCE" && + attendee => + attendee.cutype === 'RESOURCE' && ((isResourceEventPreview && calendarName !== attendee.cn) || !isResourceEventPreview) ), [calendarName, event?.attendee, isResourceEventPreview] - ); + ) const eventAttendees = useMemo( () => - event?.attendee?.filter((attendee) => attendee.cutype !== "RESOURCE") ?? - [], + event?.attendee?.filter(attendee => attendee.cutype !== 'RESOURCE') ?? [], [event?.attendee] - ); + ) const attendees = eventAttendees?.filter( - (a) => a.cal_address !== event.organizer?.cal_address - ) || []; + a => a.cal_address !== event.organizer?.cal_address + ) || [] const organizer = eventAttendees?.find( - (a) => a.cal_address === event.organizer?.cal_address - ); + a => a.cal_address === event.organizer?.cal_address + ) - const showDetails = isNotPrivate || isOwn; + const showDetails = isNotPrivate || isOwn if (!showDetails) { return ( - {t("eventPreview.privateEvent.hiddenDetails")} + {t('eventPreview.privateEvent.hiddenDetails')} - ); + ) } return ( @@ -103,14 +102,14 @@ export function EventPreviewDetails({ href={event.x_openpass_videoconference} target="_blank" rel="noopener noreferrer" - sx={{ textDecoration: "none" }} + sx={{ textDecoration: 'none' }} > } @@ -119,7 +118,7 @@ export function EventPreviewDetails({ {isResourceEventPreview && organizer && - renderAttendeeBadge(organizer, "org", t, true, true)} + renderAttendeeBadge(organizer, 'org', t, true, true)} {/* Attendees */} {!isResourceEventPreview && attendees.length > 0 && ( @@ -161,31 +160,31 @@ export function EventPreviewDetails({ {resource.cn} - {index < resources.length - 1 ? "," : ""} + {index < resources.length - 1 ? ',' : ''} {t(`eventPreview.attendingStatus.${resource.partstat}`)} @@ -193,8 +192,8 @@ export function EventPreviewDetails({ ))} style={{ - fontSize: "16px", - fontFamily: "'Inter', sans-serif", + fontSize: '16px', + fontFamily: "'Inter', sans-serif" }} /> )} @@ -221,16 +220,16 @@ export function EventPreviewDetails({ } - text={t("eventPreview.alarmText", { + text={t('eventPreview.alarmText', { trigger: t(`event.form.notifications.${event.alarm.trigger}`), action: (() => { - if (!event.alarm.action) return ""; - const translationKey = `event.form.notifications.${event.alarm.action}`; - const translated = t(translationKey); + if (!event.alarm.action) return '' + const translationKey = `event.form.notifications.${event.alarm.action}` + const translated = t(translationKey) return translated === translationKey ? event.alarm.action - : translated; - })(), + : translated + })() })} /> )} @@ -247,10 +246,10 @@ export function EventPreviewDetails({ text={makeRecurrenceString({ repetition: event.repetition, t, - startText: `${t("eventPreview.recurrentEvent")} · ${t( + startText: `${t('eventPreview.recurrentEvent')} · ${t( `eventPreview.freq.${event.repetition.freq}`, event.repetition.freq - )}`, + )}` })} /> )} @@ -269,5 +268,5 @@ export function EventPreviewDetails({ /> )} - ); + ) } diff --git a/src/features/Events/EventPreview/EventPreviewHeader.tsx b/src/features/Events/EventPreview/EventPreviewHeader.tsx index 79e0b8c..29cc2e3 100644 --- a/src/features/Events/EventPreview/EventPreviewHeader.tsx +++ b/src/features/Events/EventPreview/EventPreviewHeader.tsx @@ -1,21 +1,21 @@ -import { Box, IconButton } from "@linagora/twake-mui"; -import CloseIcon from "@mui/icons-material/Close"; -import EditIcon from "@mui/icons-material/Edit"; -import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined"; -import MoreVertIcon from "@mui/icons-material/MoreVert"; -import { dlEvent } from "../EventApi"; -import { CalendarEvent } from "../EventsTypes"; +import { Box, IconButton } from '@linagora/twake-mui' +import CloseIcon from '@mui/icons-material/Close' +import EditIcon from '@mui/icons-material/Edit' +import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined' +import MoreVertIcon from '@mui/icons-material/MoreVert' +import { dlEvent } from '../EventApi' +import { CalendarEvent } from '../EventsTypes' interface EventPreviewHeaderProps { - event: CalendarEvent; - eventId: string; - isOrganizer: boolean; - isOwn: boolean; - isWriteDelegated: boolean; - isNotPrivate: boolean; - onClose: () => void; - onEdit: () => void; - onMoreClick: (e: React.MouseEvent) => void; + event: CalendarEvent + eventId: string + isOrganizer: boolean + isOwn: boolean + isWriteDelegated: boolean + isNotPrivate: boolean + onClose: () => void + onEdit: () => void + onMoreClick: (e: React.MouseEvent) => void } export function EventPreviewHeader({ @@ -27,10 +27,10 @@ export function EventPreviewHeader({ isNotPrivate, onClose, onEdit, - onMoreClick, + onMoreClick }: EventPreviewHeaderProps) { - const canEdit = isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate)); - const canSeeMore = isNotPrivate || isOwn; + const canEdit = isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate)) + const canSeeMore = isNotPrivate || isOwn return ( { - let url: string | null = null; + let url: string | null = null try { - const icsContent = await dlEvent(event); - const blob = new Blob([icsContent], { type: "text/calendar" }); - url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `${eventId}.ics`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const icsContent = await dlEvent(event) + const blob = new Blob([icsContent], { type: 'text/calendar' }) + url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${eventId}.ics` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) } catch (error) { - console.error("Failed to download ICS file:", error); + console.error('Failed to download ICS file:', error) } finally { - if (url) URL.revokeObjectURL(url); + if (url) URL.revokeObjectURL(url) } }} > @@ -79,5 +79,5 @@ export function EventPreviewHeader({ - ); + ) } diff --git a/src/features/Events/EventPreview/EventPreviewModal.tsx b/src/features/Events/EventPreview/EventPreviewModal.tsx index a670afb..ce12abb 100644 --- a/src/features/Events/EventPreview/EventPreviewModal.tsx +++ b/src/features/Events/EventPreview/EventPreviewModal.tsx @@ -1,36 +1,36 @@ -import { CalendarName } from "@/components/Calendar/CalendarName"; -import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils"; -import ResponsiveDialog from "@/components/Dialog/ResponsiveDialog"; -import { EditModeDialog } from "@/components/Event/EditModeDialog"; -import { DateSelectArg } from "@fullcalendar/core"; -import { Box, Chip, Tooltip, Typography } from "@linagora/twake-mui"; -import CircleIcon from "@mui/icons-material/Circle"; -import LockOutlineIcon from "@mui/icons-material/LockOutline"; -import { useEffect } from "react"; -import { useI18n } from "twake-i18n"; -import { AttendanceValidation } from "../AttendanceValidation/AttendanceValidation"; -import EventPopover from "../EventModal"; -import { EventPreviewActionMenu } from "../EventPreview/EventPreviewActionMenu"; -import { EventPreviewDetails } from "../EventPreview/EventPreviewDetails"; -import { EventPreviewHeader } from "../EventPreview/EventPreviewHeader"; -import { useEventPreviewState } from "../EventPreview/useEventPreviewState"; -import EventUpdateModal from "../EventUpdateModal"; -import { EventTimeSubtitle } from "./EventTimeSubtitle"; +import { CalendarName } from '@/components/Calendar/CalendarName' +import { formatEventChipTitle } from '@/components/Calendar/utils/calendarUtils' +import ResponsiveDialog from '@/components/Dialog/ResponsiveDialog' +import { EditModeDialog } from '@/components/Event/EditModeDialog' +import { DateSelectArg } from '@fullcalendar/core' +import { Box, Chip, Tooltip, Typography } from '@linagora/twake-mui' +import CircleIcon from '@mui/icons-material/Circle' +import LockOutlineIcon from '@mui/icons-material/LockOutline' +import { useEffect } from 'react' +import { useI18n } from 'twake-i18n' +import { AttendanceValidation } from '../AttendanceValidation/AttendanceValidation' +import EventPopover from '../EventModal' +import { EventPreviewActionMenu } from '../EventPreview/EventPreviewActionMenu' +import { EventPreviewDetails } from '../EventPreview/EventPreviewDetails' +import { EventPreviewHeader } from '../EventPreview/EventPreviewHeader' +import { useEventPreviewState } from '../EventPreview/useEventPreviewState' +import EventUpdateModal from '../EventUpdateModal' +import { EventTimeSubtitle } from './EventTimeSubtitle' export default function EventPreviewModal({ eventId, calId, tempEvent, open, - onClose, + onClose }: { - eventId: string; - calId: string; - tempEvent?: boolean; - open: boolean; - onClose: (event: unknown, reason: "backdropClick" | "escapeKeyDown") => void; + eventId: string + calId: string + tempEvent?: boolean + open: boolean + onClose: (event: unknown, reason: 'backdropClick' | 'escapeKeyDown') => void }) { - const { t } = useI18n(); + const { t } = useI18n() const { event, @@ -59,41 +59,39 @@ export default function EventPreviewModal({ resolvedTypeOfAction, handleEditClick, handleDeleteClick, - handleDuplicateClick, - } = useEventPreviewState(eventId, calId, tempEvent, open, onClose); + handleDuplicateClick + } = useEventPreviewState(eventId, calId, tempEvent, open, onClose) useEffect( () => { if (open && (!event || !calendar)) { - onClose({}, "backdropClick"); + onClose({}, 'backdropClick') } }, // eslint-disable-next-line react-hooks/exhaustive-deps [open, event, calendar] - ); + ) - if (!user || !event || !calendar) return null; + if (!user || !event || !calendar) return null const isAdminOfResource = Boolean( calendar.owner?.resource && user.openpaasId && - calendar.owner?.administrators?.some( - (admin) => admin.id === user.openpaasId - ) - ); + calendar.owner?.administrators?.some(admin => admin.id === user.openpaasId) + ) return ( <> onClose({}, "backdropClick")} + onClose={() => onClose({}, 'backdropClick')} showHeaderActions={false} actionsBorderTop={ !!( - event.attendee?.find((p) => p.cal_address === user.email) && isOwn + event.attendee?.find(p => p.cal_address === user.email) && isOwn ) || isAdminOfResource } actionsJustifyContent="center" - style={{ overflow: "auto" }} + style={{ overflow: 'auto' }} title={ onClose({}, "backdropClick")} + onClose={() => onClose({}, 'backdropClick')} onEdit={handleEditClick} - onMoreClick={(e) => setToggleActionMenu(e.currentTarget)} + onMoreClick={e => setToggleActionMenu(e.currentTarget)} /> } actions={ @@ -127,10 +125,10 @@ export default function EventPreviewModal({ gap={1} mb={1} > - {event.class === "PRIVATE" && + {event.class === 'PRIVATE' && (isOwn ? ( @@ -141,19 +139,19 @@ export default function EventPreviewModal({ {formatEventChipTitle(event, t)} - {event.transp === "TRANSPARENT" && ( - + {event.transp === 'TRANSPARENT' && ( + } - label={t("eventPreview.free.label")} + label={t('eventPreview.free.label')} /> )} @@ -171,7 +169,7 @@ export default function EventPreviewModal({ /> {/* Calendar label */} - + @@ -192,9 +190,9 @@ export default function EventPreviewModal({ { - setTypeOfAction(type); - afterChoiceFunc?.(type); + eventAction={(type: 'solo' | 'all' | undefined) => { + setTypeOfAction(type) + afterChoiceFunc?.(type) }} /> @@ -202,12 +200,12 @@ export default function EventPreviewModal({ { - setOpenUpdateModal(false); - setHidePreview(false); + setOpenUpdateModal(false) + setHidePreview(false) }} onCloseAll={() => { - setOpenUpdateModal(false); - onClose({}, "backdropClick"); + setOpenUpdateModal(false) + onClose({}, 'backdropClick') }} eventId={eventId} calId={calId} @@ -224,17 +222,17 @@ export default function EventPreviewModal({ startStr: event.start, end: new Date(event.end ?? event.start), endStr: event.end ?? event.start, - allDay: event.allday ?? false, + allDay: event.allday ?? false } as DateSelectArg } setSelectedRange={() => {}} calendarRef={{ current: null }} onClose={() => { - setOpenDuplicateModal(false); - onClose({}, "backdropClick"); + setOpenDuplicateModal(false) + onClose({}, 'backdropClick') }} event={event} /> - ); + ) } diff --git a/src/features/Events/EventPreview/EventTimeSubtitle.tsx b/src/features/Events/EventPreview/EventTimeSubtitle.tsx index 53bceff..80a1a9b 100644 --- a/src/features/Events/EventPreview/EventTimeSubtitle.tsx +++ b/src/features/Events/EventPreview/EventTimeSubtitle.tsx @@ -1,27 +1,27 @@ -import { getTimezoneOffset } from "@/utils/timezone"; -import { Typography } from "@linagora/twake-mui"; -import { CalendarEvent } from "../EventsTypes"; -import { formatDate } from "./utils/formatDate"; -import { formatEnd } from "./utils/formatEnd"; +import { getTimezoneOffset } from '@/utils/timezone' +import { Typography } from '@linagora/twake-mui' +import { CalendarEvent } from '../EventsTypes' +import { formatDate } from './utils/formatDate' +import { formatEnd } from './utils/formatEnd' export function EventTimeSubtitle({ event, t, - timezone, + timezone }: { - event: CalendarEvent; - t: (k: string, p?: string | object | undefined) => string; - timezone: string; + event: CalendarEvent + t: (k: string, p?: string | object) => string + timezone: string }) { const formattedEnd = event.end ? formatEnd(event.start, event.end, t, timezone, event.allday) - : null; + : null return ( {formatDate(event.start, t, timezone, event.allday)} {formattedEnd && - ` – ${formattedEnd} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`} + ` – ${formattedEnd} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ''}`} - ); + ) } diff --git a/src/features/Events/EventPreview/index.ts b/src/features/Events/EventPreview/index.ts index aa7f1fe..c92c911 100644 --- a/src/features/Events/EventPreview/index.ts +++ b/src/features/Events/EventPreview/index.ts @@ -1,2 +1,2 @@ -export { default } from "./EventPreviewModal"; -export { makeAttendeePreview } from "./utils/makeAttendeePreview"; +export { default } from './EventPreviewModal' +export { makeAttendeePreview } from './utils/makeAttendeePreview' diff --git a/src/features/Events/EventPreview/useEventPreviewState.ts b/src/features/Events/EventPreview/useEventPreviewState.ts index 23eed24..5c6c683 100644 --- a/src/features/Events/EventPreview/useEventPreviewState.ts +++ b/src/features/Events/EventPreview/useEventPreviewState.ts @@ -1,52 +1,47 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { assertThunkSuccess } from "@/utils/assertThunkSuccess"; -import { getEffectiveEmail } from "@/utils/getEffectiveEmail"; -import { isEventOrganiser } from "@/utils/isEventOrganiser"; -import { browserDefaultTimeZone } from "@/utils/timezone"; -import { useState } from "react"; -import { deleteEventAsync } from "../../Calendars/services"; -import { ToUserData } from "../../User/type/OpenPaasUserData"; -import { createEventContext } from "../createEventContext"; -import { handleDelete } from "@/components/Event/eventHandlers/eventHandlers"; -import { useEventUpdateModalReopen } from "./useEventUpdateModalReopen"; +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { assertThunkSuccess } from '@/utils/assertThunkSuccess' +import { getEffectiveEmail } from '@/utils/getEffectiveEmail' +import { isEventOrganiser } from '@/utils/isEventOrganiser' +import { browserDefaultTimeZone } from '@/utils/timezone' +import { useState } from 'react' +import { deleteEventAsync } from '../../Calendars/services' +import { ToUserData } from '../../User/type/OpenPaasUserData' +import { createEventContext } from '../createEventContext' +import { handleDelete } from '@/components/Event/eventHandlers/eventHandlers' +import { useEventUpdateModalReopen } from './useEventUpdateModalReopen' export function useEventPreviewState( eventId: string, calId: string, tempEvent: boolean | undefined, open: boolean, - onClose: (event: unknown, reason: "backdropClick" | "escapeKeyDown") => void + onClose: (event: unknown, reason: 'backdropClick' | 'escapeKeyDown') => void ) { - const dispatch = useAppDispatch(); - const calendars = useAppSelector((state) => state.calendars); + const dispatch = useAppDispatch() + const calendars = useAppSelector(state => state.calendars) const timezone = - useAppSelector((state) => state.settings.timeZone) ?? - browserDefaultTimeZone; - const user = useAppSelector((state) => state.user.userData); + useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone + const user = useAppSelector(state => state.user.userData) - const calendar = tempEvent - ? calendars.templist[calId] - : calendars.list[calId]; - const event = calendar?.events[eventId]; + const calendar = tempEvent ? calendars.templist[calId] : calendars.list[calId] + const event = calendar?.events[eventId] // Modal visibility - const [openUpdateModal, setOpenUpdateModal] = useState(false); - const [openDuplicateModal, setOpenDuplicateModal] = useState(false); - const [hidePreview, setHidePreview] = useState(false); - const [toggleActionMenu, setToggleActionMenu] = useState( - null - ); + const [openUpdateModal, setOpenUpdateModal] = useState(false) + const [openDuplicateModal, setOpenDuplicateModal] = useState(false) + const [hidePreview, setHidePreview] = useState(false) + const [toggleActionMenu, setToggleActionMenu] = useState(null) // Recurring event handling const [openEditModePopup, setOpenEditModePopup] = useState( null - ); - const [typeOfAction, setTypeOfAction] = useState<"solo" | "all" | undefined>( + ) + const [typeOfAction, setTypeOfAction] = useState<'solo' | 'all' | undefined>( undefined - ); + ) const [afterChoiceFunc, setAfterChoiceFunc] = useState< - ((type: "solo" | "all" | undefined) => void) | undefined - >(); + ((type: 'solo' | 'all' | undefined) => void) | undefined + >() // Reopen logic (sessionStorage + custom event) useEventUpdateModalReopen({ @@ -56,70 +51,68 @@ export function useEventPreviewState( typeOfAction, setTypeOfAction, setOpenUpdateModal, - setHidePreview, - }); + setHidePreview + }) // Derived access flags - const isRecurring = event?.uid?.includes("/") ?? false; - const isOwn = calendar?.owner?.emails?.includes(user?.email ?? "") ?? false; - const isDelegated = calendar?.delegated; - const isWriteDelegated = (isDelegated && calendar?.access?.write) ?? false; + const isRecurring = event?.uid?.includes('/') ?? false + const isOwn = calendar?.owner?.emails?.includes(user?.email ?? '') ?? false + const isDelegated = calendar?.delegated + const isWriteDelegated = (isDelegated && calendar?.access?.write) ?? false const effectiveEmail = user ? getEffectiveEmail(calendar, isWriteDelegated, user.email) - : ""; + : '' const isOrganizer = event?.organizer ? isEventOrganiser(event, effectiveEmail) - : isOwn; + : isOwn const isNotPrivate = - event?.class !== "PRIVATE" && event?.class !== "CONFIDENTIAL"; + event?.class !== 'PRIVATE' && event?.class !== 'CONFIDENTIAL' const contextualizedEvent = - event && calendar && user - ? createEventContext(event, calendar, user) - : null; + event && calendar && user ? createEventContext(event, calendar, user) : null const attendanceUser = - isWriteDelegated && calendar?.owner ? ToUserData(calendar.owner) : user; + isWriteDelegated && calendar?.owner ? ToUserData(calendar.owner) : user // Resolve typeOfAction for EventUpdateModal (state or sessionStorage fallback) const resolvedTypeOfAction = (() => { - if (typeOfAction) return typeOfAction; + if (typeOfAction) return typeOfAction try { - const stored = sessionStorage.getItem("eventUpdateModalReopen"); + const stored = sessionStorage.getItem('eventUpdateModalReopen') if (stored) { - const data = JSON.parse(stored); + const data = JSON.parse(stored) if ( data.eventId === eventId && data.calId === calId && data.typeOfAction ) { - return data.typeOfAction as "solo" | "all"; + return data.typeOfAction as 'solo' | 'all' } } } catch { // Ignore } - return undefined; - })(); + return undefined + })() // Action handlers const handleEditClick = () => { if (isRecurring) { setAfterChoiceFunc(() => () => { - setHidePreview(true); - setOpenUpdateModal(true); - }); - setOpenEditModePopup("edit"); + setHidePreview(true) + setOpenUpdateModal(true) + }) + setOpenEditModePopup('edit') } else { - setHidePreview(true); - setOpenUpdateModal(true); + setHidePreview(true) + setOpenUpdateModal(true) } - }; + } const handleDeleteClick = async () => { if (isRecurring) { setAfterChoiceFunc( - () => (type?: "solo" | "all" | undefined) => + () => (type?: 'solo' | 'all') => handleDelete( isRecurring, type, @@ -130,25 +123,25 @@ export function useEventPreviewState( calId, eventId ) - ); - setOpenEditModePopup("delete"); + ) + setOpenEditModePopup('delete') } else { - onClose({}, "backdropClick"); + onClose({}, 'backdropClick') try { const result = await dispatch( deleteEventAsync({ calId, eventId, eventURL: event.URL }) - ); - await assertThunkSuccess(result); + ) + await assertThunkSuccess(result) } catch (error) { - console.error("Failed to delete event:", error); + console.error('Failed to delete event:', error) } } - }; + } const handleDuplicateClick = () => { - setHidePreview(true); - setOpenDuplicateModal(true); - }; + setHidePreview(true) + setOpenDuplicateModal(true) + } return { // Data @@ -190,6 +183,6 @@ export function useEventPreviewState( handleDeleteClick, handleDuplicateClick, - dispatch, - }; + dispatch + } } diff --git a/src/features/Events/EventPreview/useEventUpdateModalReopen.ts b/src/features/Events/EventPreview/useEventUpdateModalReopen.ts index 33be010..c830234 100644 --- a/src/features/Events/EventPreview/useEventUpdateModalReopen.ts +++ b/src/features/Events/EventPreview/useEventUpdateModalReopen.ts @@ -1,21 +1,21 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef } from 'react' interface UseEventUpdateModalReopenProps { - open: boolean; - eventId: string; - calId: string; - typeOfAction: "solo" | "all" | undefined; - setTypeOfAction: (type: "solo" | "all" | undefined) => void; - setOpenUpdateModal: (open: boolean) => void; - setHidePreview: (hide: boolean) => void; + open: boolean + eventId: string + calId: string + typeOfAction: 'solo' | 'all' | undefined + setTypeOfAction: (type: 'solo' | 'all' | undefined) => void + setOpenUpdateModal: (open: boolean) => void + setHidePreview: (hide: boolean) => void } function applyReopenData( - data: { typeOfAction?: "solo" | "all"; eventId: string; calId: string }, + data: { typeOfAction?: 'solo' | 'all'; eventId: string; calId: string }, eventId: string, calId: string, - typeOfAction: "solo" | "all" | undefined, - setTypeOfAction: (type: "solo" | "all" | undefined) => void, + typeOfAction: 'solo' | 'all' | undefined, + setTypeOfAction: (type: 'solo' | 'all' | undefined) => void, setOpenUpdateModal: (open: boolean) => void, setHidePreview: (hide: boolean) => void, delayMs = 100, @@ -24,48 +24,46 @@ function applyReopenData( const typeOfActionMatch = data.typeOfAction === typeOfAction || data.typeOfAction === undefined || - typeOfAction === undefined; + typeOfAction === undefined if (data.eventId !== eventId || data.calId !== calId || !typeOfActionMatch) { - return false; + return false } const delay = - data.typeOfAction !== undefined && typeOfAction === undefined - ? 50 - : delayMs; + data.typeOfAction !== undefined && typeOfAction === undefined ? 50 : delayMs if (data.typeOfAction !== undefined && typeOfAction === undefined) { - setTypeOfAction(data.typeOfAction); + setTypeOfAction(data.typeOfAction) } setTimeout(() => { - setOpenUpdateModal(true); - setHidePreview(true); + setOpenUpdateModal(true) + setHidePreview(true) if (clearSessionStorage) { try { - sessionStorage.removeItem("eventUpdateModalReopen"); + sessionStorage.removeItem('eventUpdateModalReopen') } catch { // Ignore sessionStorage errors } } - }, delay); + }, delay) - return true; + return true } function readSessionStorage(): { - typeOfAction?: "solo" | "all"; - eventId: string; - calId: string; + typeOfAction?: 'solo' | 'all' + eventId: string + calId: string } | null { try { - const stored = sessionStorage.getItem("eventUpdateModalReopen"); - if (stored) return JSON.parse(stored); + const stored = sessionStorage.getItem('eventUpdateModalReopen') + if (stored) return JSON.parse(stored) } catch { // Ignore } - return null; + return null } /** @@ -80,21 +78,21 @@ export function useEventUpdateModalReopen({ typeOfAction, setTypeOfAction, setOpenUpdateModal, - setHidePreview, + setHidePreview }: UseEventUpdateModalReopenProps) { - const hasCheckedSessionStorageRef = useRef(false); + const hasCheckedSessionStorageRef = useRef(false) // Check sessionStorage once when the preview opens useEffect(() => { if (!open) { - hasCheckedSessionStorageRef.current = false; - return; + hasCheckedSessionStorageRef.current = false + return } - if (hasCheckedSessionStorageRef.current) return; - hasCheckedSessionStorageRef.current = true; + if (hasCheckedSessionStorageRef.current) return + hasCheckedSessionStorageRef.current = true - const data = readSessionStorage(); + const data = readSessionStorage() if (data) { applyReopenData( data, @@ -106,16 +104,16 @@ export function useEventUpdateModalReopen({ setHidePreview, 100, true - ); + ) } // typeOfAction intentionally omitted — we only want to run this once per open // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, eventId, calId]); + }, [open, eventId, calId]) // Listen for the custom event dispatched on API failure useEffect(() => { const handleUpdateModalReopen = (e: CustomEvent) => { - const detail = e.detail; + const detail = e.detail const matched = applyReopenData( detail, eventId, @@ -126,18 +124,18 @@ export function useEventUpdateModalReopen({ setHidePreview, 50, true - ); + ) if (matched) { try { - sessionStorage.removeItem("eventUpdateModalReopen"); + sessionStorage.removeItem('eventUpdateModalReopen') } catch { // Ignore } } - }; + } // Also check sessionStorage whenever eventId/calId/typeOfAction change - const data = readSessionStorage(); + const data = readSessionStorage() if (data) { applyReopenData( data, @@ -149,25 +147,25 @@ export function useEventUpdateModalReopen({ setHidePreview, 100, true - ); + ) } window.addEventListener( - "eventUpdateModalReopen", + 'eventUpdateModalReopen', handleUpdateModalReopen as EventListener - ); + ) return () => { window.removeEventListener( - "eventUpdateModalReopen", + 'eventUpdateModalReopen', handleUpdateModalReopen as EventListener - ); - }; + ) + } }, [ eventId, calId, typeOfAction, setTypeOfAction, setOpenUpdateModal, - setHidePreview, - ]); + setHidePreview + ]) } diff --git a/src/features/Events/EventPreview/utils/formatDate.ts b/src/features/Events/EventPreview/utils/formatDate.ts index 2321919..a4e097a 100644 --- a/src/features/Events/EventPreview/utils/formatDate.ts +++ b/src/features/Events/EventPreview/utils/formatDate.ts @@ -5,23 +5,23 @@ export function formatDate( allday?: boolean ) { if (allday) { - return new Date(date).toLocaleDateString(t("locale"), { - year: "numeric", - month: "long", - weekday: "long", - day: "numeric", - timeZone, - }); + return new Date(date).toLocaleDateString(t('locale'), { + year: 'numeric', + month: 'long', + weekday: 'long', + day: 'numeric', + timeZone + }) } else { - return new Date(date).toLocaleString(t("locale"), { - year: "numeric", - month: "long", - weekday: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", + return new Date(date).toLocaleString(t('locale'), { + year: 'numeric', + month: 'long', + weekday: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', hour12: false, - timeZone, - }); + timeZone + }) } } diff --git a/src/features/Events/EventPreview/utils/formatEnd.ts b/src/features/Events/EventPreview/utils/formatEnd.ts index acbe35d..aab07a7 100644 --- a/src/features/Events/EventPreview/utils/formatEnd.ts +++ b/src/features/Events/EventPreview/utils/formatEnd.ts @@ -5,42 +5,42 @@ export function formatEnd( timeZone: string, allday?: boolean ) { - const startDate = new Date(start); - const endDate = new Date(end); + const startDate = new Date(start) + const endDate = new Date(end) const formatDatePart = (d: Date) => - d.toLocaleDateString("en-CA", { timeZone }); // YYYY-MM-DD format + d.toLocaleDateString('en-CA', { timeZone }) // YYYY-MM-DD format if (allday) { - const inclusiveEndDate = new Date(endDate); - inclusiveEndDate.setDate(inclusiveEndDate.getDate() - 1); + const inclusiveEndDate = new Date(endDate) + inclusiveEndDate.setDate(inclusiveEndDate.getDate() - 1) const sameDay = - formatDatePart(startDate) === formatDatePart(inclusiveEndDate); + formatDatePart(startDate) === formatDatePart(inclusiveEndDate) return sameDay ? null - : inclusiveEndDate.toLocaleDateString(t("locale"), { - year: "numeric", - month: "short", - day: "numeric", - timeZone, - }); + : inclusiveEndDate.toLocaleDateString(t('locale'), { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone + }) } else { - const sameDay = formatDatePart(startDate) === formatDatePart(endDate); + const sameDay = formatDatePart(startDate) === formatDatePart(endDate) if (sameDay) { - return endDate.toLocaleTimeString(t("locale"), { - hour: "2-digit", - minute: "2-digit", + return endDate.toLocaleTimeString(t('locale'), { + hour: '2-digit', + minute: '2-digit', hour12: false, - timeZone, - }); + timeZone + }) } - return endDate.toLocaleString(t("locale"), { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", + return endDate.toLocaleString(t('locale'), { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', hour12: false, - timeZone, - }); + timeZone + }) } } diff --git a/src/features/Events/EventPreview/utils/makeAttendeePreview.ts b/src/features/Events/EventPreview/utils/makeAttendeePreview.ts index c3ba6ed..0392ba7 100644 --- a/src/features/Events/EventPreview/utils/makeAttendeePreview.ts +++ b/src/features/Events/EventPreview/utils/makeAttendeePreview.ts @@ -1,40 +1,40 @@ -import { userAttendee } from "@/features/User/models/attendee"; +import { userAttendee } from '@/features/User/models/attendee' export function makeAttendeePreview( attendees: userAttendee[] | undefined, t: (k: string, p?: string | object) => string ) { - const attendeePreview = []; + const attendeePreview = [] const counts = (attendees ?? []).reduce( (acc, a) => { - if (a.partstat === "ACCEPTED") acc.yes++; - else if (a.partstat === "DECLINED") acc.no++; - else if (a.partstat === "TENTATIVE") acc.maybe++; - else if (a.partstat === "NEEDS-ACTION") acc.needAction++; - return acc; + if (a.partstat === 'ACCEPTED') acc.yes++ + else if (a.partstat === 'DECLINED') acc.no++ + else if (a.partstat === 'TENTATIVE') acc.maybe++ + else if (a.partstat === 'NEEDS-ACTION') acc.needAction++ + return acc }, { yes: 0, no: 0, maybe: 0, needAction: 0 } - ); + ) const { yes: yesCount, no: noCount, maybe: maybeCount, - needAction: needActionCount, - } = counts; + needAction: needActionCount + } = counts if (yesCount) { - attendeePreview.push(t("eventPreview.yesCount", { count: yesCount })); + attendeePreview.push(t('eventPreview.yesCount', { count: yesCount })) } if (maybeCount) { - attendeePreview.push(t("eventPreview.maybeCount", { count: maybeCount })); + attendeePreview.push(t('eventPreview.maybeCount', { count: maybeCount })) } if (needActionCount) { attendeePreview.push( - t("eventPreview.needActionCount", { count: needActionCount }) - ); + t('eventPreview.needActionCount', { count: needActionCount }) + ) } if (noCount) { - attendeePreview.push(t("eventPreview.noCount", { count: noCount })); + attendeePreview.push(t('eventPreview.noCount', { count: noCount })) } - return attendeePreview.join(", "); + return attendeePreview.join(', ') } diff --git a/src/features/Events/EventPreview/utils/makeRecurrenceString.ts b/src/features/Events/EventPreview/utils/makeRecurrenceString.ts index 74748bc..67eb634 100644 --- a/src/features/Events/EventPreview/utils/makeRecurrenceString.ts +++ b/src/features/Events/EventPreview/utils/makeRecurrenceString.ts @@ -1,13 +1,13 @@ -import { RepetitionObject } from "../../EventsTypes"; +import { RepetitionObject } from '../../EventsTypes' -const WEEK_DAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; +const WEEK_DAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] interface MakeRecurrenceStringParams { - repetition: RepetitionObject; - t: (k: string, p?: string | object) => string; - startText: string; - joinChar?: string; - enableStrForOneTimeInterval?: boolean; + repetition: RepetitionObject + t: (k: string, p?: string | object) => string + startText: string + joinChar?: string + enableStrForOneTimeInterval?: boolean } export function makeRecurrenceString({ @@ -15,64 +15,62 @@ export function makeRecurrenceString({ t, startText, joinChar, - enableStrForOneTimeInterval, + enableStrForOneTimeInterval }: MakeRecurrenceStringParams): string | undefined { - if (!repetition) return; + if (!repetition) return - const recur: string[] = [startText]; + const recur: string[] = [startText] const recurType: Record = { - daily: t("event.repeat.frequency.days"), - weekly: t("event.repeat.frequency.weeks"), - monthly: t("event.repeat.frequency.months"), - yearly: t("event.repeat.frequency.years"), - }; + daily: t('event.repeat.frequency.days'), + weekly: t('event.repeat.frequency.weeks'), + monthly: t('event.repeat.frequency.months'), + yearly: t('event.repeat.frequency.years') + } - const interval = repetition.interval ?? 1; + const interval = repetition.interval ?? 1 if (interval === 1 && enableStrForOneTimeInterval) { - recur.push(recurType[repetition.freq]); + recur.push(recurType[repetition.freq]) } if (interval > 1) { recur.push( - t("eventPreview.everyInterval", { + t('eventPreview.everyInterval', { interval, - unit: recurType[repetition.freq] ?? repetition.freq, + unit: recurType[repetition.freq] ?? repetition.freq }) - ); + ) } if (repetition.byday) { - const weekDaysByOrder = WEEK_DAYS.filter((day) => + const weekDaysByOrder = WEEK_DAYS.filter(day => repetition.byday?.includes(day) - ); + ) recur.push( - t("eventPreview.recurrenceOnDays", { - days: weekDaysByOrder - .map((s) => t(`eventPreview.onDays.${s}`)) - .join(", "), + t('eventPreview.recurrenceOnDays', { + days: weekDaysByOrder.map(s => t(`eventPreview.onDays.${s}`)).join(', ') }) - ); + ) } if (repetition.occurrences) { recur.push( - t("eventPreview.forOccurrences", { - count: repetition.occurrences, + t('eventPreview.forOccurrences', { + count: repetition.occurrences }) - ); + ) } if (repetition.endDate) { recur.push( - t("eventPreview.until", { - date: new Date(repetition.endDate).toLocaleDateString(t("locale"), { - year: "numeric", - month: "long", - day: "numeric", - }), + t('eventPreview.until', { + date: new Date(repetition.endDate).toLocaleDateString(t('locale'), { + year: 'numeric', + month: 'long', + day: 'numeric' + }) }) - ); + ) } - return recur.filter(Boolean).join(`${joinChar ?? ","} `); + return recur.filter(Boolean).join(`${joinChar ?? ','} `) } diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index 1c573a9..c956ce3 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -1,16 +1,16 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { ResponsiveDialog } from "@/components/Dialog"; -import EventFormFields from "@/components/Event/EventFormFields"; -import { addDays } from "@/components/Event/utils/dateRules"; -import { formatDateTimeInTimezone } from "@/components/Event/utils/dateTimeFormatters"; -import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers"; +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { ResponsiveDialog } from '@/components/Dialog' +import EventFormFields from '@/components/Event/EventFormFields' +import { addDays } from '@/components/Event/utils/dateRules' +import { formatDateTimeInTimezone } from '@/components/Event/utils/dateTimeFormatters' +import { convertFormDateTimeToISO } from '@/components/Event/utils/dateTimeHelpers' import { putEventAsync, updateEventInstanceAsync, - updateSeriesAsync, -} from "@/features/Calendars/services"; -import { updateAttendeesAfterTimeChange } from "@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange"; -import { assertThunkSuccess } from "@/utils/assertThunkSuccess"; + updateSeriesAsync +} from '@/features/Calendars/services' +import { updateAttendeesAfterTimeChange } from '@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange' +import { assertThunkSuccess } from '@/utils/assertThunkSuccess' import { buildEventFormTempData, clearEventFormTempData, @@ -19,32 +19,32 @@ import { restoreEventFormDataFromTemp as restoreEventFormDataFromStorage, restoreFormDataFromTemp, saveEventFormDataToTemp, - showErrorNotification, -} from "@/utils/eventFormTempStorage"; -import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; + showErrorNotification +} from '@/utils/eventFormTempStorage' +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' import { browserDefaultTimeZone, getTimezoneOffset, - resolveTimezone, -} from "@/utils/timezone"; -import { TIMEZONES } from "@/utils/timezone-data"; -import { addVideoConferenceToDescription } from "@/utils/videoConferenceUtils"; -import { Box, Button } from "@linagora/twake-mui"; -import AddIcon from "@mui/icons-material/Add"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useI18n } from "twake-i18n"; + resolveTimezone +} from '@/utils/timezone' +import { TIMEZONES } from '@/utils/timezone-data' +import { addVideoConferenceToDescription } from '@/utils/videoConferenceUtils' +import { Box, Button } from '@linagora/twake-mui' +import AddIcon from '@mui/icons-material/Add' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useI18n } from 'twake-i18n' import { clearFetchCache, removeEvent, - updateEventLocal, -} from "../Calendars/CalendarSlice"; -import { Calendar } from "../Calendars/CalendarTypes"; -import { userAttendee } from "../User/models/attendee"; -import { deleteEvent, getEvent, putEvent } from "./EventApi"; -import { CalendarEvent, RepetitionObject } from "./EventsTypes"; -import { moveEventBetweenCalendars } from "./updateEventHelpers/moveEventBetweenCalendars"; -import { detectRecurringEventChanges } from "./utils/detectRecurringEventChanges"; -import { Resource } from "@/components/Attendees/ResourceSearch"; + updateEventLocal +} from '../Calendars/CalendarSlice' +import { Calendar } from '../Calendars/CalendarTypes' +import { userAttendee } from '../User/models/attendee' +import { deleteEvent, getEvent, putEvent } from './EventApi' +import { CalendarEvent, RepetitionObject } from './EventsTypes' +import { moveEventBetweenCalendars } from './updateEventHelpers/moveEventBetweenCalendars' +import { detectRecurringEventChanges } from './utils/detectRecurringEventChanges' +import { Resource } from '@/components/Attendees/ResourceSearch' function EventUpdateModal({ eventId, @@ -53,243 +53,242 @@ function EventUpdateModal({ onClose, onCloseAll, eventData, - typeOfAction, + typeOfAction }: { - eventId: string; - calId: string; - open: boolean; - onClose: (event: unknown, reason: "backdropClick" | "escapeKeyDown") => void; - onCloseAll?: () => void; - eventData?: CalendarEvent | null; - typeOfAction?: "solo" | "all"; + eventId: string + calId: string + open: boolean + onClose: (event: unknown, reason: 'backdropClick' | 'escapeKeyDown') => void + onCloseAll?: () => void + eventData?: CalendarEvent | null + typeOfAction?: 'solo' | 'all' }) { - const { t } = useI18n(); - const dispatch = useAppDispatch(); - const calList = useAppSelector((state) => state.calendars.list); + const { t } = useI18n() + const dispatch = useAppDispatch() + const calList = useAppSelector(state => state.calendars.list) // Get event from Redux store (cached data) as fallback const cachedEvent = useAppSelector( - (state) => state.calendars.list[calId]?.events[eventId] - ); + state => state.calendars.list[calId]?.events[eventId] + ) // Use fresh data if available, otherwise use eventData from props, otherwise use cached data - const event = eventData || cachedEvent; + const event = eventData || cachedEvent - const user = useAppSelector((state) => state.user); + const user = useAppSelector(state => state.user) const userPersonalCalendars: Calendar[] = useMemo(() => { - const allCalendars = Object.values(calList) as Calendar[]; + const allCalendars = Object.values(calList) return allCalendars.filter( (calendar: Calendar) => - calendar.id?.split("/")[0] === user.userData?.openpaasId || + calendar.id?.split('/')[0] === user.userData?.openpaasId || (calendar.delegated && calendar.access?.write) - ); - }, [calList, user.userData?.openpaasId]); + ) + }, [calList, user.userData?.openpaasId]) const timezoneList = useMemo(() => { - const zones = Object.keys(TIMEZONES.zones).sort(); - const browserTz = resolveTimezone(browserDefaultTimeZone); + const zones = Object.keys(TIMEZONES.zones).sort() + const browserTz = resolveTimezone(browserDefaultTimeZone) - return { zones, browserTz, getTimezoneOffset }; - }, []); + return { zones, browserTz, getTimezoneOffset } + }, []) const resources: Resource[] = useMemo(() => { const resourcesInEvent = - event?.attendee?.filter((attendee) => attendee.cutype === "RESOURCE") ?? - []; - return resourcesInEvent.map((resource) => ({ + event?.attendee?.filter(attendee => attendee.cutype === 'RESOURCE') ?? [] + return resourcesInEvent.map(resource => ({ email: resource.cal_address, - displayName: resource.cn, - })); - }, [event?.attendee]); + displayName: resource.cn + })) + }, [event?.attendee]) - const [showMore, setShowMore] = useState(false); + const [showMore, setShowMore] = useState(false) const [showDescription, setShowDescription] = useState( event?.description ? true : false - ); + ) const [showRepeat, setShowRepeat] = useState( event?.repetition?.freq ? true : false - ); + ) // Form state - initialize with empty values - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [location, setLocation] = useState(""); - const [start, setStart] = useState(""); - const [end, setEnd] = useState(""); - const [allday, setAllDay] = useState(false); + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [location, setLocation] = useState('') + const [start, setStart] = useState('') + const [end, setEnd] = useState('') + const [allday, setAllDay] = useState(false) const [repetition, setRepetition] = useState( {} as RepetitionObject - ); - const [alarm, setAlarm] = useState(""); - const [busy, setBusy] = useState("OPAQUE"); - const [eventClass, setEventClass] = useState("PUBLIC"); + ) + const [alarm, setAlarm] = useState('') + const [busy, setBusy] = useState('OPAQUE') + const [eventClass, setEventClass] = useState('PUBLIC') const [timezone, setTimezone] = useState( resolveTimezone(browserDefaultTimeZone) - ); - const [newCalId, setNewCalId] = useState(calId); + ) + const [newCalId, setNewCalId] = useState(calId) const defaultCalendarId = useMemo( - () => userPersonalCalendars[0]?.id ?? "", + () => userPersonalCalendars[0]?.id ?? '', [userPersonalCalendars] - ); + ) - const [calendarid, setCalendarid] = useState(calId ?? defaultCalendarId); + const [calendarid, setCalendarid] = useState(calId ?? defaultCalendarId) - const [attendees, setAttendees] = useState([]); - const [hasVideoConference, setHasVideoConference] = useState(false); - const [meetingLink, setMeetingLink] = useState(null); - const [isFormValid, setIsFormValid] = useState(false); - const [showValidationErrors, setShowValidationErrors] = useState(false); - const [hasEndDateChanged, setHasEndDateChanged] = useState(false); - const [selectedResources, setSelectedResources] = useState(resources ?? []); + const [attendees, setAttendees] = useState([]) + const [hasVideoConference, setHasVideoConference] = useState(false) + const [meetingLink, setMeetingLink] = useState(null) + const [isFormValid, setIsFormValid] = useState(false) + const [showValidationErrors, setShowValidationErrors] = useState(false) + const [hasEndDateChanged, setHasEndDateChanged] = useState(false) + const [selectedResources, setSelectedResources] = useState(resources ?? []) const resetAllStateToDefault = useCallback(() => { - setShowMore(false); - setShowDescription(false); - setShowRepeat(false); - setTitle(""); - setDescription(""); - setAttendees([]); - setLocation(""); - setStart(""); - setEnd(""); + setShowMore(false) + setShowDescription(false) + setShowRepeat(false) + setTitle('') + setDescription('') + setAttendees([]) + setLocation('') + setStart('') + setEnd('') if (defaultCalendarId) { - setCalendarid(defaultCalendarId); + setCalendarid(defaultCalendarId) } - setAllDay(false); - setRepetition({} as RepetitionObject); - setAlarm(""); - setEventClass("PUBLIC"); - setBusy("OPAQUE"); - setTimezone(resolveTimezone(browserDefaultTimeZone)); - setHasVideoConference(false); - setMeetingLink(null); - setSelectedResources([]); - }, [defaultCalendarId]); + setAllDay(false) + setRepetition({} as RepetitionObject) + setAlarm('') + setEventClass('PUBLIC') + setBusy('OPAQUE') + setTimezone(resolveTimezone(browserDefaultTimeZone)) + setHasVideoConference(false) + setMeetingLink(null) + setSelectedResources([]) + }, [defaultCalendarId]) // Prevent repeated initialization loops - const initializedKeyRef = useRef(null); + const initializedKeyRef = useRef(null) // Track when restoring from error to prevent other useEffects from overriding restored data - const isRestoringFromErrorRef = useRef(false); + const isRestoringFromErrorRef = useRef(false) // State to hold the master event when editing "all events" - const [masterEvent, setMasterEvent] = useState(null); - const [isLoadingMasterEvent, setIsLoadingMasterEvent] = useState(false); + const [masterEvent, setMasterEvent] = useState(null) + const [isLoadingMasterEvent, setIsLoadingMasterEvent] = useState(false) // Fetch master event when editing "all events" of a recurring series useEffect(() => { - if (!event || !open || typeOfAction !== "all") { - setMasterEvent(null); - return; + if (!event || !open || typeOfAction !== 'all') { + setMasterEvent(null) + return } - const isRecurringEvent = !!event.repetition?.freq; + const isRecurringEvent = !!event.repetition?.freq if (!isRecurringEvent) { - setMasterEvent(null); - return; + setMasterEvent(null) + return } // Check if this is an instance (has recurrence-id) - const [baseUID, recurrenceId] = event.uid.split("/"); + const [baseUID, recurrenceId] = event.uid.split('/') if (!recurrenceId) { // This is already the master event - setMasterEvent(event); - return; + setMasterEvent(event) + return } // Fetch the master event const fetchMasterEvent = async () => { - setIsLoadingMasterEvent(true); + setIsLoadingMasterEvent(true) try { const masterEventToFetch = { ...event, - uid: baseUID, // Use base UID to get master event - }; - const fetchedMasterEvent = await getEvent(masterEventToFetch, true); - setMasterEvent(fetchedMasterEvent); + uid: baseUID // Use base UID to get master event + } + const fetchedMasterEvent = await getEvent(masterEventToFetch, true) + setMasterEvent(fetchedMasterEvent) } catch (err) { - console.error("Failed to fetch master event:", err); + console.error('Failed to fetch master event:', err) // Fallback to using the clicked instance - setMasterEvent(event); + setMasterEvent(event) } finally { - setIsLoadingMasterEvent(false); + setIsLoadingMasterEvent(false) } - }; + } - fetchMasterEvent(); - }, [event, open, typeOfAction]); + fetchMasterEvent() + }, [event, open, typeOfAction]) // Initialize form state when event data is available useEffect(() => { // Skip if restoring from error - data already restored if (isRestoringFromErrorRef.current) { - return; + return } // Skip if still loading master event if (isLoadingMasterEvent) { - return; + return } if (event && open) { // Use master event for "all" action, otherwise use the clicked event const eventToDisplay = - typeOfAction === "all" && masterEvent ? masterEvent : event; + typeOfAction === 'all' && masterEvent ? masterEvent : event // Reset validation errors when modal opens - setShowValidationErrors(false); + setShowValidationErrors(false) // Editing existing event - populate fields with event data - setTitle(eventToDisplay.title ?? ""); - setDescription(eventToDisplay.description ?? ""); - setLocation(eventToDisplay.location ?? ""); + setTitle(eventToDisplay.title ?? '') + setDescription(eventToDisplay.description ?? '') + setLocation(eventToDisplay.location ?? '') // Handle all-day events properly - const isAllDay = eventToDisplay.allday ?? false; - setAllDay(isAllDay); + const isAllDay = eventToDisplay.allday ?? false + setAllDay(isAllDay) // Get event's original timezone const eventTimezone = eventToDisplay.timezone ? resolveTimezone(eventToDisplay.timezone) - : resolveTimezone(browserDefaultTimeZone); + : resolveTimezone(browserDefaultTimeZone) // Format dates based on all-day status if (eventToDisplay.start) { if (isAllDay) { // For all-day events, use date format (YYYY-MM-DD) - const startDate = new Date(eventToDisplay.start); - setStart(startDate.toISOString().split("T")[0]); + const startDate = new Date(eventToDisplay.start) + setStart(startDate.toISOString().split('T')[0]) } else { // For timed events, format in the event's original timezone setStart( formatDateTimeInTimezone(eventToDisplay.start, eventTimezone) - ); + ) } } else { - setStart(""); + setStart('') } if (eventToDisplay.end) { if (isAllDay) { // For all-day events, use date format (YYYY-MM-DD) - const endDate = new Date(eventToDisplay.end); - endDate.setDate(endDate.getDate() - 1); - setEnd(endDate.toISOString().split("T")[0]); + const endDate = new Date(eventToDisplay.end) + endDate.setDate(endDate.getDate() - 1) + setEnd(endDate.toISOString().split('T')[0]) } else { // For timed events, format in the event's original timezone - setEnd(formatDateTimeInTimezone(eventToDisplay.end, eventTimezone)); + setEnd(formatDateTimeInTimezone(eventToDisplay.end, eventTimezone)) } } else { - setEnd(""); + setEnd('') } // Find correct calendar index - setCalendarid(calId); + setCalendarid(calId) // Handle repetition properly - check both current event and base event - const baseEventId = extractEventBaseUuid(eventToDisplay.uid); - const baseEvent = calList[calId]?.events[baseEventId]; + const baseEventId = extractEventBaseUuid(eventToDisplay.uid) + const baseEvent = calList[calId]?.events[baseEventId] const repetitionSource = - eventToDisplay.repetition || baseEvent?.repetition; + eventToDisplay.repetition || baseEvent?.repetition if (repetitionSource && repetitionSource.freq) { const repetitionData: RepetitionObject = { @@ -297,13 +296,13 @@ function EventUpdateModal({ interval: repetitionSource.interval || 1, occurrences: repetitionSource.occurrences, endDate: repetitionSource.endDate, - byday: repetitionSource.byday || null, - }; - setRepetition(repetitionData); - setShowRepeat(true); + byday: repetitionSource.byday || null + } + setRepetition(repetitionData) + setShowRepeat(true) } else { - setRepetition({} as RepetitionObject); - setShowRepeat(false); + setRepetition({} as RepetitionObject) + setShowRepeat(false) } setAttendees( @@ -311,53 +310,53 @@ function EventUpdateModal({ ? eventToDisplay.attendee.filter( (a: userAttendee) => a.cal_address !== eventToDisplay.organizer?.cal_address && - a.cutype !== "RESOURCE" + a.cutype !== 'RESOURCE' ) : [] - ); - setAlarm(eventToDisplay.alarm?.trigger ?? ""); - setEventClass(eventToDisplay.class ?? "PUBLIC"); - setBusy(eventToDisplay.transp ?? "OPAQUE"); + ) + setAlarm(eventToDisplay.alarm?.trigger ?? '') + setEventClass(eventToDisplay.class ?? 'PUBLIC') + setBusy(eventToDisplay.transp ?? 'OPAQUE') if (eventToDisplay.timezone) { - const resolvedTimezone = resolveTimezone(eventToDisplay.timezone); - setTimezone(resolvedTimezone); + const resolvedTimezone = resolveTimezone(eventToDisplay.timezone) + setTimezone(resolvedTimezone) } else { - const browserTz = resolveTimezone(browserDefaultTimeZone); - setTimezone(browserTz); + const browserTz = resolveTimezone(browserDefaultTimeZone) + setTimezone(browserTz) } setHasVideoConference( eventToDisplay.x_openpass_videoconference ? true : false - ); - setMeetingLink(eventToDisplay.x_openpass_videoconference || null); - setNewCalId(eventToDisplay.calId || calId); + ) + setMeetingLink(eventToDisplay.x_openpass_videoconference || null) + setNewCalId(eventToDisplay.calId || calId) // Update description to include video conference footer if exists if ( eventToDisplay.x_openpass_videoconference && eventToDisplay.description ) { - const hasVideoFooter = eventToDisplay.description.includes("Visio:"); + const hasVideoFooter = eventToDisplay.description.includes('Visio:') if (!hasVideoFooter) { setDescription( addVideoConferenceToDescription( eventToDisplay.description, eventToDisplay.x_openpass_videoconference ) - ); + ) } else { - setDescription(eventToDisplay.description); + setDescription(eventToDisplay.description) } } setSelectedResources( (eventToDisplay.attendee ?? []) - .filter((a: userAttendee) => a.cutype === "RESOURCE") - .map((a) => ({ + .filter((a: userAttendee) => a.cutype === 'RESOURCE') + .map(a => ({ email: a.cal_address, - displayName: a.cn, + displayName: a.cn })) - ); + ) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -368,26 +367,26 @@ function EventUpdateModal({ calList, masterEvent, isLoadingMasterEvent, - resources, - ]); + resources + ]) // Helper to close modal(s) - use onCloseAll if available to close preview modal too const closeModal = () => { if (onCloseAll) { - onCloseAll(); + onCloseAll() } else { - onClose({}, "backdropClick"); + onClose({}, 'backdropClick') } - }; + } const handleClose = () => { // Clear temp data when user manually closes modal - clearEventFormTempData("update"); - closeModal(); - setShowValidationErrors(false); - resetAllStateToDefault(); - initializedKeyRef.current = null; - }; + clearEventFormTempData('update') + closeModal() + setShowValidationErrors(false) + resetAllStateToDefault() + initializedKeyRef.current = null + } // Function to save current form data to temp storage const saveCurrentFormData = useCallback(() => { @@ -411,14 +410,14 @@ function EventUpdateModal({ showDescription, showRepeat, hasEndDateChanged, - resources: selectedResources, - }; + resources: selectedResources + } const context: EventFormContext = { eventId, calId, - typeOfAction, - }; - return buildEventFormTempData(formState, context); + typeOfAction + } + return buildEventFormTempData(formState, context) }, [ title, description, @@ -442,13 +441,13 @@ function EventUpdateModal({ eventId, calId, typeOfAction, - selectedResources, - ]); + selectedResources + ]) // Check for temp data when modal opens useEffect(() => { if (open && event) { - const tempData = restoreEventFormDataFromStorage("update"); + const tempData = restoreEventFormDataFromStorage('update') if ( tempData && tempData.fromError && @@ -457,7 +456,7 @@ function EventUpdateModal({ tempData.typeOfAction === typeOfAction ) { // Mark that we're restoring from error to prevent other useEffects from overriding - isRestoringFromErrorRef.current = true; + isRestoringFromErrorRef.current = true // Restore form data from previous failed save restoreFormDataFromTemp(tempData, { @@ -480,142 +479,142 @@ function EventUpdateModal({ setShowDescription, setShowRepeat, setHasEndDateChanged, - setSelectedResources, - }); + setSelectedResources + }) // Clear the error flag but keep data until successful save - const updatedTempData = { ...tempData, fromError: false }; - saveEventFormDataToTemp("update", updatedTempData); + const updatedTempData = { ...tempData, fromError: false } + saveEventFormDataToTemp('update', updatedTempData) // Reset flag after restore completes (use setTimeout to ensure other useEffects have checked the flag) setTimeout(() => { - isRestoringFromErrorRef.current = false; - }, 0); + isRestoringFromErrorRef.current = false + }, 0) } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, eventId, calId, typeOfAction]); + }, [open, eventId, calId, typeOfAction]) const handleSave = async () => { // Show validation errors when Save is clicked - setShowValidationErrors(true); + setShowValidationErrors(true) // Check if form is valid before saving if (!isFormValid) { - return; + return } if (!event) { - return; + return } - const organizer = event.organizer; + const organizer = event.organizer - const targetCalendar = calList[calendarid]; + const targetCalendar = calList[calendarid] if (!targetCalendar) { - console.error("Target calendar not found"); - return; + console.error('Target calendar not found') + return } // Reset validation state when validation passes - setShowValidationErrors(false); + setShowValidationErrors(false) // Handle recurrence instances - const [baseUID, recurrenceId] = event.uid.split("/"); + const [baseUID, recurrenceId] = event.uid.split('/') // Check if this is a recurring event - const isRecurringEvent = !!event.repetition?.freq; + const isRecurringEvent = !!event.repetition?.freq const getSeriesInstances = (): Record => { - const instances: Record = {}; - const seriesEvents = targetCalendar.events || {}; + const instances: Record = {} + const seriesEvents = targetCalendar.events || {} - Object.keys(seriesEvents).forEach((eventId) => { - const instance = seriesEvents[eventId]; + Object.keys(seriesEvents).forEach(eventId => { + const instance = seriesEvents[eventId] if (instance && extractEventBaseUuid(eventId) === baseUID) { - instances[eventId] = { ...instance }; + instances[eventId] = { ...instance } } - }); + }) - return instances; - }; + return instances + } // When editing "all events" of a recurring series, use the master event we already fetched - let masterEventData: CalendarEvent | null = null; - if (isRecurringEvent && typeOfAction === "all") { + let masterEventData: CalendarEvent | null = null + if (isRecurringEvent && typeOfAction === 'all') { // We already have the master event from state - masterEventData = masterEvent; + masterEventData = masterEvent if (!masterEventData) { // This shouldn't happen, but handle it gracefully - const formDataToSave = saveCurrentFormData(); + const formDataToSave = saveCurrentFormData() const errorFormData = { ...formDataToSave, - fromError: true, - }; - saveEventFormDataToTemp("update", errorFormData); + fromError: true + } + saveEventFormDataToTemp('update', errorFormData) showErrorNotification( - "Failed to load master event data. Please try again." - ); + 'Failed to load master event data. Please try again.' + ) // Dispatch eventModalError to reopen modal window.dispatchEvent( - new CustomEvent("eventModalError", { - detail: { type: "update", eventId, calId, typeOfAction }, + new CustomEvent('eventModalError', { + detail: { type: 'update', eventId, calId, typeOfAction } }) - ); - return; + ) + return } } // Handle start and end dates based on all-day status - let startDate: string; - let endDate: string; + let startDate: string + let endDate: string // For single events or "solo" edits, use the edited dates from form if (allday) { // For all-day events, use date format (YYYY-MM-DD) // Extract date string directly to avoid timezone conversion issues - const startDateOnly = (start || "").split("T")[0]; - const endDateOnlyUI = (end || start || "").split("T")[0]; + const startDateOnly = (start || '').split('T')[0] + const endDateOnlyUI = (end || start || '').split('T')[0] // API needs end date = UI end date + 1 day - const endDateOnlyAPI = addDays(endDateOnlyUI, 1); + const endDateOnlyAPI = addDays(endDateOnlyUI, 1) // Parse date string and create Date at UTC midnight to avoid timezone offset issues const [startYear, startMonth, startDay] = startDateOnly - .split("-") - .map(Number); - const [endYear, endMonth, endDay] = endDateOnlyAPI.split("-").map(Number); + .split('-') + .map(Number) + const [endYear, endMonth, endDay] = endDateOnlyAPI.split('-').map(Number) const startDateObj = new Date( Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0) - ); + ) const endDateObj = new Date( Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0) - ); - startDate = startDateObj.toISOString(); - endDate = endDateObj.toISOString(); + ) + startDate = startDateObj.toISOString() + endDate = endDateObj.toISOString() } else { // For timed events - startDate = convertFormDateTimeToISO(start, timezone); + startDate = convertFormDateTimeToISO(start, timezone) // In normal mode, only override end date when the end date field is not shown and end date is same as start date - const startDateOnly = (start || "").split("T")[0]; - const endDateOnly = (end || "").split("T")[0]; + const startDateOnly = (start || '').split('T')[0] + const endDateOnly = (end || '').split('T')[0] if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) { - const endTimeOnly = end.includes("T") - ? end.split("T")[1]?.slice(0, 5) || "00:00" - : "00:00"; - const endDateTime = `${startDateOnly}T${endTimeOnly}`; - endDate = convertFormDateTimeToISO(endDateTime, timezone); + const endTimeOnly = end.includes('T') + ? end.split('T')[1]?.slice(0, 5) || '00:00' + : '00:00' + const endDateTime = `${startDateOnly}T${endTimeOnly}` + endDate = convertFormDateTimeToISO(endDateTime, timezone) } else { // Extended mode or end date explicitly shown in normal mode or end date differs from start date: use actual end datetime - endDate = convertFormDateTimeToISO(end, timezone); + endDate = convertFormDateTimeToISO(end, timezone) } } const eventStartChanged = - new Date(event.start).getTime() !== new Date(startDate).getTime(); + new Date(event.start).getTime() !== new Date(startDate).getTime() const eventEndChanged = - new Date(event?.end ?? "").getTime() !== new Date(endDate).getTime(); - const timeChanged = eventStartChanged || eventEndChanged; + new Date(event?.end ?? '').getTime() !== new Date(endDate).getTime() + const timeChanged = eventStartChanged || eventEndChanged const newEvent: CalendarEvent = { ...updateAttendeesAfterTimeChange(event, timeChanged, attendees), @@ -635,69 +634,69 @@ function EventUpdateModal({ transp: busy, sequence: (event.sequence ?? 1) + 1, color: targetCalendar?.color, - alarm: { trigger: alarm, action: "EMAIL" }, - x_openpass_videoconference: meetingLink || undefined, - }; + alarm: { trigger: alarm, action: 'EMAIL' }, + x_openpass_videoconference: meetingLink || undefined + } // Map data of resources to attendee before creating event if (selectedResources?.length) { if (!newEvent.attendee) { - newEvent.attendee = []; + newEvent.attendee = [] } selectedResources.forEach((resource: Resource) => { newEvent.attendee.push({ - cn: resource?.displayName ?? "", - cal_address: resource?.email ?? "", + cn: resource?.displayName ?? '', + cal_address: resource?.email ?? '', partstat: event.attendee?.find( - (a) => a.cutype === "RESOURCE" && a.cn === resource.displayName - )?.partstat || "NEEDS-ACTION", - rsvp: "TRUE", - role: "REQ-PARTICIPANT", - cutype: "RESOURCE", - }); - }); + a => a.cutype === 'RESOURCE' && a.cn === resource.displayName + )?.partstat || 'NEEDS-ACTION', + rsvp: 'TRUE', + role: 'REQ-PARTICIPANT', + cutype: 'RESOURCE' + }) + }) } // Special case: When converting recurring event to non-recurring if ( recurrenceId && - typeOfAction === "all" && + typeOfAction === 'all' && event.repetition?.freq && !repetition.freq ) { - const baseUID = extractEventBaseUuid(event.uid); + const baseUID = extractEventBaseUuid(event.uid) // Save current form data to temp storage before closing - const formDataToSave = saveCurrentFormData(); - saveEventFormDataToTemp("update", formDataToSave); + const formDataToSave = saveCurrentFormData() + saveEventFormDataToTemp('update', formDataToSave) // Close modal immediately for better UX - closeModal(); + closeModal() - const seriesInstancesSnapshot = getSeriesInstances(); - let hasRemovedSeriesInstances = false; - let createdSingleEventUid: string | null = null; + const seriesInstancesSnapshot = getSeriesInstances() + let hasRemovedSeriesInstances = false + let createdSingleEventUid: string | null = null const removeSeriesInstancesFromUI = () => { - if (!seriesInstancesSnapshot) return; - Object.keys(seriesInstancesSnapshot).forEach((eventId) => { - dispatch(removeEvent({ calendarUid: calId, eventUid: eventId })); - }); - hasRemovedSeriesInstances = true; - }; + if (!seriesInstancesSnapshot) return + Object.keys(seriesInstancesSnapshot).forEach(eventId => { + dispatch(removeEvent({ calendarUid: calId, eventUid: eventId })) + }) + hasRemovedSeriesInstances = true + } const restoreSeriesInstancesToUI = () => { - if (!seriesInstancesSnapshot) return; - Object.values(seriesInstancesSnapshot).forEach((instance) => { + if (!seriesInstancesSnapshot) return + Object.values(seriesInstancesSnapshot).forEach(instance => { dispatch( updateEventLocal({ calId, - event: instance, + event: instance }) - ); - }); - }; + ) + }) + } try { // STEP 1: Delete ALL instances of recurring event @@ -705,227 +704,225 @@ function EventUpdateModal({ // Collect all instances that need to be deleted const instancesToDelete = Object.keys(targetCalendar.events) - .filter((eventId) => extractEventBaseUuid(eventId) === baseUID) - .map((eventId) => targetCalendar.events[eventId]); + .filter(eventId => extractEventBaseUuid(eventId) === baseUID) + .map(eventId => targetCalendar.events[eventId]) // Get unique URLs to avoid deleting same file multiple times - const uniqueURLs = new Set(); - const instancesByURL = new Map(); + const uniqueURLs = new Set() + const instancesByURL = new Map() - instancesToDelete.forEach((instance) => { + instancesToDelete.forEach(instance => { if (!instancesByURL.has(instance.URL)) { - instancesByURL.set(instance.URL, []); + instancesByURL.set(instance.URL, []) } - instancesByURL.get(instance.URL)!.push(instance); - uniqueURLs.add(instance.URL); - }); + instancesByURL.get(instance.URL)?.push?.(instance) + uniqueURLs.add(instance.URL) + }) // Delete each unique URL once - const deletePromises = Array.from(uniqueURLs).map(async (url) => { + const deletePromises = Array.from(uniqueURLs).map(async url => { try { - await deleteEvent(url); + await deleteEvent(url) } catch (deleteError) { // Check if error is an object with response or message properties const errorObj = deleteError as { - response?: { status?: number }; - message?: string; - }; + response?: { status?: number } + message?: string + } // Silently ignore 404 - file might already be deleted const is404 = errorObj.response?.status === 404 || - errorObj.message?.includes("404") || - errorObj.message?.includes("Not Found"); + errorObj.message?.includes('404') || + errorObj.message?.includes('Not Found') if (!is404) { console.error( - `Failed to delete event file: ${errorObj.message || "Unknown error"}` - ); + `Failed to delete event file: ${errorObj.message || 'Unknown error'}` + ) } } - }); + }) - await Promise.all(deletePromises); + await Promise.all(deletePromises) // Small delay to ensure backend processes deletions - await new Promise((resolve) => setTimeout(resolve, 100)); + await new Promise(resolve => setTimeout(resolve, 100)) // STEP 2: Create new non-recurring event - const newEventUID = crypto.randomUUID(); + const newEventUID = crypto.randomUUID() const finalNewEvent = { ...newEvent, uid: newEventUID, URL: `/calendars/${newCalId || calId}/${newEventUID}.ics`, sequence: 1, // New event with new UID starts at sequence 1 - recurrenceId: undefined, - }; + recurrenceId: undefined + } // STEP 3: Persist new event to server - await putEvent(finalNewEvent, targetCalendar.owner?.emails?.[0]); + await putEvent(finalNewEvent, targetCalendar.owner?.emails?.[0]) // STEP 4: Update Redux store - Add new event first to prevent empty grid - dispatch(updateEventLocal({ calId, event: finalNewEvent })); - createdSingleEventUid = finalNewEvent.uid; + dispatch(updateEventLocal({ calId, event: finalNewEvent })) + createdSingleEventUid = finalNewEvent.uid // Clear cache to ensure navigation to other weeks works - dispatch(clearFetchCache(calId)); + dispatch(clearFetchCache(calId)) // STEP 5: Remove old recurring instances only after the rest succeeds - removeSeriesInstancesFromUI(); + removeSeriesInstancesFromUI() // Clear temp data on successful save - clearEventFormTempData("update"); + clearEventFormTempData('update') // Reset all state to default values only on successful save - resetAllStateToDefault(); - initializedKeyRef.current = null; + resetAllStateToDefault() + initializedKeyRef.current = null } catch (err) { // Check if error is an object with a message property - const errorObj = err as { message?: string }; + const errorObj = err as { message?: string } // API failed - restore form data and mark as error const errorFormData = { ...formDataToSave, - fromError: true, - }; - saveEventFormDataToTemp("update", errorFormData); + fromError: true + } + saveEventFormDataToTemp('update', errorFormData) // Show error notification showErrorNotification( errorObj.message || - "Failed to convert recurring event. Please try again." - ); + 'Failed to convert recurring event. Please try again.' + ) if (createdSingleEventUid) { dispatch( removeEvent({ calendarUid: calId, eventUid: createdSingleEventUid }) - ); + ) } if (hasRemovedSeriesInstances) { - restoreSeriesInstancesToUI(); + restoreSeriesInstancesToUI() } // Try to reopen modal by dispatching custom event window.dispatchEvent( - new CustomEvent("eventModalError", { - detail: { type: "update", eventId, calId, typeOfAction }, + new CustomEvent('eventModalError', { + detail: { type: 'update', eventId, calId, typeOfAction } }) - ); + ) } - return; + return } // Save current form data to temp storage before closing - const formDataToSave = saveCurrentFormData(); - saveEventFormDataToTemp("update", formDataToSave); + const formDataToSave = saveCurrentFormData() + saveEventFormDataToTemp('update', formDataToSave) // Close popup immediately for better UX (for non-special cases) - closeModal(); + closeModal() // Execute API calls in background based on typeOfAction try { if (recurrenceId) { - if (typeOfAction === "solo") { + if (typeOfAction === 'solo') { // Update single instance with optimistic update + rollback dispatch( updateEventLocal({ calId, - event: { ...newEvent, recurrenceId }, + event: { ...newEvent, recurrenceId } }) - ); + ) const result = await dispatch( updateEventInstanceAsync({ cal: targetCalendar, - event: { ...newEvent, recurrenceId }, + event: { ...newEvent, recurrenceId } }) - ); + ) // Handle result of updateEventInstanceAsync - await assertThunkSuccess(result); + await assertThunkSuccess(result) // Clear temp data on successful save - clearEventFormTempData("update"); - } else if (typeOfAction === "all") { + clearEventFormTempData('update') + } else if (typeOfAction === 'all') { // Update all instances - check if repetition rules changed - const baseUID = extractEventBaseUuid(event.uid); + const baseUID = extractEventBaseUuid(event.uid) const changes = detectRecurringEventChanges( event, { repetition, timezone, allday, start, end }, masterEventData, resolveTimezone - ); - const repetitionRulesChanged = changes.repetitionRulesChanged; + ) + const repetitionRulesChanged = changes.repetitionRulesChanged if (repetitionRulesChanged) { // Date/time or repetition rules changed - remove all overrides - const seriesInstancesSnapshot = getSeriesInstances(); + const seriesInstancesSnapshot = getSeriesInstances() const removeSeriesInstancesFromUI = () => { - Object.keys(seriesInstancesSnapshot).forEach((eventId) => { - dispatch( - removeEvent({ calendarUid: calId, eventUid: eventId }) - ); - }); - }; + Object.keys(seriesInstancesSnapshot).forEach(eventId => { + dispatch(removeEvent({ calendarUid: calId, eventUid: eventId })) + }) + } const restoreSeriesInstancesFromSnapshot = () => { - Object.values(seriesInstancesSnapshot).forEach((instance) => { + Object.values(seriesInstancesSnapshot).forEach(instance => { dispatch( updateEventLocal({ calId, - event: instance, + event: instance }) - ); - }); - }; + ) + }) + } try { // STEP 1: Remove ALL old instances from UI (including solo overrides) - removeSeriesInstancesFromUI(); + removeSeriesInstancesFromUI() // STEP 2: Update series on server with removeOverrides=true // IMPORTANT: Use base event UID (master), not instance UID with recurrence-id const masterEventForUpdate = { ...newEvent, uid: baseUID, // Use base UID for updating the master - recurrenceId: undefined, // Don't send recurrence-id for master update - }; + recurrenceId: undefined // Don't send recurrence-id for master update + } const result = await dispatch( updateSeriesAsync({ cal: targetCalendar, event: masterEventForUpdate, - removeOverrides: true, + removeOverrides: true }) - ); + ) // Handle result of updateSeriesAsync - await assertThunkSuccess(result); + await assertThunkSuccess(result) // Clear cache after successful update - dispatch(clearFetchCache(calId)); + dispatch(clearFetchCache(calId)) // Clear temp data on successful save - clearEventFormTempData("update"); + clearEventFormTempData('update') } catch (seriesError) { // Restore instances on error - restoreSeriesInstancesFromSnapshot(); - throw seriesError; + restoreSeriesInstancesFromSnapshot() + throw seriesError } } else { // Only properties changed - use optimistic update and keep overrides // Store old instances for rollback - const oldInstances = getSeriesInstances(); + const oldInstances = getSeriesInstances() // Optimistic update: Apply new properties to all instances immediately - Object.keys(oldInstances).forEach((eventId) => { - const instance = oldInstances[eventId]; + Object.keys(oldInstances).forEach(eventId => { + const instance = oldInstances[eventId] dispatch( updateEventLocal({ @@ -940,36 +937,36 @@ function EventUpdateModal({ attendee: newEvent.attendee, alarm: newEvent.alarm, x_openpass_videoconference: - newEvent.x_openpass_videoconference, - }, + newEvent.x_openpass_videoconference + } }) - ); - }); + ) + }) // Update server in background with removeOverrides=false // IMPORTANT: Use base event UID (master), not instance UID with recurrence-id const masterEventForUpdate = { ...newEvent, uid: baseUID, // Use base UID for updating the master - recurrenceId: undefined, // Don't send recurrence-id for master update - }; + recurrenceId: undefined // Don't send recurrence-id for master update + } const result = await dispatch( updateSeriesAsync({ cal: targetCalendar, event: masterEventForUpdate, - removeOverrides: false, + removeOverrides: false }) - ); + ) // Handle result of updateSeriesAsync - await assertThunkSuccess(result); + await assertThunkSuccess(result) // Clear cache to ensure navigation shows updated data - dispatch(clearFetchCache(calId)); + dispatch(clearFetchCache(calId)) // Clear temp data on successful save - clearEventFormTempData("update"); + clearEventFormTempData('update') } } } else { @@ -977,35 +974,35 @@ function EventUpdateModal({ // Special case: Converting no-repeat to repeat if (!event.repetition?.freq && repetition?.freq) { - const oldEventUID = event.uid; + const oldEventUID = event.uid // API call: putEventAsync will create recurring event and fetch all instances const result = await dispatch( putEventAsync({ cal: targetCalendar, newEvent }) - ); + ) // Handle result of putEventAsync - check if rejected first - await assertThunkSuccess(result); + await assertThunkSuccess(result) // Remove old single event AFTER new recurring instances are added to store // This prevents empty grid during the transition - dispatch(removeEvent({ calendarUid: calId, eventUid: oldEventUID })); + dispatch(removeEvent({ calendarUid: calId, eventUid: oldEventUID })) // Clear cache to ensure navigation to other weeks works - dispatch(clearFetchCache(calId)); + dispatch(clearFetchCache(calId)) // Clear temp data on successful save - clearEventFormTempData("update"); + clearEventFormTempData('update') } else if (newCalId === calId) { // Normal non-recurring event update (same calendar) const result = await dispatch( putEventAsync({ cal: targetCalendar, newEvent }) - ); + ) - await assertThunkSuccess(result); + await assertThunkSuccess(result) // Clear temp data on successful save - clearEventFormTempData("update"); + clearEventFormTempData('update') } // Note: when newCalId !== calId, the move is handled below in the // "Handle calendar change" block, so we intentionally skip putEventAsync here. @@ -1018,69 +1015,69 @@ function EventUpdateModal({ calList, newEvent, oldCalId: calId, - newCalId, - }); + newCalId + }) // Clear temp data on successful move - clearEventFormTempData("update"); + clearEventFormTempData('update') } // Reset all state to default values only on successful save (after all branches) - clearEventFormTempData("update"); - resetAllStateToDefault(); - initializedKeyRef.current = null; + clearEventFormTempData('update') + resetAllStateToDefault() + initializedKeyRef.current = null } catch (error) { // Check if error is an object with a message property - const errorObj = error as { message?: string }; + const errorObj = error as { message?: string } // Handle errors for all branches // Rollback optimistic updates if any // API failed - restore form data and mark as error const errorFormData = { ...formDataToSave, - fromError: true, - }; - saveEventFormDataToTemp("update", errorFormData); + fromError: true + } + saveEventFormDataToTemp('update', errorFormData) // Show error notification showErrorNotification( - errorObj.message || "Failed to update event. Please try again." - ); + errorObj.message || 'Failed to update event. Please try again.' + ) // Try to reopen modal window.dispatchEvent( - new CustomEvent("eventModalError", { - detail: { type: "update", eventId, calId, typeOfAction }, + new CustomEvent('eventModalError', { + detail: { type: 'update', eventId, calId, typeOfAction } }) - ); + ) } - }; + } const dialogActions = ( {!showMore && ( )} - + - ); + ) - if (!event) return null; + if (!event) return null return ( setShowMore(!showMore)} actions={dialogActions} @@ -1121,15 +1118,15 @@ function EventUpdateModal({ showMore={showMore} showDescription={showDescription} setShowDescription={setShowDescription} - showRepeat={typeOfAction !== "solo" && showRepeat} + showRepeat={typeOfAction !== 'solo' && showRepeat} setShowRepeat={setShowRepeat} isOpen={open} userPersonalCalendars={userPersonalCalendars} timezoneList={timezoneList} - onCalendarChange={(newCalendarId) => { - const selectedCalendar = calList[newCalendarId]; + onCalendarChange={newCalendarId => { + const selectedCalendar = calList[newCalendarId] if (selectedCalendar) { - setNewCalId(selectedCalendar.id); + setNewCalId(selectedCalendar.id) } }} onValidationChange={setIsFormValid} @@ -1139,7 +1136,7 @@ function EventUpdateModal({ setSelectedResources={setSelectedResources} /> - ); + ) } -export default EventUpdateModal; +export default EventUpdateModal diff --git a/src/features/Events/EventsTypes.ts b/src/features/Events/EventsTypes.ts index 9fd7283..e0ed82c 100644 --- a/src/features/Events/EventsTypes.ts +++ b/src/features/Events/EventsTypes.ts @@ -1,56 +1,56 @@ -import { Resource } from "@/components/Attendees/ResourceSearch"; -import { Calendar } from "../Calendars/CalendarTypes"; -import { VObjectProperty } from "../Calendars/types/CalendarData"; -import { userAttendee } from "../User/models/attendee"; -import { userOrganiser } from "../User/userDataTypes"; +import { Resource } from '@/components/Attendees/ResourceSearch' +import { Calendar } from '../Calendars/CalendarTypes' +import { VObjectProperty } from '../Calendars/types/CalendarData' +import { userAttendee } from '../User/models/attendee' +import { userOrganiser } from '../User/userDataTypes' export interface CalendarEvent { - URL: string; - calId: string; - uid: string; - recurrenceId?: string; - transp?: string; - start: string; // ISO date string - end?: string; - class?: "PRIVATE" | "PUBLIC" | "CONFIDENTIAL"; - x_openpass_videoconference?: string; - title?: string; - description?: string; - location?: string; - organizer?: userOrganiser; - attendee: userAttendee[]; - stamp?: string; - sequence?: number; - color?: Record; - allday?: boolean; - error?: string; - status?: string; - timezone: string; - repetition?: RepetitionObject; - alarm?: AlarmObject; - exdates?: string[]; - passthroughProps?: VObjectProperty[]; - selectedResources?: Resource[]; + URL: string + calId: string + uid: string + recurrenceId?: string + transp?: string + start: string // ISO date string + end?: string + class?: 'PRIVATE' | 'PUBLIC' | 'CONFIDENTIAL' + x_openpass_videoconference?: string + title?: string + description?: string + location?: string + organizer?: userOrganiser + attendee: userAttendee[] + stamp?: string + sequence?: number + color?: Record + allday?: boolean + error?: string + status?: string + timezone: string + repetition?: RepetitionObject + alarm?: AlarmObject + exdates?: string[] + passthroughProps?: VObjectProperty[] + selectedResources?: Resource[] } export interface RepetitionObject { - freq: string; - interval?: number | null; - byday?: string[] | null; - occurrences?: number | null; - endDate?: string | null; + freq: string + interval?: number | null + byday?: string[] | null + occurrences?: number | null + endDate?: string | null } export interface AlarmObject { - trigger: string; - action: string; + trigger: string + action: string } export interface ContextualizedEvent { - event: CalendarEvent; - calendar: Calendar; - currentUserAttendee: userAttendee | undefined; - isOwn: boolean; - isRecurring: boolean; - isOrganizer: boolean; + event: CalendarEvent + calendar: Calendar + currentUserAttendee: userAttendee | undefined + isOwn: boolean + isRecurring: boolean + isOrganizer: boolean } diff --git a/src/features/Events/ImportAlert.tsx b/src/features/Events/ImportAlert.tsx index d3e9fe5..1d5652d 100644 --- a/src/features/Events/ImportAlert.tsx +++ b/src/features/Events/ImportAlert.tsx @@ -1,46 +1,44 @@ -import { useAppSelector } from "@/app/hooks"; -import { EventErrorSnackbar } from "@/components/Error/ErrorSnackbar"; -import { useMemo, useState } from "react"; +import { useAppSelector } from '@/app/hooks' +import { EventErrorSnackbar } from '@/components/Error/ErrorSnackbar' +import { useMemo, useState } from 'react' export default function ImportAlert() { - const calendars = useAppSelector((state) => state.calendars.list); - const [dismissedErrors, setDismissedErrors] = useState>( - new Set() - ); + const calendars = useAppSelector(state => state.calendars.list) + const [dismissedErrors, setDismissedErrors] = useState>(new Set()) // Collect all errors from all events in all calendars const errors = useMemo(() => { - const errorList: { id: string; message: string }[] = []; - Object.values(calendars || {}).forEach((calendar) => { + const errorList: { id: string; message: string }[] = [] + Object.values(calendars || {}).forEach(calendar => { if (calendar.events) { - Object.values(calendar.events).forEach((event) => { + Object.values(calendar.events).forEach(event => { if (event.error) { errorList.push({ id: event.uid, - message: event.error, - }); + message: event.error + }) } - }); + }) } - }); - return errorList; - }, [calendars]); + }) + return errorList + }, [calendars]) // Filter out dismissed errors const activeErrors = useMemo(() => { - return errors.filter((e) => !dismissedErrors.has(e.id)); - }, [errors, dismissedErrors]); + return errors.filter(e => !dismissedErrors.has(e.id)) + }, [errors, dismissedErrors]) - const messages = activeErrors.map((e) => e.message); + const messages = activeErrors.map(e => e.message) const handleClose = () => { // Mark all currently active errors as dismissed - setDismissedErrors((prev) => { - const next = new Set(prev); - activeErrors.forEach((e) => next.add(e.id)); - return next; - }); - }; + setDismissedErrors(prev => { + const next = new Set(prev) + activeErrors.forEach(e => next.add(e.id)) + return next + }) + } // If new errors appear that were previously dismissed (e.g. re-fetch), // we might want to un-dismiss them, but for now simple dismissal is enough. @@ -49,8 +47,8 @@ export default function ImportAlert() { // But usually uid is stable. if (messages.length === 0) { - return null; + return null } - return ; + return } diff --git a/src/features/Events/api/getFreeBusyForAddedAttendeesREPORT.ts b/src/features/Events/api/getFreeBusyForAddedAttendeesREPORT.ts index 7c3ffd6..35209d5 100644 --- a/src/features/Events/api/getFreeBusyForAddedAttendeesREPORT.ts +++ b/src/features/Events/api/getFreeBusyForAddedAttendeesREPORT.ts @@ -1,8 +1,8 @@ -import { getCalendars } from "@/features/Calendars/CalendarApi"; +import { getCalendars } from '@/features/Calendars/CalendarApi' -import { api } from "@/utils/apiUtils"; -import { extractCalendarHrefs } from "@/utils/extractCalendarHrefs"; -import { hasFreeBusyConflict } from "../../../components/Attendees/useFreeBusy"; +import { api } from '@/utils/apiUtils' +import { extractCalendarHrefs } from '@/utils/extractCalendarHrefs' +import { hasFreeBusyConflict } from '../../../components/Attendees/useFreeBusy' export async function getFreeBusyForAddedAttendeesREPORT( userId: string, @@ -11,27 +11,27 @@ export async function getFreeBusyForAddedAttendeesREPORT( ): Promise { const calendars = await getCalendars( userId, - "withFreeBusy=true&withRights=true" - ); - const hrefs = extractCalendarHrefs(calendars); - if (hrefs.length === 0) return false; + 'withFreeBusy=true&withRights=true' + ) + const hrefs = extractCalendarHrefs(calendars) + if (hrefs.length === 0) return false const freeBusyBody = JSON.stringify({ - type: "free-busy-query", - match: { start, end }, - }); + type: 'free-busy-query', + match: { start, end } + }) const results = await Promise.all( - hrefs.map((href) => + hrefs.map(href => api(`dav${href}`, { - method: "REPORT", - headers: { Accept: "application/json, text/plain, */*" }, - body: freeBusyBody, + method: 'REPORT', + headers: { Accept: 'application/json, text/plain, */*' }, + body: freeBusyBody }) - .then((r) => (r.ok ? r.json() : null)) - .then((data) => (data ? hasFreeBusyConflict(data) : false)) + .then(r => (r.ok ? r.json() : null)) + .then(data => (data ? hasFreeBusyConflict(data) : false)) .catch(() => false) ) - ); - return results.some(Boolean); + ) + return results.some(Boolean) } diff --git a/src/features/Events/api/getFreeBusyForEventAttendeesPOST.ts b/src/features/Events/api/getFreeBusyForEventAttendeesPOST.ts index 15da5f6..dc7d2b2 100644 --- a/src/features/Events/api/getFreeBusyForEventAttendeesPOST.ts +++ b/src/features/Events/api/getFreeBusyForEventAttendeesPOST.ts @@ -1,20 +1,20 @@ -import { api } from "@/utils/apiUtils"; -import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; +import { api } from '@/utils/apiUtils' +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' interface BusySlot { - uid: string; - start: string; - end: string; + uid: string + start: string + end: string } interface CalendarFreeBusy { - id: string; - busy: BusySlot[]; + id: string + busy: BusySlot[] } interface UserFreeBusy { - id: string; - calendars: CalendarFreeBusy[]; + id: string + calendars: CalendarFreeBusy[] } export async function getFreeBusyForEventAttendeesPOST( @@ -23,26 +23,26 @@ export async function getFreeBusyForEventAttendeesPOST( end: string, eventUid: string ): Promise> { - const r = await api("dav/calendars/freebusy", { - method: "POST", - headers: { Accept: "application/json, text/plain, */*" }, - body: JSON.stringify({ start, end, users: userIds, uids: [eventUid] }), - }); - if (!r.ok) throw new Error(`HTTP ${r.status}`); + const r = await api('dav/calendars/freebusy', { + method: 'POST', + headers: { Accept: 'application/json, text/plain, */*' }, + body: JSON.stringify({ start, end, users: userIds, uids: [eventUid] }) + }) + if (!r.ok) throw new Error(`HTTP ${r.status}`) - const payload: unknown = await r.json(); + const payload: unknown = await r.json() const users = Array.isArray((payload as { users?: unknown })?.users) ? ((payload as { users: UserFreeBusy[] }).users ?? []) - : []; + : [] - const eventUidBase = extractEventBaseUuid(eventUid); + const eventUidBase = extractEventBaseUuid(eventUid) return Object.fromEntries( - users.map((u) => { - const isBusy = u.calendars.some((cal) => - cal.busy.some((slot) => extractEventBaseUuid(slot.uid) !== eventUidBase) - ); - return [u.id, isBusy]; + users.map(u => { + const isBusy = u.calendars.some(cal => + cal.busy.some(slot => extractEventBaseUuid(slot.uid) !== eventUidBase) + ) + return [u.id, isBusy] }) - ); + ) } diff --git a/src/features/Events/api/getUserDataFromEmail.ts b/src/features/Events/api/getUserDataFromEmail.ts index 47cc3f7..1d0f21c 100644 --- a/src/features/Events/api/getUserDataFromEmail.ts +++ b/src/features/Events/api/getUserDataFromEmail.ts @@ -1,11 +1,11 @@ -import { api } from "@/utils/apiUtils"; -import { isValidEmail } from "@/utils/isValidEmail"; +import { api } from '@/utils/apiUtils' +import { isValidEmail } from '@/utils/isValidEmail' export async function getUserDataFromEmail(email: string) { if (!isValidEmail(email)) { - return []; + return [] } - const r = await api(`api/users?email=${encodeURIComponent(email)}`); - const result: Array> = await r.json(); - return result; + const r = await api(`api/users?email=${encodeURIComponent(email)}`) + const result: Array> = await r.json() + return result } diff --git a/src/features/Events/api/sendCounterProposal.ts b/src/features/Events/api/sendCounterProposal.ts index 9f7c488..8b3f579 100644 --- a/src/features/Events/api/sendCounterProposal.ts +++ b/src/features/Events/api/sendCounterProposal.ts @@ -1,9 +1,9 @@ -import { api } from "@/utils/apiUtils"; -import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; -import { TIMEZONES } from "@/utils/timezone-data"; -import ICAL from "ical.js"; -import { CalendarEvent } from "../EventsTypes"; -import { makeTimezone, makeVevent } from "../utils"; +import { api } from '@/utils/apiUtils' +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' +import { TIMEZONES } from '@/utils/timezone-data' +import ICAL from 'ical.js' +import { CalendarEvent } from '../EventsTypes' +import { makeTimezone, makeVevent } from '../utils' export async function postCounterProposal({ event, @@ -11,22 +11,22 @@ export async function postCounterProposal({ recipientEmail, proposedStart, proposedEnd, - message, + message }: { - event: CalendarEvent; - senderEmail: string; - recipientEmail: string; - proposedStart: string; - proposedEnd: string; - message?: string; + event: CalendarEvent + senderEmail: string + recipientEmail: string + proposedStart: string + proposedEnd: string + message?: string }): Promise { // Build the counter event with proposed dates const counterEvent: CalendarEvent = { ...event, start: proposedStart, end: proposedEnd, - sequence: event.sequence ?? 0, - }; + sequence: event.sequence ?? 0 + } // Build vevent jCal const vevent = makeVevent( @@ -34,51 +34,51 @@ export async function postCounterProposal({ counterEvent.timezone, senderEmail, !event.recurrenceId - ); + ) if (message) { - vevent?.[1]?.push(["comment", {}, "text", message]); + vevent?.[1]?.push(['comment', {}, 'text', message]) } // Build vtimezone - const timezoneData = TIMEZONES.zones[counterEvent.timezone]; - const vtimezone = makeTimezone(timezoneData, counterEvent); + const timezoneData = TIMEZONES.zones[counterEvent.timezone] + const vtimezone = makeTimezone(timezoneData, counterEvent) // Assemble full vcalendar with METHOD:COUNTER const jcal = [ - "vcalendar", + 'vcalendar', [ - ["version", {}, "text", "2.0"], - ["prodid", {}, "text", "-//OpenPaaS//OpenPaaS//EN"], - ["method", {}, "text", "COUNTER"], + ['version', {}, 'text', '2.0'], + ['prodid', {}, 'text', '-//OpenPaaS//OpenPaaS//EN'], + ['method', {}, 'text', 'COUNTER'] ], - [vevent, vtimezone.component.jCal], - ]; + [vevent, vtimezone.component.jCal] + ] // Serialize to raw ICS - const counterICS = new ICAL.Component(jcal).toString(); + const counterICS = new ICAL.Component(jcal).toString() const postResponse = await api(`dav${event.URL}`, { - method: "POST", + method: 'POST', body: JSON.stringify({ ical: counterICS, sender: senderEmail, recipient: recipientEmail, uid: extractEventBaseUuid(event.uid), sequence: counterEvent.sequence, - method: "COUNTER", + method: 'COUNTER' }), headers: { - accept: "application/json,text/plain,*/*", - "content-type": "application/calendar+json", - Prefer: "return=representation", - "X-Http-Method-Override": "ITIP", - }, - }); + accept: 'application/json,text/plain,*/*', + 'content-type': 'application/calendar+json', + Prefer: 'return=representation', + 'X-Http-Method-Override': 'ITIP' + } + }) if (!postResponse.ok) { throw new Error( `postCounterProposal failed with status ${postResponse.status}` - ); + ) } - return postResponse; + return postResponse } diff --git a/src/features/Events/api/updateSeries.ts b/src/features/Events/api/updateSeries.ts index 3de5ae1..f7c8f4d 100644 --- a/src/features/Events/api/updateSeries.ts +++ b/src/features/Events/api/updateSeries.ts @@ -1,125 +1,125 @@ -import { api } from "@/utils/apiUtils"; -import { TIMEZONES } from "@/utils/timezone-data"; +import { api } from '@/utils/apiUtils' +import { TIMEZONES } from '@/utils/timezone-data' import { VCalComponent, - VObjectProperty, -} from "../../Calendars/types/CalendarData"; -import { getAllRecurrentEvent } from "../EventApi"; -import { CalendarEvent } from "../EventsTypes"; -import { makeTimezone, makeVevent } from "../utils"; + VObjectProperty +} from '../../Calendars/types/CalendarData' +import { getAllRecurrentEvent } from '../EventApi' +import { CalendarEvent } from '../EventsTypes' +import { makeTimezone, makeVevent } from '../utils' const METADATA_FIELDS = [ - "summary", - "description", - "location", - "class", - "transp", - "attendee", - "organizer", - "x-openpaas-videoconference", -] as const; + 'summary', + 'description', + 'location', + 'class', + 'transp', + 'attendee', + 'organizer', + 'x-openpaas-videoconference' +] as const // Helper function to get field values from props const getFieldValues = (props: VObjectProperty[], fieldName: string) => { - return props.filter(([k]) => k.toLowerCase() === fieldName.toLowerCase()); -}; + return props.filter(([k]) => k.toLowerCase() === fieldName.toLowerCase()) +} // Helper function to find a single field value from props const findFieldValue = (props: VObjectProperty[], fieldName: string) => { - return props.find(([k]) => k.toLowerCase() === fieldName.toLowerCase()); -}; + return props.find(([k]) => k.toLowerCase() === fieldName.toLowerCase()) +} // Helper function to serialize for comparison const serialize = (values: VObjectProperty[] | VCalComponent[]) => { - return JSON.stringify(values); -}; + return JSON.stringify(values) +} // Helper function to filter components by name const filterComponentsByName = (components: VCalComponent[], name: string) => { return components.filter( ([componentName]) => componentName.toLowerCase() === name.toLowerCase() - ); -}; + ) +} // Detect which metadata fields changed between old and new master const detectChangedMetadataFields = ( oldMasterProps: VObjectProperty[], newMasterProps: VObjectProperty[] ) => { - const changedFields = new Map(); + const changedFields = new Map() - METADATA_FIELDS.forEach((fieldName) => { - const oldValues = getFieldValues(oldMasterProps, fieldName); - const newValues = getFieldValues(newMasterProps, fieldName); + METADATA_FIELDS.forEach(fieldName => { + const oldValues = getFieldValues(oldMasterProps, fieldName) + const newValues = getFieldValues(newMasterProps, fieldName) if (serialize(oldValues) !== serialize(newValues)) { - changedFields.set(fieldName.toLowerCase(), newValues); + changedFields.set(fieldName.toLowerCase(), newValues) } - }); + }) - return changedFields; -}; + return changedFields +} // Check if VALARM component changed between old and new master const detectValarmChanges = ( oldMaster: VCalComponent, updatedMaster: VCalComponent ) => { - const oldMasterComponents = oldMaster[2] || []; - const newMasterComponents = updatedMaster[2] || []; + const oldMasterComponents = oldMaster[2] || [] + const newMasterComponents = updatedMaster[2] || [] - const oldValarm = filterComponentsByName(oldMasterComponents, "valarm"); - const newValarm = filterComponentsByName(newMasterComponents, "valarm"); + const oldValarm = filterComponentsByName(oldMasterComponents, 'valarm') + const newValarm = filterComponentsByName(newMasterComponents, 'valarm') - const valarmChanged = serialize(oldValarm) !== serialize(newValarm); + const valarmChanged = serialize(oldValarm) !== serialize(newValarm) - return { valarmChanged, newValarm }; -}; + return { valarmChanged, newValarm } +} // Apply changed metadata fields to a vevent's properties const applyMetadataChanges = ( props: VObjectProperty[], changedFields: Map ) => { - let newProps = [...props]; + let newProps = [...props] changedFields.forEach((newValues, fieldNameLower) => { // Remove old values of this changed field from exception const filteredProps = newProps.filter( ([k]) => k.toLowerCase() !== fieldNameLower - ); + ) // Add new values from updated master - newProps = [...filteredProps, ...newValues]; - }); + newProps = [...filteredProps, ...newValues] + }) - return newProps; -}; + return newProps +} // Increment the sequence number in properties const incrementSequenceNumber = (props: VObjectProperty[]) => { - const newProps = [...props]; + const newProps = [...props] const sequenceIndex = newProps.findIndex( - ([k]) => k.toLowerCase() === "sequence" - ); + ([k]) => k.toLowerCase() === 'sequence' + ) if (sequenceIndex !== -1) { const currentSequence = parseInt( - (newProps[sequenceIndex][3] as string) || "0", + (newProps[sequenceIndex][3] as string) || '0', 10 - ); + ) newProps[sequenceIndex] = [ newProps[sequenceIndex][0], newProps[sequenceIndex][1], newProps[sequenceIndex][2], - currentSequence + 1, - ]; + currentSequence + 1 + ] } else { - newProps.push(["sequence", {}, "integer", 1]); + newProps.push(['sequence', {}, 'integer', 1]) } - return newProps; -}; + return newProps +} // Update VALARM components if they changed const updateValarmComponents = ( @@ -127,9 +127,9 @@ const updateValarmComponents = ( newValarm: VCalComponent[] ) => { return components - .filter(([name]) => name.toLowerCase() !== "valarm") - .concat(newValarm); -}; + .filter(([name]) => name.toLowerCase() !== 'valarm') + .concat(newValarm) +} // Update a single vevent with metadata changes const updateVeventWithMetadataChanges = ( @@ -142,27 +142,27 @@ const updateVeventWithMetadataChanges = ( newValarm: VCalComponent[] ): VCalComponent => { if (index === masterIndex) { - return updatedMaster; + return updatedMaster } - const [veventType, props, components = []] = vevent; + const [veventType, props, components = []] = vevent // Apply metadata changes - let newProps = applyMetadataChanges(props, changedFields); + let newProps = applyMetadataChanges(props, changedFields) // Increment sequence number if any changes were made if (changedFields.size > 0 || valarmChanged) { - newProps = incrementSequenceNumber(newProps); + newProps = incrementSequenceNumber(newProps) } // Handle VALARM component updates - let updatedComponents = components; + let updatedComponents = components if (valarmChanged) { - updatedComponents = updateValarmComponents(components, newValarm); + updatedComponents = updateValarmComponents(components, newValarm) } - return [veventType, newProps, updatedComponents]; -}; + return [veventType, newProps, updatedComponents] +} // Update all vevents with metadata changes while preserving overrides const updateVeventsPreservingOverrides = ( @@ -171,20 +171,20 @@ const updateVeventsPreservingOverrides = ( updatedMaster: VCalComponent, masterIndex: number ) => { - const oldMasterProps = oldMaster[1]; - const newMasterProps = updatedMaster[1]; + const oldMasterProps = oldMaster[1] + const newMasterProps = updatedMaster[1] // Detect which fields changed in the master const changedFields = detectChangedMetadataFields( oldMasterProps, newMasterProps - ); + ) // Check if VALARM component changed const { valarmChanged, newValarm } = detectValarmChanges( oldMaster, updatedMaster - ); + ) // Update all vevents return vevents.map((vevent, index) => @@ -197,46 +197,46 @@ const updateVeventsPreservingOverrides = ( valarmChanged, newValarm ) - ); -}; + ) +} export const updateSeries = async ( event: CalendarEvent, calOwnerEmail?: string, removeOverrides: boolean = true ) => { - const vevents = (await getAllRecurrentEvent(event)) as VCalComponent[]; + const vevents = (await getAllRecurrentEvent(event)) as VCalComponent[] const masterIndex = vevents.findIndex( - ([, props]) => !findFieldValue(props, "recurrence-id") - ); + ([, props]) => !findFieldValue(props, 'recurrence-id') + ) if (masterIndex === -1) { - throw new Error("No master VEVENT found for this series"); + throw new Error('No master VEVENT found for this series') } - const rrule = findFieldValue(vevents[masterIndex][1], "rrule"); - const tzid = event.timezone; - const oldMaster = vevents[masterIndex]; + const rrule = findFieldValue(vevents[masterIndex][1], 'rrule') + const tzid = event.timezone + const oldMaster = vevents[masterIndex] const updatedMaster = makeVevent( event, tzid, calOwnerEmail, true - ) as VCalComponent; - const newRrule = findFieldValue(updatedMaster[1], "rrule"); + ) as VCalComponent + const newRrule = findFieldValue(updatedMaster[1], 'rrule') if (!newRrule && rrule) { - updatedMaster[1].push(rrule!); + updatedMaster[1].push(rrule) } - const timezoneData = TIMEZONES.zones[event.timezone]; - const vtimezone = makeTimezone(timezoneData, event); + const timezoneData = TIMEZONES.zones[event.timezone] + const vtimezone = makeTimezone(timezoneData, event) - let finalVevents: VCalComponent[]; + let finalVevents: VCalComponent[] if (removeOverrides) { // When date/time/timezone/repeat rules changed, remove all override instances - finalVevents = [updatedMaster]; + finalVevents = [updatedMaster] } else { // When only properties changed, keep override instances and update their metadata finalVevents = updateVeventsPreservingOverrides( @@ -244,20 +244,16 @@ export const updateSeries = async ( oldMaster, updatedMaster, masterIndex - ); + ) } - const newJCal = [ - "vcalendar", - [], - [...finalVevents, vtimezone.component.jCal], - ]; + const newJCal = ['vcalendar', [], [...finalVevents, vtimezone.component.jCal]] return api(`dav${event.URL}`, { - method: "PUT", + method: 'PUT', body: JSON.stringify(newJCal), headers: { - "content-type": "text/calendar; charset=utf-8", - }, - }); -}; + 'content-type': 'text/calendar; charset=utf-8' + } + }) +} diff --git a/src/features/Events/createEventContext.ts b/src/features/Events/createEventContext.ts index ee06647..ae499eb 100644 --- a/src/features/Events/createEventContext.ts +++ b/src/features/Events/createEventContext.ts @@ -1,25 +1,25 @@ -import { Calendar } from "../Calendars/CalendarTypes"; -import { userData } from "../User/userDataTypes"; -import { CalendarEvent, ContextualizedEvent } from "./EventsTypes"; +import { Calendar } from '../Calendars/CalendarTypes' +import { userData } from '../User/userDataTypes' +import { CalendarEvent, ContextualizedEvent } from './EventsTypes' export function createEventContext( event: CalendarEvent, calendar: Calendar, user: userData ): ContextualizedEvent { - const isOwn = calendar.owner?.emails?.includes(user.email) ?? false; - const isRecurring = event?.uid?.includes("/") ?? false; + const isOwn = calendar.owner?.emails?.includes(user.email) ?? false + const isRecurring = event?.uid?.includes('/') ?? false const attendeeEmail = calendar.delegated ? calendar.owner?.emails?.[0] - : user.email; + : user.email const currentUserAttendee = calendar.owner?.resource ? event.attendee?.find( - (person) => person.cutype === "RESOURCE" && person.cn === calendar.name + person => person.cutype === 'RESOURCE' && person.cn === calendar.name ) - : event.attendee?.find((person) => person.cal_address === attendeeEmail); + : event.attendee?.find(person => person.cal_address === attendeeEmail) const isOrganizer = event.organizer ? attendeeEmail === event.organizer.cal_address - : isOwn; + : isOwn return { event, @@ -27,6 +27,6 @@ export function createEventContext( currentUserAttendee, isOwn, isRecurring, - isOrganizer, - }; + isOrganizer + } } diff --git a/src/features/Events/formHelpers.ts b/src/features/Events/formHelpers.ts index cf3634f..1024325 100644 --- a/src/features/Events/formHelpers.ts +++ b/src/features/Events/formHelpers.ts @@ -1,66 +1,66 @@ -import { resolveTimezone } from "@/utils/timezone"; -import { formatDateTimeInTimezone } from "@/components/Event/utils/dateTimeFormatters"; -import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; -import { browserDefaultTimeZone } from "@/utils/timezone"; -import { TIMEZONES } from "@/utils/timezone-data"; -import { addVideoConferenceToDescription } from "@/utils/videoConferenceUtils"; -import { userAttendee } from "../User/models/attendee"; -import { CalendarEvent, RepetitionObject } from "./EventsTypes"; -import { Calendar } from "../Calendars/CalendarTypes"; +import { resolveTimezone } from '@/utils/timezone' +import { formatDateTimeInTimezone } from '@/components/Event/utils/dateTimeFormatters' +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' +import { browserDefaultTimeZone } from '@/utils/timezone' +import { TIMEZONES } from '@/utils/timezone-data' +import { addVideoConferenceToDescription } from '@/utils/videoConferenceUtils' +import { userAttendee } from '../User/models/attendee' +import { CalendarEvent, RepetitionObject } from './EventsTypes' +import { Calendar } from '../Calendars/CalendarTypes' export interface TimezoneListResult { - zones: string[]; - browserTz: string; - getTimezoneOffset: (tzName: string) => string; + zones: string[] + browserTz: string + getTimezoneOffset: (tzName: string) => string } export function createTimezoneList(): TimezoneListResult { - const zones = Object.keys(TIMEZONES.zones).sort(); - const browserTz = resolveTimezone(browserDefaultTimeZone); + const zones = Object.keys(TIMEZONES.zones).sort() + const browserTz = resolveTimezone(browserDefaultTimeZone) const getTimezoneOffset = (tzName: string): string => { - const resolvedTz = resolveTimezone(tzName); - const tzData = TIMEZONES.zones[resolvedTz]; - if (!tzData) return ""; + const resolvedTz = resolveTimezone(tzName) + const tzData = TIMEZONES.zones[resolvedTz] + if (!tzData) return '' - const icsMatch = tzData.ics.match(/TZOFFSETTO:([+-]\d{4})/); - if (!icsMatch) return ""; + const icsMatch = tzData.ics.match(/TZOFFSETTO:([+-]\d{4})/) + if (!icsMatch) return '' - const offset = icsMatch[1]; - const hours = parseInt(offset.slice(0, 3)); - const minutes = parseInt(offset.slice(3)); + const offset = icsMatch[1] + const hours = parseInt(offset.slice(0, 3)) + const minutes = parseInt(offset.slice(3)) if (minutes === 0) { - return `UTC${hours >= 0 ? "+" : ""}${hours}`; + return `UTC${hours >= 0 ? '+' : ''}${hours}` } - return `UTC${hours >= 0 ? "+" : ""}${hours}:${Math.abs(minutes).toString().padStart(2, "0")}`; - }; + return `UTC${hours >= 0 ? '+' : ''}${hours}:${Math.abs(minutes).toString().padStart(2, '0')}` + } - return { zones, browserTz, getTimezoneOffset }; + return { zones, browserTz, getTimezoneOffset } } export interface PopulateFormFromEventParams { - event: CalendarEvent; - calendarTimezone?: string; - organizerEmail?: string; - setTitle: (value: string) => void; - setDescription: (value: string) => void; - setLocation: (value: string) => void; - setStart: (value: string) => void; - setEnd: (value: string) => void; - setAllDay: (value: boolean) => void; - setRepetition: (value: RepetitionObject) => void; - setShowRepeat: (value: boolean) => void; - setAttendees: (value: userAttendee[]) => void; - setAlarm: (value: string) => void; - setEventClass: (value: string) => void; - setBusy: (value: string) => void; - setTimezone: (value: string) => void; - setHasVideoConference: (value: boolean) => void; - setMeetingLink: (value: string | null) => void; - setCalendarid?: (value: string) => void; - calendarsList?: Record; - calId?: string; + event: CalendarEvent + calendarTimezone?: string + organizerEmail?: string + setTitle: (value: string) => void + setDescription: (value: string) => void + setLocation: (value: string) => void + setStart: (value: string) => void + setEnd: (value: string) => void + setAllDay: (value: boolean) => void + setRepetition: (value: RepetitionObject) => void + setShowRepeat: (value: boolean) => void + setAttendees: (value: userAttendee[]) => void + setAlarm: (value: string) => void + setEventClass: (value: string) => void + setBusy: (value: string) => void + setTimezone: (value: string) => void + setHasVideoConference: (value: boolean) => void + setMeetingLink: (value: string | null) => void + setCalendarid?: (value: string) => void + calendarsList?: Record + calId?: string } export function populateFormFromEvent( @@ -87,58 +87,58 @@ export function populateFormFromEvent( setMeetingLink, setCalendarid, calendarsList, - calId, - } = params; + calId + } = params // Basic fields - setTitle(event.title ?? ""); - setDescription(event.description ?? ""); - setLocation(event.location ?? ""); + setTitle(event.title ?? '') + setDescription(event.description ?? '') + setLocation(event.location ?? '') // Handle all-day events - const isAllDay = event.allday ?? false; - setAllDay(isAllDay); + const isAllDay = event.allday ?? false + setAllDay(isAllDay) // Get event's timezone for formatting const eventTimezone = event.timezone ? resolveTimezone(event.timezone) - : calendarTimezone || resolveTimezone(browserDefaultTimeZone); + : calendarTimezone || resolveTimezone(browserDefaultTimeZone) // Format dates based on all-day status and timezone if (event.start) { if (isAllDay) { - const startDate = new Date(event.start); - setStart(startDate.toISOString().split("T")[0]); + const startDate = new Date(event.start) + setStart(startDate.toISOString().split('T')[0]) } else { - setStart(formatDateTimeInTimezone(event.start, eventTimezone)); + setStart(formatDateTimeInTimezone(event.start, eventTimezone)) } } else { - setStart(""); + setStart('') } if (event.end) { if (isAllDay) { - const endDate = new Date(event.end); - setEnd(endDate.toISOString().split("T")[0]); + const endDate = new Date(event.end) + setEnd(endDate.toISOString().split('T')[0]) } else { - setEnd(formatDateTimeInTimezone(event.end, eventTimezone)); + setEnd(formatDateTimeInTimezone(event.end, eventTimezone)) } } else { - setEnd(""); + setEnd('') } // Calendar if (setCalendarid && calId) { - setCalendarid(calId); + setCalendarid(calId) } // Handle repetition - check both current event and base event (for update modal) - let repetitionSource = event.repetition; + let repetitionSource = event.repetition if (calendarsList && calId && event.uid) { - const baseEventId = extractEventBaseUuid(event.uid); - const baseEvent = calendarsList[calId]?.events[baseEventId]; + const baseEventId = extractEventBaseUuid(event.uid) + const baseEvent = calendarsList[calId]?.events[baseEventId] if (baseEvent?.repetition) { - repetitionSource = baseEvent.repetition; + repetitionSource = baseEvent.repetition } } @@ -148,45 +148,45 @@ export function populateFormFromEvent( interval: repetitionSource.interval || 1, occurrences: repetitionSource.occurrences, endDate: repetitionSource.endDate, - byday: repetitionSource.byday || null, - }; - setRepetition(repetitionData); - setShowRepeat(true); + byday: repetitionSource.byday || null + } + setRepetition(repetitionData) + setShowRepeat(true) } else { - setRepetition({} as RepetitionObject); - setShowRepeat(false); + setRepetition({} as RepetitionObject) + setShowRepeat(false) } // Attendees - filter out organizer - const organizerAddress = organizerEmail || event.organizer?.cal_address; + const organizerAddress = organizerEmail || event.organizer?.cal_address setAttendees( event.attendee ? event.attendee.filter( (a: userAttendee) => a.cal_address !== organizerAddress ) : [] - ); + ) // Other fields - setAlarm(event.alarm?.trigger ?? ""); - setEventClass(event.class ?? "PUBLIC"); - setBusy(event.transp ?? "OPAQUE"); - setTimezone(eventTimezone); - setHasVideoConference(event.x_openpass_videoconference ? true : false); - setMeetingLink(event.x_openpass_videoconference || null); + setAlarm(event.alarm?.trigger ?? '') + setEventClass(event.class ?? 'PUBLIC') + setBusy(event.transp ?? 'OPAQUE') + setTimezone(eventTimezone) + setHasVideoConference(event.x_openpass_videoconference ? true : false) + setMeetingLink(event.x_openpass_videoconference || null) // Update description to include video conference footer if exists if (event.x_openpass_videoconference && event.description) { - const hasVideoFooter = event.description.includes("Visio:"); + const hasVideoFooter = event.description.includes('Visio:') if (!hasVideoFooter) { setDescription( addVideoConferenceToDescription( event.description, event.x_openpass_videoconference ) - ); + ) } else { - setDescription(event.description); + setDescription(event.description) } } } diff --git a/src/features/Events/updateEventHelpers/moveEventBetweenCalendars.ts b/src/features/Events/updateEventHelpers/moveEventBetweenCalendars.ts index 8a21463..55a5284 100644 --- a/src/features/Events/updateEventHelpers/moveEventBetweenCalendars.ts +++ b/src/features/Events/updateEventHelpers/moveEventBetweenCalendars.ts @@ -1,40 +1,40 @@ -import { AppDispatch } from "@/app/store"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { AppDispatch } from '@/app/store' +import { Calendar } from '@/features/Calendars/CalendarTypes' import { deleteEventAsync, moveEventAsync, - putEventAsync, -} from "@/features/Calendars/services"; -import { userAttendee } from "@/features/User/models/attendee"; -import { userOrganiser } from "@/features/User/userDataTypes"; -import { assertThunkSuccess } from "@/utils/assertThunkSuccess"; -import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; -import { makeDisplayName } from "@/utils/makeDisplayName"; -import { CalendarEvent } from "../EventsTypes"; -import { buildDelegatedEventURL } from "../utils/buildDelegatedEventURL"; + putEventAsync +} from '@/features/Calendars/services' +import { userAttendee } from '@/features/User/models/attendee' +import { userOrganiser } from '@/features/User/userDataTypes' +import { assertThunkSuccess } from '@/utils/assertThunkSuccess' +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' +import { makeDisplayName } from '@/utils/makeDisplayName' +import { CalendarEvent } from '../EventsTypes' +import { buildDelegatedEventURL } from '../utils/buildDelegatedEventURL' export interface MoveEventBetweenCalendarsParams { - dispatch: AppDispatch; - calList: Record; - newEvent: CalendarEvent; - oldCalId: string; - newCalId: string; + dispatch: AppDispatch + calList: Record + newEvent: CalendarEvent + oldCalId: string + newCalId: string } function resolveOrganizerForCalendar( calendar: Calendar, - originalOrganizer: CalendarEvent["organizer"] -): CalendarEvent["organizer"] { - const ownerEmail = calendar.owner?.emails?.[0]; + originalOrganizer: CalendarEvent['organizer'] +): CalendarEvent['organizer'] { + const ownerEmail = calendar.owner?.emails?.[0] if (!ownerEmail) { - return originalOrganizer; + return originalOrganizer } return { ...originalOrganizer, cal_address: ownerEmail, - cn: makeDisplayName(calendar) ?? originalOrganizer?.cn ?? "", - }; + cn: makeDisplayName(calendar) ?? originalOrganizer?.cn ?? '' + } } function rewriteAttendeesForOrganizerChange( @@ -43,34 +43,32 @@ function rewriteAttendeesForOrganizerChange( newOrganizer?: userOrganiser ): userAttendee[] { if (!newOrganizer) { - return attendees; + return attendees } - const normalise = (addr: string | undefined) => (addr ?? "").toLowerCase(); - const oldAddr = normalise(oldOrganizer?.cal_address); - const newAddr = normalise(newOrganizer.cal_address); + const normalise = (addr: string | undefined) => (addr ?? '').toLowerCase() + const oldAddr = normalise(oldOrganizer?.cal_address) + const newAddr = normalise(newOrganizer.cal_address) // Remove the old organizer from the attendee list if there is one - const filtered = attendees.filter( - (a) => normalise(a.cal_address) !== oldAddr - ); + const filtered = attendees.filter(a => normalise(a.cal_address) !== oldAddr) // Add the new organizer as CHAIR if they are not already listed const alreadyPresent = filtered.some( - (a) => normalise(a.cal_address) === newAddr - ); + a => normalise(a.cal_address) === newAddr + ) if (!alreadyPresent && newAddr) { filtered.push({ cal_address: newOrganizer.cal_address, - partstat: "ACCEPTED", - role: "CHAIR", - rsvp: "FALSE", + partstat: 'ACCEPTED', + role: 'CHAIR', + rsvp: 'FALSE', cn: newOrganizer.cn || newOrganizer.cal_address, - cutype: "INDIVIDUAL", - }); + cutype: 'INDIVIDUAL' + }) } - return filtered; + return filtered } export async function moveEventBetweenCalendars({ @@ -78,98 +76,98 @@ export async function moveEventBetweenCalendars({ calList, newEvent, oldCalId, - newCalId, + newCalId }: MoveEventBetweenCalendarsParams): Promise { - const oldCalendar = calList[oldCalId]; + const oldCalendar = calList[oldCalId] if (!oldCalendar) { - throw new Error(`Old calendar not found: ${oldCalId}`); + throw new Error(`Old calendar not found: ${oldCalId}`) } - const targetCalendar = calList[newCalId]; + const targetCalendar = calList[newCalId] if (!targetCalendar) { - throw new Error(`Target calendar not found: ${newCalId}`); + throw new Error(`Target calendar not found: ${newCalId}`) } - const isDelegatedMove = oldCalendar.delegated || targetCalendar.delegated; + const isDelegatedMove = oldCalendar.delegated || targetCalendar.delegated if (isDelegatedMove) { await moveDelegatedEvent({ dispatch, newEvent, oldCalendar, - targetCalendar, - }); + targetCalendar + }) } else { await moveStandardEvent({ dispatch, newEvent, targetCalendar, - oldCalendar, - }); + oldCalendar + }) } } interface StandardMoveParams { - dispatch: AppDispatch; - newEvent: CalendarEvent; - targetCalendar: Calendar; - oldCalendar: Calendar; + dispatch: AppDispatch + newEvent: CalendarEvent + targetCalendar: Calendar + oldCalendar: Calendar } async function moveStandardEvent({ dispatch, newEvent, targetCalendar, - oldCalendar, + oldCalendar }: StandardMoveParams): Promise { - const newCalId = targetCalendar.id; + const newCalId = targetCalendar.id const putResult = await dispatch( putEventAsync({ cal: oldCalendar, - newEvent: { ...newEvent, calId: oldCalendar.id }, + newEvent: { ...newEvent, calId: oldCalendar.id } }) - ); - await assertThunkSuccess(putResult); - const newURL = `/calendars/${newCalId}/${extractEventBaseUuid(newEvent.uid)}.ics`; + ) + await assertThunkSuccess(putResult) + const newURL = `/calendars/${newCalId}/${extractEventBaseUuid(newEvent.uid)}.ics` const moveResult = await dispatch( moveEventAsync({ cal: targetCalendar, newEvent, - newURL, + newURL }) - ); - await assertThunkSuccess(moveResult); + ) + await assertThunkSuccess(moveResult) } interface DelegatedMoveParams { - dispatch: AppDispatch; - newEvent: CalendarEvent; - oldCalendar: Calendar; - targetCalendar: Calendar; + dispatch: AppDispatch + newEvent: CalendarEvent + oldCalendar: Calendar + targetCalendar: Calendar } async function moveDelegatedEvent({ dispatch, newEvent, oldCalendar, - targetCalendar, + targetCalendar }: DelegatedMoveParams): Promise { - const newCalId = targetCalendar.id; + const newCalId = targetCalendar.id const newOrganizer = resolveOrganizerForCalendar( targetCalendar, newEvent.organizer - ); + ) const newAttendees = rewriteAttendeesForOrganizerChange( newEvent.attendee ?? [], newEvent.organizer, newOrganizer - ); + ) - const newURL = `/calendars/${newCalId}/${extractEventBaseUuid(newEvent.uid)}.ics`; + const newURL = `/calendars/${newCalId}/${extractEventBaseUuid(newEvent.uid)}.ics` const eventForTargetCalendar: CalendarEvent = { ...newEvent, @@ -178,22 +176,22 @@ async function moveDelegatedEvent({ ? buildDelegatedEventURL(targetCalendar, newURL) : newURL, organizer: newOrganizer, - attendee: newAttendees, - }; + attendee: newAttendees + } const putResult = await dispatch( putEventAsync({ cal: targetCalendar, newEvent: eventForTargetCalendar }) - ); + ) - await assertThunkSuccess(putResult); + await assertThunkSuccess(putResult) const deleteResult = await dispatch( deleteEventAsync({ calId: oldCalendar.id, eventId: newEvent.uid, - eventURL: newEvent.URL, + eventURL: newEvent.URL }) - ); + ) - await assertThunkSuccess(deleteResult); + await assertThunkSuccess(deleteResult) } diff --git a/src/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.ts b/src/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.ts index b419361..09063cc 100644 --- a/src/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.ts +++ b/src/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.ts @@ -1,67 +1,65 @@ -import { userAttendee } from "@/features/User/models/attendee"; -import { CalendarEvent } from "../EventsTypes"; +import { userAttendee } from '@/features/User/models/attendee' +import { CalendarEvent } from '../EventsTypes' export const updateAttendeesAfterTimeChange = ( event: CalendarEvent, timeChanged?: boolean, attendees?: userAttendee[] ): CalendarEvent => { - const { attendee, organizer } = event; - if (!attendee) return event; + const { attendee, organizer } = event + if (!attendee) return event - const organizerAddr = organizer?.cal_address; + const organizerAddr = organizer?.cal_address const markNeedsAction = (att: userAttendee): userAttendee => ({ ...att, - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - }); + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE' + }) const getExistingOrDefault = (addr: string, fallback: userAttendee) => - attendee.find((a) => a?.cal_address === addr) ?? fallback; + attendee.find(a => a?.cal_address === addr) ?? fallback if (attendees) { - const updatedAttendees = attendees.map((att) => { + const updatedAttendees = attendees.map(att => { const existing = getExistingOrDefault( att.cal_address, markNeedsAction(att) - ); - return timeChanged ? markNeedsAction(existing) : existing; - }); + ) + return timeChanged ? markNeedsAction(existing) : existing + }) // Only append organizer entry if organizer exists const organizerEntry = organizer && organizerAddr ? getExistingOrDefault(organizerAddr, { ...organizer, - role: "CHAIR", - cutype: "INDIVIDUAL", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", + role: 'CHAIR', + cutype: 'INDIVIDUAL', + partstat: 'NEEDS-ACTION', + rsvp: 'TRUE' }) - : null; + : null const deduped = organizerEntry ? updatedAttendees.filter( - (attendee) => attendee.cal_address !== organizerAddr + attendee => attendee.cal_address !== organizerAddr ) - : updatedAttendees; + : updatedAttendees return { ...event, - attendee: organizerEntry - ? [...deduped, organizerEntry] - : updatedAttendees, - }; + attendee: organizerEntry ? [...deduped, organizerEntry] : updatedAttendees + } } - const updatedAttendees = attendee.map((att) => { - if (organizerAddr && att.cal_address === organizerAddr) return att; - return timeChanged ? markNeedsAction(att) : att; - }); + const updatedAttendees = attendee.map(att => { + if (organizerAddr && att.cal_address === organizerAddr) return att + return timeChanged ? markNeedsAction(att) : att + }) return { ...event, - attendee: updatedAttendees, - }; -}; + attendee: updatedAttendees + } +} diff --git a/src/features/Events/useEventOrganizer.ts b/src/features/Events/useEventOrganizer.ts index 59f07fe..81b3701 100644 --- a/src/features/Events/useEventOrganizer.ts +++ b/src/features/Events/useEventOrganizer.ts @@ -1,21 +1,21 @@ -import { makeDisplayName } from "@/utils/makeDisplayName"; -import { useMemo } from "react"; -import { Calendar } from "../Calendars/CalendarTypes"; +import { makeDisplayName } from '@/utils/makeDisplayName' +import { useMemo } from 'react' +import { Calendar } from '../Calendars/CalendarTypes' // Update event organizer accordingly to selected calendar's delegated status export function useEventOrganizer({ calendarid, calList, - userOrganizer, + userOrganizer }: { - calendarid: string; - calList: Record; - userOrganizer?: { cn: string; cal_address: string }; + calendarid: string + calList: Record + userOrganizer?: { cn: string; cal_address: string } }) { const selectedCalendar = useMemo( () => calList?.[calendarid], [calList, calendarid] - ); + ) const organizer = useMemo(() => { if (selectedCalendar?.delegated && selectedCalendar?.owner) { @@ -23,12 +23,12 @@ export function useEventOrganizer({ cn: makeDisplayName(selectedCalendar) ?? selectedCalendar.owner.emails?.[0] ?? - "", - cal_address: selectedCalendar.owner.emails?.[0] ?? "", - }; + '', + cal_address: selectedCalendar.owner.emails?.[0] ?? '' + } } - return userOrganizer; - }, [selectedCalendar, userOrganizer]); + return userOrganizer + }, [selectedCalendar, userOrganizer]) - return { organizer, selectedCalendar }; + return { organizer, selectedCalendar } } diff --git a/src/features/Events/utils/buildDelegatedEventURL.ts b/src/features/Events/utils/buildDelegatedEventURL.ts index b8839c7..15b488c 100644 --- a/src/features/Events/utils/buildDelegatedEventURL.ts +++ b/src/features/Events/utils/buildDelegatedEventURL.ts @@ -1,16 +1,16 @@ -import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { Calendar } from '@/features/Calendars/CalendarTypes' export function buildDelegatedEventURL( calendar: Calendar, eventURL: string ): string { - if (!calendar.link || !calendar.link.endsWith(".json")) { - throw new Error(`Invalid calendar link format: ${calendar.link}`); + if (!calendar.link || !calendar.link.endsWith('.json')) { + throw new Error(`Invalid calendar link format: ${calendar.link}`) } - const calendarBasePath = calendar.link.replace(/\.json$/, ""); - const eventFilename = eventURL.split("/").pop(); + const calendarBasePath = calendar.link.replace(/\.json$/, '') + const eventFilename = eventURL.split('/').pop() if (!eventFilename) { - throw new Error(`Cannot extract filename from event URL: ${eventURL}`); + throw new Error(`Cannot extract filename from event URL: ${eventURL}`) } - return `${calendarBasePath}/${eventFilename}`; + return `${calendarBasePath}/${eventFilename}` } diff --git a/src/features/Events/utils/calendarEventToJCal.ts b/src/features/Events/utils/calendarEventToJCal.ts index 81f28e6..d33141c 100644 --- a/src/features/Events/utils/calendarEventToJCal.ts +++ b/src/features/Events/utils/calendarEventToJCal.ts @@ -1,18 +1,18 @@ -import { TIMEZONES } from "@/utils/timezone-data"; -import { CalendarEvent } from "../EventsTypes"; -import { makeVevent } from "./makeVevent"; -import { makeTimezone } from "./makeTimezone"; +import { TIMEZONES } from '@/utils/timezone-data' +import { CalendarEvent } from '../EventsTypes' +import { makeVevent } from './makeVevent' +import { makeTimezone } from './makeTimezone' export function calendarEventToJCal( event: CalendarEvent, calOwnerEmail?: string ) { - const tzid = event.timezone; + const tzid = event.timezone - const vevent = makeVevent(event, tzid, calOwnerEmail); + const vevent = makeVevent(event, tzid, calOwnerEmail) - const timezoneData = TIMEZONES.zones[event.timezone]; - const vtimezone = makeTimezone(timezoneData, event); + const timezoneData = TIMEZONES.zones[event.timezone] + const vtimezone = makeTimezone(timezoneData, event) - return ["vcalendar", [], [vevent, vtimezone.component.jCal]]; + return ['vcalendar', [], [vevent, vtimezone.component.jCal]] } diff --git a/src/features/Events/utils/combineMasterDateWithFormTime.ts b/src/features/Events/utils/combineMasterDateWithFormTime.ts index e012827..7956904 100644 --- a/src/features/Events/utils/combineMasterDateWithFormTime.ts +++ b/src/features/Events/utils/combineMasterDateWithFormTime.ts @@ -1,5 +1,5 @@ -import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers"; -import { CalendarEvent } from "../EventsTypes"; +import { convertFormDateTimeToISO } from '@/components/Event/utils/dateTimeHelpers' +import { CalendarEvent } from '../EventsTypes' /** * Combine master event's date with form input's time for recurring event updates @@ -16,59 +16,57 @@ export function combineMasterDateWithFormTime( ): { startDate: string; endDate: string } { if (isAllDay) { // Extract date string from master event (which is ISO UTC string) - const startDateStr = new Date(masterEvent.start) - .toISOString() - .split("T")[0]; + const startDateStr = new Date(masterEvent.start).toISOString().split('T')[0] const endDateStr = masterEvent.end - ? new Date(masterEvent.end).toISOString().split("T")[0] - : startDateStr; + ? new Date(masterEvent.end).toISOString().split('T')[0] + : startDateStr // Parse date string and create Date at UTC midnight to avoid timezone offset issues const [startYear, startMonth, startDay] = startDateStr - .split("-") - .map(Number); - const [endYear, endMonth, endDay] = endDateStr.split("-").map(Number); + .split('-') + .map(Number) + const [endYear, endMonth, endDay] = endDateStr.split('-').map(Number) const startDateObj = new Date( Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0) - ); + ) const endDateObj = new Date( Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0) - ); + ) return { startDate: startDateObj.toISOString(), - endDate: endDateObj.toISOString(), - }; + endDate: endDateObj.toISOString() + } } // For timed events: combine master's date with form's time const masterFormattedStart = formatDateTimeInTimezone( masterEvent.start, timezone - ); + ) const masterFormattedEnd = masterEvent.end ? formatDateTimeInTimezone(masterEvent.end, timezone) - : masterFormattedStart; + : masterFormattedStart // Extract date portion from master (YYYY-MM-DD) - const masterDatePart = masterFormattedStart.split("T")[0]; - const masterEndDatePart = masterFormattedEnd.split("T")[0]; + const masterDatePart = masterFormattedStart.split('T')[0] + const masterEndDatePart = masterFormattedEnd.split('T')[0] // Extract time portion from form input (HH:MM or HH:MM:SS) - const formTimePart = formStart.includes("T") - ? formStart.split("T")[1] - : formStart.substring(11); - const formEndTimePart = formEnd.includes("T") - ? formEnd.split("T")[1] - : formEnd.substring(11); + const formTimePart = formStart.includes('T') + ? formStart.split('T')[1] + : formStart.substring(11) + const formEndTimePart = formEnd.includes('T') + ? formEnd.split('T')[1] + : formEnd.substring(11) // Combine master's date + form's time - const combinedStartStr = `${masterDatePart}T${formTimePart}`; - const combinedEndStr = `${masterEndDatePart}T${formEndTimePart}`; + const combinedStartStr = `${masterDatePart}T${formTimePart}` + const combinedEndStr = `${masterEndDatePart}T${formEndTimePart}` // Parse and convert to ISO - const startDate = convertFormDateTimeToISO(combinedStartStr, timezone); - const endDate = convertFormDateTimeToISO(combinedEndStr, timezone); + const startDate = convertFormDateTimeToISO(combinedStartStr, timezone) + const endDate = convertFormDateTimeToISO(combinedEndStr, timezone) - return { startDate, endDate }; + return { startDate, endDate } } diff --git a/src/features/Events/utils/detectRecurringEventChanges.ts b/src/features/Events/utils/detectRecurringEventChanges.ts index d6dec53..73f0552 100644 --- a/src/features/Events/utils/detectRecurringEventChanges.ts +++ b/src/features/Events/utils/detectRecurringEventChanges.ts @@ -1,6 +1,6 @@ -import moment from "moment-timezone"; -import { CalendarEvent, RepetitionObject } from "../EventsTypes"; -import { normalizeRepetition } from "./normalizeRepetition"; +import moment from 'moment-timezone' +import { CalendarEvent, RepetitionObject } from '../EventsTypes' +import { normalizeRepetition } from './normalizeRepetition' /** * Detect what changed in recurring event update @@ -8,52 +8,51 @@ import { normalizeRepetition } from "./normalizeRepetition"; export function detectRecurringEventChanges( oldEvent: CalendarEvent, newData: { - repetition: RepetitionObject; - timezone: string; - allday: boolean; - start: string; - end: string; + repetition: RepetitionObject + timezone: string + allday: boolean + start: string + end: string }, masterEventData: CalendarEvent | null, resolveTimezone: (tz: string) => string ): { - timeChanged: boolean; - timezoneChanged: boolean; - repetitionRulesChanged: boolean; + timeChanged: boolean + timezoneChanged: boolean + repetitionRulesChanged: boolean } { - const oldTimezone = resolveTimezone(oldEvent.timezone || "UTC"); - const newTimezone = resolveTimezone(newData.timezone || "UTC"); - const timezoneChanged = oldTimezone !== newTimezone; + const oldTimezone = resolveTimezone(oldEvent.timezone || 'UTC') + const newTimezone = resolveTimezone(newData.timezone || 'UTC') + const timezoneChanged = oldTimezone !== newTimezone // Use master event as the source of truth for the "old" times, // falling back to the clicked instance if master isn't available. - const oldStart = masterEventData?.start || oldEvent.start; - const oldEnd = masterEventData?.end || oldEvent.end; + const oldStart = masterEventData?.start || oldEvent.start + const oldEnd = masterEventData?.end || oldEvent.end // Parse old times (ISO strings from the server) into the event's timezone // and extract HH:mm for comparison. - const oldStartTime = moment.tz(oldStart, oldTimezone).format("HH:mm"); - const oldEndTime = moment.tz(oldEnd, oldTimezone).format("HH:mm"); + const oldStartTime = moment.tz(oldStart, oldTimezone).format('HH:mm') + const oldEndTime = moment.tz(oldEnd, oldTimezone).format('HH:mm') // Parse new times from the form. These may be either: // - local datetime strings like "2025-01-15T10:00" (from the form) // - ISO strings like "2025-01-15T10:00:00.000Z" (if pre-converted) // moment.tz with a format avoids ambiguous parsing in both cases. - const newStartTime = moment.tz(newData.start, newTimezone).format("HH:mm"); - const newEndTime = moment.tz(newData.end, newTimezone).format("HH:mm"); + const newStartTime = moment.tz(newData.start, newTimezone).format('HH:mm') + const newEndTime = moment.tz(newData.end, newTimezone).format('HH:mm') - const timeChanged = - oldStartTime !== newStartTime || oldEndTime !== newEndTime; + const timeChanged = oldStartTime !== newStartTime || oldEndTime !== newEndTime const repetitionRulesChanged = JSON.stringify(normalizeRepetition(oldEvent.repetition)) !== JSON.stringify(normalizeRepetition(newData.repetition)) || timezoneChanged || oldEvent.allday !== newData.allday || - timeChanged; + timeChanged return { timeChanged, timezoneChanged, - repetitionRulesChanged, - }; + repetitionRulesChanged + } } diff --git a/src/features/Events/utils/formatDateToICal.ts b/src/features/Events/utils/formatDateToICal.ts index f65b365..e87414a 100644 --- a/src/features/Events/utils/formatDateToICal.ts +++ b/src/features/Events/utils/formatDateToICal.ts @@ -1,37 +1,37 @@ -import moment from "moment-timezone"; +import moment from 'moment-timezone' export function formatDateToICal( date: Date, allday: boolean, timezone?: string ) { - const pad = (n: number) => n.toString().padStart(2, "0"); + const pad = (n: number) => n.toString().padStart(2, '0') if (allday) { - const year = date.getUTCFullYear(); - const month = pad(date.getUTCMonth() + 1); - const day = pad(date.getUTCDate()); - return `${year}-${month}-${day}`; + const year = date.getUTCFullYear() + const month = pad(date.getUTCMonth() + 1) + const day = pad(date.getUTCDate()) + return `${year}-${month}-${day}` } if (timezone) { if (!moment.tz.zone(timezone)) { console.warn( `[formatDateToICal] Unrecognized timezone: "${timezone}", falling back to UTC` - ); + ) } else { - const momentDate = moment.utc(date).tz(timezone); + const momentDate = moment.utc(date).tz(timezone) if (momentDate.isValid()) { - return momentDate.format("YYYY-MM-DDTHH:mm:ss"); + return momentDate.format('YYYY-MM-DDTHH:mm:ss') } } } - const year = date.getUTCFullYear(); - const month = pad(date.getUTCMonth() + 1); - const day = pad(date.getUTCDate()); - const hours = pad(date.getUTCHours()); - const minutes = pad(date.getUTCMinutes()); - const seconds = pad(date.getUTCSeconds()); - return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`; + const year = date.getUTCFullYear() + const month = pad(date.getUTCMonth() + 1) + const day = pad(date.getUTCDate()) + const hours = pad(date.getUTCHours()) + const minutes = pad(date.getUTCMinutes()) + const seconds = pad(date.getUTCSeconds()) + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z` } diff --git a/src/features/Events/utils/index.ts b/src/features/Events/utils/index.ts index 4fba4f4..31e653e 100644 --- a/src/features/Events/utils/index.ts +++ b/src/features/Events/utils/index.ts @@ -1,11 +1,11 @@ -export { buildDelegatedEventURL } from "./buildDelegatedEventURL"; -export { calendarEventToJCal } from "./calendarEventToJCal"; -export { combineMasterDateWithFormTime } from "./combineMasterDateWithFormTime"; -export { detectRecurringEventChanges } from "./detectRecurringEventChanges"; -export { formatDateToICal } from "./formatDateToICal"; -export { inferTimezoneFromValue } from "./inferTimezoneFromValue"; -export { makeTimezone } from "./makeTimezone"; -export { makeVevent } from "./makeVevent"; -export { normalizeRepetition } from "./normalizeRepetition"; -export { normalizeTimezone } from "./normalizeTimezone"; -export { parseCalendarEvent } from "./parseCalendarEvent"; +export { buildDelegatedEventURL } from './buildDelegatedEventURL' +export { calendarEventToJCal } from './calendarEventToJCal' +export { combineMasterDateWithFormTime } from './combineMasterDateWithFormTime' +export { detectRecurringEventChanges } from './detectRecurringEventChanges' +export { formatDateToICal } from './formatDateToICal' +export { inferTimezoneFromValue } from './inferTimezoneFromValue' +export { makeTimezone } from './makeTimezone' +export { makeVevent } from './makeVevent' +export { normalizeRepetition } from './normalizeRepetition' +export { normalizeTimezone } from './normalizeTimezone' +export { parseCalendarEvent } from './parseCalendarEvent' diff --git a/src/features/Events/utils/inferTimezoneFromValue.ts b/src/features/Events/utils/inferTimezoneFromValue.ts index 33a9967..cb58986 100644 --- a/src/features/Events/utils/inferTimezoneFromValue.ts +++ b/src/features/Events/utils/inferTimezoneFromValue.ts @@ -1,20 +1,20 @@ -import { resolveTimezoneId } from "@/utils/timezone"; +import { resolveTimezoneId } from '@/utils/timezone' export function inferTimezoneFromValue( params: Record | undefined ): string | undefined { if (!params) { - return undefined; + return undefined } - const tzKey = Object.keys(params).find((k) => k.toLowerCase() === "tzid"); - const tzParam = tzKey ? params[tzKey] : undefined; + const tzKey = Object.keys(params).find(k => k.toLowerCase() === 'tzid') + const tzParam = tzKey ? params[tzKey] : undefined if (tzParam) { - const resolved = resolveTimezoneId(tzParam); + const resolved = resolveTimezoneId(tzParam) if (resolved) { - return resolved; + return resolved } } - return undefined; + return undefined } diff --git a/src/features/Events/utils/makeTimezone.ts b/src/features/Events/utils/makeTimezone.ts index d96ee23..6aa7156 100644 --- a/src/features/Events/utils/makeTimezone.ts +++ b/src/features/Events/utils/makeTimezone.ts @@ -1,6 +1,6 @@ -import { TIMEZONES } from "@/utils/timezone-data"; -import ICAL from "ical.js"; -import { CalendarEvent } from "../EventsTypes"; +import { TIMEZONES } from '@/utils/timezone-data' +import ICAL from 'ical.js' +import { CalendarEvent } from '../EventsTypes' export function makeTimezone( timezoneData: @@ -10,12 +10,12 @@ export function makeTimezone( ) { if (!timezoneData) { return new ICAL.Timezone({ - component: TIMEZONES.zones["Etc/UTC"].ics, - tzid: "Etc/UTC", - }); + component: TIMEZONES.zones['Etc/UTC'].ics, + tzid: 'Etc/UTC' + }) } return new ICAL.Timezone({ component: timezoneData.ics, - tzid: event.timezone, - }); + tzid: event.timezone + }) } diff --git a/src/features/Events/utils/makeVevent.ts b/src/features/Events/utils/makeVevent.ts index 21ead92..b3fa110 100644 --- a/src/features/Events/utils/makeVevent.ts +++ b/src/features/Events/utils/makeVevent.ts @@ -1,7 +1,7 @@ -import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; -import { RepetitionRule } from "../../Calendars/types/CalendarData"; -import { CalendarEvent } from "../EventsTypes"; -import { formatDateToICal } from "./formatDateToICal"; +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' +import { RepetitionRule } from '../../Calendars/types/CalendarData' +import { CalendarEvent } from '../EventsTypes' +import { formatDateToICal } from './formatDateToICal' export function makeVevent( event: CalendarEvent, @@ -10,127 +10,122 @@ export function makeVevent( isMasterEvent?: boolean ) { const vevent: [string, unknown[]] = [ - "vevent", + 'vevent', [ - ["uid", {}, "text", extractEventBaseUuid(event.uid)], - ["transp", {}, "text", event.transp ?? "OPAQUE"], + ['uid', {}, 'text', extractEventBaseUuid(event.uid)], + ['transp', {}, 'text', event.transp ?? 'OPAQUE'], [ - "dtstart", + 'dtstart', { tzid }, - event.allday ? "date" : "date-time", - formatDateToICal(new Date(event.start), event.allday ?? false, tzid), + event.allday ? 'date' : 'date-time', + formatDateToICal(new Date(event.start), event.allday ?? false, tzid) ], - ["class", {}, "text", event.class ?? "PUBLIC"], - ["sequence", {}, "integer", event.sequence ?? 1], + ['class', {}, 'text', event.class ?? 'PUBLIC'], + ['sequence', {}, 'integer', event.sequence ?? 1], [ - "x-openpaas-videoconference", + 'x-openpaas-videoconference', {}, - "unknown", - event.x_openpass_videoconference ?? null, + 'unknown', + event.x_openpass_videoconference ?? null ], - ["summary", {}, "text", event.title ?? ""], - ], - ]; + ['summary', {}, 'text', event.title ?? ''] + ] + ] if (event.alarm?.trigger) { const valarm = [ - ["trigger", {}, "duration", event.alarm.trigger], - ["action", {}, "text", event.alarm.action], - ["attendee", {}, "cal-address", `mailto:${calOwnerEmail}`], - ["summary", {}, "text", event.title], - [ - "description", - {}, - "text", - "This is an automatic alarm sent by OpenPaas", - ], - ]; - vevent.push([["valarm", valarm]]); + ['trigger', {}, 'duration', event.alarm.trigger], + ['action', {}, 'text', event.alarm.action], + ['attendee', {}, 'cal-address', `mailto:${calOwnerEmail}`], + ['summary', {}, 'text', event.title], + ['description', {}, 'text', 'This is an automatic alarm sent by OpenPaas'] + ] + vevent.push([['valarm', valarm]]) } if (event.end) { vevent[1].push([ - "dtend", + 'dtend', { tzid }, - event.allday ? "date" : "date-time", - formatDateToICal(new Date(event.end), event.allday ?? false, tzid), - ]); + event.allday ? 'date' : 'date-time', + formatDateToICal(new Date(event.end), event.allday ?? false, tzid) + ]) } if (event.organizer) { vevent[1].push([ - "organizer", + 'organizer', { cn: event.organizer.cn }, - "cal-address", - `mailto:${event.organizer.cal_address}`, - ]); + 'cal-address', + `mailto:${event.organizer.cal_address}` + ]) } if (event.location) { - vevent[1].push(["location", {}, "text", event.location]); + vevent[1].push(['location', {}, 'text', event.location]) } if (event.recurrenceId && !isMasterEvent) { - vevent[1].push(["recurrence-id", {}, "date-time", event.recurrenceId]); + vevent[1].push(['recurrence-id', {}, 'date-time', event.recurrenceId]) } if (event.description) { - vevent[1].push(["description", {}, "text", event.description]); + vevent[1].push(['description', {}, 'text', event.description]) } if (event.repetition?.freq) { - const repetitionRule: RepetitionRule = { freq: event.repetition.freq }; + const repetitionRule: RepetitionRule = { freq: event.repetition.freq } if (event.repetition.interval) { - repetitionRule.interval = event.repetition.interval; + repetitionRule.interval = event.repetition.interval } if (event.repetition.occurrences) { - repetitionRule.count = event.repetition.occurrences; + repetitionRule.count = event.repetition.occurrences } if (event.repetition.endDate) { - repetitionRule.until = event.repetition.endDate; + repetitionRule.until = event.repetition.endDate } if ( event.repetition.byday !== null && event.repetition.byday !== undefined ) { - repetitionRule.byday = event.repetition.byday; + repetitionRule.byday = event.repetition.byday } - vevent[1].push(["rrule", {}, "recur", repetitionRule]); + vevent[1].push(['rrule', {}, 'recur', repetitionRule]) } - event.attendee.forEach((att) => { + event.attendee.forEach(att => { const attendee: Record = { partstat: att.partstat, rsvp: att.rsvp, role: att.role, - cutype: att.cutype, - }; + cutype: att.cutype + } if (att.cn) { - attendee.cn = att.cn; + attendee.cn = att.cn } vevent[1].push([ - "attendee", + 'attendee', attendee, - "cal-address", - `mailto:${att.cal_address}`, - ]); - }); + 'cal-address', + `mailto:${att.cal_address}` + ]) + }) if (event.exdates && event.exdates.length > 0) { - event.exdates.forEach((ex) => { + event.exdates.forEach(ex => { vevent[1].push([ - "exdate", + 'exdate', { tzid }, - "date-time", - formatDateToICal(new Date(ex), false, tzid), - ]); - }); + 'date-time', + formatDateToICal(new Date(ex), false, tzid) + ]) + }) } if (event.passthroughProps?.length) { const existingKeys = new Set( - (vevent[1] as unknown[]).map((p) => (p as [string])[0].toLowerCase()) - ); + vevent[1].map(p => (p as [string])[0].toLowerCase()) + ) for (const prop of event.passthroughProps) { if (!existingKeys.has(prop[0].toLowerCase())) { - vevent[1].push(prop); + vevent[1].push(prop) } } } - return vevent; + return vevent } diff --git a/src/features/Events/utils/normalizeRepetition.ts b/src/features/Events/utils/normalizeRepetition.ts index 72a2453..07e30d3 100644 --- a/src/features/Events/utils/normalizeRepetition.ts +++ b/src/features/Events/utils/normalizeRepetition.ts @@ -1,17 +1,17 @@ -import { RepetitionObject } from "../EventsTypes"; +import { RepetitionObject } from '../EventsTypes' /** * Normalize repetition object for accurate comparison */ export function normalizeRepetition(repetition: RepetitionObject | undefined): { - freq: string; - interval: number; - byday: string[] | null; - occurrences: number | null; - endDate: string | null; + freq: string + interval: number + byday: string[] | null + occurrences: number | null + endDate: string | null } | null { - if (!repetition || !repetition.freq) return null; + if (!repetition || !repetition.freq) return null return { freq: repetition.freq, @@ -21,6 +21,6 @@ export function normalizeRepetition(repetition: RepetitionObject | undefined): { ? null : [...repetition.byday].sort(), occurrences: repetition.occurrences || null, - endDate: repetition.endDate || null, - }; + endDate: repetition.endDate || null + } } diff --git a/src/features/Events/utils/normalizeTimezone.ts b/src/features/Events/utils/normalizeTimezone.ts index 57d9ad5..d339ef7 100644 --- a/src/features/Events/utils/normalizeTimezone.ts +++ b/src/features/Events/utils/normalizeTimezone.ts @@ -6,6 +6,6 @@ export function normalizeTimezone( timezone: string | undefined | null, resolveTimezone: (tz: string) => string ): string | null { - if (!timezone) return null; - return resolveTimezone(timezone); + if (!timezone) return null + return resolveTimezone(timezone) } diff --git a/src/features/Events/utils/parseCalendarEvent.ts b/src/features/Events/utils/parseCalendarEvent.ts index c4ea9cb..783f53e 100644 --- a/src/features/Events/utils/parseCalendarEvent.ts +++ b/src/features/Events/utils/parseCalendarEvent.ts @@ -1,38 +1,38 @@ -import { convertEventDateTimeToISO } from "@/utils/timezone"; -import moment from "moment-timezone"; -import { Calendar } from "../../Calendars/CalendarTypes"; +import { convertEventDateTimeToISO } from '@/utils/timezone' +import moment from 'moment-timezone' +import { Calendar } from '../../Calendars/CalendarTypes' import { RepetitionRule, VCalComponent, - VObjectProperty, -} from "../../Calendars/types/CalendarData"; -import { userAttendee } from "../../User/models/attendee"; -import { createAttendee } from "../../User/models/attendee.mapper"; -import { AlarmObject, CalendarEvent } from "../EventsTypes"; -import { buildDelegatedEventURL } from "./buildDelegatedEventURL"; -import { formatDateToICal } from "./formatDateToICal"; -import { inferTimezoneFromValue } from "./inferTimezoneFromValue"; + VObjectProperty +} from '../../Calendars/types/CalendarData' +import { userAttendee } from '../../User/models/attendee' +import { createAttendee } from '../../User/models/attendee.mapper' +import { AlarmObject, CalendarEvent } from '../EventsTypes' +import { buildDelegatedEventURL } from './buildDelegatedEventURL' +import { formatDateToICal } from './formatDateToICal' +import { inferTimezoneFromValue } from './inferTimezoneFromValue' const KNOWN_PROPS = new Set([ - "uid", - "transp", - "dtstart", - "dtend", - "class", - "x-openpaas-videoconference", - "summary", - "description", - "location", - "organizer", - "attendee", - "dtstamp", - "sequence", - "recurrence-id", - "exdate", - "status", - "duration", - "rrule", -]); + 'uid', + 'transp', + 'dtstart', + 'dtend', + 'class', + 'x-openpaas-videoconference', + 'summary', + 'description', + 'location', + 'organizer', + 'attendee', + 'dtstamp', + 'sequence', + 'recurrence-id', + 'exdate', + 'status', + 'duration', + 'rrule' +]) export function parseCalendarEvent( data: VObjectProperty[], @@ -41,200 +41,200 @@ export function parseCalendarEvent( eventURL: string, valarm?: VCalComponent ): CalendarEvent { - const event: Partial = { color, attendee: [] }; - let recurrenceId; - let duration; - const dateRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + const event: Partial = { color, attendee: [] } + let recurrenceId + let duration + const dateRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/ for (const [key, params, , value] of data) { switch (key.toLowerCase()) { - case "uid": - event.uid = String(value); - break; - case "transp": - event.transp = String(value); - break; - case "dtstart": { - event.start = String(value); + case 'uid': + event.uid = String(value) + break + case 'transp': + event.transp = String(value) + break + case 'dtstart': { + event.start = String(value) const detectedTz = inferTimezoneFromValue( params as Record - ); + ) if (detectedTz) { - event.timezone = detectedTz; + event.timezone = detectedTz } if (dateRegex.test(String(value))) { - event.allday = true; + event.allday = true } else { - event.allday = false; + event.allday = false } - break; + break } - case "dtend": { - event.end = String(value); + case 'dtend': { + event.end = String(value) if (!event.timezone) { const detectedTz = inferTimezoneFromValue( params as Record - ); + ) if (detectedTz) { - event.timezone = detectedTz; + event.timezone = detectedTz } } if (dateRegex.test(String(value))) { - event.allday = true; + event.allday = true } else { - event.allday = false; + event.allday = false } - break; + break } - case "class": + case 'class': if ( - ["PRIVATE", "PUBLIC", "CONFIDENTIAL"].includes( + ['PRIVATE', 'PUBLIC', 'CONFIDENTIAL'].includes( String(value).toUpperCase() ) ) { - event.class = String(value).toUpperCase() as CalendarEvent["class"]; + event.class = String(value).toUpperCase() as CalendarEvent['class'] } - break; - case "x-openpaas-videoconference": - event.x_openpass_videoconference = String(value); - break; - case "summary": - event.title = String(value); - break; - case "description": - event.description = String(value); - break; - case "location": - event.location = String(value); - break; - case "organizer": { - const paramsObj = params as Record; + break + case 'x-openpaas-videoconference': + event.x_openpass_videoconference = String(value) + break + case 'summary': + event.title = String(value) + break + case 'description': + event.description = String(value) + break + case 'location': + event.location = String(value) + break + case 'organizer': { + const paramsObj = params as Record event.organizer = { - cn: paramsObj?.cn ?? "", - cal_address: String(value).replace(/^mailto:/i, ""), - }; - break; + cn: paramsObj?.cn ?? '', + cal_address: String(value).replace(/^mailto:/i, '') + } + break } - case "attendee": { - const paramsObj = params as Record; + case 'attendee': { + const paramsObj = params as Record if ( !event.attendee?.find( - (attendee) => - attendee.cal_address === String(value).replace(/^mailto:/i, "") + attendee => + attendee.cal_address === String(value).replace(/^mailto:/i, '') ) ) { - (event.attendee as userAttendee[]).push( + ;(event.attendee as userAttendee[]).push( createAttendee({ cn: paramsObj?.cn, - cal_address: String(value).replace(/^mailto:/i, ""), - partstat: paramsObj?.partstat as userAttendee["partstat"], - rsvp: paramsObj?.rsvp as userAttendee["rsvp"], - role: paramsObj?.role as userAttendee["role"], - cutype: paramsObj?.cutype as userAttendee["cutype"], + cal_address: String(value).replace(/^mailto:/i, ''), + partstat: paramsObj?.partstat as userAttendee['partstat'], + rsvp: paramsObj?.rsvp as userAttendee['rsvp'], + role: paramsObj?.role as userAttendee['role'], + cutype: paramsObj?.cutype as userAttendee['cutype'] }) - ); + ) } - break; + break } - case "dtstamp": - event.stamp = String(value); - break; - case "sequence": - event.sequence = Number(value); - break; - case "recurrence-id": - recurrenceId = String(value); - break; - case "exdate": - if (!event.exdates) event.exdates = []; - event.exdates.push(String(value)); - break; - case "status": - event.status = String(value); - break; - case "duration": - duration = String(value); - break; - case "rrule": { - const ruleValue = value as RepetitionRule; - event.repetition = { freq: ruleValue.freq.toLowerCase() }; + case 'dtstamp': + event.stamp = String(value) + break + case 'sequence': + event.sequence = Number(value) + break + case 'recurrence-id': + recurrenceId = String(value) + break + case 'exdate': + if (!event.exdates) event.exdates = [] + event.exdates.push(String(value)) + break + case 'status': + event.status = String(value) + break + case 'duration': + duration = String(value) + break + case 'rrule': { + const ruleValue = value as RepetitionRule + event.repetition = { freq: ruleValue.freq.toLowerCase() } if (ruleValue.byday) { - if (typeof ruleValue.byday === "string") { - event.repetition.byday = [ruleValue.byday]; + if (typeof ruleValue.byday === 'string') { + event.repetition.byday = [ruleValue.byday] } else { - event.repetition.byday = ruleValue.byday; + event.repetition.byday = ruleValue.byday } } if (ruleValue.until) { - event.repetition.endDate = ruleValue.until; + event.repetition.endDate = ruleValue.until } if (ruleValue.count) { - event.repetition.occurrences = ruleValue.count; + event.repetition.occurrences = ruleValue.count } if (ruleValue.interval) { - event.repetition.interval = ruleValue.interval; + event.repetition.interval = ruleValue.interval } - break; + break } } } if (recurrenceId && event.uid) { - event.uid = `${event.uid}/${recurrenceId}`; - event.recurrenceId = recurrenceId; + event.uid = `${event.uid}/${recurrenceId}` + event.recurrenceId = recurrenceId } if (valarm) { - event.alarm = {} as AlarmObject; + event.alarm = {} as AlarmObject for (const [key, , , value] of valarm[1]) { switch (key.toLowerCase()) { - case "action": - event.alarm.action = String(value); - break; - case "trigger": - event.alarm.trigger = String(value); - break; + case 'action': + event.alarm.action = String(value) + break + case 'trigger': + event.alarm.trigger = String(value) + break } } } - event.calId = calendar.id; + event.calId = calendar.id event.URL = calendar.delegated ? buildDelegatedEventURL(calendar, eventURL) - : eventURL; + : eventURL if (!event.uid || !event.start) { console.error( `missing crucial event param in calendar ${calendar.id} `, data - ); - event.error = `missing crucial event param in calendar ${calendar.id} `; + ) + event.error = `missing crucial event param in calendar ${calendar.id} ` } - const eventTimezone = event.timezone; + const eventTimezone = event.timezone if (!event.end) { - const start = event.start ? new Date(event.start) : new Date(); + const start = event.start ? new Date(event.start) : new Date() const timeToAdd = duration ? moment.duration(duration).asMilliseconds() - : moment.duration(30, "minutes").asMilliseconds(); - const artificialEnd = new Date(start.getTime() + timeToAdd); - event.end = formatDateToICal(artificialEnd, false, eventTimezone); + : moment.duration(30, 'minutes').asMilliseconds() + const artificialEnd = new Date(start.getTime() + timeToAdd) + event.end = formatDateToICal(artificialEnd, false, eventTimezone) } if (!event.allday && event.start && eventTimezone) { - const startISO = convertEventDateTimeToISO(event.start, eventTimezone); + const startISO = convertEventDateTimeToISO(event.start, eventTimezone) if (startISO) { - event.start = startISO; + event.start = startISO } } if (!event.allday && event.end && eventTimezone) { - const endISO = convertEventDateTimeToISO(event.end, eventTimezone); + const endISO = convertEventDateTimeToISO(event.end, eventTimezone) if (endISO) { - event.end = endISO; + event.end = endISO } } event.passthroughProps = data.filter( ([key]) => !KNOWN_PROPS.has(key.toLowerCase()) - ); + ) - return event as CalendarEvent; + return event as CalendarEvent } diff --git a/src/features/Search/SearchResultsPage.tsx b/src/features/Search/SearchResultsPage.tsx index effc815..4e67c73 100644 --- a/src/features/Search/SearchResultsPage.tsx +++ b/src/features/Search/SearchResultsPage.tsx @@ -1,113 +1,113 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { AppDispatch } from "@/app/store"; -import { defaultColors } from "@/utils/defaultColors"; -import { browserDefaultTimeZone } from "@/utils/timezone"; +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { AppDispatch } from '@/app/store' +import { defaultColors } from '@/utils/defaultColors' +import { browserDefaultTimeZone } from '@/utils/timezone' import { Box, Button, CircularProgress, IconButton, Stack, - Typography, -} from "@linagora/twake-mui"; -import ArrowBackIcon from "@mui/icons-material/ArrowBack"; -import RepeatIcon from "@mui/icons-material/Repeat"; -import SquareRoundedIcon from "@mui/icons-material/SquareRounded"; -import VideocamIcon from "@mui/icons-material/Videocam"; -import { useState } from "react"; -import { useI18n } from "twake-i18n"; -import logo from "../../static/noResult-logo.svg"; -import { getEventAsync } from "../Calendars/services"; -import EventPreviewModal from "@/features/Events/EventPreview"; -import { CalendarEvent } from "../Events/EventsTypes"; -import { setView } from "../Settings/SettingsSlice"; -import "./searchResult.styl"; -import { SearchEventResult } from "./types/SearchEventResult"; + Typography +} from '@linagora/twake-mui' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import RepeatIcon from '@mui/icons-material/Repeat' +import SquareRoundedIcon from '@mui/icons-material/SquareRounded' +import VideocamIcon from '@mui/icons-material/Videocam' +import { useState } from 'react' +import { useI18n } from 'twake-i18n' +import logo from '../../static/noResult-logo.svg' +import { getEventAsync } from '../Calendars/services' +import EventPreviewModal from '@/features/Events/EventPreview' +import { CalendarEvent } from '../Events/EventsTypes' +import { setView } from '../Settings/SettingsSlice' +import './searchResult.styl' +import { SearchEventResult } from './types/SearchEventResult' const styles = { M3BodyLarge: { - fontFamily: "Roboto", + fontFamily: 'Roboto', fontWeight: 400, - fontStyle: "normal", - fontSize: "22px", - lineHeight: "28px", - letterSpacing: "0%", - color: "#243B55", + fontStyle: 'normal', + fontSize: '22px', + lineHeight: '28px', + letterSpacing: '0%', + color: '#243B55' }, M3BodyMedium1: { - fontFamily: "Inter", + fontFamily: 'Inter', fontWeight: 400, - fontStyle: "normal", - fontSize: "16px", - lineHeight: "24px", - letterSpacing: "-0.15px", - color: "#243B55", + fontStyle: 'normal', + fontSize: '16px', + lineHeight: '24px', + letterSpacing: '-0.15px', + color: '#243B55' }, M3BodyMedium: { - fontFamily: "Roboto", + fontFamily: 'Roboto', fontWeight: 400, - fontStyle: "normal", - fontSize: "14px", - lineHeight: "20px", - letterSpacing: "0.25px", - verticalAlign: "middle", - color: "#8C9CAF", + fontStyle: 'normal', + fontSize: '14px', + lineHeight: '20px', + letterSpacing: '0.25px', + verticalAlign: 'middle', + color: '#8C9CAF' }, M3BodyMedium3: { - fontFamily: "Inter", + fontFamily: 'Inter', fontWeight: 400, - fontSize: "14px", - lineHeight: "20px", - letterSpacing: "0.25px", - verticalAlign: "middle", - color: "#8C9CAF", + fontSize: '14px', + lineHeight: '20px', + letterSpacing: '0.25px', + verticalAlign: 'middle', + color: '#8C9CAF' }, M3TitleMedium: { - fontFamily: "Roboto", + fontFamily: 'Roboto', fontWeight: 500, - fontStyle: "medium", - fontSize: "16px", - lineHeight: "24px", - letterSpacing: "0.15px", - textAlign: "center", - verticalAlign: "middle", - color: "#243B55", - }, -}; + fontStyle: 'medium', + fontSize: '16px', + lineHeight: '24px', + letterSpacing: '0.15px', + textAlign: 'center', + verticalAlign: 'middle', + color: '#243B55' + } +} export default function SearchResultsPage() { - const { t } = useI18n(); - const dispatch = useAppDispatch(); + const { t } = useI18n() + const dispatch = useAppDispatch() const { error, loading, hits, results } = useAppSelector( - (state) => state.searchResult - ); + state => state.searchResult + ) - let layout; + let layout if (loading) { layout = ( - ); + ) } else if (error) { layout = ( {error} - ); + ) } else if (!hits) { layout = ( - {t("search.noResults")} + {t('search.noResults')} - {t("search.noResults")} + {t('search.noResults')} - {t("search.noResultsSubtitle")} + {t('search.noResultsSubtitle')} - ); + ) } else { layout = ( @@ -121,49 +121,48 @@ export default function SearchResultsPage() { ))} - ); + ) } return ( - + dispatch(setView("calendar"))} - aria-label={t("settings.back")} + onClick={() => dispatch(setView('calendar'))} + aria-label={t('settings.back')} > - {t("search.resultsTitle")} + {t('search.resultsTitle')} {layout} - ); + ) } function ResultItem({ eventData, - dispatch, + dispatch }: { - eventData: SearchEventResult; - dispatch: AppDispatch; + eventData: SearchEventResult + dispatch: AppDispatch }) { - const { t } = useI18n(); - const startDate = new Date(eventData.data.start); - const endDate = eventData.data.end ? new Date(eventData.data.end) : startDate; + const { t } = useI18n() + const startDate = new Date(eventData.data.start) + const endDate = eventData.data.end ? new Date(eventData.data.end) : startDate const timeZone = - useAppSelector((state) => state.settings.timeZone) ?? - browserDefaultTimeZone; + useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone const calendar = useAppSelector( - (state) => + state => state.calendars.list[ `${eventData.data.userId}/${eventData.data.calendarId}` ] - ); - const calendarColor = calendar?.color?.light; + ) + const calendarColor = calendar?.color?.light - const [openPreview, setOpenPreview] = useState(false); + const [openPreview, setOpenPreview] = useState(false) const handleOpenResult = async (eventData: SearchEventResult) => { if (calendar) { @@ -181,59 +180,59 @@ function ResultItem({ location: eventData.data.location, organizer: eventData.data.organizer, title: eventData.data.summary, - timezone: timeZone, - } as CalendarEvent; - await dispatch(getEventAsync(event)); - setOpenPreview(true); + timezone: timeZone + } as CalendarEvent + await dispatch(getEventAsync(event)) + setOpenPreview(true) } - }; + } return ( <> handleOpenResult(eventData)} > - - {startDate.toLocaleDateString(t("locale"), { - day: "2-digit", - month: "short", - timeZone, + + {startDate.toLocaleDateString(t('locale'), { + day: '2-digit', + month: 'short', + timeZone })} {startDate.toDateString() !== endDate.toDateString() && ( <> - {" - "} - {endDate.toLocaleDateString(t("locale"), { - day: "2-digit", - month: "short", - timeZone, + {' - '} + {endDate.toLocaleDateString(t('locale'), { + day: '2-digit', + month: 'short', + timeZone })} )} {!eventData.data.allDay && ( - - {startDate.toLocaleTimeString(t("locale"), { - hour: "2-digit", - minute: "2-digit", - timeZone, + + {startDate.toLocaleTimeString(t('locale'), { + hour: '2-digit', + minute: '2-digit', + timeZone })} - - {endDate.toLocaleTimeString(t("locale"), { - hour: "2-digit", - minute: "2-digit", - timeZone, + {endDate.toLocaleTimeString(t('locale'), { + hour: '2-digit', + minute: '2-digit', + timeZone })} )} @@ -243,12 +242,12 @@ function ResultItem({ color: calendarColor ?? defaultColors[0].light, width: 24, height: 24, - flexShrink: 0, + flexShrink: 0 }} /> - {eventData.data.summary || t("event.untitled")} + {eventData.data.summary || t('event.untitled')} {eventData.data.isRecurrentMaster && } @@ -256,11 +255,11 @@ function ResultItem({ {eventData.data.organizer.cn || eventData.data.organizer.email} @@ -270,11 +269,11 @@ function ResultItem({ {eventData.data.location} @@ -284,30 +283,30 @@ function ResultItem({ - {eventData.data.description.replace(/\n/g, " ")} + {eventData.data.description.replace(/\n/g, ' ')} )} - {eventData.data["x-openpaas-videoconference"] && ( + {eventData.data['x-openpaas-videoconference'] && ( )} @@ -320,5 +319,5 @@ function ResultItem({ /> )} - ); + ) } diff --git a/src/features/Search/SearchSlice.ts b/src/features/Search/SearchSlice.ts index 22e1da7..757c548 100644 --- a/src/features/Search/SearchSlice.ts +++ b/src/features/Search/SearchSlice.ts @@ -1,78 +1,78 @@ -import { formatReduxError } from "@/utils/errorUtils"; -import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { searchEvent } from "../Events/EventApi"; +import { formatReduxError } from '@/utils/errorUtils' +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit' +import { searchEvent } from '../Events/EventApi' export interface SearchResultsState { - hits: number; - results: Record[]; - error: string | null; - loading: boolean; + hits: number + results: Record[] + error: string | null + loading: boolean } const initialState: SearchResultsState = { results: [], hits: 0, error: null, - loading: false, -}; + loading: false +} export const searchEventsAsync = createAsyncThunk< { hits: number; events: Record[] }, { - search: string; + search: string filters: { - searchIn: string[]; - keywords: string; - organizers: string[]; - attendees: string[]; - }; + searchIn: string[] + keywords: string + organizers: string[] + attendees: string[] + } }, { rejectValue: { message: string; status?: number } } ->("events/searchEvents", async ({ search, filters }, { rejectWithValue }) => { +>('events/searchEvents', async ({ search, filters }, { rejectWithValue }) => { try { - const response = await searchEvent(search, filters); + const response = await searchEvent(search, filters) return { hits: Number(response._total_hits), - events: response._embedded?.events ?? [], - }; + events: response._embedded?.events ?? [] + } } catch (err) { - const error = err as { response?: { status?: number } }; + const error = err as { response?: { status?: number } } return rejectWithValue({ message: formatReduxError(err), - status: error.response?.status, - }); + status: error.response?.status + }) } -}); +}) export const searchResultsSlice = createSlice({ - name: "settings", + name: 'settings', initialState, reducers: { setResults: (state, action: PayloadAction<[]>) => { - state.results = action.payload; + state.results = action.payload }, setHits: (state, action: PayloadAction) => { - state.hits = action.payload; - }, + state.hits = action.payload + } }, - extraReducers: (builder) => { + extraReducers: builder => { builder - .addCase(searchEventsAsync.pending, (state) => { - state.loading = true; - state.error = null; // reset error on new search + .addCase(searchEventsAsync.pending, state => { + state.loading = true + state.error = null // reset error on new search }) .addCase(searchEventsAsync.fulfilled, (state, action) => { - state.loading = false; - state.hits = action.payload.hits; - state.results = action.payload.events; + state.loading = false + state.hits = action.payload.hits + state.results = action.payload.events }) .addCase(searchEventsAsync.rejected, (state, action) => { - state.loading = false; - state.error = action.payload?.message || "Unknown error occurred"; - }); - }, -}); + state.loading = false + state.error = action.payload?.message || 'Unknown error occurred' + }) + } +}) -export const { setResults, setHits } = searchResultsSlice.actions; -export default searchResultsSlice.reducer; +export const { setResults, setHits } = searchResultsSlice.actions +export default searchResultsSlice.reducer diff --git a/src/features/Search/types/SearchEventData.ts b/src/features/Search/types/SearchEventData.ts index 36f4c11..7b936de 100644 --- a/src/features/Search/types/SearchEventData.ts +++ b/src/features/Search/types/SearchEventData.ts @@ -1,20 +1,20 @@ export type SearchEventData = { - uid: string; - userId: string; - calendarId: string; - start: string; - end?: string; - allDay?: boolean; - summary?: string; - description?: string; - location?: string; - class?: string; - dtstamp?: string; - isRecurrentMaster?: boolean; - attendees?: unknown[]; + uid: string + userId: string + calendarId: string + start: string + end?: string + allDay?: boolean + summary?: string + description?: string + location?: string + class?: string + dtstamp?: string + isRecurrentMaster?: boolean + attendees?: unknown[] organizer?: { - cn?: string; - email?: string; - }; - ["x-openpaas-videoconference"]?: string; -}; + cn?: string + email?: string + } + ['x-openpaas-videoconference']?: string +} diff --git a/src/features/Search/types/SearchEventResult.ts b/src/features/Search/types/SearchEventResult.ts index 8cb32d6..b94e8fc 100644 --- a/src/features/Search/types/SearchEventResult.ts +++ b/src/features/Search/types/SearchEventResult.ts @@ -1,10 +1,10 @@ -import { SearchEventData } from "./SearchEventData"; +import { SearchEventData } from './SearchEventData' export type SearchEventResult = { - data: SearchEventData; + data: SearchEventData _links: { self: { - href: string; - }; - }; -}; + href: string + } + } +} diff --git a/src/features/Search/types/SearchEventsResponse.ts b/src/features/Search/types/SearchEventsResponse.ts index abdef01..0dfb96d 100644 --- a/src/features/Search/types/SearchEventsResponse.ts +++ b/src/features/Search/types/SearchEventsResponse.ts @@ -1,6 +1,6 @@ export type SearchEventsResponse = { - _total_hits?: number | string; + _total_hits?: number | string _embedded?: { - events?: Record[]; - }; -}; + events?: Record[] + } +} diff --git a/src/features/Settings/GeneralSettings.tsx b/src/features/Settings/GeneralSettings.tsx index f8a7058..79d1b84 100644 --- a/src/features/Settings/GeneralSettings.tsx +++ b/src/features/Settings/GeneralSettings.tsx @@ -1,8 +1,8 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { useTimeZoneList } from "@/components/Calendar/TimezoneSelector"; -import { WeekDaySelector } from "@/components/Event/WeekDaySelector"; -import { TimezoneAutocomplete } from "@/components/Timezone/TimezoneAutocomplete"; -import { browserDefaultTimeZone, getTimezoneOffset } from "@/utils/timezone"; +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { useTimeZoneList } from '@/components/Calendar/TimezoneSelector' +import { WeekDaySelector } from '@/components/Event/WeekDaySelector' +import { TimezoneAutocomplete } from '@/components/Timezone/TimezoneAutocomplete' +import { browserDefaultTimeZone, getTimezoneOffset } from '@/utils/timezone' import { Box, FormControl, @@ -11,16 +11,16 @@ import { Select, SelectChangeEvent, Switch, - Typography, -} from "@linagora/twake-mui"; -import { useRef, useCallback, useEffect } from "react"; -import { useI18n } from "twake-i18n"; + Typography +} from '@linagora/twake-mui' +import { useRef, useCallback, useEffect } from 'react' +import { useI18n } from 'twake-i18n' import { setLanguage as setUserLanguage, setTimezone as setUserTimeZone, - updateUserConfigurationsAsync, -} from "../User/userSlice"; -import { AVAILABLE_LANGUAGES } from "./constants"; + updateUserConfigurationsAsync +} from '../User/userSlice' +import { AVAILABLE_LANGUAGES } from './constants' import { BusinessHour, setBusinessHours, @@ -29,15 +29,15 @@ import { setIsBrowserDefaultTimeZone, setLanguage as setSettingsLanguage, setTimeZone as setSettingsTimeZone, - setWorkingDays, -} from "./SettingsSlice"; + setWorkingDays +} from './SettingsSlice' interface GeneralSettingsProps { - onLanguageError: () => void; - onTimeZoneError: () => void; - onHideDeclinedEventsError: () => void; - onDisplayWeekNumbersError: () => void; - onWorkingDaysError: () => void; + onLanguageError: () => void + onTimeZoneError: () => void + onHideDeclinedEventsError: () => void + onDisplayWeekNumbersError: () => void + onWorkingDaysError: () => void } export function GeneralSettings({ @@ -45,175 +45,173 @@ export function GeneralSettings({ onTimeZoneError, onHideDeclinedEventsError, onDisplayWeekNumbersError, - onWorkingDaysError, + onWorkingDaysError }: GeneralSettingsProps) { - const dispatch = useAppDispatch(); - const { t } = useI18n(); + const dispatch = useAppDispatch() + const { t } = useI18n() - const previousConfig = useAppSelector((state) => state.user.coreConfig); - const userLanguage = useAppSelector( - (state) => state.user?.coreConfig.language - ); - const settingsLanguage = useAppSelector((state) => state.settings?.language); - const currentLanguage = userLanguage || settingsLanguage || "en"; + const previousConfig = useAppSelector(state => state.user.coreConfig) + const userLanguage = useAppSelector(state => state.user?.coreConfig.language) + const settingsLanguage = useAppSelector(state => state.settings?.language) + const currentLanguage = userLanguage || settingsLanguage || 'en' - const timezoneList = useTimeZoneList(); + const timezoneList = useTimeZoneList() const userTimeZone = useAppSelector( - (state) => state.user?.coreConfig?.datetime?.timeZone - ); - const settingTimeZone = useAppSelector((state) => state.settings?.timeZone); + state => state.user?.coreConfig?.datetime?.timeZone + ) + const settingTimeZone = useAppSelector(state => state.settings?.timeZone) const currentTimeZone = - userTimeZone ?? settingTimeZone ?? browserDefaultTimeZone; + userTimeZone ?? settingTimeZone ?? browserDefaultTimeZone const isBrowserDefault = useAppSelector( - (state) => state.settings.isBrowserDefaultTimeZone - ); + state => state.settings.isBrowserDefaultTimeZone + ) const hideDeclinedEvents = useAppSelector( - (state) => state.settings?.hideDeclinedEvents - ); + state => state.settings?.hideDeclinedEvents + ) const displayWeekNumbers = useAppSelector( - (state) => state.settings?.displayWeekNumbers - ); - const workingDays = useAppSelector((state) => state.settings.workingDays); - const businessHours = useAppSelector((state) => state.settings.businessHours); - const pendingBusinessHoursRef = useRef(null); + state => state.settings?.displayWeekNumbers + ) + const workingDays = useAppSelector(state => state.settings.workingDays) + const businessHours = useAppSelector(state => state.settings.businessHours) + const pendingBusinessHoursRef = useRef(null) const businessHoursTimeoutRef = useRef | null>( null - ); + ) const handleLanguageChange = (event: SelectChangeEvent) => { - const newLanguage = event.target.value; - const previousLanguage = currentLanguage; - dispatch(setUserLanguage(newLanguage)); - dispatch(setSettingsLanguage(newLanguage)); + const newLanguage = event.target.value + const previousLanguage = currentLanguage + dispatch(setUserLanguage(newLanguage)) + dispatch(setSettingsLanguage(newLanguage)) dispatch(updateUserConfigurationsAsync({ language: newLanguage })) .unwrap() .catch(() => { - dispatch(setUserLanguage(previousLanguage)); - dispatch(setSettingsLanguage(previousLanguage)); - onLanguageError(); - }); - }; + dispatch(setUserLanguage(previousLanguage)) + dispatch(setSettingsLanguage(previousLanguage)) + onLanguageError() + }) + } const handleTimeZoneChange = (newTimeZone: string) => { - const previousTimeZone = currentTimeZone; - dispatch(setUserTimeZone(newTimeZone)); - dispatch(setSettingsTimeZone(newTimeZone)); + const previousTimeZone = currentTimeZone + dispatch(setUserTimeZone(newTimeZone)) + dispatch(setSettingsTimeZone(newTimeZone)) dispatch( updateUserConfigurationsAsync({ timezone: newTimeZone, previousConfig }) ) .unwrap() .catch(() => { - dispatch(setUserTimeZone(previousTimeZone)); - dispatch(setSettingsTimeZone(previousTimeZone)); - onTimeZoneError(); - }); - }; + dispatch(setUserTimeZone(previousTimeZone)) + dispatch(setSettingsTimeZone(previousTimeZone)) + onTimeZoneError() + }) + } const handleTimeZoneDefaultChange = (isDefault: boolean) => { - const previousTimeZone = currentTimeZone; - dispatch(setIsBrowserDefaultTimeZone(isDefault)); + const previousTimeZone = currentTimeZone + dispatch(setIsBrowserDefaultTimeZone(isDefault)) if (isDefault) { - dispatch(setUserTimeZone(null)); - dispatch(setSettingsTimeZone(browserDefaultTimeZone)); + dispatch(setUserTimeZone(null)) + dispatch(setSettingsTimeZone(browserDefaultTimeZone)) dispatch( updateUserConfigurationsAsync({ timezone: null, previousConfig }) ) .unwrap() .catch(() => { - dispatch(setUserTimeZone(previousTimeZone)); - dispatch(setSettingsTimeZone(previousTimeZone)); - dispatch(setIsBrowserDefaultTimeZone(!isDefault)); - onTimeZoneError(); - }); + dispatch(setUserTimeZone(previousTimeZone)) + dispatch(setSettingsTimeZone(previousTimeZone)) + dispatch(setIsBrowserDefaultTimeZone(!isDefault)) + onTimeZoneError() + }) } - }; + } const handleHideDeclinedEvents = (value: boolean) => { - dispatch(setHideDeclinedEvents(value)); + dispatch(setHideDeclinedEvents(value)) dispatch(updateUserConfigurationsAsync({ hideDeclinedEvents: value })) .unwrap() .catch(() => { - dispatch(setHideDeclinedEvents(!value)); - onHideDeclinedEventsError(); - }); - }; + dispatch(setHideDeclinedEvents(!value)) + onHideDeclinedEventsError() + }) + } const handleDisplayWeekNumbers = (value: boolean) => { - dispatch(setDisplayWeekNumbers(value)); + dispatch(setDisplayWeekNumbers(value)) dispatch(updateUserConfigurationsAsync({ displayWeekNumbers: value })) .unwrap() .catch(() => { - dispatch(setDisplayWeekNumbers(!value)); - onDisplayWeekNumbersError(); - }); - }; + dispatch(setDisplayWeekNumbers(!value)) + onDisplayWeekNumbersError() + }) + } const handleBusinessHour = useCallback( ({ days }: { days: number[] }) => { - const previousHours = businessHours; + const previousHours = businessHours const value: BusinessHour | null = businessHours ? { ...businessHours, daysOfWeek: days } - : null; + : null - dispatch(setBusinessHours(value)); - pendingBusinessHoursRef.current = value; + dispatch(setBusinessHours(value)) + pendingBusinessHoursRef.current = value if (businessHoursTimeoutRef.current) { - clearTimeout(businessHoursTimeoutRef.current); + clearTimeout(businessHoursTimeoutRef.current) } businessHoursTimeoutRef.current = setTimeout(() => { dispatch( updateUserConfigurationsAsync({ - businessHours: pendingBusinessHoursRef.current, + businessHours: pendingBusinessHoursRef.current }) ) .unwrap() .catch(() => { - dispatch(setBusinessHours(previousHours)); - onWorkingDaysError(); - }); - }, 500); + dispatch(setBusinessHours(previousHours)) + onWorkingDaysError() + }) + }, 500) }, [businessHours, dispatch, onWorkingDaysError] - ); + ) useEffect(() => { return () => { if (businessHoursTimeoutRef.current) { - clearTimeout(businessHoursTimeoutRef.current); + clearTimeout(businessHoursTimeoutRef.current) } - }; - }, []); + } + }, []) const handleWorkingDays = (value: boolean) => { - dispatch(setWorkingDays(value)); + dispatch(setWorkingDays(value)) dispatch(updateUserConfigurationsAsync({ workingDays: value })) .unwrap() .catch(() => { - dispatch(setWorkingDays(!value)); - onWorkingDaysError(); - }); - }; + dispatch(setWorkingDays(!value)) + onWorkingDaysError() + }) + } return ( {/* Language */} - {t("settings.language") || "Language"} + {t('settings.language') || 'Language'} - {t("settings.languageDescription") || - "This will be the language used in your Twake Calendar"} + {t('settings.languageDescription') || + 'This will be the language used in your Twake Calendar'}