Skip to content

Commit b1d34fb

Browse files
andrest50asithade
andauthored
feat(meetings): add ability to cancel meeting occurrences (#132)
* feat(meetings): add ability to cancel meeting occurrences Implement modal flow to cancel individual occurrences of recurring meetings: - Add delete type selection modal for recurring meetings - Add cancel occurrence confirmation modal with occurrence details - Implement ETag-based concurrency control for cancellation API - Filter cancelled occurrences from all meeting displays - Add centralized getActiveOccurrences utility function in shared package - Update frontend service with cancelOccurrence method - Add backend route, controller, and service for cancel occurrence API - Fix modal subscription logic to prevent unintended page refreshes Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon <andrest2455@gmail.com> * refactor(meetings): improve cancel occurrence implementation Minor improvements to the cancel occurrence feature: - Move MeetingCancelOccurrenceResult interface to shared package - Refactor delete type selection to use ngClass instead of individual class bindings - Remove error handling from cancelOccurrence service method (handled in component) - Update imports to use shared interface These changes improve code organization and maintainability. Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon <andrest2455@gmail.com> * fix(meetings): resolve merge conflict and improve cancel occurrence logic - Remove merge conflict markers from meeting-card component imports - Fix cancel occurrence to use current/selected occurrence instead of always using next occurrence - Add fallback to next occurrence if no specific occurrence is selected Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon <andrest2455@gmail.com> --------- Signed-off-by: Andres Tobon <andrest2455@gmail.com> Signed-off-by: Asitha de Silva <asithade@gmail.com> Co-authored-by: Asitha de Silva <asithade@gmail.com>
1 parent 2f2fd9b commit b1d34fb

File tree

13 files changed

+458
-6
lines changed

13 files changed

+458
-6
lines changed

apps/lfx-one/src/app/modules/dashboards/components/my-meetings/my-meetings.component.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Router } from '@angular/router';
88
import { MeetingService } from '@app/shared/services/meeting.service';
99
import { ButtonComponent } from '@components/button/button.component';
1010
import { DashboardMeetingCardComponent } from '@components/dashboard-meeting-card/dashboard-meeting-card.component';
11+
import { getActiveOccurrences } from '@lfx-one/shared';
1112
import { SkeletonModule } from 'primeng/skeleton';
1213
import { finalize } from 'rxjs';
1314

@@ -38,7 +39,10 @@ export class MyMeetingsComponent {
3839
for (const meeting of this.allMeetings()) {
3940
// Process occurrences if they exist
4041
if (meeting.occurrences && meeting.occurrences.length > 0) {
41-
for (const occurrence of meeting.occurrences) {
42+
// Get only active (non-cancelled) occurrences
43+
const activeOccurrences = getActiveOccurrences(meeting.occurrences);
44+
45+
for (const occurrence of activeOccurrences) {
4246
const startTime = new Date(occurrence.start_time);
4347
const startTimeMs = startTime.getTime();
4448
const endTime = startTimeMs + occurrence.duration * 60 * 1000 + buffer;
@@ -89,7 +93,10 @@ export class MyMeetingsComponent {
8993
for (const meeting of this.allMeetings()) {
9094
// Process occurrences if they exist
9195
if (meeting.occurrences && meeting.occurrences.length > 0) {
92-
for (const occurrence of meeting.occurrences) {
96+
// Get only active (non-cancelled) occurrences
97+
const activeOccurrences = getActiveOccurrences(meeting.occurrences);
98+
99+
for (const occurrence of activeOccurrences) {
93100
const startTime = new Date(occurrence.start_time);
94101
const startTimeMs = startTime.getTime();
95102

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
<div class="meeting-cancel-occurrence-confirmation">
5+
<!-- Occurrence Details -->
6+
<div class="mb-6">
7+
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
8+
<h3 class="text-lg font-semibold text-gray-900 mb-3">Occurrence Details</h3>
9+
10+
<div class="space-y-2">
11+
<div class="flex items-start">
12+
<span class="font-medium text-gray-700 w-20 flex-shrink-0">Title:</span>
13+
<span class="text-gray-900">{{ occurrence.title || meeting.title || 'Untitled Meeting' }}</span>
14+
</div>
15+
16+
<div class="flex items-start">
17+
<span class="font-medium text-gray-700 w-20 flex-shrink-0">Date:</span>
18+
<span class="text-gray-900">{{ occurrence.start_time | meetingTime: occurrence.duration : 'date' }}</span>
19+
</div>
20+
21+
<div class="flex items-start">
22+
<span class="font-medium text-gray-700 w-20 flex-shrink-0">Time:</span>
23+
<span class="text-gray-900">{{ occurrence.start_time | meetingTime: occurrence.duration : 'time' }}</span>
24+
</div>
25+
26+
<div class="flex items-start">
27+
<span class="font-medium text-gray-700 w-20 flex-shrink-0">Series:</span>
28+
<span class="text-gray-900 flex items-center gap-2">
29+
<i class="fa-light fa-repeat text-blue-500"></i>
30+
Part of Recurring Series
31+
</span>
32+
</div>
33+
</div>
34+
</div>
35+
</div>
36+
37+
<!-- Warning Message -->
38+
<lfx-message severity="error" icon="fa-light fa-triangle-exclamation" styleClass="mb-6">
39+
<ng-template #content>
40+
<h4 class="font-medium mb-1">Warning</h4>
41+
<p class="text-sm">This will permanently cancel this specific occurrence. Guests will be notified of the cancellation. This action cannot be undone.</p>
42+
</ng-template>
43+
</lfx-message>
44+
45+
<!-- Action Buttons -->
46+
<div class="flex justify-end gap-3">
47+
<lfx-button
48+
label="Cancel"
49+
severity="secondary"
50+
[outlined]="true"
51+
size="small"
52+
type="button"
53+
[disabled]="isCanceling()"
54+
(click)="onCancel()"
55+
data-testid="cancel-occurrence-cancel-button">
56+
</lfx-button>
57+
58+
<lfx-button
59+
[label]="isCanceling() ? 'Canceling...' : 'Cancel Occurrence'"
60+
severity="danger"
61+
size="small"
62+
type="button"
63+
[icon]="isCanceling() ? 'fa-light fa-circle-notch fa-spin' : 'fa-light fa-ban'"
64+
[disabled]="isCanceling()"
65+
(click)="onConfirm()"
66+
data-testid="cancel-occurrence-confirm-button">
67+
</lfx-button>
68+
</div>
69+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { Component, inject, signal } from '@angular/core';
5+
import { CommonModule } from '@angular/common';
6+
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
7+
import { Meeting, MeetingOccurrence } from '@lfx-one/shared/interfaces';
8+
import { MeetingService } from '@services/meeting.service';
9+
import { ButtonComponent } from '@components/button/button.component';
10+
import { MessageComponent } from '@components/message/message.component';
11+
import { MeetingTimePipe } from '@pipes/meeting-time.pipe';
12+
import { HttpErrorResponse } from '@angular/common/http';
13+
14+
@Component({
15+
selector: 'lfx-meeting-cancel-occurrence-confirmation',
16+
standalone: true,
17+
imports: [CommonModule, ButtonComponent, MessageComponent, MeetingTimePipe],
18+
templateUrl: './meeting-cancel-occurrence-confirmation.component.html',
19+
})
20+
export class MeetingCancelOccurrenceConfirmationComponent {
21+
private readonly dialogRef = inject(DynamicDialogRef);
22+
private readonly config = inject(DynamicDialogConfig);
23+
private readonly meetingService = inject(MeetingService);
24+
25+
public readonly meeting: Meeting = this.config.data.meeting;
26+
public readonly occurrence: MeetingOccurrence = this.config.data.occurrence;
27+
public readonly isCanceling = signal(false);
28+
29+
public onCancel(): void {
30+
this.dialogRef.close({ confirmed: false });
31+
}
32+
33+
public onConfirm(): void {
34+
this.isCanceling.set(true);
35+
36+
this.meetingService.cancelOccurrence(this.meeting.uid, this.occurrence.occurrence_id).subscribe({
37+
next: () => {
38+
this.isCanceling.set(false);
39+
this.dialogRef.close({ confirmed: true });
40+
},
41+
error: (error: HttpErrorResponse) => {
42+
this.isCanceling.set(false);
43+
let errorMessage = 'Failed to cancel occurrence. Please try again.';
44+
45+
if (error.status === 404) {
46+
errorMessage = 'Meeting occurrence not found.';
47+
} else if (error.status === 403) {
48+
errorMessage = 'You do not have permission to cancel this occurrence.';
49+
} else if (error.status === 500) {
50+
errorMessage = 'Server error occurred while canceling occurrence.';
51+
} else if (error.status === 0) {
52+
errorMessage = 'Network error. Please check your connection.';
53+
}
54+
55+
this.dialogRef.close({ confirmed: false, error: errorMessage });
56+
},
57+
});
58+
}
59+
}

apps/lfx-one/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
getCurrentOrNextOccurrence,
2121
Meeting,
2222
MeetingAttachment,
23+
MeetingCancelOccurrenceResult,
2324
MeetingOccurrence,
2425
MeetingRegistrant,
2526
PastMeeting,
@@ -37,8 +38,10 @@ import { DialogService } from 'primeng/dynamicdialog';
3738
import { TooltipModule } from 'primeng/tooltip';
3839
import { BehaviorSubject, catchError, filter, finalize, map, of, switchMap, take, tap } from 'rxjs';
3940

41+
import { MeetingCancelOccurrenceConfirmationComponent } from '../meeting-cancel-occurrence-confirmation/meeting-cancel-occurrence-confirmation.component';
4042
import { MeetingCommitteeModalComponent } from '../meeting-committee-modal/meeting-committee-modal.component';
4143
import { MeetingDeleteConfirmationComponent, MeetingDeleteResult } from '../meeting-delete-confirmation/meeting-delete-confirmation.component';
44+
import { MeetingDeleteTypeSelectionComponent, MeetingDeleteTypeResult } from '../meeting-delete-type-selection/meeting-delete-type-selection.component';
4245
import { RecordingModalComponent } from '../recording-modal/recording-modal.component';
4346
import { RegistrantModalComponent } from '../registrant-modal/registrant-modal.component';
4447
import { SummaryModalComponent } from '../summary-modal/summary-modal.component';
@@ -464,6 +467,85 @@ export class MeetingCardComponent implements OnInit {
464467
const meeting = this.meeting();
465468
if (!meeting) return;
466469

470+
// Check if meeting is recurring
471+
const isRecurring = !!meeting.recurrence;
472+
473+
if (isRecurring) {
474+
// For recurring meetings, first show the delete type selection modal
475+
this.dialogService
476+
.open(MeetingDeleteTypeSelectionComponent, {
477+
header: 'Delete Recurring Meeting',
478+
width: '500px',
479+
modal: true,
480+
closable: true,
481+
dismissableMask: true,
482+
data: {
483+
meeting: meeting,
484+
},
485+
})
486+
.onClose.pipe(take(1))
487+
.subscribe((typeResult: MeetingDeleteTypeResult) => {
488+
if (typeResult) {
489+
if (typeResult.deleteType === 'occurrence') {
490+
// User wants to cancel just this occurrence
491+
this.showCancelOccurrenceModal(meeting);
492+
} else {
493+
// User wants to delete the entire series
494+
this.showDeleteMeetingModal(meeting);
495+
}
496+
}
497+
});
498+
} else {
499+
// For non-recurring meetings, show delete confirmation directly
500+
this.showDeleteMeetingModal(meeting);
501+
}
502+
}
503+
504+
private showCancelOccurrenceModal(meeting: Meeting): void {
505+
// Prefer the explicitly selected/current occurrence; fallback to next active
506+
const occurrenceToCancel = this.occurrence() ?? getCurrentOrNextOccurrence(meeting);
507+
508+
if (!occurrenceToCancel) {
509+
this.messageService.add({
510+
severity: 'error',
511+
summary: 'Error',
512+
detail: 'No upcoming occurrence found to cancel.',
513+
});
514+
return;
515+
}
516+
517+
this.dialogService
518+
.open(MeetingCancelOccurrenceConfirmationComponent, {
519+
header: 'Cancel Occurrence',
520+
width: '450px',
521+
modal: true,
522+
closable: true,
523+
dismissableMask: true,
524+
data: {
525+
meeting: meeting,
526+
occurrence: occurrenceToCancel,
527+
},
528+
})
529+
.onClose.pipe(take(1))
530+
.subscribe((result: MeetingCancelOccurrenceResult) => {
531+
if (result?.confirmed) {
532+
this.messageService.add({
533+
severity: 'success',
534+
summary: 'Success',
535+
detail: 'Meeting occurrence canceled successfully',
536+
});
537+
this.meetingDeleted.emit();
538+
} else if (result?.error) {
539+
this.messageService.add({
540+
severity: 'error',
541+
summary: 'Error',
542+
detail: result.error,
543+
});
544+
}
545+
});
546+
}
547+
548+
private showDeleteMeetingModal(meeting: Meeting): void {
467549
this.dialogService
468550
.open(MeetingDeleteConfirmationComponent, {
469551
header: 'Delete Meeting',
@@ -477,7 +559,7 @@ export class MeetingCardComponent implements OnInit {
477559
})
478560
.onClose.pipe(take(1))
479561
.subscribe((result: MeetingDeleteResult) => {
480-
if (result) {
562+
if (result?.confirmed) {
481563
this.meetingDeleted.emit();
482564
}
483565
});

apps/lfx-one/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,13 @@ <h4 class="font-medium mb-1">Warning</h4>
9292

9393
<!-- Action Buttons -->
9494
<div class="flex justify-end gap-3">
95-
<lfx-button label="Cancel" severity="secondary" [outlined]="true" size="small" [disabled]="isDeleting()" (click)="onCancel()"> </lfx-button>
95+
<lfx-button label="Cancel" severity="secondary" [outlined]="true" size="small" [disabled]="isDeleting()" type="button" (click)="onCancel()"> </lfx-button>
9696

9797
<lfx-button
9898
[label]="isDeleting() ? 'Deleting...' : 'Delete Meeting'"
9999
severity="danger"
100100
size="small"
101+
type="button"
101102
[icon]="isDeleting() ? 'fa-light fa-circle-notch fa-spin' : 'fa-light fa-trash'"
102103
[disabled]="isDeleting()"
103104
(click)="onConfirm()">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
<div class="meeting-delete-type-selection">
5+
<!-- Description -->
6+
<div class="mb-6">
7+
<p class="text-gray-700">This is a recurring meeting. Would you like to cancel just this occurrence or delete the entire meeting series?</p>
8+
</div>
9+
10+
<!-- Selection Options -->
11+
<div class="space-y-3 mb-6">
12+
<div
13+
class="border rounded-lg p-4 cursor-pointer transition-all"
14+
[ngClass]="{
15+
'border-blue-500 bg-blue-50': selectedType === 'occurrence',
16+
'border-gray-200': selectedType !== 'occurrence',
17+
}"
18+
(click)="selectType('occurrence')"
19+
data-testid="delete-type-occurrence-option">
20+
<div class="flex items-start gap-3">
21+
<div class="mt-1">
22+
<i
23+
class="fa-light text-lg"
24+
[ngClass]="{
25+
'fa-circle-dot text-blue-500': selectedType === 'occurrence',
26+
'fa-circle text-gray-400': selectedType !== 'occurrence',
27+
}"></i>
28+
</div>
29+
<div class="flex-1">
30+
<h4 class="font-semibold text-gray-900 mb-1">Cancel This Occurrence</h4>
31+
<p class="text-sm text-gray-600">Only this specific meeting instance will be canceled. The rest of the series will remain.</p>
32+
</div>
33+
</div>
34+
</div>
35+
36+
<div
37+
class="border rounded-lg p-4 cursor-pointer transition-all"
38+
[ngClass]="{
39+
'border-blue-500 bg-blue-50': selectedType === 'series',
40+
'border-gray-200': selectedType !== 'series',
41+
}"
42+
(click)="selectType('series')"
43+
data-testid="delete-type-series-option">
44+
<div class="flex items-start gap-3">
45+
<div class="mt-1">
46+
<i
47+
class="fa-light text-lg"
48+
[ngClass]="{
49+
'fa-circle-dot text-blue-500': selectedType === 'series',
50+
'fa-circle text-gray-400': selectedType !== 'series',
51+
}"></i>
52+
</div>
53+
<div class="flex-1">
54+
<h4 class="font-semibold text-gray-900 mb-1">Delete Entire Series</h4>
55+
<p class="text-sm text-gray-600">The entire recurring meeting series will be permanently deleted.</p>
56+
</div>
57+
</div>
58+
</div>
59+
</div>
60+
61+
<!-- Action Buttons -->
62+
<div class="flex justify-end gap-3">
63+
<lfx-button label="Cancel" severity="secondary" [outlined]="true" size="small" type="button" (click)="onCancel()" data-testid="delete-type-cancel-button">
64+
</lfx-button>
65+
66+
<lfx-button
67+
label="Continue"
68+
severity="primary"
69+
size="small"
70+
type="button"
71+
[disabled]="!selectedType"
72+
(click)="onContinue()"
73+
data-testid="delete-type-continue-button">
74+
</lfx-button>
75+
</div>
76+
</div>

0 commit comments

Comments
 (0)