From 631e9d9690daec42524f3828ef46674e70024259 Mon Sep 17 00:00:00 2001
From: AHMAD KADRI <52747422+ahmadkadri@users.noreply.github.com>
Date: Thu, 14 Sep 2023 11:09:44 +0200
Subject: [PATCH 01/10] update confirm delete modal / implement delete one
instance logic / fix tests
Signed-off-by: AHMAD KADRI <52747422+ahmadkadri@users.noreply.github.com>
---
.../public/locales/de/translation.json | 5 +-
.../public/locales/en/translation.json | 5 +-
.../ConfirmDeleteDialog.tsx | 18 +++-
.../MeetingDetailsHeader.test.tsx | 6 +-
.../MeetingDetailsHeader.tsx | 87 ++++++++++++++++++-
5 files changed, 109 insertions(+), 12 deletions(-)
diff --git a/matrix-meetings-widget/public/locales/de/translation.json b/matrix-meetings-widget/public/locales/de/translation.json
index 2b9a87d6..94c86792 100644
--- a/matrix-meetings-widget/public/locales/de/translation.json
+++ b/matrix-meetings-widget/public/locales/de/translation.json
@@ -150,13 +150,14 @@
}
},
"header": {
- "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?",
+ "deleteConfirmMessage": "Du bist dabei die Besprechung oder die Besprechungsserie „{{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",
+ "deleteMeetingConfirmButton": "Besprechung löschen",
"deleteMenu": "Löschen",
+ "deleteSeriesConfirmButton": "Besprechungsserie löschen",
"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 504a5cee..bd4dbd30 100644
--- a/matrix-meetings-widget/public/locales/en/translation.json
+++ b/matrix-meetings-widget/public/locales/en/translation.json
@@ -150,13 +150,14 @@
}
},
"header": {
- "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?",
+ "deleteConfirmMessage": "Are you sure you want to delete the meeting or the meeting series “{{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",
+ "deleteMeetingConfirmButton": "Delete meeting",
"deleteMenu": "Delete",
+ "deleteSeriesConfirmButton": "Delete series",
"editInOpenXchangeMenu": "Edit meeting in Open-Xchange",
"editMenu": "Edit",
"join": "Join",
diff --git a/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx b/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx
index d30b1b89..bc2d3244 100644
--- a/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx
+++ b/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx
@@ -33,6 +33,9 @@ type ConfirmDeleteDialogProps = PropsWithChildren<{
description: string;
confirmTitle: string;
loading?: boolean;
+ confirmSeriesTitle?: string;
+ isRecurring?: boolean;
+ onConfirmSeries?: DispatchWithoutAction;
onCancel: DispatchWithoutAction;
onConfirm: DispatchWithoutAction;
}>;
@@ -42,9 +45,12 @@ export function ConfirmDeleteDialog({
title,
description,
confirmTitle,
+ confirmSeriesTitle,
loading,
+ isRecurring,
onCancel,
onConfirm,
+ onConfirmSeries,
children,
}: ConfirmDeleteDialogProps) {
const { t } = useTranslation();
@@ -79,10 +85,20 @@ export function ConfirmDeleteDialog({
color="error"
loading={loading}
onClick={onConfirm}
- variant="contained"
+ variant="outlined"
>
{confirmTitle}
+ {isRecurring && (
+
+ {confirmSeriesTitle}
+
+ )}
);
diff --git a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.test.tsx b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.test.tsx
index 5055dccc..88b16bfa 100644
--- a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.test.tsx
+++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.test.tsx
@@ -768,7 +768,7 @@ describe('', () => {
});
await userEvent.click(
- within(deleteModal).getByRole('button', { name: 'Delete' }),
+ within(deleteModal).getByRole('button', { name: 'Delete meeting' }),
);
await waitFor(() => {
@@ -810,7 +810,7 @@ describe('', () => {
});
await userEvent.click(
- within(deleteModal).getByRole('button', { name: 'Delete' }),
+ within(deleteModal).getByRole('button', { name: 'Delete meeting' }),
);
await waitFor(() => {
@@ -856,7 +856,7 @@ describe('', () => {
});
const deleteButton = within(deleteModal).getByRole('button', {
- name: 'Delete',
+ name: 'Delete meeting',
});
await userEvent.click(deleteButton);
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 3c355850..7e5f7bc9 100644
--- a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.tsx
+++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.tsx
@@ -44,14 +44,20 @@ import {
Tooltip,
} from '@mui/material';
import { unstable_useId as useId } from '@mui/utils';
+import { DateTime } from 'luxon';
import { DispatchWithoutAction, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import {
+ formatICalDate,
+ isRecurringCalendarSourceEntry,
+} from '../../../../lib/utils';
import {
Meeting,
makeSelectRoomPermissions,
selectNordeckMeetingMetadataEventByRoomId,
selectRoomPowerLevelsEventByRoomId,
useCloseMeetingMutation,
+ useUpdateMeetingDetailsMutation,
} from '../../../../reducer/meetingsApi';
import { useAppSelector } from '../../../../store';
import { ConfirmDeleteDialog } from '../../../common/ConfirmDeleteDialog';
@@ -77,6 +83,7 @@ export function MeetingDetailsHeader({
const widgetApi = useWidgetApi();
const { t } = useTranslation();
const { editMeeting } = useEditMeeting();
+ const [updateMeetingDetails] = useUpdateMeetingDetailsMutation();
const [showErrorDialog, setShowErrorDialog] = useState(false);
const [openDeleteConfirm, setOpenDeleteConfirm] = useState(false);
const selectRoomPermissions = useMemo(makeSelectRoomPermissions, []);
@@ -99,6 +106,10 @@ export function MeetingDetailsHeader({
canUpdateMeetingParticipantsInvite &&
canUpdateMeetingParticipantsKick;
+ const isDeleteRecurringMeeting = isRecurringCalendarSourceEntry(
+ meeting.calendarEntries,
+ );
+
const [
closeMeeting,
{ isLoading: isDeleting, isError, data: deleteResponse },
@@ -142,7 +153,66 @@ export function MeetingDetailsHeader({
setOpenDeleteConfirm(false);
}, []);
- const handleClickDeleteConfirm = useCallback(async () => {
+ const handleClickDeleteMeetingConfirm = useCallback(async () => {
+ try {
+ if (isDeleteRecurringMeeting && meeting.recurrenceId) {
+ const calendar = meeting.calendarEntries[0];
+ const newExtended = formatICalDate(
+ DateTime.fromISO(meeting.recurrenceId),
+ new Intl.DateTimeFormat().resolvedOptions().timeZone,
+ );
+ const newCalendar = {
+ ...calendar,
+ exdate: calendar.exdate
+ ? [...calendar.exdate, newExtended]
+ : [newExtended],
+ };
+ meeting.calendarEntries[0] = newCalendar;
+
+ const detailsResult = await updateMeetingDetails({
+ roomId: meeting.meetingId,
+ updates: { ...meeting, calendar: meeting.calendarEntries },
+ }).unwrap();
+
+ if (!detailsResult.acknowledgement?.error) {
+ const isInMeetingRoom =
+ meeting.meetingId === widgetApi.widgetParameters.roomId;
+
+ if (isInMeetingRoom && meeting.parentRoomId) {
+ navigateToRoom(widgetApi, meeting.parentRoomId);
+ }
+
+ handleCloseDeleteConfirm();
+ }
+ } else {
+ 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,
+ isDeleteRecurringMeeting,
+ meeting,
+ updateMeetingDetails,
+ widgetApi,
+ ]);
+
+ const handleClickDeleteSeriesConfirm = useCallback(async () => {
try {
const { acknowledgement } = await closeMeeting({
roomId: meeting.meetingId,
@@ -261,10 +331,17 @@ export function MeetingDetailsHeader({
{showErrorDialog && }
From 9a070890170dfda70a59e3e1b0f706de0136b0b4 Mon Sep 17 00:00:00 2001
From: AHMAD KADRI <52747422+ahmadkadri@users.noreply.github.com>
Date: Fri, 15 Sep 2023 10:34:03 +0200
Subject: [PATCH 02/10] delete button style / add new tests
Signed-off-by: AHMAD KADRI <52747422+ahmadkadri@users.noreply.github.com>
---
.../ConfirmDeleteDialog.tsx | 2 +-
.../MeetingDetailsHeader.test.tsx | 125 ++++++++++++++++++
2 files changed, 126 insertions(+), 1 deletion(-)
diff --git a/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx b/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx
index bc2d3244..532d1eef 100644
--- a/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx
+++ b/matrix-meetings-widget/src/components/common/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx
@@ -85,7 +85,7 @@ export function ConfirmDeleteDialog({
color="error"
loading={loading}
onClick={onConfirm}
- variant="outlined"
+ variant={isRecurring ? 'outlined' : 'contained'}
>
{confirmTitle}
diff --git a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.test.tsx b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.test.tsx
index 88b16bfa..ad3e92c5 100644
--- a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.test.tsx
+++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.test.tsx
@@ -26,6 +26,7 @@ import { Provider } from 'react-redux';
import {
acknowledgeAllEvents,
mockCalendar,
+ mockCalendarEntry,
mockConfigEndpoint,
mockCreateMeetingRoom,
mockMeeting,
@@ -828,6 +829,130 @@ describe('', () => {
});
});
+ it('should delete recurring meeting', async () => {
+ widgetApi
+ .observeRoomEvents('net.nordeck.meetings.meeting.close')
+ .subscribe(acknowledgeAllEvents(widgetApi));
+
+ render(
+ ,
+ {
+ wrapper: Wrapper,
+ },
+ );
+
+ await userEvent.click(
+ await screen.findByRole('button', { name: /Delete/i }),
+ );
+
+ const deleteModal = screen.getByRole('dialog', {
+ name: /delete meeting/i,
+ });
+
+ await userEvent.click(
+ within(deleteModal).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(deleteModal).not.toBeInTheDocument();
+ });
+ });
+
+ it.skip('should delete one instance of recurring meeting', async () => {
+ widgetApi
+ .observeRoomEvents('net.nordeck.meetings.meeting.close')
+ .subscribe(acknowledgeAllEvents(widgetApi));
+
+ render(
+ ,
+ {
+ wrapper: Wrapper,
+ },
+ );
+
+ await userEvent.click(
+ await screen.findByRole('button', { name: /Delete/i }),
+ );
+
+ const deleteModal = screen.getByRole('dialog', {
+ name: /delete meeting/i,
+ });
+
+ await userEvent.click(
+ within(deleteModal).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',
+ title: 'An important meeting',
+ description: 'A brief description',
+ calendar: [
+ {
+ uid: 'entry-0',
+ dtstart: { tzid: 'UTC', value: '20000101T010000' },
+ dtend: { tzid: 'UTC', value: '20000101T030000' },
+ exdate: [
+ {
+ tzid: 'UTC',
+ value: '20000102T010000',
+ },
+ ],
+ recurrenceId: undefined,
+ rrule: 'FREQ=DAILY;UNTIL=20401001T125500Z',
+ },
+ ],
+ },
+ },
+ );
+ });
+
+ await waitFor(() => {
+ expect(deleteModal).not.toBeInTheDocument();
+ });
+ });
+
it('should show error if deletion failed', async () => {
widgetApi
.observeRoomEvents('net.nordeck.meetings.meeting.close')
From 5e1393de6647e16ea6356c8ef4deaf1b85c25ecf Mon Sep 17 00:00:00 2001
From: Dominik Henneke
Date: Tue, 19 Sep 2023 12:20:13 +0200
Subject: [PATCH 03/10] Move the delete dialog into a separate component
Signed-off-by: Dominik Henneke
---
.../public/locales/de/translation.json | 9 +-
.../public/locales/en/translation.json | 9 +-
.../ConfirmDeleteDialog.test.tsx | 75 +-
.../ConfirmDeleteDialog.tsx | 32 +-
.../meetings/MeetingCard/MeetingCardMenu.tsx | 83 +--
.../DeleteMeetingDialog.test.tsx | 650 ++++++++++++++++++
.../DeleteMeetingDialog.tsx | 243 +++++++
.../MeetingDetailsHeader.test.tsx | 131 +---
.../MeetingDetailsHeader.tsx | 161 +----
.../calendarUtils/deleteCalendarEvent.test.ts | 155 +++++
.../calendarUtils/deleteCalendarEvent.ts | 58 ++
.../src/lib/utils/calendarUtils/index.ts | 1 +
matrix-meetings-widget/src/store/store.ts | 10 +
13 files changed, 1224 insertions(+), 393 deletions(-)
create mode 100644 matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.test.tsx
create mode 100644 matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.tsx
create mode 100644 matrix-meetings-widget/src/lib/utils/calendarUtils/deleteCalendarEvent.test.ts
create mode 100644 matrix-meetings-widget/src/lib/utils/calendarUtils/deleteCalendarEvent.ts
diff --git a/matrix-meetings-widget/public/locales/de/translation.json b/matrix-meetings-widget/public/locales/de/translation.json
index 94c86792..76f8f3fe 100644
--- a/matrix-meetings-widget/public/locales/de/translation.json
+++ b/matrix-meetings-widget/public/locales/de/translation.json
@@ -69,11 +69,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}}",
@@ -150,14 +145,16 @@
}
},
"header": {
+ "deleteConfirmButton": "Löschen",
"deleteConfirmHeader": "Besprechung löschen",
- "deleteConfirmMessage": "Du bist dabei die Besprechung oder die Besprechungsserie „{{title}}“ am {{startTime, datetime}} und alles darin zu löschen. Bist du sicher?",
+ "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",
"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 bd4dbd30..63b69a4c 100644
--- a/matrix-meetings-widget/public/locales/en/translation.json
+++ b/matrix-meetings-widget/public/locales/en/translation.json
@@ -69,11 +69,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}}",
@@ -150,14 +145,16 @@
}
},
"header": {
+ "deleteConfirmButton": "Delete",
"deleteConfirmHeader": "Delete meeting",
- "deleteConfirmMessage": "Are you sure you want to delete the meeting or the meeting series “{{title}}” on {{startTime, datetime}} and every content related to it?",
+ "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",
"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 532d1eef..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,11 +37,10 @@ type ConfirmDeleteDialogProps = PropsWithChildren<{
description: string;
confirmTitle: string;
loading?: boolean;
- confirmSeriesTitle?: string;
- isRecurring?: boolean;
- onConfirmSeries?: DispatchWithoutAction;
+ additionalButtons?: ReactElement;
onCancel: DispatchWithoutAction;
onConfirm: DispatchWithoutAction;
+ onEnter?: DispatchWithoutAction;
}>;
export function ConfirmDeleteDialog({
@@ -45,12 +48,11 @@ export function ConfirmDeleteDialog({
title,
description,
confirmTitle,
- confirmSeriesTitle,
loading,
- isRecurring,
+ additionalButtons,
onCancel,
onConfirm,
- onConfirmSeries,
+ onEnter,
children,
}: ConfirmDeleteDialogProps) {
const { t } = useTranslation();
@@ -64,6 +66,7 @@ export function ConfirmDeleteDialog({
aria-labelledby={dialogTitleId}
onClose={onCancel}
open={open}
+ onTransitionEnter={onEnter}
>
{title}
@@ -81,24 +84,17 @@ export function ConfirmDeleteDialog({
+
+ {additionalButtons}
+
{confirmTitle}
- {isRecurring && (
-
- {confirmSeriesTitle}
-
- )}
);
diff --git a/matrix-meetings-widget/src/components/meetings/MeetingCard/MeetingCardMenu.tsx b/matrix-meetings-widget/src/components/meetings/MeetingCard/MeetingCardMenu.tsx
index 0e1b9fbc..782e4c11 100644
--- a/matrix-meetings-widget/src/components/meetings/MeetingCard/MeetingCardMenu.tsx
+++ b/matrix-meetings-widget/src/components/meetings/MeetingCard/MeetingCardMenu.tsx
@@ -14,13 +14,10 @@
* limitations under the License.
*/
-import { navigateToRoom } from '@matrix-widget-toolkit/api';
import { useWidgetApi } from '@matrix-widget-toolkit/react';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import {
- Alert,
- AlertTitle,
Button,
Dialog,
DialogActions,
@@ -36,19 +33,16 @@ import {
makeSelectRoomPermissions,
selectNordeckMeetingMetadataEventByRoomId,
selectRoomPowerLevelsEventByRoomId,
- useCloseMeetingMutation,
} from '../../../reducer/meetingsApi';
import { useAppSelector } from '../../../store';
-import { ConfirmDeleteDialog } from '../../common/ConfirmDeleteDialog';
-import { withoutYearDateFormat } from '../../common/DateTimePickers';
import {
MenuButton,
MenuButtonItem,
OpenXchangeMenuButtonItem,
getOpenXChangeExternalReference,
} from '../../common/MenuButton';
+import { DeleteMeetingDialog } from '../MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog';
import { useEditMeeting } from '../ScheduleMeetingModal';
-import { ScheduledDeletionWarning } from './ScheduledDeletionWarning';
type MeetingCardMenuProps = {
meeting: Meeting;
@@ -64,11 +58,6 @@ export function MeetingCardMenu({
const [showErrorDialog, setShowErrorDialog] = useState(false);
- const [
- closeMeeting,
- { isLoading: isDeleting, isError, data: deleteResponse, reset },
- ] = useCloseMeetingMutation();
-
const selectRoomPermissions = useMemo(makeSelectRoomPermissions, []);
const {
canCloseMeeting,
@@ -116,33 +105,6 @@ export function MeetingCardMenu({
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 { editMeeting } = useEditMeeting();
const isMessagingEnabled = useAppSelector((state) => {
@@ -168,7 +130,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..92c785be
--- /dev/null
+++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.test.tsx
@@ -0,0 +1,650 @@
+/*
+ * 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 { extractWidgetApiParameters as extractWidgetApiParametersMocked } from '@matrix-widget-toolkit/api';
+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 { setupServer } from 'msw/node';
+import { ComponentType, PropsWithChildren, useState } from 'react';
+import { Provider } from 'react-redux';
+import {
+ acknowledgeAllEvents,
+ mockCalendarEntry,
+ mockConfigEndpoint,
+ mockCreateMeetingRoom,
+ mockMeeting,
+ mockMeetingSharingInformationEndpoint,
+ mockWidgetEndpoint,
+} from '../../../../lib/testUtils';
+import { createStore } from '../../../../store';
+import { initializeStore } from '../../../../store/store';
+import {
+ DeleteMeetingDialog,
+ deleteSingleMeetingOccurrenceThunk,
+} from './DeleteMeetingDialog';
+
+jest.mock('@matrix-widget-toolkit/api', () => ({
+ ...jest.requireActual('@matrix-widget-toolkit/api'),
+ extractWidgetApiParameters: jest.fn(),
+}));
+
+const extractWidgetApiParameters = jest.mocked(
+ extractWidgetApiParametersMocked,
+);
+
+const server = setupServer();
+
+beforeAll(() => server.listen());
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+let widgetApi: MockedWidgetApi;
+
+afterEach(() => widgetApi.stop());
+
+beforeEach(() => (widgetApi = mockWidgetApi()));
+
+describe('', () => {
+ const onClose = jest.fn();
+ let Wrapper: ComponentType>;
+
+ beforeEach(() => {
+ jest.mocked(extractWidgetApiParameters).mockReturnValue({
+ clientOrigin: 'http://element.local',
+ widgetId: '',
+ });
+
+ mockWidgetEndpoint(server);
+ mockConfigEndpoint(server);
+ mockMeetingSharingInformationEndpoint(server);
+
+ Wrapper = ({ children }: PropsWithChildren<{}>) => {
+ const [store] = useState(() => {
+ const store = createStore({ widgetApi });
+ initializeStore(store);
+ return store;
+ });
+ return (
+
+ {children}
+
+ );
+ };
+ });
+
+ afterEach(() => {
+ // Restore the spy on Date.now()
+ jest.restoreAllMocks();
+ });
+
+ it('should render without exploding', () => {
+ render(
+ ,
+ { wrapper: Wrapper },
+ );
+
+ const dialog = screen.getByRole('dialog', { name: 'Delete meeting' });
+
+ 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' });
+
+ 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' });
+
+ 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' });
+
+ 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 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' });
+
+ 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'],
+ }),
+ ],
+ },
+ },
+ );
+ });
+});
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..f8c1ab45
--- /dev/null
+++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.tsx
@@ -0,0 +1,243 @@
+/*
+ * 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 } from '@reduxjs/toolkit';
+import { isEqual } from 'lodash';
+import { DispatchWithoutAction, Fragment, useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ deleteCalendarEvent,
+ isSingleCalendarSourceEntry,
+} from '../../../../lib/utils';
+import {
+ Meeting,
+ MutationResponse,
+ meetingsApi,
+ selectNordeckMeetingMetadataEventByRoomId,
+ useCloseMeetingMutation,
+} from '../../../../reducer/meetingsApi';
+import { StateThunkConfig, useAppDispatch } 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]);
+
+ let description: string;
+ let confirmTitle: string;
+ let additionalButtons = ;
+
+ if (isSingleCalendarSourceEntry(meeting.calendarEntries)) {
+ 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');
+ } else {
+ 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',
+ )}
+
+ );
+ }
+
+ 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;
+});
diff --git a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.test.tsx b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.test.tsx
index ad3e92c5..5055dccc 100644
--- a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.test.tsx
+++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.test.tsx
@@ -26,7 +26,6 @@ import { Provider } from 'react-redux';
import {
acknowledgeAllEvents,
mockCalendar,
- mockCalendarEntry,
mockConfigEndpoint,
mockCreateMeetingRoom,
mockMeeting,
@@ -769,7 +768,7 @@ describe('', () => {
});
await userEvent.click(
- within(deleteModal).getByRole('button', { name: 'Delete meeting' }),
+ within(deleteModal).getByRole('button', { name: 'Delete' }),
);
await waitFor(() => {
@@ -811,7 +810,7 @@ describe('', () => {
});
await userEvent.click(
- within(deleteModal).getByRole('button', { name: 'Delete meeting' }),
+ within(deleteModal).getByRole('button', { name: 'Delete' }),
);
await waitFor(() => {
@@ -829,130 +828,6 @@ describe('', () => {
});
});
- it('should delete recurring meeting', async () => {
- widgetApi
- .observeRoomEvents('net.nordeck.meetings.meeting.close')
- .subscribe(acknowledgeAllEvents(widgetApi));
-
- render(
- ,
- {
- wrapper: Wrapper,
- },
- );
-
- await userEvent.click(
- await screen.findByRole('button', { name: /Delete/i }),
- );
-
- const deleteModal = screen.getByRole('dialog', {
- name: /delete meeting/i,
- });
-
- await userEvent.click(
- within(deleteModal).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(deleteModal).not.toBeInTheDocument();
- });
- });
-
- it.skip('should delete one instance of recurring meeting', async () => {
- widgetApi
- .observeRoomEvents('net.nordeck.meetings.meeting.close')
- .subscribe(acknowledgeAllEvents(widgetApi));
-
- render(
- ,
- {
- wrapper: Wrapper,
- },
- );
-
- await userEvent.click(
- await screen.findByRole('button', { name: /Delete/i }),
- );
-
- const deleteModal = screen.getByRole('dialog', {
- name: /delete meeting/i,
- });
-
- await userEvent.click(
- within(deleteModal).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',
- title: 'An important meeting',
- description: 'A brief description',
- calendar: [
- {
- uid: 'entry-0',
- dtstart: { tzid: 'UTC', value: '20000101T010000' },
- dtend: { tzid: 'UTC', value: '20000101T030000' },
- exdate: [
- {
- tzid: 'UTC',
- value: '20000102T010000',
- },
- ],
- recurrenceId: undefined,
- rrule: 'FREQ=DAILY;UNTIL=20401001T125500Z',
- },
- ],
- },
- },
- );
- });
-
- await waitFor(() => {
- expect(deleteModal).not.toBeInTheDocument();
- });
- });
-
it('should show error if deletion failed', async () => {
widgetApi
.observeRoomEvents('net.nordeck.meetings.meeting.close')
@@ -981,7 +856,7 @@ describe('', () => {
});
const deleteButton = within(deleteModal).getByRole('button', {
- name: 'Delete meeting',
+ name: 'Delete',
});
await userEvent.click(deleteButton);
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 7e5f7bc9..048a28dc 100644
--- a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.tsx
+++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.tsx
@@ -30,12 +30,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,
@@ -44,27 +41,18 @@ import {
Tooltip,
} from '@mui/material';
import { unstable_useId as useId } from '@mui/utils';
-import { DateTime } from 'luxon';
import { DispatchWithoutAction, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import {
- formatICalDate,
- isRecurringCalendarSourceEntry,
-} from '../../../../lib/utils';
import {
Meeting,
makeSelectRoomPermissions,
selectNordeckMeetingMetadataEventByRoomId,
selectRoomPowerLevelsEventByRoomId,
- useCloseMeetingMutation,
- useUpdateMeetingDetailsMutation,
} from '../../../../reducer/meetingsApi';
import { useAppSelector } from '../../../../store';
-import { ConfirmDeleteDialog } from '../../../common/ConfirmDeleteDialog';
-import { withoutYearDateFormat } from '../../../common/DateTimePickers';
import { UpdateFailedDialog } from '../../MeetingCard/MeetingCardMenu';
-import { ScheduledDeletionWarning } from '../../MeetingCard/ScheduledDeletionWarning';
import { useEditMeeting } from '../../ScheduleMeetingModal';
+import { DeleteMeetingDialog } from './DeleteMeetingDialog';
import { MeetingDetailsJoinButton } from './MeetingDetailsJoinButton';
import { getOpenXChangeExternalReference } from './OpenXchangeButton';
import { OpenXchangeButton } from './OpenXchangeButton/OpenXchangeButton';
@@ -83,7 +71,6 @@ export function MeetingDetailsHeader({
const widgetApi = useWidgetApi();
const { t } = useTranslation();
const { editMeeting } = useEditMeeting();
- const [updateMeetingDetails] = useUpdateMeetingDetailsMutation();
const [showErrorDialog, setShowErrorDialog] = useState(false);
const [openDeleteConfirm, setOpenDeleteConfirm] = useState(false);
const selectRoomPermissions = useMemo(makeSelectRoomPermissions, []);
@@ -106,15 +93,6 @@ export function MeetingDetailsHeader({
canUpdateMeetingParticipantsInvite &&
canUpdateMeetingParticipantsKick;
- const isDeleteRecurringMeeting = isRecurringCalendarSourceEntry(
- meeting.calendarEntries,
- );
-
- const [
- closeMeeting,
- { isLoading: isDeleting, isError, data: deleteResponse },
- ] = useCloseMeetingMutation();
-
const metadataEvent = useAppSelector((state) => {
const event = selectNordeckMeetingMetadataEventByRoomId(
state,
@@ -153,92 +131,6 @@ export function MeetingDetailsHeader({
setOpenDeleteConfirm(false);
}, []);
- const handleClickDeleteMeetingConfirm = useCallback(async () => {
- try {
- if (isDeleteRecurringMeeting && meeting.recurrenceId) {
- const calendar = meeting.calendarEntries[0];
- const newExtended = formatICalDate(
- DateTime.fromISO(meeting.recurrenceId),
- new Intl.DateTimeFormat().resolvedOptions().timeZone,
- );
- const newCalendar = {
- ...calendar,
- exdate: calendar.exdate
- ? [...calendar.exdate, newExtended]
- : [newExtended],
- };
- meeting.calendarEntries[0] = newCalendar;
-
- const detailsResult = await updateMeetingDetails({
- roomId: meeting.meetingId,
- updates: { ...meeting, calendar: meeting.calendarEntries },
- }).unwrap();
-
- if (!detailsResult.acknowledgement?.error) {
- const isInMeetingRoom =
- meeting.meetingId === widgetApi.widgetParameters.roomId;
-
- if (isInMeetingRoom && meeting.parentRoomId) {
- navigateToRoom(widgetApi, meeting.parentRoomId);
- }
-
- handleCloseDeleteConfirm();
- }
- } else {
- 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,
- isDeleteRecurringMeeting,
- meeting,
- updateMeetingDetails,
- widgetApi,
- ]);
-
- const handleClickDeleteSeriesConfirm = 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 &&
@@ -330,54 +222,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/index.ts b/matrix-meetings-widget/src/lib/utils/calendarUtils/index.ts
index 1479caba..95a872f0 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 {
diff --git a/matrix-meetings-widget/src/store/store.ts b/matrix-meetings-widget/src/store/store.ts
index 87674f65..59c5e008 100644
--- a/matrix-meetings-widget/src/store/store.ts
+++ b/matrix-meetings-widget/src/store/store.ts
@@ -69,3 +69,13 @@ export type AppDispatch = StoreType['dispatch'];
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook = useSelector;
+
+/**
+ * The specific type of the `ThunkApiConfig` that can be used with `createAsyncThunk`
+ */
+export type StateThunkConfig = {
+ /** return type for `thunkApi.getState` */
+ state: RootState;
+ /** type of the `extra` argument for the thunk middleware, which will be passed in as `thunkApi.extra` */
+ extra: ThunkExtraArgument;
+};
From 6203e64393c1f275a44bd193ef475c71083b738d Mon Sep 17 00:00:00 2001
From: Dominik Henneke
Date: Tue, 19 Sep 2023 12:22:46 +0200
Subject: [PATCH 04/10] Add a changeset
Signed-off-by: Dominik Henneke
---
.changeset/nervous-crabs-hang.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/nervous-crabs-hang.md
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.
From 1b92fa4c5b40ea46402362369e69b0efc9ec477d Mon Sep 17 00:00:00 2001
From: Dominik Henneke
Date: Tue, 19 Sep 2023 14:26:56 +0200
Subject: [PATCH 05/10] Cleanup test file
Signed-off-by: Dominik Henneke
---
.../DeleteMeetingDialog.test.tsx | 34 -------------------
1 file changed, 34 deletions(-)
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
index 92c785be..45e25109 100644
--- a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.test.tsx
+++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.test.tsx
@@ -14,23 +14,18 @@
* limitations under the License.
*/
-import { extractWidgetApiParameters as extractWidgetApiParametersMocked } from '@matrix-widget-toolkit/api';
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 { setupServer } from 'msw/node';
import { ComponentType, PropsWithChildren, useState } from 'react';
import { Provider } from 'react-redux';
import {
acknowledgeAllEvents,
mockCalendarEntry,
- mockConfigEndpoint,
mockCreateMeetingRoom,
mockMeeting,
- mockMeetingSharingInformationEndpoint,
- mockWidgetEndpoint,
} from '../../../../lib/testUtils';
import { createStore } from '../../../../store';
import { initializeStore } from '../../../../store/store';
@@ -39,21 +34,6 @@ import {
deleteSingleMeetingOccurrenceThunk,
} from './DeleteMeetingDialog';
-jest.mock('@matrix-widget-toolkit/api', () => ({
- ...jest.requireActual('@matrix-widget-toolkit/api'),
- extractWidgetApiParameters: jest.fn(),
-}));
-
-const extractWidgetApiParameters = jest.mocked(
- extractWidgetApiParametersMocked,
-);
-
-const server = setupServer();
-
-beforeAll(() => server.listen());
-afterEach(() => server.resetHandlers());
-afterAll(() => server.close());
-
let widgetApi: MockedWidgetApi;
afterEach(() => widgetApi.stop());
@@ -65,15 +45,6 @@ describe('', () => {
let Wrapper: ComponentType>;
beforeEach(() => {
- jest.mocked(extractWidgetApiParameters).mockReturnValue({
- clientOrigin: 'http://element.local',
- widgetId: '',
- });
-
- mockWidgetEndpoint(server);
- mockConfigEndpoint(server);
- mockMeetingSharingInformationEndpoint(server);
-
Wrapper = ({ children }: PropsWithChildren<{}>) => {
const [store] = useState(() => {
const store = createStore({ widgetApi });
@@ -88,11 +59,6 @@ describe('', () => {
};
});
- afterEach(() => {
- // Restore the spy on Date.now()
- jest.restoreAllMocks();
- });
-
it('should render without exploding', () => {
render(
,
From 80e4b0b068dcbb325fed370d63dc5ea84bc24a56 Mon Sep 17 00:00:00 2001
From: Dominik Henneke
Date: Tue, 19 Sep 2023 16:30:05 +0200
Subject: [PATCH 06/10] Add an e2e test
Signed-off-by: Dominik Henneke
---
e2e/src/pages/meetingCardPage.ts | 8 ++++++--
e2e/src/recurringMeetings.spec.ts | 33 +++++++++++++++++++++++++++++--
2 files changed, 37 insertions(+), 4 deletions(-)
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 dee8d141..069fbb93 100644
--- a/e2e/src/recurringMeetings.spec.ts
+++ b/e2e/src/recurringMeetings.spec.ts
@@ -322,14 +322,43 @@ 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);
+ });
// TODO: Delete starting from
});
From 7986de8d3c15c2da3f8fe6de82132446d60b9275 Mon Sep 17 00:00:00 2001
From: Dominik Henneke
Date: Wed, 20 Sep 2023 13:50:50 +0200
Subject: [PATCH 07/10] Don't allow removing the last occurrence entry in a
meeting room but delete the room instead
Signed-off-by: Dominik Henneke
---
.../DeleteMeetingDialog.test.tsx | 154 +++++++++++++++++-
.../DeleteMeetingDialog.tsx | 67 ++++++--
.../calendarUtils/getCalendarEnd.test.ts | 19 ++-
.../lib/utils/calendarUtils/getCalendarEnd.ts | 15 +-
.../utils/calendarUtils/getCalendarEvent.ts | 2 +-
5 files changed, 231 insertions(+), 26 deletions(-)
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
index 45e25109..c7249052 100644
--- a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.test.tsx
+++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.test.tsx
@@ -32,6 +32,7 @@ import { initializeStore } from '../../../../store/store';
import {
DeleteMeetingDialog,
deleteSingleMeetingOccurrenceThunk,
+ selectIsLastMeetingOccurrence,
} from './DeleteMeetingDialog';
let widgetApi: MockedWidgetApi;
@@ -65,7 +66,11 @@ describe('', () => {
{ wrapper: Wrapper },
);
- const dialog = screen.getByRole('dialog', { name: 'Delete meeting' });
+ 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' }),
@@ -107,7 +112,11 @@ describe('', () => {
wrapper: Wrapper,
});
- const dialog = screen.getByRole('dialog', { name: 'Delete meeting' });
+ 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' }),
@@ -153,7 +162,11 @@ describe('', () => {
wrapper: Wrapper,
});
- const dialog = screen.getByRole('dialog', { name: 'Delete meeting' });
+ 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' }),
@@ -202,7 +215,11 @@ describe('', () => {
wrapper: Wrapper,
});
- const dialog = screen.getByRole('dialog', { name: 'Delete meeting' });
+ 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' }),
@@ -233,6 +250,60 @@ describe('', () => {
});
});
+ 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')
@@ -248,7 +319,11 @@ describe('', () => {
wrapper: Wrapper,
});
- const dialog = screen.getByRole('dialog', { name: 'Delete meeting' });
+ 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' }),
@@ -614,3 +689,72 @@ describe('deleteSingleMeetingOccurrenceThunk', () => {
);
});
});
+
+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
index f8c1ab45..a494295c 100644
--- a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.tsx
+++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/DeleteMeetingDialog.tsx
@@ -18,13 +18,14 @@ 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 } from '@reduxjs/toolkit';
+import { createAsyncThunk, createSelector } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { DispatchWithoutAction, Fragment, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
deleteCalendarEvent,
- isSingleCalendarSourceEntry,
+ getCalendarEnd,
+ isRecurringCalendarSourceEntry,
} from '../../../../lib/utils';
import {
Meeting,
@@ -33,7 +34,12 @@ import {
selectNordeckMeetingMetadataEventByRoomId,
useCloseMeetingMutation,
} from '../../../../reducer/meetingsApi';
-import { StateThunkConfig, useAppDispatch } from '../../../../store/store';
+import {
+ RootState,
+ StateThunkConfig,
+ useAppDispatch,
+ useAppSelector,
+} from '../../../../store/store';
import { ConfirmDeleteDialog } from '../../../common/ConfirmDeleteDialog';
import { withoutYearDateFormat } from '../../../common/DateTimePickers';
import { ScheduledDeletionWarning } from '../../MeetingCard/ScheduledDeletionWarning';
@@ -119,25 +125,18 @@ export function DeleteMeetingDialog({
}
}, [dispatch, handleCheckNavigateToParent, meeting, reset]);
+ const isLastMeetingOccurrence = useAppSelector((state) =>
+ selectIsLastMeetingOccurrence(state, meeting.meetingId, meeting),
+ );
+
let description: string;
let confirmTitle: string;
let additionalButtons = ;
- if (isSingleCalendarSourceEntry(meeting.calendarEntries)) {
- 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');
- } else {
+ 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?',
@@ -168,6 +167,20 @@ export function DeleteMeetingDialog({
)}
);
+ } 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;
@@ -241,3 +254,21 @@ export const deleteSingleMeetingOccurrenceThunk = createAsyncThunk<
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/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
From 07ac980a9c60ec04feb0d28568144083287ac1a6 Mon Sep 17 00:00:00 2001
From: Dominik Henneke
Date: Wed, 20 Sep 2023 15:58:13 +0200
Subject: [PATCH 08/10] Check if the remaining meetings in the series are still
present
Signed-off-by: Dominik Henneke
---
e2e/src/recurringMeetings.spec.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/e2e/src/recurringMeetings.spec.ts b/e2e/src/recurringMeetings.spec.ts
index d28c9ab1..0ed6c3ce 100644
--- a/e2e/src/recurringMeetings.spec.ts
+++ b/e2e/src/recurringMeetings.spec.ts
@@ -400,6 +400,10 @@ test.describe('Recurring Meetings', () => {
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
From b5e4230ecde93cd500b8907bd368827cf4ec8281 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 21 Sep 2023 08:01:15 +0000
Subject: [PATCH 09/10] Bump joi from 17.10.1 to 17.10.2
Bumps [joi](https://github.com/hapijs/joi) from 17.10.1 to 17.10.2.
- [Commits](https://github.com/hapijs/joi/compare/v17.10.1...v17.10.2)
---
updated-dependencies:
- dependency-name: joi
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
matrix-meetings-bot/package.json | 2 +-
matrix-meetings-widget/package.json | 2 +-
yarn.lock | 8 ++++----
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/matrix-meetings-bot/package.json b/matrix-meetings-bot/package.json
index eab81327..d6860c09 100644
--- a/matrix-meetings-bot/package.json
+++ b/matrix-meetings-bot/package.json
@@ -46,7 +46,7 @@
"i18next": "^23.4.5",
"i18next-fs-backend": "^2.1.5",
"i18next-http-middleware": "^3.3.2",
- "joi": "^17.10.1",
+ "joi": "^17.10.2",
"lodash": "^4.17.20",
"luxon": "^3.3.0",
"matrix-bot-sdk": "^0.6.6",
diff --git a/matrix-meetings-widget/package.json b/matrix-meetings-widget/package.json
index 790b86da..f306e773 100644
--- a/matrix-meetings-widget/package.json
+++ b/matrix-meetings-widget/package.json
@@ -24,7 +24,7 @@
"i18next-chained-backend": "^4.4.0",
"i18next-http-backend": "^2.2.2",
"ical-generator": "^5.0.1",
- "joi": "^17.10.1",
+ "joi": "^17.10.2",
"lodash": "^4.17.20",
"luxon": "^3.3.0",
"matrix-widget-api": "^1.5.0",
diff --git a/yarn.lock b/yarn.lock
index 38aab521..d281b81a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8563,10 +8563,10 @@ jest@^27.4.3:
import-local "^3.0.2"
jest-cli "^27.5.1"
-joi@^17.10.1:
- version "17.10.1"
- resolved "https://registry.yarnpkg.com/joi/-/joi-17.10.1.tgz#f908ee1617137cca5d83b91587cde80e472b5753"
- integrity sha512-vIiDxQKmRidUVp8KngT8MZSOcmRVm2zV7jbMjNYWuHcJWI0bUck3nRTGQjhpPlQenIQIBC5Vp9AhcnHbWQqafw==
+joi@^17.10.2:
+ version "17.10.2"
+ resolved "https://registry.yarnpkg.com/joi/-/joi-17.10.2.tgz#4ecc348aa89ede0b48335aad172e0f5591e55b29"
+ integrity sha512-hcVhjBxRNW/is3nNLdGLIjkgXetkeGc2wyhydhz8KumG23Aerk4HPjU5zaPAMRqXQFc0xNqXTC7+zQjxr0GlKA==
dependencies:
"@hapi/hoek" "^9.0.0"
"@hapi/topo" "^5.0.0"
From e9ef468169ad0e2de0dfea2be6b7029c5c4e28e1 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 21 Sep 2023 10:00:55 +0000
Subject: [PATCH 10/10] Bump uuid and @types/uuid
Bumps [uuid](https://github.com/uuidjs/uuid) and [@types/uuid](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/uuid). These dependencies needed to be updated together.
Updates `uuid` from 9.0.0 to 9.0.1
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v9.0.0...v9.0.1)
Updates `@types/uuid` from 9.0.2 to 9.0.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/uuid)
---
updated-dependencies:
- dependency-name: uuid
dependency-type: direct:production
update-type: version-update:semver-patch
- dependency-name: "@types/uuid"
dependency-type: direct:development
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
matrix-meetings-bot/package.json | 4 ++--
yarn.lock | 15 ++++++++++-----
2 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/matrix-meetings-bot/package.json b/matrix-meetings-bot/package.json
index d6860c09..5507d8c1 100644
--- a/matrix-meetings-bot/package.json
+++ b/matrix-meetings-bot/package.json
@@ -61,7 +61,7 @@
"rfc4648": "^1.5.2",
"rrule": "^2.7.2",
"rxjs": "^7.8.0",
- "uuid": "^9.0.0"
+ "uuid": "^9.0.1"
},
"devDependencies": {
"@nestjs/cli": "^10.1.14",
@@ -72,7 +72,7 @@
"@types/mustache": "^4.2.2",
"@types/node": "^16.18.44",
"@types/node-fetch": "^2.6.4",
- "@types/uuid": "^9.0.2",
+ "@types/uuid": "^9.0.4",
"copyfiles": "^2.4.1",
"depcheck": "^1.4.5",
"dotenv-cli": "^7.3.0",
diff --git a/yarn.lock b/yarn.lock
index d281b81a..d6652f14 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3169,10 +3169,10 @@
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43"
integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
-"@types/uuid@^9.0.2":
- version "9.0.2"
- resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.2.tgz#ede1d1b1e451548d44919dc226253e32a6952c4b"
- integrity sha512-kNnC1GFBLuhImSnV7w4njQkUiJi0ZXUycu1rUaouPqiKlXkh77JKgdRnTAp1x5eBwcIwbtI+3otwzuIDEuDoxQ==
+"@types/uuid@^9.0.4":
+ version "9.0.4"
+ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.4.tgz#e884a59338da907bda8d2ed03e01c5c49d036f1c"
+ integrity sha512-zAuJWQflfx6dYJM62vna+Sn5aeSWhh3OB+wfUEACNcqUSc0AGc5JKl+ycL1vrH7frGTXhJchYjE1Hak8L819dA==
"@types/validator@^13.7.10":
version "13.7.12"
@@ -13321,7 +13321,7 @@ uuid-random@^1.3.2:
resolved "https://registry.yarnpkg.com/uuid-random/-/uuid-random-1.3.2.tgz#96715edbaef4e84b1dcf5024b00d16f30220e2d0"
integrity sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ==
-uuid@9.0.0, uuid@^9.0.0:
+uuid@9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"
integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
@@ -13336,6 +13336,11 @@ uuid@^8.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
+uuid@^9.0.1:
+ version "9.0.1"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
+ integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
+
v8-compile-cache-lib@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"