diff --git a/.changeset/nervous-crabs-hang.md b/.changeset/nervous-crabs-hang.md new file mode 100644 index 00000000..5a79c7c5 --- /dev/null +++ b/.changeset/nervous-crabs-hang.md @@ -0,0 +1,5 @@ +--- +'@nordeck/matrix-meetings-widget': minor +--- + +Support deleting a single occurrence of a recurring meeting. diff --git a/e2e/src/pages/meetingCardPage.ts b/e2e/src/pages/meetingCardPage.ts index d7c725f7..5cab9c9e 100644 --- a/e2e/src/pages/meetingCardPage.ts +++ b/e2e/src/pages/meetingCardPage.ts @@ -42,13 +42,17 @@ export class MeetingCardPage { return new MoreSettingsMenuPage(this.widget.getByRole('menu')); } - async deleteMeeting() { + async deleteMeeting(buttonLabel: string = 'Delete') { const menu = await this.openMoreSettingsMenu(); await menu.deleteMenuItem.click(); await this.widget .getByRole('dialog', { name: 'Delete meeting' }) - .getByRole('button', { name: 'Delete' }) + .getByRole('button', { name: buttonLabel }) .click(); + + await this.widget + .getByRole('dialog', { name: 'Delete meeting' }) + .waitFor({ state: 'hidden' }); } async editMeeting(): Promise { diff --git a/e2e/src/recurringMeetings.spec.ts b/e2e/src/recurringMeetings.spec.ts index 354d0d8f..0ed6c3ce 100644 --- a/e2e/src/recurringMeetings.spec.ts +++ b/e2e/src/recurringMeetings.spec.ts @@ -364,14 +364,47 @@ test.describe('Recurring Meetings', () => { await expect(meeting.meetingTimeRangeText).toHaveText( '10:30 AM – 11:30 AM. Recurrence: Every day for 5 times', ); - await meeting.deleteMeeting(); + await meeting.deleteMeeting('Delete series'); await expect( aliceMeetingsWidgetPage.getMeeting('My Meeting').card, ).toHaveCount(0); }); - // TODO: Delete single meeting + test('should delete a single recurring meeting', async ({ + aliceMeetingsWidgetPage, + }) => { + await aliceMeetingsWidgetPage.setDateFilter([2040, 10, 1], [2040, 10, 9]); + + const aliceScheduleMeetingWidgetPage = + await aliceMeetingsWidgetPage.scheduleMeeting(); + + await aliceScheduleMeetingWidgetPage.titleTextbox.fill('My Meeting'); + await aliceScheduleMeetingWidgetPage.descriptionTextbox.fill( + 'My Description', + ); + await aliceScheduleMeetingWidgetPage.setStart([2040, 10, 3], '10:30 AM'); + await aliceScheduleMeetingWidgetPage.selectRecurrence('daily'); + await aliceScheduleMeetingWidgetPage.setEndAfterMeetingCount(5); + await aliceScheduleMeetingWidgetPage.submit(); + + const meeting = aliceMeetingsWidgetPage.getMeeting( + 'My Meeting', + '10/03/2040', + ); + await expect(meeting.meetingTimeRangeText).toHaveText( + '10:30 AM – 11:30 AM. Recurrence: Every day for 5 times', + ); + await meeting.deleteMeeting('Delete meeting'); + + await expect( + aliceMeetingsWidgetPage.getMeeting('My Meeting', '10/03/2040').card, + ).toHaveCount(0); + + await expect( + aliceMeetingsWidgetPage.getMeeting('My Meeting').card, + ).toHaveCount(4); + }); // TODO: Delete starting from }); diff --git a/matrix-meetings-widget/public/locales/de/translation.json b/matrix-meetings-widget/public/locales/de/translation.json index cb9caeca..312144f8 100644 --- a/matrix-meetings-widget/public/locales/de/translation.json +++ b/matrix-meetings-widget/public/locales/de/translation.json @@ -73,11 +73,6 @@ }, "meetingCard": { "actions": "Aktionen", - "deleteConfirmButton": "Löschen", - "deleteConfirmHeader": "Besprechung löschen", - "deleteConfirmMessage": "Du bist dabei die Besprechung „{{title}}“ am {{startTime, datetime}} und alles darin zu löschen. Bist du sicher?", - "deleteFailed": "Bitte versuche es erneut.", - "deleteFailedTitle": "Fehler beim Löschen der Besprechung", "deleteInOpenXchangeMenu": "Besprechung in Open-Xchange löschen", "deleteMenu": "Besprechung löschen", "durationSubheader_default": "{{startDate, datetime}} – {{endDate, datetime}}", @@ -160,7 +155,10 @@ "deleteFailed": "Bitte versuche es erneut.", "deleteFailedTitle": "Fehler beim Löschen der Besprechung", "deleteInOpenXchangeMenu": "Besprechung in Open-Xchange löschen", + "deleteMeetingConfirmButton": "Besprechung löschen", "deleteMenu": "Löschen", + "deleteSeriesConfirmButton": "Besprechungsserie löschen", + "deleteSeriesConfirmMessage": "Du bist dabei die Besprechung oder die Besprechungsserie „{{title}}“ am {{startTime, datetime}} und alles darin zu löschen. Bist du sicher?", "editInOpenXchangeMenu": "Besprechung in Open-Xchange bearbeiten", "editMenu": "Bearbeiten", "join": "Beitreten", diff --git a/matrix-meetings-widget/public/locales/en/translation.json b/matrix-meetings-widget/public/locales/en/translation.json index a535b27a..baf60dc1 100644 --- a/matrix-meetings-widget/public/locales/en/translation.json +++ b/matrix-meetings-widget/public/locales/en/translation.json @@ -73,11 +73,6 @@ }, "meetingCard": { "actions": "Actions", - "deleteConfirmButton": "Delete", - "deleteConfirmHeader": "Delete meeting", - "deleteConfirmMessage": "Are you sure you want to delete the meeting “{{title}}” on {{startTime, datetime}} and every content related to it?", - "deleteFailed": "Please try again.", - "deleteFailedTitle": "Failed to delete the meeting", "deleteInOpenXchangeMenu": "Delete meeting in Open-Xchange", "deleteMenu": "Delete meeting", "durationSubheader_default": "{{startDate, datetime}} – {{endDate, datetime}}", @@ -160,7 +155,10 @@ "deleteFailed": "Please try again.", "deleteFailedTitle": "Failed to delete the meeting", "deleteInOpenXchangeMenu": "Delete meeting in Open-Xchange", + "deleteMeetingConfirmButton": "Delete meeting", "deleteMenu": "Delete", + "deleteSeriesConfirmButton": "Delete series", + "deleteSeriesConfirmMessage": "Are you sure you want to delete the meeting or the meeting series “{{title}}” on {{startTime, datetime}} and every content related to it?", "editInOpenXchangeMenu": "Edit meeting in Open-Xchange", "editMenu": "Edit", "join": "Join", diff --git a/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.test.tsx b/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.test.tsx index e9795d0e..40ae336a 100644 --- a/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.test.tsx +++ b/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'; @@ -140,4 +140,77 @@ describe('', () => { expect(onConfirm).not.toBeCalled(); expect(onCancel).toBeCalledTimes(1); }); + + it('should render additional buttons', async () => { + render( + Example} + />, + ); + + const deleteModal = screen.getByRole('dialog', { + name: 'Confirm the deletion', + }); + + expect( + within(deleteModal).getByRole('button', { name: 'Example' }), + ).toBeInTheDocument(); + }); + + it('should call onEnter every time the dialog is displayed', async () => { + const onEnter = jest.fn(); + + const { rerender } = render( + , + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(onEnter).toBeCalledTimes(1); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + expect(onEnter).toBeCalledTimes(1); + + rerender( + , + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(onEnter).toBeCalledTimes(2); + }); }); diff --git a/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx b/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx index d30b1b89..5012afcb 100644 --- a/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx +++ b/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx @@ -24,7 +24,11 @@ import { DialogTitle, } from '@mui/material'; import { unstable_useId as useId } from '@mui/utils'; -import React, { DispatchWithoutAction, PropsWithChildren } from 'react'; +import React, { + DispatchWithoutAction, + PropsWithChildren, + ReactElement, +} from 'react'; import { useTranslation } from 'react-i18next'; type ConfirmDeleteDialogProps = PropsWithChildren<{ @@ -33,8 +37,10 @@ type ConfirmDeleteDialogProps = PropsWithChildren<{ description: string; confirmTitle: string; loading?: boolean; + additionalButtons?: ReactElement; onCancel: DispatchWithoutAction; onConfirm: DispatchWithoutAction; + onEnter?: DispatchWithoutAction; }>; export function ConfirmDeleteDialog({ @@ -43,8 +49,10 @@ export function ConfirmDeleteDialog({ description, confirmTitle, loading, + additionalButtons, onCancel, onConfirm, + onEnter, children, }: ConfirmDeleteDialogProps) { const { t } = useTranslation(); @@ -58,6 +66,7 @@ export function ConfirmDeleteDialog({ aria-labelledby={dialogTitleId} onClose={onCancel} open={open} + onTransitionEnter={onEnter} > {title} @@ -75,6 +84,9 @@ export function ConfirmDeleteDialog({ + + {additionalButtons} + { - try { - const { acknowledgement } = await closeMeeting({ - roomId: meeting.meetingId, - }).unwrap(); - - if (!acknowledgement?.error) { - const isInMeetingRoom = - meeting.meetingId === widgetApi.widgetParameters.roomId; - - if (isInMeetingRoom && meeting.parentRoomId) { - navigateToRoom(widgetApi, meeting.parentRoomId); - } - - handleCloseDeleteConfirm(); - } - } catch { - // ignore - } - }, [ - closeMeeting, - handleCloseDeleteConfirm, - meeting.meetingId, - meeting.parentRoomId, - widgetApi, - ]); - const dispatch = useAppDispatch(); const handleClickEditMeeting = useCallback(async () => { try { @@ -161,7 +123,6 @@ export function MeetingCardMenu({ {canUpdateMeeting && isExternalReference && ( } - - {meeting.deletionTime !== undefined && ( - - )} - - {(isError || deleteResponse?.acknowledgement.error) && ( - - - {t( - 'meetingCard.deleteFailedTitle', - 'Failed to delete the meeting', - )} - - {t('meetingCard.deleteFailed', 'Please try again.')} - - )} - + /> ); } diff --git a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.test.tsx b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.test.tsx new file mode 100644 index 00000000..c7249052 --- /dev/null +++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.test.tsx @@ -0,0 +1,760 @@ +/* + * Copyright 2023 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { WidgetApiMockProvider } from '@matrix-widget-toolkit/react'; +import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; +import { ComponentType, PropsWithChildren, useState } from 'react'; +import { Provider } from 'react-redux'; +import { + acknowledgeAllEvents, + mockCalendarEntry, + mockCreateMeetingRoom, + mockMeeting, +} from '../../../../lib/testUtils'; +import { createStore } from '../../../../store'; +import { initializeStore } from '../../../../store/store'; +import { + DeleteMeetingDialog, + deleteSingleMeetingOccurrenceThunk, + selectIsLastMeetingOccurrence, +} from './DeleteMeetingDialog'; + +let widgetApi: MockedWidgetApi; + +afterEach(() => widgetApi.stop()); + +beforeEach(() => (widgetApi = mockWidgetApi())); + +describe('', () => { + const onClose = jest.fn(); + let Wrapper: ComponentType>; + + beforeEach(() => { + Wrapper = ({ children }: PropsWithChildren<{}>) => { + const [store] = useState(() => { + const store = createStore({ widgetApi }); + initializeStore(store); + return store; + }); + return ( + + {children} + + ); + }; + }); + + it('should render without exploding', () => { + render( + , + { wrapper: Wrapper }, + ); + + const dialog = screen.getByRole('dialog', { + name: 'Delete meeting', + description: + 'Are you sure you want to delete the meeting “An important meeting” on Jan 1, 10:00 AM and every content related to it?', + }); + + expect( + within(dialog).getByRole('heading', { level: 2, name: 'Delete meeting' }), + ).toBeInTheDocument(); + expect( + within(dialog).getByText( + 'Are you sure you want to delete the meeting “An important meeting” on Jan 1, 10:00 AM and every content related to it?', + ), + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: 'Cancel' }), + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: 'Delete' }), + ).toBeInTheDocument(); + }); + + it('should have no accessibility violations', async () => { + const { container } = render( + , + { wrapper: Wrapper }, + ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it('should delete the meeting', async () => { + widgetApi + .observeRoomEvents('net.nordeck.meetings.meeting.close') + .subscribe(acknowledgeAllEvents(widgetApi)); + + const meeting = mockMeeting(); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + render(, { + wrapper: Wrapper, + }); + + const dialog = screen.getByRole('dialog', { + name: 'Delete meeting', + description: + 'Are you sure you want to delete the meeting “An important meeting” on Jan 1, 10:00 AM and every content related to it?', + }); + + await userEvent.click( + within(dialog).getByRole('button', { name: 'Delete' }), + ); + + await waitFor(() => { + expect(widgetApi.sendRoomEvent).toBeCalledWith( + 'net.nordeck.meetings.meeting.close', + { + context: { locale: 'en', timezone: 'UTC' }, + data: { target_room_id: '!meeting-room-id' }, + }, + ); + }); + + await waitFor(() => { + expect(onClose).toBeCalled(); + }); + }); + + it('should delete recurring meeting', async () => { + widgetApi + .observeRoomEvents('net.nordeck.meetings.meeting.close') + .subscribe(acknowledgeAllEvents(widgetApi)); + + const meeting = mockMeeting({ + content: { + calendarEntries: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + }), + ], + }, + }); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + render(, { + wrapper: Wrapper, + }); + + const dialog = screen.getByRole('dialog', { + name: 'Delete meeting', + description: + 'Are you sure you want to delete the meeting or the meeting series “An important meeting” on Jan 1, 10:00 AM and every content related to it?', + }); + + await userEvent.click( + within(dialog).getByRole('button', { name: 'Delete series' }), + ); + + await waitFor(() => { + expect(widgetApi.sendRoomEvent).toBeCalledWith( + 'net.nordeck.meetings.meeting.close', + { + context: { locale: 'en', timezone: 'UTC' }, + data: { target_room_id: '!meeting-room-id' }, + }, + ); + }); + + await waitFor(() => { + expect(onClose).toBeCalled(); + }); + }); + + it('should delete one occurrence of a recurring meeting', async () => { + widgetApi + .observeRoomEvents('net.nordeck.meetings.meeting.update') + .subscribe(acknowledgeAllEvents(widgetApi)); + + const meeting = mockMeeting({ + content: { + startTime: '2999-01-02T10:00:00Z', + endTime: '2999-01-02T14:00:00Z', + recurrenceId: '2999-01-02T10:00:00Z', + calendarEntries: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + }), + ], + }, + }); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + render(, { + wrapper: Wrapper, + }); + + const dialog = screen.getByRole('dialog', { + name: 'Delete meeting', + description: + 'Are you sure you want to delete the meeting or the meeting series “An important meeting” on Jan 2, 10:00 AM and every content related to it?', + }); + + await userEvent.click( + within(dialog).getByRole('button', { name: 'Delete meeting' }), + ); + + await waitFor(() => { + expect(widgetApi.sendRoomEvent).toBeCalledWith( + 'net.nordeck.meetings.meeting.update', + { + context: expect.anything(), + data: { + target_room_id: '!meeting-room-id', + calendar: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + exdate: ['29990102T100000'], + }), + ], + }, + }, + ); + }); + + await waitFor(() => { + expect(onClose).toBeCalled(); + }); + }); + + it('should delete the last occurrence of a recurring meeting', async () => { + widgetApi + .observeRoomEvents('net.nordeck.meetings.meeting.close') + .subscribe(acknowledgeAllEvents(widgetApi)); + + const meeting = mockMeeting({ + content: { + startTime: '2999-01-02T10:00:00Z', + endTime: '2999-01-02T14:00:00Z', + recurrenceId: '2999-01-02T10:00:00Z', + calendarEntries: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY;COUNT=2', + exdate: ['29990101T100000'], + }), + ], + }, + }); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + render(, { + wrapper: Wrapper, + }); + + const dialog = await screen.findByRole('dialog', { + name: 'Delete meeting', + description: + 'Are you sure you want to delete the meeting “An important meeting” on Jan 2, 10:00 AM and every content related to it?', + }); + + await userEvent.click( + within(dialog).getByRole('button', { name: 'Delete' }), + ); + + await waitFor(() => { + expect(widgetApi.sendRoomEvent).toBeCalledWith( + 'net.nordeck.meetings.meeting.close', + { + context: { locale: 'en', timezone: 'UTC' }, + data: { target_room_id: '!meeting-room-id' }, + }, + ); + }); + + await waitFor(() => { + expect(onClose).toBeCalled(); + }); + }); + + it('should show error if deletion failed', async () => { + widgetApi + .observeRoomEvents('net.nordeck.meetings.meeting.close') + .subscribe(acknowledgeAllEvents(widgetApi, { key: 'X' })); + + const meeting = mockMeeting(); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + render(, { + wrapper: Wrapper, + }); + + const dialog = screen.getByRole('dialog', { + name: 'Delete meeting', + description: + 'Are you sure you want to delete the meeting “An important meeting” on Jan 1, 10:00 AM and every content related to it?', + }); + + await userEvent.click( + within(dialog).getByRole('button', { name: 'Delete' }), + ); + + const alert = await screen.findByRole('alert'); + expect( + within(alert).getByText('Failed to delete the meeting'), + ).toBeInTheDocument(); + expect(within(alert).getByText('Please try again.')).toBeInTheDocument(); + + expect( + within(dialog).getByRole('button', { name: 'Delete' }), + ).toBeEnabled(); + }); + + it('should show error if deletion of meeting series failed', async () => { + widgetApi + .observeRoomEvents('net.nordeck.meetings.meeting.close') + .subscribe(acknowledgeAllEvents(widgetApi, { key: 'X' })); + + const meeting = mockMeeting({ + content: { + calendarEntries: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + }), + ], + }, + }); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + render(, { + wrapper: Wrapper, + }); + + const dialog = screen.getByRole('dialog', { name: 'Delete meeting' }); + + await userEvent.click( + within(dialog).getByRole('button', { name: 'Delete series' }), + ); + + const alert = await screen.findByRole('alert'); + expect( + within(alert).getByText('Failed to delete the meeting'), + ).toBeInTheDocument(); + expect(within(alert).getByText('Please try again.')).toBeInTheDocument(); + + expect( + within(dialog).getByRole('button', { name: 'Delete series' }), + ).toBeEnabled(); + expect( + within(dialog).getByRole('button', { name: 'Delete meeting' }), + ).toBeEnabled(); + }); + + it('should show error if deletion of meeting series occurrence failed', async () => { + widgetApi + .observeRoomEvents('net.nordeck.meetings.meeting.update') + .subscribe(acknowledgeAllEvents(widgetApi, { key: 'X' })); + + const meeting = mockMeeting({ + content: { + startTime: '2999-01-02T10:00:00Z', + endTime: '2999-01-02T14:00:00Z', + recurrenceId: '2999-01-02T10:00:00Z', + calendarEntries: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + }), + ], + }, + }); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + render(, { + wrapper: Wrapper, + }); + + const dialog = screen.getByRole('dialog', { name: 'Delete meeting' }); + + await userEvent.click( + within(dialog).getByRole('button', { name: 'Delete meeting' }), + ); + + const alert = await screen.findByRole('alert'); + expect( + within(alert).getByText('Failed to delete the meeting'), + ).toBeInTheDocument(); + expect(within(alert).getByText('Please try again.')).toBeInTheDocument(); + + expect( + within(dialog).getByRole('button', { name: 'Delete series' }), + ).toBeEnabled(); + expect( + within(dialog).getByRole('button', { name: 'Delete meeting' }), + ).toBeEnabled(); + }); + + it('should reset the error after reopening the dialog', async () => { + widgetApi + .observeRoomEvents('net.nordeck.meetings.meeting.close') + .subscribe(acknowledgeAllEvents(widgetApi, { key: 'X' })); + + const meeting = mockMeeting(); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + const { rerender } = render( + , + { wrapper: Wrapper }, + ); + + const dialog0 = screen.getByRole('dialog', { name: 'Delete meeting' }); + + await userEvent.click( + within(dialog0).getByRole('button', { name: 'Delete' }), + ); + + expect(await within(dialog0).findByRole('alert')).toBeInTheDocument(); + + rerender( + , + ); + + await waitFor(() => { + expect(dialog0).not.toBeInTheDocument(); + }); + + rerender(); + + const dialog1 = screen.getByRole('dialog', { name: 'Delete meeting' }); + + expect(within(dialog1).queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('should go to parent room if the current meeting room is deleted', async () => { + widgetApi + .observeRoomEvents('net.nordeck.meetings.meeting.close') + .subscribe(acknowledgeAllEvents(widgetApi)); + + const meeting = mockMeeting({ + room_id: '!room-id', + parentRoomId: '!parent-room-id', + }); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + room_id: "'!room-id", + parentRoomId: '!parent-room-id', + }); + + render(, { + wrapper: Wrapper, + }); + + const dialog = screen.getByRole('dialog', { name: 'Delete meeting' }); + + await userEvent.click( + within(dialog).getByRole('button', { name: 'Delete' }), + ); + + await waitFor(() => { + expect(widgetApi.sendRoomEvent).toBeCalledWith( + 'net.nordeck.meetings.meeting.close', + { + context: { locale: 'en', timezone: 'UTC' }, + data: { target_room_id: '!room-id' }, + }, + ); + }); + + await waitFor(() => { + expect(widgetApi.navigateTo).toBeCalledWith( + 'https://matrix.to/#/!parent-room-id', + ); + }); + + await waitFor(() => { + expect(onClose).toBeCalled(); + }); + }); +}); + +describe('deleteSingleMeetingOccurrenceThunk', () => { + it('should ignore meeting without a metadata event', async () => { + const store = createStore({ widgetApi }); + + const meeting = mockMeeting(); + + await initializeStore(store); + + expect( + await store + .dispatch(deleteSingleMeetingOccurrenceThunk(meeting)) + .unwrap(), + ).toBeUndefined(); + + expect(widgetApi.sendStateEvent).not.toBeCalled(); + }); + + it('should ignore meeting without a recurrence id', async () => { + widgetApi + .observeRoomEvents('net.nordeck.meetings.meeting.update') + .subscribe(acknowledgeAllEvents(widgetApi)); + + const store = createStore({ widgetApi }); + + const meeting = mockMeeting({ + content: { + recurrenceId: undefined, + calendarEntries: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + }), + ], + }, + }); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + await initializeStore(store); + + expect( + await store + .dispatch(deleteSingleMeetingOccurrenceThunk(meeting)) + .unwrap(), + ).toBeUndefined(); + + expect(widgetApi.sendRoomEvent).not.toBeCalled(); + }); + + it('should skip unchanged events', async () => { + widgetApi + .observeRoomEvents('net.nordeck.meetings.meeting.update') + .subscribe(acknowledgeAllEvents(widgetApi)); + + const store = createStore({ widgetApi }); + + const meeting = mockMeeting({ + content: { + startTime: '2999-01-02T10:00:00Z', + endTime: '2999-01-02T14:00:00Z', + recurrenceId: '2999-01-02T10:00:00Z', + calendarEntries: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + // This meeting is already excluded + exdate: ['29990102T100000'], + }), + ], + }, + }); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + await initializeStore(store); + + expect( + await store + .dispatch(deleteSingleMeetingOccurrenceThunk(meeting)) + .unwrap(), + ).toBeUndefined(); + + expect(widgetApi.sendRoomEvent).not.toBeCalled(); + }); + + it('should apply deletion', async () => { + widgetApi + .observeRoomEvents('net.nordeck.meetings.meeting.update') + .subscribe(acknowledgeAllEvents(widgetApi)); + + const store = createStore({ widgetApi }); + + const meeting = mockMeeting({ + content: { + startTime: '2999-01-02T10:00:00Z', + endTime: '2999-01-02T14:00:00Z', + recurrenceId: '2999-01-02T10:00:00Z', + calendarEntries: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + }), + ], + }, + }); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + await initializeStore(store); + + expect( + await store + .dispatch(deleteSingleMeetingOccurrenceThunk(meeting)) + .unwrap(), + ).toEqual({ + acknowledgement: { success: true }, + event: { + content: { + context: expect.anything(), + data: { + title: undefined, + description: undefined, + target_room_id: '!meeting-room-id', + calendar: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + exdate: ['29990102T100000'], + }), + ], + }, + }, + event_id: expect.any(String), + origin_server_ts: expect.any(Number), + room_id: '!room-id', + sender: '@user-id', + type: 'net.nordeck.meetings.meeting.update', + }, + }); + + expect(widgetApi.sendRoomEvent).toBeCalledWith( + 'net.nordeck.meetings.meeting.update', + { + context: expect.anything(), + data: { + target_room_id: '!meeting-room-id', + calendar: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + exdate: ['29990102T100000'], + }), + ], + }, + }, + ); + }); +}); + +describe('selectIsLastMeetingOccurrence', () => { + it('should return false if more entries are available', async () => { + const store = createStore({ widgetApi }); + + const meeting = mockMeeting({ + content: { + startTime: '2999-01-02T10:00:00Z', + endTime: '2999-01-02T14:00:00Z', + recurrenceId: '2999-01-02T10:00:00Z', + calendarEntries: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY;COUNT=2', + }), + ], + }, + }); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + await initializeStore(store); + + expect( + selectIsLastMeetingOccurrence( + store.getState(), + meeting.meetingId, + meeting, + ), + ).toBe(false); + }); + + it('should return true if this is the last entry', async () => { + const store = createStore({ widgetApi }); + + const meeting = mockMeeting({ + content: { + startTime: '2999-01-02T10:00:00Z', + endTime: '2999-01-02T14:00:00Z', + recurrenceId: '2999-01-02T10:00:00Z', + calendarEntries: [ + mockCalendarEntry({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY;COUNT=2', + exdate: ['29990101T100000'], + }), + ], + }, + }); + + mockCreateMeetingRoom(widgetApi, { + metadata: { calendar: meeting.calendarEntries }, + }); + + await initializeStore(store); + + expect( + selectIsLastMeetingOccurrence( + store.getState(), + meeting.meetingId, + meeting, + ), + ).toBe(true); + }); +}); diff --git a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.tsx b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.tsx new file mode 100644 index 00000000..a494295c --- /dev/null +++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.tsx @@ -0,0 +1,274 @@ +/* + * Copyright 2023 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { navigateToRoom } from '@matrix-widget-toolkit/api'; +import { useWidgetApi } from '@matrix-widget-toolkit/react'; +import { LoadingButton } from '@mui/lab'; +import { Alert, AlertTitle } from '@mui/material'; +import { createAsyncThunk, createSelector } from '@reduxjs/toolkit'; +import { isEqual } from 'lodash'; +import { DispatchWithoutAction, Fragment, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + deleteCalendarEvent, + getCalendarEnd, + isRecurringCalendarSourceEntry, +} from '../../../../lib/utils'; +import { + Meeting, + MutationResponse, + meetingsApi, + selectNordeckMeetingMetadataEventByRoomId, + useCloseMeetingMutation, +} from '../../../../reducer/meetingsApi'; +import { + RootState, + StateThunkConfig, + useAppDispatch, + useAppSelector, +} from '../../../../store/store'; +import { ConfirmDeleteDialog } from '../../../common/ConfirmDeleteDialog'; +import { withoutYearDateFormat } from '../../../common/DateTimePickers'; +import { ScheduledDeletionWarning } from '../../MeetingCard/ScheduledDeletionWarning'; + +export function DeleteMeetingDialog({ + meeting, + open, + onClose, +}: { + meeting: Meeting; + open: boolean; + onClose: DispatchWithoutAction; +}) { + const { t } = useTranslation(); + const widgetApi = useWidgetApi(); + + const [deleteSingleMeetingState, setDeleteSingleMeetingState] = useState({ + loading: false, + error: false, + }); + + const [ + closeMeeting, + { + isLoading: isCloseMeetingLoading, + isError, + data: deleteResponse, + reset: resetDelete, + }, + ] = useCloseMeetingMutation(); + + const reset = useCallback(() => { + resetDelete(); + setDeleteSingleMeetingState({ loading: false, error: false }); + }, [resetDelete]); + + const handleCheckNavigateToParent = useCallback(() => { + const isInMeetingRoom = + meeting.meetingId === widgetApi.widgetParameters.roomId; + + if (isInMeetingRoom && meeting.parentRoomId) { + navigateToRoom(widgetApi, meeting.parentRoomId); + } + + onClose(); + }, [meeting.meetingId, meeting.parentRoomId, onClose, widgetApi]); + + const handleClickDeleteConfirm = useCallback(async () => { + reset(); + + try { + const { acknowledgement } = await closeMeeting({ + roomId: meeting.meetingId, + }).unwrap(); + + if (!acknowledgement?.error) { + handleCheckNavigateToParent(); + } + } catch { + // ignore + } + }, [closeMeeting, handleCheckNavigateToParent, meeting.meetingId, reset]); + + const dispatch = useAppDispatch(); + + const handleClickDeleteSingleMeetingConfirm = useCallback(async () => { + reset(); + setDeleteSingleMeetingState({ loading: true, error: false }); + + try { + const result = await dispatch( + deleteSingleMeetingOccurrenceThunk(meeting), + ).unwrap(); + + if (result?.acknowledgement.error) { + setDeleteSingleMeetingState({ loading: false, error: true }); + } else { + handleCheckNavigateToParent(); + setDeleteSingleMeetingState({ loading: false, error: false }); + } + } catch { + setDeleteSingleMeetingState({ loading: false, error: true }); + } + }, [dispatch, handleCheckNavigateToParent, meeting, reset]); + + const isLastMeetingOccurrence = useAppSelector((state) => + selectIsLastMeetingOccurrence(state, meeting.meetingId, meeting), + ); + + let description: string; + let confirmTitle: string; + let additionalButtons = ; + + if ( + isRecurringCalendarSourceEntry(meeting.calendarEntries) && + !isLastMeetingOccurrence + ) { + description = t( + 'meetingDetails.header.deleteSeriesConfirmMessage', + 'Are you sure you want to delete the meeting or the meeting series “{{title}}” on {{startTime, datetime}} and every content related to it?', + { + title: meeting.title, + startTime: new Date(meeting.startTime), + formatParams: { + startTime: withoutYearDateFormat, + }, + }, + ); + + confirmTitle = t( + 'meetingDetails.header.deleteSeriesConfirmButton', + 'Delete series', + ); + + additionalButtons = ( + + {t( + 'meetingDetails.header.deleteMeetingConfirmButton', + 'Delete meeting', + )} + + ); + } else { + description = t( + 'meetingDetails.header.deleteConfirmMessage', + 'Are you sure you want to delete the meeting “{{title}}” on {{startTime, datetime}} and every content related to it?', + { + title: meeting.title, + startTime: new Date(meeting.startTime), + formatParams: { + startTime: withoutYearDateFormat, + }, + }, + ); + + confirmTitle = t('meetingDetails.header.deleteConfirmButton', 'Delete'); + } + + const isCloseMeetingError = isError || deleteResponse?.acknowledgement.error; + + return ( + + {meeting.deletionTime !== undefined && ( + + )} + + {(isCloseMeetingError || deleteSingleMeetingState.error) && ( + + + {t( + 'meetingDetails.header.deleteFailedTitle', + 'Failed to delete the meeting', + )} + + {t('meetingDetails.header.deleteFailed', 'Please try again.')} + + )} + + ); +} + +export const deleteSingleMeetingOccurrenceThunk = createAsyncThunk< + MutationResponse | undefined, + Meeting, + StateThunkConfig +>('editMeetingThunk', async (meeting, { getState, dispatch }) => { + const state = getState(); + + const meetingCalendar = selectNordeckMeetingMetadataEventByRoomId( + state, + meeting.meetingId, + )?.content.calendar; + + if (!meetingCalendar || !meeting.recurrenceId) { + return undefined; + } + + const updatedMeetingCalendar = deleteCalendarEvent( + meetingCalendar, + meeting.calendarUid, + meeting.recurrenceId, + ); + + // only update if something changed. the widget API will get stuck if we + // override the event with the same content. + if (!isEqual(meetingCalendar, updatedMeetingCalendar)) { + return await dispatch( + meetingsApi.endpoints.updateMeetingDetails.initiate({ + roomId: meeting.meetingId, + updates: { calendar: updatedMeetingCalendar }, + }), + ).unwrap(); + } + + return undefined; +}); + +export const selectIsLastMeetingOccurrence = createSelector( + (_: RootState, __: string, meeting: Meeting) => meeting, + selectNordeckMeetingMetadataEventByRoomId, + (meeting, meetingMetadataEvent) => { + if (!meetingMetadataEvent || !meeting.recurrenceId) { + return false; + } + + const newCalendar = deleteCalendarEvent( + meetingMetadataEvent.content.calendar, + meeting.calendarUid, + meeting.recurrenceId, + ); + + return getCalendarEnd(newCalendar) === undefined; + }, +); diff --git a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.tsx b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.tsx index 93df5b6b..ac9dab32 100644 --- a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.tsx +++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.tsx @@ -14,12 +14,9 @@ * limitations under the License. */ -import { navigateToRoom } from '@matrix-widget-toolkit/api'; import { useWidgetApi } from '@matrix-widget-toolkit/react'; import CloseIcon from '@mui/icons-material/Close'; import { - Alert, - AlertTitle, Box, Button, DialogTitle, @@ -34,14 +31,11 @@ import { Meeting, makeSelectRoomPermissions, selectNordeckMeetingMetadataEventByRoomId, - useCloseMeetingMutation, } from '../../../../reducer/meetingsApi'; import { useAppDispatch, useAppSelector } from '../../../../store'; -import { ConfirmDeleteDialog } from '../../../common/ConfirmDeleteDialog'; -import { withoutYearDateFormat } from '../../../common/DateTimePickers'; import { UpdateFailedDialog } from '../../MeetingCard/MeetingCardMenu'; -import { ScheduledDeletionWarning } from '../../MeetingCard/ScheduledDeletionWarning'; import { editMeetingThunk } from '../../ScheduleMeetingModal'; +import { DeleteMeetingDialog } from './DeleteMeetingDialog'; import { MeetingDetailsJoinButton } from './MeetingDetailsJoinButton'; import { getOpenXChangeExternalReference } from './OpenXchangeButton'; import { OpenXchangeButton } from './OpenXchangeButton/OpenXchangeButton'; @@ -81,11 +75,6 @@ export function MeetingDetailsHeader({ canUpdateMeetingParticipantsInvite && canUpdateMeetingParticipantsKick; - const [ - closeMeeting, - { isLoading: isDeleting, isError, data: deleteResponse }, - ] = useCloseMeetingMutation(); - const metadataEvent = useAppSelector((state) => { const event = selectNordeckMeetingMetadataEventByRoomId( state, @@ -120,33 +109,6 @@ export function MeetingDetailsHeader({ setOpenDeleteConfirm(false); }, []); - const handleClickDeleteConfirm = useCallback(async () => { - try { - const { acknowledgement } = await closeMeeting({ - roomId: meeting.meetingId, - }).unwrap(); - - if (!acknowledgement?.error) { - const isInMeetingRoom = - meeting.meetingId === widgetApi.widgetParameters.roomId; - - if (isInMeetingRoom && meeting.parentRoomId) { - navigateToRoom(widgetApi, meeting.parentRoomId); - } - - handleCloseDeleteConfirm(); - } - } catch { - // ignore - } - }, [ - closeMeeting, - handleCloseDeleteConfirm, - meeting.meetingId, - meeting.parentRoomId, - widgetApi, - ]); - const isMeetingInvitation = meeting.participants.some( (p) => p.userId === widgetApi.widgetParameters.userId && @@ -238,45 +200,11 @@ export function MeetingDetailsHeader({ {showErrorDialog && } - - {meeting.deletionTime !== undefined && ( - - )} - - {(isError || deleteResponse?.acknowledgement.error) && ( - - - {t( - 'meetingDetails.header..deleteFailedTitle', - 'Failed to delete the meeting', - )} - - {t('meetingDetails.header..deleteFailed', 'Please try again.')} - - )} - + onClose={handleCloseDeleteConfirm} + /> ); } diff --git a/matrix-meetings-widget/src/lib/utils/calendarUtils/deleteCalendarEvent.test.ts b/matrix-meetings-widget/src/lib/utils/calendarUtils/deleteCalendarEvent.test.ts new file mode 100644 index 00000000..5bec4261 --- /dev/null +++ b/matrix-meetings-widget/src/lib/utils/calendarUtils/deleteCalendarEvent.test.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2023 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockCalendarEntry } from '../../testUtils'; +import { deleteCalendarEvent } from './deleteCalendarEvent'; + +describe('deleteCalendarEvent', () => { + it('should handle empty array', () => { + expect(deleteCalendarEvent([], 'entry-0', '2020-01-01T00:00:00Z')).toEqual( + [], + ); + }); + + it('should delete an occurrence of a meeting', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + rrule: 'FREQ=DAILY', + }), + ]; + + expect( + deleteCalendarEvent(calendar, 'entry-0', '2020-02-15T10:00:00Z'), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200215T100000'], + }), + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + rrule: 'FREQ=DAILY', + }), + ]); + }); + + it('should delete an occurrence of a meeting with an existing exdate', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + rrule: 'FREQ=DAILY', + }), + ]; + + expect( + deleteCalendarEvent(calendar, 'entry-0', '2020-02-15T10:00:00Z'), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000', '20200215T100000'], + }), + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + rrule: 'FREQ=DAILY', + }), + ]); + }); + + it('should delete existing overrides', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + mockCalendarEntry({ + dtstart: '20200110T103000', + dtend: '20200110T113000', + recurrenceId: '20200110T100000', + }), + + // another override that should stay + mockCalendarEntry({ + dtstart: '20200115T103000', + dtend: '20200115T113000', + recurrenceId: '20200115T100000', + }), + ]; + + expect( + deleteCalendarEvent(calendar, 'entry-0', '2020-01-10T10:00:00Z'), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + mockCalendarEntry({ + dtstart: '20200115T103000', + dtend: '20200115T113000', + recurrenceId: '20200115T100000', + }), + ]); + }); + + it('should skip an occurrence of a meeting that does not match the series', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + ]; + + expect( + deleteCalendarEvent(calendar, 'entry-0', '2020-02-15T23:59:59Z'), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + ]); + }); +}); diff --git a/matrix-meetings-widget/src/lib/utils/calendarUtils/deleteCalendarEvent.ts b/matrix-meetings-widget/src/lib/utils/calendarUtils/deleteCalendarEvent.ts new file mode 100644 index 00000000..cd063db9 --- /dev/null +++ b/matrix-meetings-widget/src/lib/utils/calendarUtils/deleteCalendarEvent.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2023 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DateTime } from 'luxon'; +import { CalendarEntry } from '../../matrix'; +import { formatICalDate, parseICalDate } from '../dateTimeUtils'; +import { getCalendarEvent } from './getCalendarEvent'; +import { isRRuleEntry, isRRuleOverrideEntry } from './helpers'; + +export function deleteCalendarEvent( + calendarEntries: CalendarEntry[], + uid: string, + recurrenceId: string, +): CalendarEntry[] { + const event = getCalendarEvent(calendarEntries, uid, recurrenceId); + + if (event) { + return calendarEntries + .filter( + (c) => + // Remove the override that matches this recurrence id + !isRRuleOverrideEntry(c) || + c.uid !== uid || + +parseICalDate(c.recurrenceId) !== +DateTime.fromISO(recurrenceId), + ) + .map((c) => { + // Add the exdate to the matching series + if (c.uid === uid && isRRuleEntry(c)) { + return { + ...c, + exdate: (c.exdate ?? []).concat( + formatICalDate( + DateTime.fromISO(recurrenceId), + new Intl.DateTimeFormat().resolvedOptions().timeZone, + ), + ), + }; + } + + return c; + }); + } + + return calendarEntries; +} diff --git a/matrix-meetings-widget/src/lib/utils/calendarUtils/getCalendarEnd.test.ts b/matrix-meetings-widget/src/lib/utils/calendarUtils/getCalendarEnd.test.ts index 36e58800..3ebc265e 100644 --- a/matrix-meetings-widget/src/lib/utils/calendarUtils/getCalendarEnd.test.ts +++ b/matrix-meetings-widget/src/lib/utils/calendarUtils/getCalendarEnd.test.ts @@ -19,6 +19,10 @@ import { mockCalendar, mockCalendarEntry } from '../../testUtils'; import { getCalendarEnd } from './getCalendarEnd'; describe('getCalendarEnd', () => { + it('should handle calendar with no event', () => { + expect(getCalendarEnd([])).toEqual(undefined); + }); + it('should handle calendar with a single event', () => { expect( getCalendarEnd( @@ -44,7 +48,7 @@ describe('getCalendarEnd', () => { rrule: 'FREQ=DAILY', }), ]), - ).toEqual(undefined); + ).toEqual('infinite'); }); it('should handle calendar with a single recurring event that ends', () => { @@ -92,4 +96,17 @@ describe('getCalendarEnd', () => { ]), ).toEqual(DateTime.fromISO('2020-01-11T11:00:00Z')); }); + + it('should handle calendar with a recurring event that has no events', () => { + expect( + getCalendarEnd([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY;COUNT=1', + exdate: ['20200109T100000'], + }), + ]), + ).toEqual(undefined); + }); }); diff --git a/matrix-meetings-widget/src/lib/utils/calendarUtils/getCalendarEnd.ts b/matrix-meetings-widget/src/lib/utils/calendarUtils/getCalendarEnd.ts index 0469557a..286f5113 100644 --- a/matrix-meetings-widget/src/lib/utils/calendarUtils/getCalendarEnd.ts +++ b/matrix-meetings-widget/src/lib/utils/calendarUtils/getCalendarEnd.ts @@ -21,12 +21,20 @@ import { parseICalDate } from '../dateTimeUtils'; import { generateRruleSet } from './generateRruleSet'; import { isRRuleEntry, isRRuleOverrideEntry, isSingleEntry } from './helpers'; +/** + * Calculate the last end date of the calendar. + * + * @param calendar - the calendar property of a calendar room + * @returns the date of the last entry, `'infinite'` if the series never ends, + * or undefined if no end date could be calculated. + */ export function getCalendarEnd( calendar: CalendarEntry[], -): DateTime | undefined { +): DateTime | 'infinite' | undefined { let endDates = calendar .filter(isSingleEntry) .map((entry) => parseICalDate(entry.dtend)); + let isInfinite = false; calendar.filter(isRRuleEntry).forEach((entry) => { const { rruleSet, fromRruleSetDate, toRruleSetDate, isFinite } = @@ -35,6 +43,7 @@ export function getCalendarEnd( // an infinite series never ends if (!isFinite) { endDates = []; + isInfinite = true; return; } @@ -74,5 +83,9 @@ export function getCalendarEnd( } }); + if (isInfinite) { + return 'infinite'; + } + return max(endDates); } diff --git a/matrix-meetings-widget/src/lib/utils/calendarUtils/getCalendarEvent.ts b/matrix-meetings-widget/src/lib/utils/calendarUtils/getCalendarEvent.ts index de5ed14f..d2f75964 100644 --- a/matrix-meetings-widget/src/lib/utils/calendarUtils/getCalendarEvent.ts +++ b/matrix-meetings-widget/src/lib/utils/calendarUtils/getCalendarEvent.ts @@ -108,7 +108,7 @@ export function getCalendarEvent( // find the last entry after the series ended const calendarEnd = getCalendarEnd(relatedCalendar); - if (calendarEnd && calendarEnd < DateTime.now()) { + if (DateTime.isDateTime(calendarEnd) && calendarEnd < DateTime.now()) { [entry] = calculateCalendarEvents({ calendar: relatedCalendar, // meeting end is exclusive, therefore we need to adjust our search diff --git a/matrix-meetings-widget/src/lib/utils/calendarUtils/index.ts b/matrix-meetings-widget/src/lib/utils/calendarUtils/index.ts index 96d783c7..8a88073c 100644 --- a/matrix-meetings-widget/src/lib/utils/calendarUtils/index.ts +++ b/matrix-meetings-widget/src/lib/utils/calendarUtils/index.ts @@ -16,6 +16,7 @@ export { calculateCalendarEvents } from './calculateCalendarEvents'; export type { CalendarSourceEntries } from './calculateCalendarEvents'; +export { deleteCalendarEvent } from './deleteCalendarEvent'; export { getCalendarEnd } from './getCalendarEnd'; export { getCalendarEvent } from './getCalendarEvent'; export {