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) => (
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;
+};