From 69cbd001317f5262fa5db37261c38cb69f7decce Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Sun, 19 Oct 2025 20:15:38 -0400 Subject: [PATCH 1/4] fix: update, delete, accept, devline all occurrences Signed-off-by: SebastianKrupinski --- .../Editor/InvitationResponseButtons.vue | 60 ++++-- src/components/Editor/Repeat/Repeat.vue | 33 ++- src/components/Editor/SaveButtons.vue | 78 ++++--- src/mixins/EditorMixin.js | 149 +++++++++----- src/models/event.js | 2 - src/store/calendarObjectInstance.js | 120 +++++++++-- src/views/EditFull.vue | 52 ++--- src/views/EditSimple.vue | 35 ++-- .../InvitationResponseButtons.test.js | 41 ++++ .../javascript/unit/components/Repeat.test.js | 31 +++ .../unit/components/SaveButtons.test.js | 57 +++++ .../unit/mixins/EditorMixin.test.js | 194 +++++++++++++++++- tests/javascript/unit/models/event.test.js | 15 -- .../unit/store/calendarObjectInstance.test.ts | 143 ++++++++++++- 14 files changed, 824 insertions(+), 186 deletions(-) create mode 100644 tests/javascript/unit/components/InvitationResponseButtons.test.js create mode 100644 tests/javascript/unit/components/Repeat.test.js create mode 100644 tests/javascript/unit/components/SaveButtons.test.js diff --git a/src/components/Editor/InvitationResponseButtons.vue b/src/components/Editor/InvitationResponseButtons.vue index ea91e53835..30c3a5ddd2 100644 --- a/src/components/Editor/InvitationResponseButtons.vue +++ b/src/components/Editor/InvitationResponseButtons.vue @@ -13,7 +13,7 @@ class="invitation-response-buttons__button" :disabled="loading" @click="accept"> - {{ t('calendar', 'Accept') }} + {{ acceptLabel }} - {{ t('calendar', 'Decline') }} + {{ declineLabel }} - {{ t('calendar', 'Tentative') }} + {{ tentativeLabel }} @@ -72,11 +72,6 @@ export default { required: true, }, - calendarId: { - type: String, - required: true, - }, - narrow: { type: Boolean, default: false, @@ -109,6 +104,45 @@ export default { isTentative() { return this.attendee.participationStatus === 'TENTATIVE' }, + + responseScope() { + const eventComponent = this.calendarObjectInstanceStore.calendarObjectInstance?.eventComponent + if (!eventComponent?.isPartOfRecurrenceSet()) { + return null + } + + return eventComponent.isRecurrenceException() ? 'occurrence' : 'series' + }, + + acceptLabel() { + if (this.responseScope === 'occurrence') { + return this.t('calendar', 'Accept this occurrence') + } + if (this.responseScope === 'series') { + return this.t('calendar', 'Accept entire series') + } + return this.t('calendar', 'Accept') + }, + + declineLabel() { + if (this.responseScope === 'occurrence') { + return this.t('calendar', 'Decline this occurrence') + } + if (this.responseScope === 'series') { + return this.t('calendar', 'Decline entire series') + } + return this.t('calendar', 'Decline') + }, + + tentativeLabel() { + if (this.responseScope === 'occurrence') { + return this.t('calendar', 'Tentative for this occurrence') + } + if (this.responseScope === 'series') { + return this.t('calendar', 'Tentative for entire series') + } + return this.t('calendar', 'Tentative') + }, }, methods: { @@ -154,16 +188,10 @@ export default { async setParticipationStatus(participationStatus) { this.loading = true try { - this.calendarObjectInstanceStore.changeAttendeesParticipationStatus({ + await this.calendarObjectInstanceStore.saveAttendeeParticipationResponse({ attendee: this.attendee, participationStatus, }) - // TODO: What about recurring events? Add new buttons like "Accept this and all future"? - // Currently, this will only accept a single occurrence. - await this.calendarObjectInstanceStore.saveCalendarObjectInstance({ - thisAndAllFuture: false, - calendarId: this.calendarId, - }) } catch (error) { logger.error('Failed to set participation status', { error, participationStatus }) throw error diff --git a/src/components/Editor/Repeat/Repeat.vue b/src/components/Editor/Repeat/Repeat.vue index f5f655ef40..47cc3686a0 100644 --- a/src/components/Editor/Repeat/Repeat.vue +++ b/src/components/Editor/Repeat/Repeat.vue @@ -33,18 +33,18 @@

{{ $t('calendar', 'Repeat event') }}

- - + +
@@ -163,7 +170,6 @@ @@ -298,7 +304,7 @@ + @click="acceptAttachmentsModal()"> {{ t('calendar', 'Invite') }}
@@ -345,7 +351,6 @@ import { NcActionButton, NcActionLink, NcActions, - NcActionSeparator, NcButton, NcCheckboxRadioSwitch, NcDialog, @@ -422,7 +427,6 @@ export default { IconVideo, HelpCircleIcon, NcActions, - NcActionSeparator, Close, }, @@ -432,7 +436,7 @@ export default { data() { return { - thisAndAllFuture: false, + saveScope: 'occurrence', doNotShare: false, showModal: false, showModalNewAttachments: [], @@ -719,7 +723,7 @@ export default { this.showModal = false this.showModalNewAttachments = [] this.showModalUsers = [] - this.saveEvent(this.thisAndAllFuture) + this.saveEvent(this.saveScope) }, 500) // trigger save event after make each attachment access // 1) if !isPrivate get attachments NOT SHARED and SharedType is empry -> API ADD SHARE @@ -743,8 +747,8 @@ export default { return name.split('/').pop() }, - prepareAccessForAttachments(thisAndAllFuture = false) { - this.thisAndAllFuture = thisAndAllFuture + prepareAccessForAttachments(scope = 'occurrence') { + this.saveScope = scope const newAttachments = this.calendarObjectInstance.attachments.filter((attachment) => { // get only new attachments // TODO get NOT only new attachments =) Maybe we should filter all attachments without share-type, 'cause event can be private and AFTER save owner could add new participant @@ -764,14 +768,14 @@ export default { return false }) } else { - this.saveEvent(thisAndAllFuture) + this.saveEvent(this.saveScope) } }, - saveEvent(thisAndAllFuture = false) { + saveEvent(scope = 'occurrence') { // if there is new attachments and !private, then make modal with users and files/ // maybe check shared access before add file - this.saveAndLeave(thisAndAllFuture) + this.saveAndLeave(scope) this.calendarObjectInstance.attachments = this.calendarObjectInstance.attachments.map((attachment) => { if (attachment.isNew) { delete attachment.isNew diff --git a/src/views/EditSimple.vue b/src/views/EditSimple.vue index fa63dfbf50..2a0247e786 100644 --- a/src/views/EditSimple.vue +++ b/src/views/EditSimple.vue @@ -88,24 +88,30 @@ {{ $t('calendar', 'Duplicate') }} - + {{ $t('calendar', 'Delete') }} - + {{ $t('calendar', 'Delete this occurrence') }} - - + + - {{ $t('calendar', 'Delete this and all future') }} + {{ $t('calendar', 'Delete this and future occurrences') }} + + + + {{ $t('calendar', 'Delete entire series') }} @@ -214,7 +220,6 @@ v-if="isViewedByAttendee && isViewing" class="event-popover__response-buttons" :attendee="userAsAttendee" - :calendarId="calendarId" @close="closeEditorAndSkipAction" />
@@ -229,15 +234,17 @@ } */ - async saveAndView(thisAndAllFuture) { + async saveAndView(scope) { // Transitioning from new to edit routes is not implemented for now if (this.isNew) { - await this.saveAndLeave(thisAndAllFuture) + await this.saveAndLeave(scope) return } this.isViewing = true try { - await this.save(thisAndAllFuture) + await this.save(scope) this.requiresActionOnRouteLeave = false } catch (error) { logger.error('Failed to save event, reverting to edit mode', { error }) diff --git a/tests/javascript/unit/components/InvitationResponseButtons.test.js b/tests/javascript/unit/components/InvitationResponseButtons.test.js new file mode 100644 index 0000000000..abad352e68 --- /dev/null +++ b/tests/javascript/unit/components/InvitationResponseButtons.test.js @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import InvitationResponseButtons from '@/components/Editor/InvitationResponseButtons.vue' + +describe('components/Editor/InvitationResponseButtons', () => { + it.each([ + [false, false, null], + [true, false, 'series'], + [true, true, 'occurrence'], + ])('determines the response scope', (isRecurring, isException, expected) => { + const vm = { + calendarObjectInstanceStore: { + calendarObjectInstance: { + eventComponent: { + isPartOfRecurrenceSet: vi.fn().mockReturnValue(isRecurring), + isRecurrenceException: vi.fn().mockReturnValue(isException), + }, + }, + }, + } + + expect(InvitationResponseButtons.computed.responseScope.call(vm)).toBe(expected) + }) + + it.each([ + [null, 'Accept', 'Decline', 'Tentative'], + ['occurrence', 'Accept this occurrence', 'Decline this occurrence', 'Tentative for this occurrence'], + ['series', 'Accept entire series', 'Decline entire series', 'Tentative for entire series'], + ])('labels responses for the selected scope', (responseScope, accept, decline, tentative) => { + const vm = { + responseScope, + t: (app, text) => text, + } + + expect(InvitationResponseButtons.computed.acceptLabel.call(vm)).toBe(accept) + expect(InvitationResponseButtons.computed.declineLabel.call(vm)).toBe(decline) + expect(InvitationResponseButtons.computed.tentativeLabel.call(vm)).toBe(tentative) + }) +}) diff --git a/tests/javascript/unit/components/Repeat.test.js b/tests/javascript/unit/components/Repeat.test.js new file mode 100644 index 0000000000..edfa87a31d --- /dev/null +++ b/tests/javascript/unit/components/Repeat.test.js @@ -0,0 +1,31 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import Repeat from '@/components/Editor/Repeat/Repeat.vue' + +describe('components/Editor/Repeat/Repeat', () => { + it.each([ + [false, [['requireFutureUpdate']]], + [true, []], + ])('requires a future update only outside the master item', (isEditingBaseInstance , expectedCalls) => { + const $emit = vi.fn() + const vm = { + $emit, + isEditingBaseInstance , + recurrenceRule: { isUnsupported: false }, + calendarObjectInstanceStore: { + calendarObjectInstance: { + canModifyAllDay: false, + eventComponent: { + canModifyAllDay: vi.fn().mockReturnValue(true), + }, + }, + }, + } + + Repeat.methods.modified.call(vm) + + expect($emit.mock.calls).toEqual(expectedCalls) + }) +}) diff --git a/tests/javascript/unit/components/SaveButtons.test.js b/tests/javascript/unit/components/SaveButtons.test.js new file mode 100644 index 0000000000..0084a16514 --- /dev/null +++ b/tests/javascript/unit/components/SaveButtons.test.js @@ -0,0 +1,57 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import SaveButtons from '@/components/Editor/SaveButtons.vue' + +describe('components/Editor/SaveButtons', () => { + /** + * Compute the visible controls for the given props. + * + * @param {object} overrides Props to override + * @return {object} Visibility by control + */ + function getVisibility(overrides = {}) { + const vm = { + isReadOnly: false, + isNew: false, + canUpdateOccurrence: false, + canUpdateFuture: false, + canUpdateSeries: false, + ...overrides, + } + vm.allowedUpdateScopeCount = SaveButtons.computed.allowedUpdateScopeCount.call(vm) + + return { + save: SaveButtons.computed.showSaveButton.call(vm), + update: SaveButtons.computed.showUpdateButton.call(vm), + future: SaveButtons.computed.showUpdateFutureButton.call(vm), + series: SaveButtons.computed.showUpdateSeriesButton.call(vm), + menu: SaveButtons.computed.showUpdateMenu.call(vm), + } + } + + it.each([ + [{ isNew: true, canUpdateOccurrence: true }, { save: true, update: false, future: false, series: false, menu: false }], + [{ canUpdateOccurrence: true }, { save: false, update: true, future: false, series: false, menu: false }], + [{ canUpdateFuture: true }, { save: false, update: false, future: true, series: false, menu: false }], + [{ canUpdateSeries: true }, { save: false, update: false, future: false, series: true, menu: false }], + [{ canUpdateOccurrence: true, canUpdateFuture: true, canUpdateSeries: true }, { save: false, update: false, future: false, series: false, menu: true }], + [{ isReadOnly: true, canUpdateOccurrence: true }, { save: false, update: false, future: false, series: false, menu: false }], + ])('shows the controls for the allowed update scopes', (props, expected) => { + expect(getVisibility(props)).toEqual(expected) + }) + + it.each([ + ['saveOccurrence', 'saveOccurrence'], + ['saveFuture', 'saveFuture'], + ['saveSeries', 'saveSeries'], + ['showMore', 'showMore'], + ])('%s emits %s', (method, event) => { + const $emit = vi.fn() + + SaveButtons.methods[method].call({ $emit }) + + expect($emit).toHaveBeenCalledWith(event) + }) +}) diff --git a/tests/javascript/unit/mixins/EditorMixin.test.js b/tests/javascript/unit/mixins/EditorMixin.test.js index 5886dd07be..0320ff6a12 100644 --- a/tests/javascript/unit/mixins/EditorMixin.test.js +++ b/tests/javascript/unit/mixins/EditorMixin.test.js @@ -2,8 +2,8 @@ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import EditorMixin from '../../../../src/mixins/EditorMixin.js' -import { ViewMode } from '../../../../src/utils/router.js' +import EditorMixin from '@/mixins/EditorMixin.js' +import { ViewMode } from '@/utils/router.js' describe('mixins/EditorMixin test suite', () => { describe('viewMode', () => { @@ -28,6 +28,196 @@ describe('mixins/EditorMixin test suite', () => { }) }) + describe('isRecurringInstance', () => { + it.each([ + [false, false, false], + [true, false, true], + [false, true, true], + ])('returns the recurrence state for generated and exception instances', (canCreateRecurrenceException, isRecurrenceException, expected) => { + expect(EditorMixin.computed.isRecurringInstance.call({ + canCreateRecurrenceException, + isRecurrenceException, + })).toBe(expected) + }) + }) + + describe('canDelete', () => { + it.each([ + [{ calendarObject: null }, 'occurrence', false], + [{ calendarObject: { existsOnServer: false } }, 'occurrence', false], + [{ isReadOnly: true }, 'occurrence', false], + [{ isLoading: true }, 'occurrence', false], + [{ isRecurringInstance: false }, 'occurrence', true], + [{ isRecurringInstance: false }, 'series', false], + [{ isRecurringInstance: true }, 'occurrence', true], + [{ isRecurringInstance: true }, 'future', true], + [{ isRecurringInstance: true }, 'series', true], + [{ isRecurringInstance: true, isViewedByAttendee: true }, 'occurrence', false], + [{ isRecurringInstance: true, isViewedByAttendee: true }, 'future', false], + [{ isRecurringInstance: true, isViewedByAttendee: true }, 'series', true], + [{ isRecurringInstance: true, isRecurrenceException: true, isViewedByAttendee: true }, 'occurrence', true], + [{ isRecurringInstance: true, isRecurrenceException: true, isViewedByAttendee: true }, 'future', false], + ])('restricts deletion by availability, recurrence, and attendee state', (overrides, scope, expected) => { + const vm = { + calendarObject: { existsOnServer: true }, + isReadOnly: false, + isLoading: false, + isRecurringInstance: false, + isRecurrenceException: false, + isViewedByAttendee: false, + ...overrides, + } + expect(EditorMixin.methods.canDelete.call(vm, scope)).toBe(expected) + }) + }) + + describe('delete', () => { + it('does not execute a disallowed deletion mode', async () => { + const deleteCalendarObjectInstance = vi.fn() + const vm = { + calendarObject: {}, + canDelete: vi.fn().mockReturnValue(false), + calendarObjectInstanceStore: { deleteCalendarObjectInstance }, + isLoading: false, + } + + await EditorMixin.methods.delete.call(vm, 'occurrence') + + expect(deleteCalendarObjectInstance).not.toHaveBeenCalled() + expect(vm.isLoading).toBe(false) + }) + + it('executes an allowed deletion mode', async () => { + const deleteCalendarObjectInstance = vi.fn().mockResolvedValue() + const vm = { + calendarObject: {}, + canDelete: vi.fn().mockReturnValue(true), + calendarObjectInstanceStore: { deleteCalendarObjectInstance }, + isLoading: false, + } + + await EditorMixin.methods.delete.call(vm, 'series') + + expect(deleteCalendarObjectInstance).toHaveBeenCalledWith({ scope: 'series' }) + expect(vm.isLoading).toBe(false) + }) + }) + + describe('canUpdate', () => { + it.each([ + [{ calendarObject: null }, 'occurrence', false], + [{ calendarObject: { existsOnServer: false } }, 'occurrence', false], + [{ isReadOnly: true }, 'occurrence', false], + [{ isLoading: true }, 'occurrence', false], + [{ isNew: true }, 'occurrence', true], + [{ isNew: true }, 'series', false], + [{ requiresFutureUpdate: true }, 'occurrence', false], + [{ requiresFutureUpdate: true }, 'future', true], + [{ isRecurringInstance: false }, 'occurrence', true], + [{ isRecurringInstance: false }, 'series', false], + [{ isRecurringInstance: true }, 'occurrence', true], + [{ isRecurringInstance: true }, 'future', true], + [{ isRecurringInstance: true }, 'series', true], + [{ isRecurringInstance: true, isViewedByAttendee: true }, 'occurrence', false], + [{ isRecurringInstance: true, isViewedByAttendee: true }, 'future', false], + [{ isRecurringInstance: true, isViewedByAttendee: true }, 'series', true], + [{ isRecurringInstance: true, isRecurrenceException: true, isViewedByAttendee: true }, 'occurrence', true], + [{ isRecurringInstance: true, isRecurrenceException: true, isViewedByAttendee: true }, 'future', false], + ])('restricts updates by availability, recurrence, and attendee state', (overrides, scope, expected) => { + const vm = { + calendarObject: { existsOnServer: true }, + isReadOnly: false, + isLoading: false, + isNew: false, + requiresFutureUpdate: false, + isRecurringInstance: false, + isRecurrenceException: false, + isViewedByAttendee: false, + ...overrides, + } + expect(EditorMixin.methods.canUpdate.call(vm, scope)).toBe(expected) + }) + }) + + describe('requireFutureUpdate', () => { + it('marks future updates as required', () => { + const vm = { requiresFutureUpdate: false } + + EditorMixin.methods.requireFutureUpdate.call(vm) + + expect(vm.requiresFutureUpdate).toBe(true) + }) + }) + + describe('created', () => { + it('marks a new event as its own master item', async () => { + const vm = { + isWidget: false, + isLoading: true, + isEditingBaseInstance : false, + calendarId: null, + $route: { name: 'NewFullView', params: { allDay: '0', dtstart: '1000', dtend: '2000' } }, + settingsStore: { getResolvedTimezone: 'UTC' }, + calendarObjectInstanceStore: { + getCalendarObjectInstanceForNewEvent: vi.fn().mockResolvedValue(), + }, + loadingCalendars: vi.fn().mockResolvedValue(), + addDelegatorAsAttendeeIfNeeded: vi.fn(), + calendarObject: { calendarId: 'calendar-1' }, + selectedCalendar: {}, + } + + await EditorMixin.created.call(vm) + + // Without this, a recurrence-rule change on a brand new event is + // wrongly treated as requiring a future-only update, which a new + // event can never satisfy, silently blocking the save. + expect(vm.isEditingBaseInstance ).toBe(true) + }) + }) + + describe('save', () => { + it('does not execute a disallowed update scope', async () => { + const saveCalendarObjectInstance = vi.fn() + const vm = { + calendarObject: {}, + requiresFutureUpdate: false, + canUpdate: vi.fn().mockReturnValue(false), + calendarObjectInstanceStore: { saveCalendarObjectInstance }, + isLoading: false, + isSaving: false, + } + + await EditorMixin.methods.save.call(vm, 'occurrence') + + expect(saveCalendarObjectInstance).not.toHaveBeenCalled() + expect(vm.isLoading).toBe(false) + expect(vm.isSaving).toBe(false) + }) + + it('executes an allowed update scope', async () => { + const saveCalendarObjectInstance = vi.fn().mockResolvedValue() + const vm = { + calendarObject: {}, + calendarId: 'calendar-1', + requiresFutureUpdate: false, + canUpdate: vi.fn().mockReturnValue(true), + calendarObjectInstanceStore: { saveCalendarObjectInstance }, + isLoading: false, + isSaving: false, + } + + await EditorMixin.methods.save.call(vm, 'series') + + expect(saveCalendarObjectInstance).toHaveBeenCalledWith({ + scope: 'series', + calendarId: 'calendar-1', + }) + expect(vm.isLoading).toBe(false) + expect(vm.isSaving).toBe(false) + }) + }) + describe('duplicateEvent', () => { it('does nothing when duplication is not allowed in the current view (e.g. public/embedded/widget)', async () => { const duplicateCalendarObjectInstance = vi.fn() diff --git a/tests/javascript/unit/models/event.test.js b/tests/javascript/unit/models/event.test.js index f2f830c3ee..4fa5e65506 100644 --- a/tests/javascript/unit/models/event.test.js +++ b/tests/javascript/unit/models/event.test.js @@ -55,7 +55,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: false, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -98,7 +97,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: false, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -149,7 +147,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -209,7 +206,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [ 'ATTENDEE1', @@ -283,7 +279,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -341,7 +336,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -398,7 +392,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -458,7 +451,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -515,7 +507,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -569,7 +560,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -623,7 +613,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -681,7 +670,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: false, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: true, attendees: [], organizer: null, @@ -738,7 +726,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: false, isRecurrenceException: true, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -793,7 +780,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: true, isMasterItem: false, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: true, attendees: [], organizer: null, @@ -851,7 +837,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: false, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: true, attendees: [], organizer: null, diff --git a/tests/javascript/unit/store/calendarObjectInstance.test.ts b/tests/javascript/unit/store/calendarObjectInstance.test.ts index 33b22e8d1f..1c71e168de 100644 --- a/tests/javascript/unit/store/calendarObjectInstance.test.ts +++ b/tests/javascript/unit/store/calendarObjectInstance.test.ts @@ -58,9 +58,7 @@ describe('store/calendarObjectInstance test suite', () => { await store.duplicateCalendarObjectInstance({ calendarId: 'writable-calendar' }) - expect(calendarObjectsStore.createNewEvent).toHaveBeenCalledWith( - expect.objectContaining({ calendarId: 'writable-calendar' }), - ) + expect(calendarObjectsStore.createNewEvent).toHaveBeenCalledWith(expect.objectContaining({ calendarId: 'writable-calendar' })) }) it('marks the duplicated event as a new, unsaved calendar-object', async () => { @@ -138,4 +136,143 @@ describe('store/calendarObjectInstance test suite', () => { expect(calendarObjectInstance.alarms).not.toContain(alarm) }) }) + + describe('saveAttendeeParticipationResponse', () => { + it('updates the recurring master when responding to a generated occurrence', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const masterAttendee = { + email: 'attendee@example.com', + participationStatus: 'NEEDS-ACTION', + } + const masterComponent = { + name: 'VEVENT', + hasProperty: vi.fn().mockReturnValue(false), + getAttendeeIterator: vi.fn().mockReturnValue([masterAttendee]), + } + const occurrenceAttendee = { + email: 'ATTENDEE@example.com', + participationStatus: 'NEEDS-ACTION', + } + const eventComponent = { + name: 'VEVENT', + isRecurrenceException: vi.fn().mockReturnValue(false), + } + const attendee = { + attendeeProperty: occurrenceAttendee, + participationStatus: 'NEEDS-ACTION', + } + const calendarObject = { + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([masterComponent]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + + await store.saveAttendeeParticipationResponse({ + attendee, + participationStatus: 'ACCEPTED', + }) + + expect(masterAttendee.participationStatus).toBe('ACCEPTED') + expect(occurrenceAttendee.participationStatus).toBe('NEEDS-ACTION') + expect(attendee.participationStatus).toBe('ACCEPTED') + expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject }) + }) + + it('updates an existing recurrence exception without changing the master', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const masterAttendee = { + email: 'attendee@example.com', + participationStatus: 'ACCEPTED', + } + const exceptionAttendee = { + email: 'attendee@example.com', + participationStatus: 'NEEDS-ACTION', + } + const eventComponent = { + name: 'VEVENT', + isRecurrenceException: vi.fn().mockReturnValue(true), + } + const attendee = { + attendeeProperty: exceptionAttendee, + participationStatus: 'NEEDS-ACTION', + } + const calendarObject = { + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([{ + name: 'VEVENT', + getAttendeeIterator: vi.fn().mockReturnValue([masterAttendee]), + }]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + + await store.saveAttendeeParticipationResponse({ + attendee, + participationStatus: 'DECLINED', + }) + + expect(exceptionAttendee.participationStatus).toBe('DECLINED') + expect(masterAttendee.participationStatus).toBe('ACCEPTED') + expect(attendee.participationStatus).toBe('DECLINED') + expect(calendarObject.calendarComponent.getComponentIterator).not.toHaveBeenCalled() + expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject }) + }) + }) + + describe('saveCalendarObjectInstance', () => { + it('updates the recurring base component when saving the series from an exception', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const baseProperty = { + name: 'SUMMARY', + } + const exceptionPropertyClone = {} + const exceptionProperty = { + name: 'SUMMARY', + clone: vi.fn().mockReturnValue(exceptionPropertyClone), + } + const baseComponent = { + name: 'VEVENT', + hasProperty: vi.fn().mockReturnValue(false), + getPropertyIterator: vi.fn().mockReturnValue([baseProperty]), + deleteAllProperties: vi.fn(), + addProperty: vi.fn(), + deleteAllComponents: vi.fn(), + addComponent: vi.fn(), + } + const exceptionComponent = { + name: 'VEVENT', + primaryItem: {}, + isDirty: vi.fn().mockReturnValue(true), + isPartOfRecurrenceSet: vi.fn().mockReturnValue(true), + getPropertyIterator: vi.fn().mockReturnValue([exceptionProperty]), + getAlarmIterator: vi.fn().mockReturnValue([]), + } + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([baseComponent, exceptionComponent]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: exceptionComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + + await store.saveCalendarObjectInstance({ + scope: 'series', + calendarId: 'calendar-1', + }) + + expect(baseComponent.deleteAllProperties).toHaveBeenCalledWith('SUMMARY') + expect(baseComponent.addProperty).toHaveBeenCalledWith(exceptionPropertyClone) + expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject }) + }) + }) }) From d447b1a57e8d8fa8ba04ba5e3e581d3d8997dd43 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Fri, 28 Aug 2026 10:31:39 -0400 Subject: [PATCH 2/4] fixup! fix: update, delete, accept, devline all occurrences Signed-off-by: SebastianKrupinski --- src/store/calendarObjectInstance.js | 21 +++++++++++++++------ src/views/EditFull.vue | 4 ++-- src/views/EditSimple.vue | 3 --- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/store/calendarObjectInstance.js b/src/store/calendarObjectInstance.js index 53a94c594f..49bcd88e18 100644 --- a/src/store/calendarObjectInstance.js +++ b/src/store/calendarObjectInstance.js @@ -1449,7 +1449,7 @@ export default defineStore('calendarObjectInstance', { updateAlarms(eventComponent) - if (eventComponent.isDirty() && eventComponent.isPartOfRecurrenceSet() && scope === 'series' && isForkedItem) { + if (eventComponent.isDirty() && eventComponent.isPartOfRecurrenceSet() && scope === 'series') { // Find the master component (without RECURRENCE-ID) let baseComponent = null for (const component of calendarObject.calendarComponent.getComponentIterator()) { @@ -1459,8 +1459,10 @@ export default defineStore('calendarObjectInstance', { } } - if (baseComponent) { - // construct list of properties to clone + if (!baseComponent) { + logger.error('Could not find master component to save series-wide changes to') + } else { + // construct list of properties to clone as we might be editing a instance or fork not the base component const propertyNames = [] for (const property of baseComponent.getPropertyIterator()) { if (property.name === 'UID' || property.name === 'RECURRENCE-ID' || property.name === 'DTSTART' || property.name === 'DTEND') { @@ -1476,14 +1478,21 @@ export default defineStore('calendarObjectInstance', { } baseComponent.addProperty(property.clone()) } - // clone alarms + // DTSTART and DTEND need to be cloned separately so that internal logic of ical.js + // can adjust all the recurrence rules and exceptions accordingly + baseComponent.startDate = eventComponent.startDate.clone() + baseComponent.endDate = eventComponent.endDate.clone() + // Only VALARM is copied here because it's the only sub-component the + // editor currently lets users change; other sub-components (e.g. + // PARTICIPANT, VLOCATION, VRESOURCE) that another client may have set + // are left untouched on baseComponent. baseComponent.deleteAllComponents('VALARM') for (const alarm of eventComponent.getAlarmIterator()) { baseComponent.addComponent(alarm.clone()) } - } - await calendarObjectsStore.updateCalendarObject({ calendarObject }) + await calendarObjectsStore.updateCalendarObject({ calendarObject }) + } } if (eventComponent.isDirty() && scope !== 'series') { diff --git a/src/views/EditFull.vue b/src/views/EditFull.vue index 2a6181b927..b516a88401 100644 --- a/src/views/EditFull.vue +++ b/src/views/EditFull.vue @@ -747,7 +747,7 @@ export default { return name.split('/').pop() }, - prepareAccessForAttachments(scope = 'occurrence') { + prepareAccessForAttachments(scope) { this.saveScope = scope const newAttachments = this.calendarObjectInstance.attachments.filter((attachment) => { // get only new attachments @@ -772,7 +772,7 @@ export default { } }, - saveEvent(scope = 'occurrence') { + saveEvent(scope) { // if there is new attachments and !private, then make modal with users and files/ // maybe check shared access before add file this.saveAndLeave(scope) diff --git a/src/views/EditSimple.vue b/src/views/EditSimple.vue index 2a0247e786..f4763fb6e6 100644 --- a/src/views/EditSimple.vue +++ b/src/views/EditSimple.vue @@ -100,7 +100,6 @@ {{ $t('calendar', 'Delete this occurrence') }} -