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"