frontend: implement room deletion service with confirmation dialog and error handling

This commit is contained in:
CSantosM 2026-03-02 18:19:38 +01:00
parent 5b9fa3149c
commit f49fd863b7
6 changed files with 289 additions and 132 deletions

View File

@ -15,7 +15,7 @@ import { MatSortModule, Sort } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MEET_ROOM_SORT_FIELDS, MeetRoom, MeetRoomSortField, MeetRoomStatus, SortOrder } from '@openvidu-meet/typings';
import { MeetRoom, MeetRoomSortField, MeetRoomStatus, SortOrder } from '@openvidu-meet/typings';
import { setsAreEqual } from '../../../../shared/utils/array.utils';
import { RoomUiUtils } from '../../utils/ui';

View File

@ -4,15 +4,15 @@ import { CommonModule } from '@angular/common';
import { Component, computed, input, output } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatStepperModule } from '@angular/material/stepper';
import { WizardStep } from '../../models';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { WizardStep } from '../../models';
@Component({
selector: 'ov-step-indicator',
imports: [CommonModule, MatStepperModule, ReactiveFormsModule],
templateUrl: './step-indicator.component.html',
styleUrl: './step-indicator.component.scss'
selector: 'ov-step-indicator',
imports: [CommonModule, MatStepperModule, ReactiveFormsModule],
templateUrl: './step-indicator.component.html',
styleUrl: './step-indicator.component.scss'
})
export class StepIndicatorComponent {
steps = input.required<WizardStep[]>();

View File

@ -3,13 +3,19 @@ import { Component, OnInit, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MeetRecordingInfo, MeetRoom, MeetRoomMember, MeetRoomStatus, SortOrder } from '@openvidu-meet/typings';
import {
MeetRecordingInfo,
MeetRoom,
MeetRoomDeletionSuccessCode,
MeetRoomMember,
MeetRoomStatus,
SortOrder
} from '@openvidu-meet/typings';
import { ILogger, LoggerService } from 'openvidu-components-angular';
import { BreadcrumbComponent, BreadcrumbItem } from '../../../../shared/components/breadcrumb/breadcrumb.component';
import { NavigationService } from '../../../../shared/services/navigation.service';
@ -17,8 +23,13 @@ import { NotificationService } from '../../../../shared/services/notification.se
import { RecordingListsComponent } from '../../../recordings/components/recording-lists/recording-lists.component';
import { RecordingTableAction, RecordingTableFilter } from '../../../recordings/models/recording-list.model';
import { RecordingService } from '../../../recordings/services/recording.service';
import {
MemberTableAction,
MemberTableFilter,
RoomMembersListsComponent
} from '../../../room-members/components/room-members-list/room-members-list.component';
import { RoomMemberService } from '../../../room-members/services/room-member.service';
import { DeleteRoomDialogComponent } from '../../components/delete-room-dialog/delete-room-dialog.component';
import { RoomDeletionService } from '../../services/room-deletion.service';
import { RoomService } from '../../services/room.service';
import { RoomUiUtils } from '../../utils/ui';
@ -34,7 +45,8 @@ import { RoomUiUtils } from '../../utils/ui';
MatTabsModule,
RouterModule,
BreadcrumbComponent,
RecordingListsComponent
RecordingListsComponent,
RoomMembersListsComponent
],
templateUrl: './room-detail.component.html',
styleUrl: './room-detail.component.scss'
@ -47,6 +59,8 @@ export class RoomDetailComponent implements OnInit {
// Room Members tab
roomMembers = signal<MeetRoomMember[]>([]);
loadingMembers = signal(false);
hasMoreMembers = false;
private nextMembersPageToken?: string;
// Recordings tab
recordings = signal<MeetRecordingInfo[]>([]);
@ -66,12 +80,12 @@ export class RoomDetailComponent implements OnInit {
private route: ActivatedRoute,
private router: Router,
private roomService: RoomService,
private roomDeletionService: RoomDeletionService,
private roomMemberService: RoomMemberService,
private recordingService: RecordingService,
private notificationService: NotificationService,
protected navigationService: NavigationService,
private clipboard: Clipboard,
private dialog: MatDialog,
protected loggerService: LoggerService
) {
this.log = this.loggerService.get('OpenVidu Meet - RoomDetailComponent');
@ -106,7 +120,7 @@ export class RoomDetailComponent implements OnInit {
]);
// Load initial data for tabs
await Promise.all([this.loadRoomMembers(roomId), this.loadRecordings(roomId)]);
await Promise.all([this.loadRoomMembers(roomId, undefined), this.loadRecordings(roomId)]);
} catch (error) {
this.log.e('Error loading room details:', error);
this.notificationService.showSnackbar('Failed to load room details');
@ -116,15 +130,26 @@ export class RoomDetailComponent implements OnInit {
}
}
private async loadRoomMembers(roomId: string) {
private async loadRoomMembers(roomId: string, filters?: MemberTableFilter, refresh = false) {
try {
this.loadingMembers.set(true);
const response = await this.roomMemberService.listRoomMembers(roomId, {
maxItems: 100,
sortField: 'membershipDate',
sortOrder: SortOrder.DESC
maxItems: 50,
nextPageToken: !refresh ? this.nextMembersPageToken : undefined,
sortField: filters?.sortField ?? 'membershipDate',
sortOrder: filters?.sortOrder ?? SortOrder.DESC,
...(filters?.nameFilter ? { name: filters.nameFilter } : {})
});
this.roomMembers.set(response.members);
if (!refresh) {
const currentMembers = this.roomMembers();
this.roomMembers.set([...currentMembers, ...response.members]);
} else {
this.roomMembers.set(response.members);
}
this.nextMembersPageToken = response.pagination.nextPageToken;
this.hasMoreMembers = response.pagination.isTruncated;
} catch (error) {
this.log.e('Error loading room members:', error);
this.notificationService.showSnackbar('Failed to load room members');
@ -133,6 +158,89 @@ export class RoomDetailComponent implements OnInit {
}
}
async loadMoreRoomMembers(filters: MemberTableFilter) {
if (!this.hasMoreMembers || this.loadingMembers()) return;
const roomId = this.room()?.roomId;
if (roomId) {
await this.loadRoomMembers(roomId, filters);
}
}
async refreshRoomMembers(filters: MemberTableFilter) {
const roomId = this.room()?.roomId;
if (roomId) {
this.nextMembersPageToken = undefined;
await this.loadRoomMembers(roomId, filters, true);
}
}
async onMemberAction(action: MemberTableAction) {
switch (action.action) {
case 'copyLink': {
const member = action.members[0];
if (member?.accessUrl) {
this.clipboard.copy(member.accessUrl);
this.notificationService.showSnackbar('Member access URL copied to clipboard');
}
break;
}
case 'delete': {
const member = action.members[0];
if (!member) break;
this.notificationService.showDialog({
title: 'Remove Member',
icon: 'person_remove',
message: `Are you sure you want to remove <b>${member.name}</b> from this room?`,
confirmText: 'Remove',
cancelText: 'Cancel',
confirmCallback: async () => {
try {
const roomId = this.room()?.roomId;
if (!roomId) return;
await this.roomMemberService.deleteRoomMember(roomId, member.memberId);
this.roomMembers.set(this.roomMembers().filter((m) => m.memberId !== member.memberId));
this.notificationService.showSnackbar('Member removed successfully');
} catch (error) {
this.log.e('Error removing member:', error);
this.notificationService.showSnackbar('Failed to remove member');
}
}
});
break;
}
case 'bulkDelete': {
const memberIds = action.members.map((m) => m.memberId);
const roomId = this.room()?.roomId;
if (!roomId || memberIds.length === 0) break;
this.notificationService.showDialog({
title: 'Remove Members',
icon: 'group_remove',
message: `Are you sure you want to remove <b>${memberIds.length}</b> members from this room?`,
confirmText: 'Remove all',
cancelText: 'Cancel',
confirmCallback: async () => {
try {
await this.roomMemberService.bulkDeleteRoomMembers(roomId, memberIds);
this.roomMembers.set(this.roomMembers().filter((m) => !memberIds.includes(m.memberId)));
this.notificationService.showSnackbar('Members removed successfully');
} catch (error) {
this.log.e('Error removing members:', error);
this.notificationService.showSnackbar('Failed to remove members');
}
}
});
break;
}
}
}
async onAddMember(): Promise<void> {
const roomId = this.room()?.roomId;
if (roomId) {
await this.navigationService.navigateTo(`/rooms/${roomId}/members/new`);
}
}
private async loadRecordings(roomId: string, refresh = false) {
try {
this.loadingRecordings.set(true);
@ -219,31 +327,34 @@ export class RoomDetailComponent implements OnInit {
await this.navigationService.navigateTo(`/rooms/${room.roomId}/edit`);
}
async deleteRoom() {
deleteRoom() {
const room = this.room();
if (!room) return;
const dialogRef = this.dialog.open(DeleteRoomDialogComponent, {
data: {
rooms: [room],
hasMeetings: room.status === MeetRoomStatus.ACTIVE_MEETING,
hasRecordings: false // You may need to check this separately
},
width: '500px',
disableClose: true
});
const result = await dialogRef.afterClosed().toPromise();
if (result?.confirmed) {
try {
await this.roomService.deleteRoom(room.roomId, result.deletionPolicy);
this.notificationService.showSnackbar('Room deleted successfully');
await this.navigationService.navigateTo('/rooms');
} catch (error) {
this.log.e('Error deleting room:', error);
this.notificationService.showSnackbar('Failed to delete room');
this.roomDeletionService.deleteRoomWithConfirmation({
roomId: room.roomId,
log: this.log,
onSuccess: async ({ successCode, message, room: updatedRoom }) => {
await this.handleSuccessfulDeletion(successCode, message, updatedRoom);
}
});
}
private async handleSuccessfulDeletion(
successCode: MeetRoomDeletionSuccessCode,
message: string,
updatedRoom?: MeetRoom
) {
if (updatedRoom) {
// Room was not deleted but updated (e.g., closed due to active meeting)
if (successCode === MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_CLOSED) {
updatedRoom.status = MeetRoomStatus.CLOSED;
}
this.room.set(updatedRoom);
} else {
// Room was deleted, navigate back to the rooms list
await this.navigationService.navigateTo('/rooms');
}
this.notificationService.showSnackbar(this.roomDeletionService.removeRoomIdFromMessage(message));
}
}

View File

@ -17,7 +17,6 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import {
MeetRoom,
MeetRoomDeletionErrorCode,
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings,
MeetRoomDeletionSuccessCode,
@ -36,6 +35,7 @@ import {
RoomTableAction,
RoomTableFilter
} from '../../components/rooms-lists/rooms-lists.component';
import { RoomDeletionService } from '../../services/room-deletion.service';
import { RoomService } from '../../services/room.service';
@Component({
@ -84,6 +84,7 @@ export class RoomsComponent implements OnInit {
constructor(
protected loggerService: LoggerService,
private roomService: RoomService,
private roomDeletionService: RoomDeletionService,
private notificationService: NotificationService,
protected navigationService: NavigationService,
private clipboard: Clipboard,
@ -270,7 +271,7 @@ export class RoomsComponent implements OnInit {
// Update room in the list
this.rooms.set(this.rooms().map((r) => (r.roomId === updatedRoom.roomId ? updatedRoom : r)));
this.notificationService.showSnackbar(this.removeRoomIdFromMessage(message));
this.notificationService.showSnackbar(this.roomDeletionService.removeRoomIdFromMessage(message));
} catch (error) {
this.notificationService.showSnackbar('Failed to close room');
this.log.e('Error closing room:', error);
@ -278,39 +279,12 @@ export class RoomsComponent implements OnInit {
}
private deleteRoom({ roomId }: MeetRoom) {
const deleteCallback = async () => {
try {
const {
successCode,
message,
room: updatedRoom
} = await this.roomService.deleteRoom(
roomId,
MeetRoomDeletionPolicyWithMeeting.FAIL,
MeetRoomDeletionPolicyWithRecordings.FAIL
);
this.roomDeletionService.deleteRoomWithConfirmation({
roomId,
log: this.log,
onSuccess: ({ room: updatedRoom, successCode, message }) => {
this.handleSuccessfulDeletion(roomId, successCode, message, updatedRoom);
} catch (error: any) {
// Check if errorCode exists and is a valid MeetRoomDeletionErrorCode
const errorCode = error.error?.error;
if (errorCode && this.isValidMeetRoomDeletionErrorCode(errorCode)) {
const errorMessage = this.removeRoomIdFromMessage(error.error.message);
this.showDeletionErrorDialogWithOptions(roomId, errorMessage);
} else {
this.notificationService.showSnackbar('Failed to delete room');
this.log.e('Error deleting room:', error);
return;
}
}
};
this.notificationService.showDialog({
title: 'Delete Room',
icon: 'delete_outline',
message: `Are you sure you want to delete the room <b>${roomId}</b>?`,
confirmText: 'Delete',
cancelText: 'Cancel',
confirmCallback: deleteCallback
});
}
@ -332,40 +306,7 @@ export class RoomsComponent implements OnInit {
this.rooms.set(this.rooms().filter((r) => r.roomId !== roomId));
}
this.notificationService.showSnackbar(this.removeRoomIdFromMessage(message));
}
private showDeletionErrorDialogWithOptions(roomId: string, errorMessage: string) {
const deleteWithPoliciesCallback = async (
meetingPolicy: MeetRoomDeletionPolicyWithMeeting,
recordingPolicy: MeetRoomDeletionPolicyWithRecordings
) => {
try {
const {
successCode,
message,
room: updatedRoom
} = await this.roomService.deleteRoom(roomId, meetingPolicy, recordingPolicy);
this.handleSuccessfulDeletion(roomId, successCode, message, updatedRoom);
} catch (error) {
// If it fails again, just show a snackbar
this.notificationService.showSnackbar('Failed to delete room');
this.log.e('Error in second deletion attempt:', error);
}
};
const dialogOptions: DeleteRoomDialogOptions = {
title: 'Error Deleting Room',
message: errorMessage,
confirmText: 'Delete with Options',
showWithMeetingPolicy: true,
showWithRecordingsPolicy: true,
confirmCallback: deleteWithPoliciesCallback
};
this.dialog.open(DeleteRoomDialogComponent, {
data: dialogOptions,
disableClose: true
});
this.notificationService.showSnackbar(this.roomDeletionService.removeRoomIdFromMessage(message));
}
private bulkDeleteRooms(rooms: MeetRoom[]) {
@ -390,7 +331,7 @@ export class RoomsComponent implements OnInit {
this.handleSuccessfulBulkDeletion(successful);
const hasRoomDeletionError = failed.some((result) =>
this.isValidMeetRoomDeletionErrorCode(result.error)
this.roomDeletionService.isValidDeletionErrorCode(result.error)
);
if (hasRoomDeletionError) {
this.showBulkDeletionErrorDialogWithOptions(failed, errorMessage);
@ -507,30 +448,4 @@ export class RoomsComponent implements OnInit {
});
}
private isValidMeetRoomDeletionErrorCode(errorCode: string): boolean {
const validErrorCodes = [
MeetRoomDeletionErrorCode.ROOM_HAS_ACTIVE_MEETING,
MeetRoomDeletionErrorCode.ROOM_HAS_RECORDINGS,
MeetRoomDeletionErrorCode.ROOM_WITH_ACTIVE_MEETING_HAS_RECORDINGS,
MeetRoomDeletionErrorCode.ROOM_WITH_ACTIVE_MEETING_HAS_RECORDINGS_CANNOT_SCHEDULE_DELETION,
MeetRoomDeletionErrorCode.ROOM_WITH_RECORDINGS_HAS_ACTIVE_MEETING
];
return validErrorCodes.includes(errorCode as MeetRoomDeletionErrorCode);
}
/**
* Removes the room ID from API response messages to create generic messages.
*
* @param message - The original message from the API response
* @returns The message without the specific room ID
*/
private removeRoomIdFromMessage(message: string): string {
// Pattern to match room ID in single quotes: 'room-id'
const roomIdPattern = /'[^']+'/g;
let filteredMessage = message.replace(roomIdPattern, '');
// Clean up any double spaces that might result from the replacement
filteredMessage = filteredMessage.replace(/\s+/g, ' ').trim();
return filteredMessage;
}
}

View File

@ -1,3 +1,4 @@
export * from './room-deletion.service';
export * from './room-feature.service';
export * from './room.service';
export * from './wizard-state.service';

View File

@ -0,0 +1,130 @@
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import {
MeetRoom,
MeetRoomDeletionErrorCode,
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings,
MeetRoomDeletionSuccessCode
} from '@openvidu-meet/typings';
import { ILogger } from 'openvidu-components-angular';
import { DeleteRoomDialogOptions } from '../../../shared/models/notification.model';
import { NotificationService } from '../../../shared/services/notification.service';
import { DeleteRoomDialogComponent } from '../components/delete-room-dialog/delete-room-dialog.component';
import { RoomService } from './room.service';
interface RoomDeletionResult {
roomId: string;
successCode: MeetRoomDeletionSuccessCode;
message: string;
room?: MeetRoom;
}
interface RoomDeletionOptions {
roomId: string;
log: ILogger;
onSuccess: (result: RoomDeletionResult) => void | Promise<void>;
}
@Injectable({
providedIn: 'root'
})
export class RoomDeletionService {
constructor(
private roomService: RoomService,
private notificationService: NotificationService,
private dialog: MatDialog
) {}
deleteRoomWithConfirmation({ roomId, log, onSuccess }: RoomDeletionOptions): void {
const deleteCallback = async () => {
await this.deleteRoomWithDefaultPolicies(roomId, log, onSuccess);
};
this.notificationService.showDialog({
title: 'Delete Room',
icon: 'delete_outline',
message: `Are you sure you want to delete the room <b>${roomId}</b>?`,
confirmText: 'Delete',
cancelText: 'Cancel',
confirmCallback: deleteCallback
});
}
isValidDeletionErrorCode(errorCode: string): boolean {
const validErrorCodes = [
MeetRoomDeletionErrorCode.ROOM_HAS_ACTIVE_MEETING,
MeetRoomDeletionErrorCode.ROOM_HAS_RECORDINGS,
MeetRoomDeletionErrorCode.ROOM_WITH_ACTIVE_MEETING_HAS_RECORDINGS,
MeetRoomDeletionErrorCode.ROOM_WITH_ACTIVE_MEETING_HAS_RECORDINGS_CANNOT_SCHEDULE_DELETION,
MeetRoomDeletionErrorCode.ROOM_WITH_RECORDINGS_HAS_ACTIVE_MEETING
];
return validErrorCodes.includes(errorCode as MeetRoomDeletionErrorCode);
}
removeRoomIdFromMessage(message: string): string {
const roomIdPattern = /'[^']+'/g;
let filteredMessage = message.replace(roomIdPattern, '');
filteredMessage = filteredMessage.replace(/\s+/g, ' ').trim();
return filteredMessage;
}
private async deleteRoomWithDefaultPolicies(
roomId: string,
log: ILogger,
onSuccess: (result: RoomDeletionResult) => void | Promise<void>
) {
try {
const { successCode, message, room } = await this.roomService.deleteRoom(
roomId,
MeetRoomDeletionPolicyWithMeeting.FAIL,
MeetRoomDeletionPolicyWithRecordings.FAIL
);
await onSuccess({ roomId, successCode, message, room });
} catch (error: any) {
const errorCode = error.error?.error;
if (errorCode && this.isValidDeletionErrorCode(errorCode)) {
const errorMessage = this.removeRoomIdFromMessage(error.error.message);
this.showDeletionErrorDialogWithOptions(roomId, errorMessage, log, onSuccess);
} else {
this.notificationService.showSnackbar('Failed to delete room');
log.e('Error deleting room:', error);
}
}
}
private showDeletionErrorDialogWithOptions(
roomId: string,
errorMessage: string,
log: ILogger,
onSuccess: (result: RoomDeletionResult) => void | Promise<void>
): void {
const deleteWithPoliciesCallback = async (
meetingPolicy: MeetRoomDeletionPolicyWithMeeting,
recordingPolicy: MeetRoomDeletionPolicyWithRecordings
) => {
try {
const { successCode, message, room } = await this.roomService.deleteRoom(roomId, meetingPolicy, recordingPolicy);
await onSuccess({ roomId, successCode, message, room });
} catch (error) {
this.notificationService.showSnackbar('Failed to delete room');
log.e('Error in second deletion attempt:', error);
}
};
const dialogOptions: DeleteRoomDialogOptions = {
title: 'Error Deleting Room',
message: errorMessage,
confirmText: 'Delete with Options',
showWithMeetingPolicy: true,
showWithRecordingsPolicy: true,
confirmCallback: deleteWithPoliciesCallback
};
this.dialog.open(DeleteRoomDialogComponent, {
data: dialogOptions,
disableClose: true
});
}
}