diff --git a/.changeset/dry-coins-fetch.md b/.changeset/dry-coins-fetch.md new file mode 100644 index 00000000..575fb804 --- /dev/null +++ b/.changeset/dry-coins-fetch.md @@ -0,0 +1,5 @@ +--- +'@nordeck/matrix-meetings-widget': minor +--- + +Support editing a single occurrence of a recurring meeting. diff --git a/e2e/src/meetingReaper.spec.ts b/e2e/src/meetingReaper.spec.ts index cab4bb64..f8f7c2ff 100644 --- a/e2e/src/meetingReaper.spec.ts +++ b/e2e/src/meetingReaper.spec.ts @@ -144,6 +144,7 @@ test.describe('Meeting Reaper', () => { ); const aliceEditMeetingWidgetPage = await meeting.editMeeting(); + await aliceEditMeetingWidgetPage.toggleRecurringEdit(); await aliceEditMeetingWidgetPage.setStart([2040, 10, 4], '11:00 AM'); await aliceEditMeetingWidgetPage.submit(); await aliceElementWebPage.approveWidgetIdentity(); diff --git a/e2e/src/pages/scheduleMeetingWidgetPage.ts b/e2e/src/pages/scheduleMeetingWidgetPage.ts index dcdd9d95..8cc2ac8a 100644 --- a/e2e/src/pages/scheduleMeetingWidgetPage.ts +++ b/e2e/src/pages/scheduleMeetingWidgetPage.ts @@ -29,6 +29,7 @@ export class ScheduleMeetingWidgetPage { public readonly afterMeetingCountRadio: Locator; public readonly afterMeetingCountSpinbutton: Locator; public readonly customRecurrenceRuleGroup: Locator; + public readonly switchRecurringEdit: Locator; constructor( private readonly page: Page, @@ -37,6 +38,9 @@ export class ScheduleMeetingWidgetPage { const dialog = this.page.getByRole('dialog', { name: /Schedule Meeting|Edit Meeting/, }); + this.switchRecurringEdit = widget.getByRole('checkbox', { + name: 'Edit the recurring meeting series', + }); this.submitMeetingButton = dialog.getByRole('button', { name: /Create Meeting|Save/, }); @@ -87,6 +91,14 @@ export class ScheduleMeetingWidgetPage { ); } + async toggleRecurringEdit() { + await this.switchRecurringEdit.click(); + } + + async toggleChatPermission() { + await this.allowMessagingCheckbox.click(); + } + async addParticipant(name: string) { await this.participantsCombobox.type(name); await this.widget.getByRole('option', { name }).waitFor(); @@ -94,10 +106,6 @@ export class ScheduleMeetingWidgetPage { await this.participantsCombobox.press('Enter'); } - async toggleChatPermission() { - await this.allowMessagingCheckbox.click(); - } - async addWidget(widgetName: string) { await this.widgetsCombobox.click(); await this.widget.getByRole('option', { name: widgetName }).click(); diff --git a/e2e/src/recurringMeetings.spec.ts b/e2e/src/recurringMeetings.spec.ts index dee8d141..354d0d8f 100644 --- a/e2e/src/recurringMeetings.spec.ts +++ b/e2e/src/recurringMeetings.spec.ts @@ -149,6 +149,7 @@ test.describe('Recurring Meetings', () => { ); const aliceEditMeetingWidgetPage = await meeting.editMeeting(); + await aliceEditMeetingWidgetPage.toggleRecurringEdit(); await aliceEditMeetingWidgetPage.setStart([2040, 10, 4], '11:00 AM'); await aliceEditMeetingWidgetPage.selectRecurrence('custom'); await aliceEditMeetingWidgetPage.selectRecurrenceFrequency('weeks'); @@ -187,6 +188,48 @@ test.describe('Recurring Meetings', () => { ).toBeVisible(); }); + test('should edit one instance of the recurring meeting', async ({ + aliceMeetingsWidgetPage, + aliceElementWebPage, + }) => { + 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', + ); + + const aliceEditMeetingWidgetPage = await meeting.editMeeting(); + await aliceEditMeetingWidgetPage.setStart([2040, 10, 9], '10:40 AM'); + await aliceEditMeetingWidgetPage.submit(); + await aliceElementWebPage.approveWidgetIdentity(); + + await expect( + aliceMeetingsWidgetPage.getMeeting('My Meeting', '10/09/2040') + .meetingTimeRangeText, + ).toHaveText('10:40 AM – 11:40 AM. Recurrence: Every day for 5 times'); + + await expect( + aliceMeetingsWidgetPage.getMeeting('My Meeting', '10/04/2040') + .meetingTimeRangeText, + ).toHaveText('10:30 AM – 11:30 AM. Recurrence: Every day for 5 times'); + }); + test('should covert a recurring meeting into a single meeting', async ({ aliceMeetingsWidgetPage, aliceElementWebPage, @@ -214,6 +257,7 @@ test.describe('Recurring Meetings', () => { ); const aliceEditMeetingWidgetPage = await meeting.editMeeting(); + await aliceEditMeetingWidgetPage.toggleRecurringEdit(); await aliceEditMeetingWidgetPage.selectRecurrence('no repetition'); await aliceEditMeetingWidgetPage.submit(); await aliceElementWebPage.approveWidgetIdentity(); @@ -294,8 +338,6 @@ test.describe('Recurring Meetings', () => { ).toBeVisible(); }); - // TODO: Edit single meeting - // TODO: Edit starting from test('should delete recurring meeting', async ({ diff --git a/matrix-meetings-widget/public/locales/de/translation.json b/matrix-meetings-widget/public/locales/de/translation.json index 2b9a87d6..cb9caeca 100644 --- a/matrix-meetings-widget/public/locales/de/translation.json +++ b/matrix-meetings-widget/public/locales/de/translation.json @@ -59,6 +59,10 @@ "save": "Speichern", "title": "Bearbeiten" }, + "editRecurringMessage": { + "message": "Alle Instanzen der Besprechungsserie können bearbeitet werden", + "titleOne": "Wiederkehrende Besprechungsserie bearbeiten" + }, "invitedMeetingList": { "detail": { "invitedBy": "Einladung von: {{name}}" @@ -364,10 +368,6 @@ "hasPowerToKickUser": "Du hast nicht die Berechtigung um diesen Nutzer aus der Besprechung zu entfernen.", "meetingAlreadyStarted": "Die Besprechung ist bereits gestartet.", "participants": "Teilnehmer", - "recurringMeetingMessage": { - "message": "Alle Wiederholungen des Termins werden bearbeitet.", - "title": "Du bearbeitest einen Serientermin" - }, "startAt": "Startzeitpunkt", "title": "Titel (erforderlich)", "titleHelperText": "Ein Titel ist erforderlich", diff --git a/matrix-meetings-widget/public/locales/en/translation.json b/matrix-meetings-widget/public/locales/en/translation.json index 504a5cee..a535b27a 100644 --- a/matrix-meetings-widget/public/locales/en/translation.json +++ b/matrix-meetings-widget/public/locales/en/translation.json @@ -59,6 +59,10 @@ "save": "Save", "title": "Edit Meeting" }, + "editRecurringMessage": { + "message": "All instances of the recurring meeting are editable", + "titleOne": "Edit the recurring meeting series" + }, "invitedMeetingList": { "detail": { "invitedBy": "Invited By: {{name}}" @@ -367,10 +371,6 @@ "hasPowerToKickUser": "You don't have the permission to remove this participant.", "meetingAlreadyStarted": "The meeting already started.", "participants": "Participants", - "recurringMeetingMessage": { - "message": "All instances of the recurring meeting are edited", - "title": "You are editing a recurring meeting" - }, "startAt": "Start at", "title": "Title (required)", "titleHelperText": "A title is required", diff --git a/matrix-meetings-widget/src/components/common/MeetingNotEndedGuard/MeetingNotEndedGuard.test.tsx b/matrix-meetings-widget/src/components/common/MeetingNotEndedGuard/MeetingNotEndedGuard.test.tsx index 83fde450..4c7be0c0 100644 --- a/matrix-meetings-widget/src/components/common/MeetingNotEndedGuard/MeetingNotEndedGuard.test.tsx +++ b/matrix-meetings-widget/src/components/common/MeetingNotEndedGuard/MeetingNotEndedGuard.test.tsx @@ -36,9 +36,9 @@ describe('', () => { ', () => { content: { startTime: '2000-01-02T01:00:00Z', endTime: '2000-01-02T02:00:00Z', - recurrenceId: '2000-01-02T01:00:00Z', + recurrenceId: undefined, calendarEntries: [ mockCalendarEntry({ dtstart: '20000101T010000', @@ -115,7 +115,7 @@ describe('', () => { content: { startTime: '2000-01-02T01:00:00Z', endTime: '2000-01-02T02:00:00Z', - recurrenceId: '2000-01-02T01:00:00Z', + recurrenceId: undefined, calendarEntries: [ mockCalendarEntry({ dtstart: '20000101T010000', diff --git a/matrix-meetings-widget/src/components/common/MeetingNotEndedGuard/MeetingNotEndedGuard.tsx b/matrix-meetings-widget/src/components/common/MeetingNotEndedGuard/MeetingNotEndedGuard.tsx index 4353309d..ccb85736 100644 --- a/matrix-meetings-widget/src/components/common/MeetingNotEndedGuard/MeetingNotEndedGuard.tsx +++ b/matrix-meetings-widget/src/components/common/MeetingNotEndedGuard/MeetingNotEndedGuard.tsx @@ -24,7 +24,7 @@ import React, { ReactNode, } from 'react'; import { useTranslation } from 'react-i18next'; -import { getCalendarEnd } from '../../../lib/utils'; +import { getCalendarEvent } from '../../../lib/utils'; import { isMeetingBreakOutRoom, Meeting } from '../../../reducer/meetingsApi'; import { useUpdateOnDate } from '../hooks'; @@ -56,13 +56,19 @@ export function MeetingNotEndedGuard({ }: MeetingNotEndedGuardProps): ReactElement { const { t } = useTranslation(); - const endTime = meeting ? getCalendarEnd(meeting.calendarEntries) : undefined; + const endTime = meeting + ? getCalendarEvent( + meeting.calendarEntries, + meeting.calendarUid, + meeting?.recurrenceId, + )?.endTime + : undefined; // Make sure this component is rerendered (and the now below re-evaluated) // after the meeting ended. - useUpdateOnDate(endTime?.toJSDate()); + useUpdateOnDate(endTime); - if (meeting && endTime && DateTime.now() >= endTime) { + if (meeting && endTime && DateTime.now() >= DateTime.fromISO(endTime)) { if (!withMessage) { return ; } @@ -81,7 +87,12 @@ export function MeetingNotEndedGuard({ const isBreakOutRoom = isMeetingBreakOutRoom(meeting.type); return ( - } role="status" severity="info"> + } + role="status" + severity="info" + sx={{ mx: 1 }} + > {isBreakOutRoom ? t( 'meetingViewEditMeeting.breakoutSessionIsOver', diff --git a/matrix-meetings-widget/src/components/common/hooks/useUpdateOnDate.test.tsx b/matrix-meetings-widget/src/components/common/hooks/useUpdateOnDate.test.tsx index 339449a0..165c48cb 100644 --- a/matrix-meetings-widget/src/components/common/hooks/useUpdateOnDate.test.tsx +++ b/matrix-meetings-widget/src/components/common/hooks/useUpdateOnDate.test.tsx @@ -24,7 +24,7 @@ describe('useUpdateOnDate', () => { }); it('should rerender at date', () => { - function Component({ date }: { date: Date }) { + function Component({ date }: { date: string }) { useUpdateOnDate(date); return
{new Date(Date.now()).toISOString()}
; @@ -33,7 +33,7 @@ describe('useUpdateOnDate', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2022-12-07T20:30:00.000Z')); - render(); + render(); expect(screen.getByText('2022-12-07T20:30:00.000Z')).toBeInTheDocument(); diff --git a/matrix-meetings-widget/src/components/common/hooks/useUpdateOnDate.tsx b/matrix-meetings-widget/src/components/common/hooks/useUpdateOnDate.tsx index 0b0bcf05..3ec8a40b 100644 --- a/matrix-meetings-widget/src/components/common/hooks/useUpdateOnDate.tsx +++ b/matrix-meetings-widget/src/components/common/hooks/useUpdateOnDate.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { DateTime } from 'luxon'; import { useEffect } from 'react'; import { useUpdate } from 'react-use'; @@ -21,7 +22,7 @@ import { useUpdate } from 'react-use'; * Triggers to update a component after date. * @param date */ -export function useUpdateOnDate(date: Date | undefined): void { +export function useUpdateOnDate(date: string | undefined): void { const update = useUpdate(); // trigger an update when the meeting ends @@ -50,7 +51,7 @@ export function useUpdateOnDate(date: Date | undefined): void { return () => {}; } - schedule(date.getTime()); + schedule(+DateTime.fromISO(date)); return () => { clearTimeout(timeoutRef); diff --git a/matrix-meetings-widget/src/components/common/withRoomMeeting/withCurrentRoomMeeting.tsx b/matrix-meetings-widget/src/components/common/withRoomMeeting/withCurrentRoomMeeting.tsx index 0396e4f4..301e0acb 100644 --- a/matrix-meetings-widget/src/components/common/withRoomMeeting/withCurrentRoomMeeting.tsx +++ b/matrix-meetings-widget/src/components/common/withRoomMeeting/withCurrentRoomMeeting.tsx @@ -55,9 +55,9 @@ export function withCurrentRoomMeeting( const NewComponent = useMemo(() => withRoomIdMeeting(WrappedComponent), []); // As the meeting is the current instance of a recurring meeting, we have - // to reevaluate the meeting everytime the end of the meeting instance is + // to reevaluate the meeting every time the end of the meeting instance is // reached! - useUpdateOnDate(entry ? new Date(entry.endTime) : undefined); + useUpdateOnDate(entry?.endTime); return ( { - const event = selectRoomPowerLevelsEventByRoomId(state, meeting.meetingId); - return event?.content.events_default === 0; - }); - + const dispatch = useAppDispatch(); const handleClickEditMeeting = useCallback(async () => { try { - await editMeeting(meeting, isMessagingEnabled); + await dispatch(editMeetingThunk(meeting)).unwrap(); } catch { setShowErrorDialog(true); } - }, [editMeeting, meeting, isMessagingEnabled]); + }, [dispatch, meeting]); if (!canUpdateMeeting || !canCloseMeeting) { // If the menu would be empty, skip it 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..93df5b6b 100644 --- a/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.tsx +++ b/matrix-meetings-widget/src/components/meetings/MeetingDetails/MeetingDetailsHeader/MeetingDetailsHeader.tsx @@ -14,22 +14,6 @@ * limitations under the License. */ -/* - * Copyright 2022 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 CloseIcon from '@mui/icons-material/Close'; @@ -50,15 +34,14 @@ import { Meeting, makeSelectRoomPermissions, selectNordeckMeetingMetadataEventByRoomId, - selectRoomPowerLevelsEventByRoomId, useCloseMeetingMutation, } from '../../../../reducer/meetingsApi'; -import { useAppSelector } from '../../../../store'; +import { useAppDispatch, useAppSelector } from '../../../../store'; import { ConfirmDeleteDialog } from '../../../common/ConfirmDeleteDialog'; import { withoutYearDateFormat } from '../../../common/DateTimePickers'; import { UpdateFailedDialog } from '../../MeetingCard/MeetingCardMenu'; import { ScheduledDeletionWarning } from '../../MeetingCard/ScheduledDeletionWarning'; -import { useEditMeeting } from '../../ScheduleMeetingModal'; +import { editMeetingThunk } from '../../ScheduleMeetingModal'; import { MeetingDetailsJoinButton } from './MeetingDetailsJoinButton'; import { getOpenXChangeExternalReference } from './OpenXchangeButton'; import { OpenXchangeButton } from './OpenXchangeButton/OpenXchangeButton'; @@ -76,7 +59,6 @@ export function MeetingDetailsHeader({ }) { const widgetApi = useWidgetApi(); const { t } = useTranslation(); - const { editMeeting } = useEditMeeting(); const [showErrorDialog, setShowErrorDialog] = useState(false); const [openDeleteConfirm, setOpenDeleteConfirm] = useState(false); const selectRoomPermissions = useMemo(makeSelectRoomPermissions, []); @@ -113,26 +95,22 @@ export function MeetingDetailsHeader({ return event; }); - const isMessagingEnabled = useAppSelector((state) => { - const event = selectRoomPowerLevelsEventByRoomId(state, meeting.meetingId); - return event?.content.events_default === 0; - }); - const openXChangeReference = useMemo( () => metadataEvent && getOpenXChangeExternalReference(metadataEvent), [metadataEvent], ); const isExternalReference = openXChangeReference !== undefined; + const dispatch = useAppDispatch(); const handleClickEditMeeting = useCallback(async () => { try { if (meeting) { - await editMeeting(meeting, isMessagingEnabled); + await dispatch(editMeetingThunk(meeting)).unwrap(); } } catch { setShowErrorDialog(true); } - }, [editMeeting, isMessagingEnabled, meeting]); + }, [dispatch, meeting]); const handleClickOpenDeleteConfirm = useCallback(() => { setOpenDeleteConfirm(true); diff --git a/matrix-meetings-widget/src/components/meetings/MeetingsCalendar/MeetingsCalendar.test.tsx b/matrix-meetings-widget/src/components/meetings/MeetingsCalendar/MeetingsCalendar.test.tsx index fcb59396..cd4b55f3 100644 --- a/matrix-meetings-widget/src/components/meetings/MeetingsCalendar/MeetingsCalendar.test.tsx +++ b/matrix-meetings-widget/src/components/meetings/MeetingsCalendar/MeetingsCalendar.test.tsx @@ -20,9 +20,15 @@ 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 { mockCalendar, mockCreateMeetingRoom } from '../../../lib/testUtils'; +import { + mockCalendar, + mockConfigEndpoint, + mockCreateMeetingRoom, + mockMeetingSharingInformationEndpoint, +} from '../../../lib/testUtils'; import { createStore } from '../../../store'; import { initializeStore } from '../../../store/store'; import { MeetingsCalendar } from './MeetingsCalendar'; @@ -32,6 +38,12 @@ jest.mock('@matrix-widget-toolkit/api', () => ({ extractWidgetApiParameters: jest.fn(), })); +const server = setupServer(); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + let widgetApi: MockedWidgetApi; afterEach(() => widgetApi.stop()); @@ -52,6 +64,9 @@ describe('', () => { widgetId: '', }); + mockConfigEndpoint(server); + mockMeetingSharingInformationEndpoint(server); + mockCreateMeetingRoom(widgetApi, { room_id: '!meeting-room-id-0', name: { name: 'Meeting 0' }, diff --git a/matrix-meetings-widget/src/components/meetings/MeetingsCalendar/MeetingsCalendarDetailsDialog.test.tsx b/matrix-meetings-widget/src/components/meetings/MeetingsCalendar/MeetingsCalendarDetailsDialog.test.tsx index 3401dcc9..99cef319 100644 --- a/matrix-meetings-widget/src/components/meetings/MeetingsCalendar/MeetingsCalendarDetailsDialog.test.tsx +++ b/matrix-meetings-widget/src/components/meetings/MeetingsCalendar/MeetingsCalendarDetailsDialog.test.tsx @@ -20,11 +20,14 @@ 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 { mockCalendar, + mockConfigEndpoint, mockCreateMeetingRoom, + mockMeetingSharingInformationEndpoint, mockRoomTombstone, } from '../../../lib/testUtils'; import { createStore } from '../../../store'; @@ -38,6 +41,12 @@ jest.mock('@matrix-widget-toolkit/api', () => ({ jest.mock('@mui/material/useMediaQuery'); +const server = setupServer(); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + let widgetApi: MockedWidgetApi; afterEach(() => widgetApi.stop()); @@ -55,6 +64,9 @@ describe('', () => { widgetId: '', }); + mockConfigEndpoint(server); + mockMeetingSharingInformationEndpoint(server); + mockCreateMeetingRoom(widgetApi); Wrapper = ({ children }: PropsWithChildren<{}>) => { diff --git a/matrix-meetings-widget/src/components/meetings/MemberSelectionDropdown/MemberSelectionDropdown.tsx b/matrix-meetings-widget/src/components/meetings/MemberSelectionDropdown/MemberSelectionDropdown.tsx index 28f5fead..eae25f86 100644 --- a/matrix-meetings-widget/src/components/meetings/MemberSelectionDropdown/MemberSelectionDropdown.tsx +++ b/matrix-meetings-widget/src/components/meetings/MemberSelectionDropdown/MemberSelectionDropdown.tsx @@ -94,6 +94,9 @@ type MemberSelectionDropdownProps = { /** Text to display when there are no options. */ noOptionsText?: ReactNode; + + /** To disable the dropdown. */ + disabled?: boolean; }; /** @@ -116,6 +119,7 @@ export function MemberSelectionDropdown({ loading, error, noOptionsText, + disabled, }: MemberSelectionDropdownProps): ReactElement { const { t } = useTranslation(); const widgetApi = useWidgetApi(); @@ -406,6 +410,7 @@ export function MemberSelectionDropdown({ renderTags={renderTags} sx={{ mt: 1, mb: 0.5 }} value={selectedMembers} + disabled={disabled} /> ); diff --git a/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomMonthlyRecurringMeeting.tsx b/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomMonthlyRecurringMeeting.tsx index 5ef87bac..41818582 100644 --- a/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomMonthlyRecurringMeeting.tsx +++ b/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomMonthlyRecurringMeeting.tsx @@ -37,6 +37,7 @@ type MonthlyRecurringMeetingProps = { customNthMonthday: string; customWeekday: number; customNth: number; + disabled?: boolean; onCustomRuleModeChange: Dispatch; onCustomNthMonthdayChange: Dispatch; onCustomWeekdayChange: Dispatch; @@ -48,6 +49,7 @@ export const CustomMonthlyRecurringMeeting = ({ customNthMonthday, customWeekday, customNth, + disabled, onCustomRuleModeChange, onCustomNthMonthdayChange, onCustomWeekdayChange, @@ -91,6 +93,7 @@ export const CustomMonthlyRecurringMeeting = ({ > diff --git a/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomRecurringMeeting.tsx b/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomRecurringMeeting.tsx index e6fe7e27..4fe99f0d 100644 --- a/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomRecurringMeeting.tsx +++ b/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomRecurringMeeting.tsx @@ -43,6 +43,7 @@ type CustomRecurringMeetingProps = { customNthMonthday: string; customWeekday: number; customNth: number; + disabled?: boolean; onCustomFrequencyChange: Dispatch; onCustomIntervalChange: Dispatch; onCustomByWeekdayChange: Dispatch; @@ -62,6 +63,7 @@ export const CustomRecurringMeeting = ({ customNthMonthday, customWeekday, customNth, + disabled, onCustomFrequencyChange, onCustomIntervalChange, onCustomByWeekdayChange, @@ -121,6 +123,7 @@ export const CustomRecurringMeeting = ({ margin="dense" onChange={handleIntervalChange} value={customInterval} + disabled={disabled} /> @@ -131,6 +134,7 @@ export const CustomRecurringMeeting = ({ labelId={repetitionLabelId} onChange={handleFrequencyChange} value={customFrequency} + disabled={disabled} > {[ Frequency.DAILY, @@ -150,6 +154,7 @@ export const CustomRecurringMeeting = ({ )} @@ -163,6 +168,7 @@ export const CustomRecurringMeeting = ({ onCustomNthMonthdayChange={onCustomNthMonthdayChange} onCustomRuleModeChange={onCustomRuleModeChange} onCustomWeekdayChange={onCustomWeekdayChange} + disabled={disabled} /> )} @@ -178,6 +184,7 @@ export const CustomRecurringMeeting = ({ onCustomNthMonthdayChange={onCustomNthMonthdayChange} onCustomRuleModeChange={onCustomRuleModeChange} onCustomWeekdayChange={onCustomWeekdayChange} + disabled={disabled} /> )} diff --git a/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomWeeklyRecurringMeeting.tsx b/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomWeeklyRecurringMeeting.tsx index bfc0786d..820587e4 100644 --- a/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomWeeklyRecurringMeeting.tsx +++ b/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomWeeklyRecurringMeeting.tsx @@ -32,11 +32,13 @@ import { convertWeekdayFromLocaleToRRule } from '../utils'; type CustomWeeklyRecurringMeetingProps = { onByWeekdayChange: Dispatch>; byWeekday: Array; + disabled?: boolean; }; export const CustomWeeklyRecurringMeeting = ({ onByWeekdayChange, byWeekday, + disabled, }: CustomWeeklyRecurringMeetingProps) => { const { t } = useTranslation(); @@ -53,7 +55,7 @@ export const CustomWeeklyRecurringMeeting = ({ const toggleButtonGroupLabelId = useId(); return ( - + {t( 'recurrenceEditor.custom.weekly.repeatOnWeekday', @@ -69,6 +71,7 @@ export const CustomWeeklyRecurringMeeting = ({ size="small" sx={{ mt: 1 }} value={byWeekday} + disabled={disabled} > {localeWeekdays().map((m, i) => ( diff --git a/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomYearlyRecurringMeeting.tsx b/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomYearlyRecurringMeeting.tsx index 998af004..2c3fec60 100644 --- a/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomYearlyRecurringMeeting.tsx +++ b/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/CustomRecurringMeeting/CustomYearlyRecurringMeeting.tsx @@ -39,6 +39,7 @@ type CustomYearlyRecurringMeetingProps = { customNthMonthday: string; customWeekday: number; customNth: number; + disabled?: boolean; onCustomRuleModeChange: Dispatch; onCustomMonthChange: Dispatch; onCustomNthMonthdayChange: Dispatch; @@ -52,6 +53,7 @@ export const CustomYearlyRecurringMeeting = ({ customNthMonthday, customWeekday, customNth, + disabled, onCustomRuleModeChange, onCustomMonthChange, onCustomNthMonthdayChange, @@ -96,6 +98,7 @@ export const CustomYearlyRecurringMeeting = ({ > @@ -162,6 +169,7 @@ export const CustomYearlyRecurringMeeting = ({ {t('recurrenceEditor.custom.yearly.byWeekdayOf', 'of')} diff --git a/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/RecurrenceEditor.tsx b/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/RecurrenceEditor.tsx index a75ef4e8..cc037a6a 100644 --- a/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/RecurrenceEditor.tsx +++ b/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/RecurrenceEditor.tsx @@ -49,6 +49,7 @@ type RecurrenceEditorProps = { ) => void; rule: string | undefined; isMeetingCreation?: boolean; + disabled?: boolean; }; export const RecurrenceEditor = ({ @@ -56,6 +57,7 @@ export const RecurrenceEditor = ({ onChange, rule, isMeetingCreation = true, + disabled, }: RecurrenceEditorProps) => { const { t } = useTranslation(); const { state, rrule, isValid, dispatch } = useRecurrenceEditorState( @@ -186,6 +188,7 @@ export const RecurrenceEditor = ({ onChange={handleRecurrencePresetChange} renderValue={renderValue} value={state.recurrencePreset} + disabled={disabled} > {Object.values(RecurrencePreset).map((v, i) => ( @@ -220,6 +223,7 @@ export const RecurrenceEditor = ({ onCustomNthMonthdayChange={handleCustomNthMonthdayChange} onCustomRuleModeChange={handleCustomRuleModeChange} onCustomWeekdayChange={handleCustomWeekdayChange} + disabled={disabled} /> )} @@ -231,6 +235,7 @@ export const RecurrenceEditor = ({ recurrenceEnd={state.recurrenceEnd} startDate={state.startDate} untilDate={state.untilDate} + disabled={disabled} /> diff --git a/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/RecurringMeetingEnd/RecurringMeetingEnd.tsx b/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/RecurringMeetingEnd/RecurringMeetingEnd.tsx index d7b49f7c..bbab3e4c 100644 --- a/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/RecurringMeetingEnd/RecurringMeetingEnd.tsx +++ b/matrix-meetings-widget/src/components/meetings/RecurrenceEditor/RecurringMeetingEnd/RecurringMeetingEnd.tsx @@ -42,6 +42,7 @@ type RecurringMeetingEndProps = { onRecurrenceEndChange: Dispatch; untilDate: DateTime; onUntilDateChange: Dispatch; + disabled?: boolean; }; export const RecurringMeetingEnd = ({ @@ -52,6 +53,7 @@ export const RecurringMeetingEnd = ({ onRecurrenceEndChange, untilDate, onUntilDateChange, + disabled, }: RecurringMeetingEndProps) => { const { t } = useTranslation(); const afterMeetingCountParsed = parseInt(afterMeetingCount); @@ -118,6 +120,7 @@ export const RecurringMeetingEnd = ({ value={recurrenceEnd} > ), }} - disabled={recurrenceEnd !== RecurrenceEnd.AfterMeetingCount} + disabled={ + recurrenceEnd !== RecurrenceEnd.AfterMeetingCount || disabled + } error={isAfterMeetingCountInvalid} helperText={ isAfterMeetingCountInvalid && diff --git a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/EditRecurringMessage.tsx b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/EditRecurringMessage.tsx new file mode 100644 index 00000000..b6197080 --- /dev/null +++ b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/EditRecurringMessage.tsx @@ -0,0 +1,77 @@ +/* + * 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 { Alert, AlertTitle, FormControl, Switch } from '@mui/material'; +import { unstable_useId as useId } from '@mui/utils'; +import { useTranslation } from 'react-i18next'; + +/** + * Props for the {@link EditRecurringMessage} component. + */ +type EditRecurringMessageProps = { + editRecurringSeries: boolean; + onChange: (checked: boolean) => void; +}; + +/** + * Show an info message when edit a recurring meeting. + * + * @remarks the message include switch to change the edit between one occurrence or all series. + * + * @param param0 - {@link EditRecurringMessageProps} + */ +export function EditRecurringMessage({ + editRecurringSeries, + onChange, +}: EditRecurringMessageProps) { + const { t } = useTranslation(); + + const titleId = useId(); + + return ( + theme.palette.background.paper, + }} + action={ + + onChange(checked)} + inputProps={{ + 'aria-labelledby': titleId, + }} + /> + + } + > + + {t( + 'editRecurringMessage.titleOne', + 'Edit the recurring meeting series', + )} + + {t( + 'editRecurringMessage.message', + 'All instances of the recurring meeting are editable', + )} + + ); +} diff --git a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeeting.test.tsx b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeeting.test.tsx index 027c4bb0..1a982155 100644 --- a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeeting.test.tsx +++ b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeeting.test.tsx @@ -157,7 +157,7 @@ describe('', () => { it('should have no accessibility violations', async () => { const { container } = render( - , + , { wrapper: Wrapper }, ); @@ -286,10 +286,9 @@ describe('', () => { ], }); - render( - , - { wrapper: Wrapper }, - ); + render(, { + wrapper: Wrapper, + }); await userEvent.type( screen.getByRole('textbox', { name: 'Title (required)' }), @@ -327,10 +326,9 @@ describe('', () => { it('should show a message if members could not be loaded', async () => { widgetApi.searchUserDirectory.mockRejectedValue(new Error('unexpected')); - render( - , - { wrapper: Wrapper }, - ); + render(, { + wrapper: Wrapper, + }); await userEvent.type( screen.getByRole('textbox', { name: 'Title (required)' }), @@ -398,7 +396,6 @@ describe('', () => { , { wrapper: Wrapper }, ); @@ -628,10 +625,11 @@ describe('', () => { }); }, 10000); - it('should fill the form for editing a recurring meeting', async () => { + it('should fill the form for editing a recurring meeting instance', async () => { render( ', () => { { wrapper: Wrapper }, ); - expect(screen.getByRole('status')).toHaveTextContent( - /You are editing a recurring meeting/, - ); + const titleTextbox = screen.getByRole('textbox', { + name: 'Title (required)', + }); + expect(titleTextbox).toHaveValue('An important meeting'); + expect(titleTextbox).toBeDisabled(); - expect( - screen.getByRole('textbox', { name: 'Title (required)' }), - ).toHaveValue('An important meeting'); - expect(screen.getByRole('textbox', { name: 'Description' })).toHaveValue( - 'A brief description', + const descriptionTextbox = screen.getByRole('textbox', { + name: 'Description', + }); + expect(descriptionTextbox).toHaveValue('A brief description'); + expect(descriptionTextbox).toBeDisabled(); + + const startDateTextbox = screen.getByRole('textbox', { + name: 'Start date', + }); + expect(startDateTextbox).toHaveValue('01/02/2022'); + expect(startDateTextbox).toHaveAttribute('readonly'); + expect(startDateTextbox).toBeValid(); + + const startTimeTextbox = screen.getByRole('textbox', { + name: 'Start time', + }); + expect(startTimeTextbox).toHaveValue('10:00 AM'); + expect(startTimeTextbox).toHaveAttribute('readonly'); + expect(startTimeTextbox).toBeValid(); + + const endDateTextbox = screen.getByRole('textbox', { name: 'End date' }); + expect(endDateTextbox).toHaveValue('01/02/2022'); + expect(endDateTextbox).not.toHaveAttribute('readonly'); + expect(endDateTextbox).toBeInvalid(); + + const endTimeTextbox = screen.getByRole('textbox', { name: 'End time' }); + expect(endTimeTextbox).toHaveValue('02:00 PM'); + expect(endTimeTextbox).not.toHaveAttribute('readonly'); + expect(endTimeTextbox).toBeValid(); + + const participantsCombobox = screen.getByRole('combobox', { + name: 'Participants', + }); + expect(participantsCombobox).toBeDisabled(); + + const messagingCheckbox = screen.getByRole('checkbox', { + name: 'Allow messaging for all participants', + checked: true, + }); + expect(messagingCheckbox).toBeDisabled(); + + const widgetsCombobox = screen.getByRole('combobox', { + name: 'Widgets', + }); + expect(widgetsCombobox).toBeDisabled(); + + const repetitionButton = screen.getByRole('button', { + name: 'Repeat meeting Every day', + }); + expect(repetitionButton).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should fill the form for editing a recurring meeting', async () => { + render( + , + { wrapper: Wrapper }, ); + const titleTextbox = screen.getByRole('textbox', { + name: 'Title (required)', + }); + expect(titleTextbox).toHaveValue('An important meeting'); + expect(titleTextbox).toBeEnabled(); + + const descriptionTextbox = screen.getByRole('textbox', { + name: 'Description', + }); + expect(descriptionTextbox).toHaveValue('A brief description'); + expect(descriptionTextbox).toBeEnabled(); + const startDateTextbox = screen.getByRole('textbox', { name: 'Start date', }); @@ -672,7 +750,7 @@ describe('', () => { expect(startTimeTextbox).not.toHaveAttribute('readonly'); expect(startTimeTextbox).toBeValid(); - const endDateTextbox = screen.getByRole('textbox', { name: 'Start date' }); + const endDateTextbox = screen.getByRole('textbox', { name: 'End date' }); expect(endDateTextbox).toHaveValue('01/01/2022'); expect(endDateTextbox).not.toHaveAttribute('readonly'); expect(endDateTextbox).toBeValid(); @@ -681,6 +759,27 @@ describe('', () => { expect(endTimeTextbox).toHaveValue('02:00 PM'); expect(endTimeTextbox).not.toHaveAttribute('readonly'); expect(endTimeTextbox).toBeValid(); + + const participantsCombobox = screen.getByRole('combobox', { + name: 'Participants', + }); + expect(participantsCombobox).toBeEnabled(); + + const messagingCheckbox = screen.getByRole('checkbox', { + name: 'Allow messaging for all participants', + checked: true, + }); + expect(messagingCheckbox).toBeEnabled(); + + const widgetsCombobox = screen.getByRole('combobox', { + name: 'Widgets', + }); + expect(widgetsCombobox).toBeEnabled(); + + const repetitionButton = screen.getByRole('button', { + name: 'Repeat meeting Every day', + }); + expect(repetitionButton).toBeEnabled(); }); it('should edit the meeting even if it already started', async () => { @@ -889,7 +988,6 @@ describe('', () => { , { wrapper: Wrapper }, ); diff --git a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeeting.tsx b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeeting.tsx index b767aa6c..2d063b15 100644 --- a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeeting.tsx +++ b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeeting.tsx @@ -16,8 +16,6 @@ import { useWidgetApi } from '@matrix-widget-toolkit/react'; import { - Alert, - AlertTitle, FormControl, FormControlLabel, FormGroup, @@ -64,7 +62,6 @@ export type ScheduleMeetingProps = { onMeetingChange: (meeting: CreateMeeting | undefined) => void; initialMeeting?: Meeting | undefined; initialIsMessagingEnabled?: boolean; - showParticipants?: boolean; parentRoomId?: string; }; @@ -87,13 +84,11 @@ export const ScheduleMeeting = ({ ); const [startDate, setStartDate] = useState( initialMeeting - ? parseICalDate(initialMeeting.calendarEntries[0].dtstart) + ? DateTime.fromISO(initialMeeting.startTime) : initialStartDate, ); const [endDate, setEndDate] = useState( - initialMeeting - ? parseICalDate(initialMeeting.calendarEntries[0].dtend) - : initialEndDate, + initialMeeting ? DateTime.fromISO(initialMeeting.endTime) : initialEndDate, ); const [participants, setParticipants] = useState(() => { @@ -144,6 +139,7 @@ export const ScheduleMeeting = ({ const isMeetingCreation = !initialMeeting; const isEditingRecurringMeeting = initialMeeting && + initialMeeting.recurrenceId === undefined && isRecurringCalendarSourceEntry(initialMeeting.calendarEntries); const startDateReadOnly = !isEditingRecurringMeeting && @@ -344,7 +340,8 @@ export const ScheduleMeeting = ({ participants, powerLevels, widgetIds: widgets, - rrule: recurrence.rrule, + rrule: !initialMeeting?.recurrenceId ? recurrence.rrule : undefined, + recurrenceId: initialMeeting?.recurrenceId, }); } }, [ @@ -373,6 +370,9 @@ export const ScheduleMeeting = ({ const isBreakoutSession = initialMeeting?.type === 'net.nordeck.meetings.breakoutsession'; + const isEditingSingleRecurrence = + initialMeeting && initialMeeting.recurrenceId !== undefined; + const titleId = useId(); const descriptionId = useId(); const messagingId = useId(); @@ -383,21 +383,6 @@ export const ScheduleMeeting = ({ )} - {isEditingRecurringMeeting && ( - - - {t( - 'scheduleMeeting.recurringMeetingMessage.title', - 'You are editing a recurring meeting', - )} - - {t( - 'scheduleMeeting.recurringMeetingMessage.message', - 'All instances of the recurring meeting are edited', - )} - - )} - @@ -497,6 +483,7 @@ export const ScheduleMeeting = ({ multiline onChange={handleChangeDescription} value={description} + disabled={isEditingSingleRecurrence} /> @@ -550,6 +538,7 @@ export const ScheduleMeeting = ({ )} sx={{ mx: 0 }} labelPlacement="start" + disabled={isEditingSingleRecurrence} /> @@ -558,6 +547,7 @@ export const ScheduleMeeting = ({ autoSelectAllWidgets={!initialMeeting} onChange={handleChangeWidgets} selectedWidgets={widgets} + disabled={isEditingSingleRecurrence} /> {!isBreakoutSession && ( @@ -566,6 +556,7 @@ export const ScheduleMeeting = ({ onChange={handleChangeRecurrence} rule={recurrence.rrule} startDate={startDate.toJSDate()} + disabled={isEditingSingleRecurrence} /> )} diff --git a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeetingModal.test.tsx b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeetingModal.test.tsx index b84855af..55cb3e64 100644 --- a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeetingModal.test.tsx +++ b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeetingModal.test.tsx @@ -17,17 +17,30 @@ import { WidgetConfig } from '@matrix-widget-toolkit/api'; import { WidgetApiMockProvider } from '@matrix-widget-toolkit/react'; import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; -import { render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { setupServer } from 'msw/node'; import { ComponentType, PropsWithChildren, useMemo } from 'react'; import { Provider } from 'react-redux'; import { Subject } from 'rxjs'; -import { mockMeeting, mockWidgetEndpoint } from '../../../lib/testUtils'; +import { + mockCalendar, + mockMeeting, + mockWidgetEndpoint, +} from '../../../lib/testUtils'; import { createStore } from '../../../store'; import { initializeStore } from '../../../store/store'; import { LocalizationProvider } from '../../common/LocalizationProvider'; -import { ScheduleMeetingModal } from './ScheduleMeetingModal'; +import { + ScheduleMeetingModal, + getEditableInitialMeeting, +} from './ScheduleMeetingModal'; import { ScheduleMeetingModalRequest } from './types'; const server = setupServer(); @@ -128,6 +141,117 @@ describe('', () => { }); }); + it('should edit existing recurring meeting instance', async () => { + const subject = new Subject(); + widgetApi.getWidgetConfig.mockReturnValue({ + data: { + meeting: mockMeeting({ + content: { + startTime: '2999-01-02T10:00:00Z', + endTime: '2999-01-02T14:00:00Z', + recurrenceId: '2999-01-02T10:00:00Z', + calendarEntries: mockCalendar({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + }), + }, + }), + }, + } as WidgetConfig); + widgetApi.observeModalButtons.mockReturnValue(subject.asObservable()); + + render(, { wrapper: Wrapper }); + + const recurrenceAlert = screen.getByRole('status'); + expect( + within(recurrenceAlert).getByRole('checkbox', { + name: 'Edit the recurring meeting series', + checked: false, + }), + ).toBeInTheDocument(); + + expect(screen.getByRole('textbox', { name: /title/i })).toBeDisabled(); + + const endTimeTextbox = screen.getByRole('textbox', { name: 'End time' }); + + // userEvent.type doesn't work here, so we have to use fireEvent + fireEvent.change(endTimeTextbox, { target: { value: '08:00 PM' } }); + + subject.next('nic.schedule.meeting.submit'); + + await waitFor(() => { + expect(widgetApi.closeModal).toHaveBeenLastCalledWith({ + meeting: { + description: 'A brief description', + endTime: '2999-01-02T20:00:00.000Z', + participants: ['@user-id'], + startTime: '2999-01-02T10:00:00.000Z', + title: 'An important meeting', + widgetIds: [], + recurrenceId: '2999-01-02T10:00:00Z', + }, + type: 'nic.schedule.meeting.submit', + }); + }); + }); + + it('should edit existing recurring meeting', async () => { + const subject = new Subject(); + widgetApi.getWidgetConfig.mockReturnValue({ + data: { + meeting: mockMeeting({ + content: { + startTime: '2999-01-02T10:00:00Z', + endTime: '2999-01-02T14:00:00Z', + recurrenceId: '2999-01-02T10:00:00Z', + calendarEntries: mockCalendar({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + }), + }, + }), + }, + } as WidgetConfig); + widgetApi.observeModalButtons.mockReturnValue(subject.asObservable()); + + render(, { wrapper: Wrapper }); + + const recurrenceAlert = screen.getByRole('status'); + await userEvent.click( + within(recurrenceAlert).getByRole('checkbox', { + name: 'Edit the recurring meeting series', + checked: false, + }), + ); + + expect(screen.getByRole('textbox', { name: /title/i })).toBeEnabled(); + + const endTimeTextbox = screen.getByRole('textbox', { name: 'End time' }); + + // userEvent.type doesn't work here, so we have to use fireEvent + fireEvent.change(endTimeTextbox, { target: { value: '08:00 PM' } }); + + subject.next('nic.schedule.meeting.submit'); + + await waitFor(() => { + expect(widgetApi.closeModal).toHaveBeenLastCalledWith({ + meeting: { + description: 'A brief description', + endTime: '2999-01-01T20:00:00.000Z', + participants: ['@user-id'], + startTime: '2999-01-01T10:00:00.000Z', + title: 'An important meeting', + widgetIds: [], + rrule: 'FREQ=DAILY', + recurrenceId: undefined, + }, + type: 'nic.schedule.meeting.submit', + }); + }); + }); + it('should disable submission if input is invalid', async () => { render(, { wrapper: Wrapper }); @@ -163,3 +287,78 @@ describe('', () => { }); }); }); + +describe('getEditableInitialMeeting', () => { + it.each([true, false])( + 'should return a undefined meeting with editRecurringSeries=%s', + (editRecurringSeries) => { + expect(getEditableInitialMeeting(undefined, editRecurringSeries)).toEqual( + { + key: 'edit-normal', + editableInitialMeeting: undefined, + }, + ); + }, + ); + + it.each([true, false])( + 'should return a normal meeting with editRecurringSeries=%s', + (editRecurringSeries) => { + const meeting = mockMeeting(); + + expect(getEditableInitialMeeting(meeting, editRecurringSeries)).toEqual({ + key: 'edit-normal', + editableInitialMeeting: meeting, + }); + }, + ); + + it('should return the single recurrence entry meeting with editRecurringSeries=false', () => { + const meeting = mockMeeting({ + content: { + startTime: '2999-01-02T10:00:00Z', + endTime: '2999-01-02T14:00:00Z', + recurrenceId: '2999-01-02T10:00:00Z', + calendarEntries: mockCalendar({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + }), + }, + }); + + expect(getEditableInitialMeeting(meeting, false)).toEqual({ + key: 'edit-normal', + editableInitialMeeting: meeting, + }); + }); + + it('should return the recurrence entry meeting with editRecurringSeries=true', () => { + const meeting = mockMeeting({ + content: { + startTime: '2999-01-02T10:00:00Z', + endTime: '2999-01-02T14:00:00Z', + recurrenceId: '2999-01-02T10:00:00Z', + calendarEntries: mockCalendar({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + }), + }, + }); + + expect(getEditableInitialMeeting(meeting, true)).toEqual({ + key: 'edit-series', + editableInitialMeeting: mockMeeting({ + content: { + calendarEntries: mockCalendar({ + dtstart: '29990101T100000', + dtend: '29990101T140000', + rrule: 'FREQ=DAILY', + }), + recurrenceId: undefined, + }, + }), + }); + }); +}); diff --git a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeetingModal.tsx b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeetingModal.tsx index f40fa9fa..5e6a130a 100644 --- a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeetingModal.tsx +++ b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/ScheduleMeetingModal.tsx @@ -15,7 +15,14 @@ */ import { useWidgetApi } from '@matrix-widget-toolkit/react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { + isRecurringCalendarSourceEntry, + parseICalDate, + toISOString, +} from '../../../lib/utils'; +import { Meeting } from '../../../reducer/meetingsApi'; +import { EditRecurringMessage } from './EditRecurringMessage'; import { ScheduleMeeting } from './ScheduleMeeting'; import { CancelScheduleMeetingModal, @@ -64,12 +71,73 @@ export const ScheduleMeetingModal = () => { }; }, [meeting, widgetApi]); + const [isEditRecurringSeries, setEditRecurringSeries] = useState(false); + + const isEditingRecurringMeeting = + initialMeeting && + isRecurringCalendarSourceEntry(initialMeeting.calendarEntries); + + const { key, editableInitialMeeting } = useMemo( + () => getEditableInitialMeeting(initialMeeting, isEditRecurringSeries), + [initialMeeting, isEditRecurringSeries], + ); + return ( - + <> + {isEditingRecurringMeeting && ( + + )} + + + ); }; + +/** + * Returns the {@link Meeting} instance that should be edited in the form. This is a no-op for all + * non-recurring meetings. For recurring meetings, the user can switch between editing the selected + * recurrence entry (i.e. the single meeting in the series) or editing the complete series. + */ +export function getEditableInitialMeeting( + initialMeeting: Meeting | undefined, + editRecurringSeries: boolean, +): { + /** + * The `key` to provide to the rendering component. The key defines whether the component should + * be reused or whether the internal state should be reset to the defaults. + */ + key: string; + + /** The Meeting to edit (or undefined if a new meeting should be created). */ + editableInitialMeeting: Meeting | undefined; +} { + if ( + initialMeeting && + editRecurringSeries && + isRecurringCalendarSourceEntry(initialMeeting.calendarEntries) + ) { + return { + key: 'edit-series', + editableInitialMeeting: { + ...initialMeeting, + startTime: toISOString( + parseICalDate(initialMeeting.calendarEntries[0].dtstart), + ), + endTime: toISOString( + parseICalDate(initialMeeting.calendarEntries[0].dtend), + ), + recurrenceId: undefined, + }, + }; + } + + return { key: 'edit-normal', editableInitialMeeting: initialMeeting }; +} diff --git a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/useEditMeeting.test.tsx b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/editMeetingThunk.test.tsx similarity index 73% rename from matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/useEditMeeting.test.tsx rename to matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/editMeetingThunk.test.tsx index 09565c9b..7ab4731b 100644 --- a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/useEditMeeting.test.tsx +++ b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/editMeetingThunk.test.tsx @@ -16,8 +16,8 @@ import { mockMeeting, mockRoomMember } from '../../../lib/testUtils'; import { Meeting } from '../../../reducer/meetingsApi'; +import { diffMeeting } from './editMeetingThunk'; import { CreateMeeting } from './types'; -import { diffMeeting } from './useEditMeeting'; describe('diffMeeting', () => { it('should find difference between old and updated meeting', () => { @@ -58,25 +58,25 @@ describe('diffMeeting', () => { 'poll_groups', ]; - expect(diffMeeting(oldMeeting, true, newMeeting, availableWidgets)).toEqual( - { - addUserIds: ['@charlie-user'], - removeUserIds: ['@bob-user'], - addWidgets: ['poll_groups'], - removeWidgets: ['jitsi', 'whiteboard'], - meetingDetails: { - description: 'My new Description', - calendar: [ - { - uid: 'entry-0', - dtstart: { tzid: 'UTC', value: '20401029T120000' }, - dtend: { tzid: 'UTC', value: '20401029T130000' }, - }, - ], - title: 'My new Meeting', - }, - powerLevels: {}, + expect( + diffMeeting(oldMeeting, true, newMeeting, availableWidgets, []), + ).toEqual({ + addUserIds: ['@charlie-user'], + removeUserIds: ['@bob-user'], + addWidgets: ['poll_groups'], + removeWidgets: ['jitsi', 'whiteboard'], + meetingDetails: { + description: 'My new Description', + calendar: [ + { + uid: 'entry-0', + dtstart: { tzid: 'UTC', value: '20401029T120000' }, + dtend: { tzid: 'UTC', value: '20401029T130000' }, + }, + ], + title: 'My new Meeting', }, - ); + powerLevels: {}, + }); }); }); diff --git a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/editMeetingThunk.tsx b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/editMeetingThunk.tsx new file mode 100644 index 00000000..8c1af192 --- /dev/null +++ b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/editMeetingThunk.tsx @@ -0,0 +1,239 @@ +/* + * Copyright 2022 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 { createAsyncThunk } from '@reduxjs/toolkit'; +import { t } from 'i18next'; +import { DateTime } from 'luxon'; +import { ModalButtonKind } from 'matrix-widget-api'; +import { CalendarEntry } from '../../../lib/matrix'; +import { formatICalDate, normalizeCalendarEntry } from '../../../lib/utils'; +import { overrideCalendarEntries } from '../../../lib/utils/calendarUtils/overrideCalendarEntries'; +import { meetingBotApi } from '../../../reducer/meetingBotApi'; +import { + Meeting, + UpdateMeetingDetailsOptions, + meetingsApi, + selectNordeckMeetingMetadataEventByRoomId, + selectRoomPowerLevelsEventByRoomId, +} from '../../../reducer/meetingsApi'; +import { StateThunkConfig } from '../../../store/store'; +import { + CancelScheduleMeetingModal, + CreateMeeting, + SCHEDULE_MEETING_MODAL_ROUTE, + ScheduleMeetingModalRequest, + ScheduleMeetingModalResult, + SubmitScheduleMeetingModal, +} from './types'; + +export const editMeetingThunk = createAsyncThunk< + void, + Meeting, + StateThunkConfig +>( + 'editMeetingThunk', + async (meeting, { getState, extra: { widgetApi }, dispatch }) => { + const state = getState(); + + const isMessagingEnabled = + selectRoomPowerLevelsEventByRoomId(state, meeting.meetingId)?.content + .events_default === 0; + + const meetingCalendar = + selectNordeckMeetingMetadataEventByRoomId(state, meeting.meetingId) + ?.content.calendar ?? []; + + const updatedMeeting = await widgetApi.openModal< + ScheduleMeetingModalResult, + ScheduleMeetingModalRequest + >( + SCHEDULE_MEETING_MODAL_ROUTE, + t('editMeetingModal.title', 'Edit Meeting'), + { + buttons: [ + { + id: SubmitScheduleMeetingModal, + kind: ModalButtonKind.Primary, + label: t('editMeetingModal.save', 'Save'), + disabled: true, + }, + { + id: CancelScheduleMeetingModal, + kind: ModalButtonKind.Secondary, + label: t('editMeetingModal.cancel', 'Cancel'), + }, + ], + data: { meeting, isMessagingEnabled }, + }, + ); + + if (updatedMeeting && updatedMeeting.type === SubmitScheduleMeetingModal) { + try { + const availableWidgets = await dispatch( + meetingBotApi.endpoints.getAvailableWidgets.initiate(), + ).unwrap(); + + const { + meetingDetails, + addUserIds, + removeUserIds, + addWidgets, + removeWidgets, + powerLevels, + } = diffMeeting( + meeting, + isMessagingEnabled, + updatedMeeting.meeting, + availableWidgets.map((w) => w.id), + meetingCalendar, + ); + + const detailsResult = await dispatch( + meetingsApi.endpoints.updateMeetingDetails.initiate({ + roomId: meeting.meetingId, + updates: meetingDetails, + }), + ).unwrap(); + + const participantsResult = await dispatch( + meetingsApi.endpoints.updateMeetingParticipants.initiate({ + roomId: meeting.meetingId, + addUserIds, + removeUserIds, + }), + ).unwrap(); + + const widgetsResult = await dispatch( + meetingsApi.endpoints.updateMeetingWidgets.initiate({ + roomId: meeting.meetingId, + addWidgets, + removeWidgets, + }), + ).unwrap(); + + let meetingPermissionsResult; + if (powerLevels.messaging !== undefined) { + meetingPermissionsResult = await dispatch( + meetingsApi.endpoints.updateMeetingPermissions.initiate({ + roomId: meeting.meetingId, + powerLevels: { + messaging: powerLevels.messaging, + }, + }), + ).unwrap(); + } + + if ( + detailsResult.acknowledgement.error || + participantsResult.acknowledgements.some((a) => a.error) || + widgetsResult.acknowledgements.some((a) => a.error) || + meetingPermissionsResult?.acknowledgement.error + ) { + throw new Error('Error while updating'); + } + } catch { + throw new Error('Error while updating'); + } + } + }, +); + +function diffActiveWidgets( + oldActiveWidgets: string[], + newActiveWidget: string[], + availableWidgets: string[], +) { + const addWidgets = new Array(); + const removeWidgets = new Array(); + + availableWidgets.forEach((id) => { + const wasActive = oldActiveWidgets.includes(id); + const isActive = newActiveWidget.includes(id); + + if (isActive && !wasActive) { + addWidgets.push(id); + } else if (!isActive && wasActive) { + removeWidgets.push(id); + } + }); + + return { addWidgets, removeWidgets }; +} + +function diffParticipants( + oldParticipants: string[], + newParticipants: string[], +) { + const addUserIds = newParticipants.filter( + (id) => !oldParticipants.includes(id), + ); + const removeUserIds = oldParticipants.filter( + (id) => !newParticipants.includes(id), + ); + + return { addUserIds, removeUserIds }; +} + +export const diffMeeting = ( + oldMeeting: Meeting, + isMessagingEnabled: boolean, + newMeeting: CreateMeeting, + availableWidgets: string[], + oldMeetingCalendar: CalendarEntry[], +) => { + const { addWidgets, removeWidgets } = diffActiveWidgets( + oldMeeting.widgets, + newMeeting.widgetIds, + availableWidgets, + ); + const { addUserIds, removeUserIds } = diffParticipants( + oldMeeting.participants.map((p) => p.userId), + newMeeting.participants, + ); + + const tzid = new Intl.DateTimeFormat().resolvedOptions().timeZone; + const newCalendarEntry = normalizeCalendarEntry({ + uid: oldMeeting.calendarUid, + dtstart: formatICalDate(DateTime.fromISO(newMeeting.startTime), tzid), + dtend: formatICalDate(DateTime.fromISO(newMeeting.endTime), tzid), + rrule: newMeeting.rrule, + recurrenceId: newMeeting.recurrenceId + ? formatICalDate(DateTime.fromISO(newMeeting.recurrenceId), tzid) + : undefined, + }); + + const meetingDetails: UpdateMeetingDetailsOptions['updates'] = { + title: newMeeting.title, + description: newMeeting.description, + calendar: overrideCalendarEntries(oldMeetingCalendar, newCalendarEntry), + }; + + const newMeetingIsMessagingEnabled = newMeeting.powerLevels?.messaging === 0; + const powerLevels = { + messaging: + isMessagingEnabled !== newMeetingIsMessagingEnabled + ? newMeeting.powerLevels?.messaging + : undefined, + }; + return { + meetingDetails, + addUserIds, + removeUserIds, + addWidgets, + removeWidgets, + powerLevels, + }; +}; diff --git a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/index.ts b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/index.ts index 9d8477f5..6fb00609 100644 --- a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/index.ts +++ b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/index.ts @@ -15,11 +15,11 @@ */ export { ScheduleMeetingModal } from './ScheduleMeetingModal'; +export { editMeetingThunk } from './editMeetingThunk'; export { CancelScheduleMeetingModal, SCHEDULE_MEETING_MODAL_ROUTE, SubmitScheduleMeetingModal, } from './types'; export type { CreateMeeting, ScheduleMeetingModalResult } from './types'; -export { useEditMeeting } from './useEditMeeting'; export { useScheduleMeeting } from './useScheduleMeeting'; diff --git a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/types.ts b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/types.ts index cde106ae..b50afdca 100644 --- a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/types.ts +++ b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/types.ts @@ -27,6 +27,7 @@ export type CreateMeeting = { messaging?: number; }; rrule?: string | undefined; + recurrenceId?: string | undefined; }; export type ScheduleMeetingModalRequest = { diff --git a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/useEditMeeting.tsx b/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/useEditMeeting.tsx deleted file mode 100644 index 3607b1da..00000000 --- a/matrix-meetings-widget/src/components/meetings/ScheduleMeetingModal/useEditMeeting.tsx +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright 2022 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 { useWidgetApi } from '@matrix-widget-toolkit/react'; -import { DateTime } from 'luxon'; -import { ModalButtonKind } from 'matrix-widget-api'; -import { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { formatICalDate, normalizeCalendarEntry } from '../../../lib/utils'; -import { meetingBotApi } from '../../../reducer/meetingBotApi'; -import { - Meeting, - UpdateMeetingDetailsOptions, - useUpdateMeetingDetailsMutation, - useUpdateMeetingParticipantsMutation, - useUpdateMeetingPermissionsMutation, - useUpdateMeetingWidgetsMutation, -} from '../../../reducer/meetingsApi'; -import { useAppDispatch } from '../../../store'; -import { - CancelScheduleMeetingModal, - CreateMeeting, - SCHEDULE_MEETING_MODAL_ROUTE, - ScheduleMeetingModalRequest, - ScheduleMeetingModalResult, - SubmitScheduleMeetingModal, -} from './types'; - -export function useEditMeeting(): { - editMeeting: (meeting: Meeting, isMessagingEnabled: boolean) => Promise; -} { - const { t } = useTranslation(); - const widgetApi = useWidgetApi(); - const dispatch = useAppDispatch(); - const [updateMeetingDetails] = useUpdateMeetingDetailsMutation(); - const [updateMeetingParticipants] = useUpdateMeetingParticipantsMutation(); - const [updateMeetingPermissions] = useUpdateMeetingPermissionsMutation(); - const [updateWidget] = useUpdateMeetingWidgetsMutation(); - - const editMeeting = useCallback( - async (meeting: Meeting, isMessagingEnabled: boolean) => { - const updatedMeeting = await widgetApi.openModal< - ScheduleMeetingModalResult, - ScheduleMeetingModalRequest - >( - SCHEDULE_MEETING_MODAL_ROUTE, - t('editMeetingModal.title', 'Edit Meeting'), - { - buttons: [ - { - id: SubmitScheduleMeetingModal, - kind: ModalButtonKind.Primary, - label: t('editMeetingModal.save', 'Save'), - disabled: true, - }, - { - id: CancelScheduleMeetingModal, - kind: ModalButtonKind.Secondary, - label: t('editMeetingModal.cancel', 'Cancel'), - }, - ], - data: { meeting, isMessagingEnabled }, - }, - ); - - if ( - updatedMeeting && - updatedMeeting.type === SubmitScheduleMeetingModal - ) { - try { - const availableWidgets = await dispatch( - meetingBotApi.endpoints.getAvailableWidgets.initiate(), - ).unwrap(); - - const { - meetingDetails, - addUserIds, - removeUserIds, - addWidgets, - removeWidgets, - powerLevels, - } = diffMeeting( - meeting, - isMessagingEnabled, - updatedMeeting.meeting, - availableWidgets.map((w) => w.id), - ); - - const detailsResult = await updateMeetingDetails({ - roomId: meeting.meetingId, - updates: meetingDetails, - }).unwrap(); - - const participantsResult = await updateMeetingParticipants({ - roomId: meeting.meetingId, - addUserIds, - removeUserIds, - }).unwrap(); - - const widgetsResult = await updateWidget({ - roomId: meeting.meetingId, - addWidgets, - removeWidgets, - }).unwrap(); - - let meetingPermissionsResult; - if (powerLevels.messaging !== undefined) { - meetingPermissionsResult = await updateMeetingPermissions({ - roomId: meeting.meetingId, - powerLevels: { - messaging: powerLevels.messaging, - }, - }).unwrap(); - } - - if ( - detailsResult.acknowledgement.error || - participantsResult.acknowledgements.some((a) => a.error) || - widgetsResult.acknowledgements.some((a) => a.error) || - meetingPermissionsResult?.acknowledgement.error - ) { - throw new Error('Error while updating'); - } - } catch { - throw new Error('Error while updating'); - } - } - }, - [ - widgetApi, - t, - dispatch, - updateMeetingDetails, - updateMeetingParticipants, - updateMeetingPermissions, - updateWidget, - ], - ); - - return { editMeeting }; -} - -function diffActiveWidgets( - oldActiveWidgets: string[], - newActiveWidget: string[], - availableWidgets: string[], -) { - const addWidgets = new Array(); - const removeWidgets = new Array(); - - availableWidgets.forEach((id) => { - const wasActive = oldActiveWidgets.includes(id); - const isActive = newActiveWidget.includes(id); - - if (isActive && !wasActive) { - addWidgets.push(id); - } else if (!isActive && wasActive) { - removeWidgets.push(id); - } - }); - - return { addWidgets, removeWidgets }; -} - -function diffParticipants( - oldParticipants: string[], - newParticipants: string[], -) { - const addUserIds = newParticipants.filter( - (id) => !oldParticipants.includes(id), - ); - const removeUserIds = oldParticipants.filter( - (id) => !newParticipants.includes(id), - ); - - return { addUserIds, removeUserIds }; -} - -export const diffMeeting = ( - oldMeeting: Meeting, - isMessagingEnabled: boolean, - newMeeting: CreateMeeting, - availableWidgets: string[], -) => { - const { addWidgets, removeWidgets } = diffActiveWidgets( - oldMeeting.widgets, - newMeeting.widgetIds, - availableWidgets, - ); - const { addUserIds, removeUserIds } = diffParticipants( - oldMeeting.participants.map((p) => p.userId), - newMeeting.participants, - ); - const meetingDetails: UpdateMeetingDetailsOptions['updates'] = { - title: newMeeting.title, - description: newMeeting.description, - calendar: [ - normalizeCalendarEntry({ - uid: oldMeeting.calendarUid, - dtstart: formatICalDate( - DateTime.fromISO(newMeeting.startTime), - new Intl.DateTimeFormat().resolvedOptions().timeZone, - ), - dtend: formatICalDate( - DateTime.fromISO(newMeeting.endTime), - new Intl.DateTimeFormat().resolvedOptions().timeZone, - ), - rrule: newMeeting.rrule, - }), - ], - }; - - const newMeetingIsMessagingEnabled = newMeeting.powerLevels?.messaging === 0; - const powerLevels = { - messaging: - isMessagingEnabled !== newMeetingIsMessagingEnabled - ? newMeeting.powerLevels?.messaging - : undefined, - }; - return { - meetingDetails, - addUserIds, - removeUserIds, - addWidgets, - removeWidgets, - powerLevels, - }; -}; diff --git a/matrix-meetings-widget/src/components/meetings/WidgetsSelectionDropdown/WidgetsSelectionDropdown.tsx b/matrix-meetings-widget/src/components/meetings/WidgetsSelectionDropdown/WidgetsSelectionDropdown.tsx index bc7f89bf..fe20cbf4 100644 --- a/matrix-meetings-widget/src/components/meetings/WidgetsSelectionDropdown/WidgetsSelectionDropdown.tsx +++ b/matrix-meetings-widget/src/components/meetings/WidgetsSelectionDropdown/WidgetsSelectionDropdown.tsx @@ -40,12 +40,14 @@ type WidgetsSelectionDropdownProps = { selectedWidgets: string[]; autoSelectAllWidgets?: boolean; onChange: (widgetIds: string[]) => void; + disabled?: boolean; }; export function WidgetsSelectionDropdown({ selectedWidgets, onChange, autoSelectAllWidgets, + disabled, }: WidgetsSelectionDropdownProps): ReactElement { const { t } = useTranslation(); const instructionId = useId(); @@ -201,6 +203,7 @@ export function WidgetsSelectionDropdown({ renderTags={renderTags} sx={{ mt: 1, mb: 0.5 }} value={availableSelectedWidgets} + disabled={disabled} /> ); diff --git a/matrix-meetings-widget/src/lib/utils/calendarUtils/index.ts b/matrix-meetings-widget/src/lib/utils/calendarUtils/index.ts index 1479caba..96d783c7 100644 --- a/matrix-meetings-widget/src/lib/utils/calendarUtils/index.ts +++ b/matrix-meetings-widget/src/lib/utils/calendarUtils/index.ts @@ -24,3 +24,4 @@ export { } from './helpers'; export { isRecurringMeeting } from './isRecurringMeeting'; export { normalizeCalendarEntry } from './normalizeCalendarEntry'; +export { overrideCalendarEntries } from './overrideCalendarEntries'; diff --git a/matrix-meetings-widget/src/lib/utils/calendarUtils/overrideCalendarEntries.test.ts b/matrix-meetings-widget/src/lib/utils/calendarUtils/overrideCalendarEntries.test.ts new file mode 100644 index 00000000..b20858b4 --- /dev/null +++ b/matrix-meetings-widget/src/lib/utils/calendarUtils/overrideCalendarEntries.test.ts @@ -0,0 +1,245 @@ +/* + * 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 '../../../lib/testUtils'; +import { overrideCalendarEntries } from './overrideCalendarEntries'; + +describe('overrideCalendarEntries', () => { + it('should update existing single entry', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + ]; + + expect( + overrideCalendarEntries( + calendar, + mockCalendarEntry({ + dtstart: '20200111T100000', + dtend: '20200111T110000', + }), + ), + ).toEqual([ + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + mockCalendarEntry({ + dtstart: '20200111T100000', + dtend: '20200111T110000', + }), + ]); + }); + + it('should update existing single recurring entry', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + ]; + + expect( + overrideCalendarEntries( + calendar, + mockCalendarEntry({ + dtstart: '20200111T100000', + dtend: '20200111T110000', + rrule: 'FREQ=DAILY', + }), + ), + ).toEqual([ + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + mockCalendarEntry({ + dtstart: '20200111T100000', + dtend: '20200111T110000', + rrule: 'FREQ=DAILY', + }), + ]); + }); + + it('should update single recurring entry with existing overrides', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + ]; + + expect( + overrideCalendarEntries( + calendar, + mockCalendarEntry({ + dtstart: '20200111T100000', + dtend: '20200111T110000', + rrule: 'FREQ=DAILY', + }), + ), + ).toEqual([ + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + mockCalendarEntry({ + dtstart: '20200111T100000', + dtend: '20200111T110000', + rrule: 'FREQ=DAILY', + }), + ]); + }); + + it('should add an updated occurrence to a single recurring meeting', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + ]; + + expect( + overrideCalendarEntries( + calendar, + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + ), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + ]); + }); + + it('should update a single occurrence of a recurring meeting', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + mockCalendarEntry({ + dtstart: '20200111T103000', + dtend: '20200111T113000', + recurrenceId: '20200111T100000', + }), + + // another override that should stay + mockCalendarEntry({ + dtstart: '20200115T103000', + dtend: '20200115T113000', + recurrenceId: '20200115T100000', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + ]; + + expect( + overrideCalendarEntries( + calendar, + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + ), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + mockCalendarEntry({ + dtstart: '20200115T103000', + dtend: '20200115T113000', + recurrenceId: '20200115T100000', + }), + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + ]); + }); +}); diff --git a/matrix-meetings-widget/src/lib/utils/calendarUtils/overrideCalendarEntries.ts b/matrix-meetings-widget/src/lib/utils/calendarUtils/overrideCalendarEntries.ts new file mode 100644 index 00000000..8be477a0 --- /dev/null +++ b/matrix-meetings-widget/src/lib/utils/calendarUtils/overrideCalendarEntries.ts @@ -0,0 +1,51 @@ +/* + * 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 { CalendarEntry } from '../../../lib/matrix'; +import { parseICalDate } from '../dateTimeUtils'; +import { isRRuleOverrideEntry } from './helpers'; + +/** + * A list of updated calendar entries + * add new override entry or update existing one + */ +export function overrideCalendarEntries( + calendarEntries: CalendarEntry[], + newCalendarEntry: CalendarEntry, +): CalendarEntry[] { + if (isRRuleOverrideEntry(newCalendarEntry)) { + return calendarEntries + .filter( + (c) => + !( + c.recurrenceId && + newCalendarEntry.recurrenceId && + c.uid === newCalendarEntry.uid && + +parseICalDate(c.recurrenceId) === + +parseICalDate(newCalendarEntry.recurrenceId) + ), + ) + .concat(newCalendarEntry); + } + + return ( + calendarEntries + // Remove all existing entries for this single or recurring event + // TODO (PB-2988): try to keep all overrides that are still part of the new series + .filter((c) => c.uid !== newCalendarEntry.uid) + .concat(newCalendarEntry) + ); +} 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; +};