frontend: Refactors recording list component to use signals

Migrates the recording list component to use Angular signals for input properties and data binding.
This improves performance and simplifies the component's change detection.

- Converts input properties to input signals.
- Uses computed signals for derived values.
- Introduces effect for side effects related to recordings changes.
- Moves recording list model interfaces to shared location.
This commit is contained in:
Carlos Santos 2026-01-15 14:43:19 +01:00
parent b24992ad24
commit 520816b983
7 changed files with 119 additions and 120 deletions

View File

@ -1,11 +1,11 @@
@if (!loading && recordings.length === 0 && !showEmptyFilterMessage) {
@if (!loading() && recordings().length === 0 && !showEmptyFilterMessage) {
<!-- Empty State -->
<div class="no-recordings-state">
<div class="empty-content">
<h3>No recordings yet</h3>
@if (roomName) {
@if (roomName()) {
<p>
No recordings found for <strong>{{ roomName }}</strong
No recordings found for <strong>{{ roomName() }}</strong
>. Start a recording in this room to see them listed here.
</p>
} @else {
@ -25,7 +25,7 @@
<!-- Left Section: Search -->
@if (!isMobileView()) {
<div class="toolbar-left">
@if (showSearchBox) {
@if (showSearchBox()) {
<mat-form-field class="search-field" appearance="outline">
<mat-label>Search recordings</mat-label>
<input
@ -39,7 +39,7 @@
matSuffix
class="search-btn"
(click)="triggerSearch()"
[disabled]="loading || !nameFilterControl.value"
[disabled]="loading() || !nameFilterControl.value"
matTooltip="Search"
>
<mat-icon>search</mat-icon>
@ -50,15 +50,15 @@
}
<!-- Center Section: Bulk Actions (visible when items selected) -->
@if (showSelection && selectedRecordings().size > 0) {
@if (showSelection() && selectedRecordings().size > 0) {
<div class="toolbar-center">
<div class="batch-actions">
@if (canDeleteRecordings) {
@if (canDeleteRecordings()) {
<button
mat-icon-button
color="warn"
(click)="bulkDeleteSelected()"
[disabled]="loading"
[disabled]="loading()"
matTooltip="Delete selected recordings"
>
<mat-icon [matBadge]="selectedRecordings().size" matBadgePosition="below after">
@ -70,7 +70,7 @@
<button
mat-icon-button
(click)="bulkDownloadSelected()"
[disabled]="loading"
[disabled]="loading()"
matTooltip="Download selected recordings"
>
<mat-icon [matBadge]="selectedRecordings().size" matBadgePosition="below after">
@ -88,7 +88,7 @@
mat-icon-button
class="clear-btn"
(click)="clearFilters()"
[disabled]="loading"
[disabled]="loading()"
matTooltip="Clear all filters"
>
<mat-icon>filter_alt_off</mat-icon>
@ -99,13 +99,13 @@
mat-icon-button
class="refresh-btn"
(click)="refreshRecordings()"
[disabled]="loading"
[disabled]="loading()"
matTooltip="Refresh recordings"
>
<mat-icon>refresh</mat-icon>
</button>
@if (showFilters) {
@if (showFilters()) {
<button mat-icon-button [matMenuTriggerFor]="filtersMenu" matTooltip="Filter recordings">
<mat-icon>tune</mat-icon>
</button>
@ -128,12 +128,12 @@
</mat-toolbar>
<!-- Loading Spinner -->
@if (loading) {
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
<span>Loading recordings...</span>
</div>
} @else if (recordings.length === 0 && showEmptyFilterMessage) {
} @else if (recordings().length === 0 && showEmptyFilterMessage) {
<!-- No recordings match the current filters -->
<div class="no-recordings-state">
<div class="empty-content">
@ -151,12 +151,12 @@
@if (isMobileView()) {
<!-- Mobile Cards View -->
<div class="cards-container">
@for (recording of recordings; track recording.recordingId) {
@for (recording of recordings(); track recording.recordingId) {
<div class="recording-card" [class.selected-card]="isRecordingSelected(recording)">
<!-- Card Header with Selection and Status -->
<div class="card-header">
<div class="card-header-left">
@if (showSelection && canSelectRecording(recording)) {
@if (showSelection() && canSelectRecording(recording)) {
<mat-checkbox
[checked]="isRecordingSelected(recording)"
(change)="toggleRecordingSelection(recording)"
@ -167,7 +167,7 @@
</mat-checkbox>
}
@if (showRoomInfo) {
@if (showRoomInfo()) {
<div class="room-info">
<div class="room-name">{{ recording.roomName }}</div>
<div class="room-id">{{ recording.recordingId }}</div>
@ -233,7 +233,7 @@
<button
mat-icon-button
(click)="playRecording(recording)"
[disabled]="loading"
[disabled]="loading()"
class="action-button play-button"
matTooltip="Play Recording"
id="play-recording-btn-{{ recording.recordingId }}"
@ -246,7 +246,7 @@
<button
mat-icon-button
(click)="downloadRecording(recording)"
[disabled]="loading"
[disabled]="loading()"
class="action-button download-button"
matTooltip="Download Recording"
id="download-recording-btn-{{ recording.recordingId }}"
@ -259,7 +259,7 @@
<button
mat-icon-button
(click)="deleteRecording(recording)"
[disabled]="loading"
[disabled]="loading()"
class="action-button delete-button"
matTooltip="Delete Recording"
id="delete-recording-btn-{{ recording.recordingId }}"
@ -270,7 +270,7 @@
<button
mat-icon-button
[matMenuTriggerFor]="cardActionsMenu"
[disabled]="loading"
[disabled]="loading()"
class="action-button more-button"
matTooltip="More Actions"
id="more-actions-btn-{{ recording.recordingId }}"
@ -310,7 +310,7 @@
<div class="table-container">
<table
mat-table
[dataSource]="recordings"
[dataSource]="recordings()"
matSort
matSortDisableClear
[matSortActive]="currentSortField"
@ -319,14 +319,14 @@
class="recordings-table"
>
<!-- Selection Column -->
@if (showSelection) {
@if (showSelection()) {
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef class="select-header">
<mat-checkbox
[checked]="allSelected()"
[indeterminate]="someSelected()"
(change)="toggleAllSelection()"
[disabled]="loading"
[disabled]="loading()"
[attr.aria-label]="allSelected() ? 'Deselect all' : 'Select all'"
>
</mat-checkbox>
@ -336,7 +336,7 @@
<mat-checkbox
[checked]="isRecordingSelected(recording)"
(change)="toggleRecordingSelection(recording)"
[disabled]="loading"
[disabled]="loading()"
[attr.aria-label]="'Select recording ' + recording.recordingId"
>
</mat-checkbox>
@ -346,7 +346,7 @@
}
<!-- Room Info Column -->
@if (showRoomInfo) {
@if (showRoomInfo()) {
<ng-container matColumnDef="roomInfo">
<th mat-header-cell *matHeaderCellDef mat-sort-header="roomName" class="room-header">
Room
@ -416,35 +416,26 @@
matTooltip="Play Recording"
class="action-button play-button"
(click)="playRecording(recording)"
[disabled]="loading"
id="play-recording-btn-{{ recording.recordingId }}"
>
<mat-icon>play_arrow</mat-icon>
</button>
}
[disabled]="loading()"
id="play-recording-btn-{{ recording.recordingId }}"
>
<mat-icon>play_arrow</mat-icon>
</button>
}
<!-- Download Button -->
@if (canDownloadRecording(recording)) {
<button
mat-icon-button
matTooltip="Download Recording"
class="action-button download-button"
(click)="downloadRecording(recording)"
[disabled]="loading"
id="download-recording-btn-{{ recording.recordingId }}"
>
<mat-icon>download</mat-icon>
</button>
}
@if (isRecordingFailed(recording)) {
<button
class="delete-action"
<!-- Download Button -->
@if (canDownloadRecording(recording)) {
<button
mat-icon-button
matTooltip="Download Recording"
class="action-button download-button"
(click)="downloadRecording(recording)"
[disabled]="loading()"
mat-icon-button
matTooltip="Delete Recording"
class="action-button delete-button"
(click)="deleteRecording(recording)"
[disabled]="loading"
[disabled]="loading()"
id="delete-recording-btn-{{ recording.recordingId }}"
>
<mat-icon>delete</mat-icon>
@ -456,7 +447,7 @@
class="action-button more-button"
[matMenuTriggerFor]="actionsMenu"
matTooltip="More Actions"
[disabled]="loading"
[disabled]="loading()"
id="more-actions-btn-{{ recording.recordingId }}"
>
<mat-icon>more_vert</mat-icon>
@ -501,13 +492,13 @@
}
<!-- Load More Section -->
@if (showLoadMore) {
@if (showLoadMore()) {
<div class="load-more-container" id="load-more-container">
<button
mat-button
class="load-more-btn"
(click)="loadMoreRecordings()"
[disabled]="loading"
[disabled]="loading()"
id="load-more-btn"
>
<mat-icon>expand_more</mat-icon>

View File

@ -2,13 +2,12 @@ import { CommonModule, DatePipe } from '@angular/common';
import {
Component,
computed,
effect,
EventEmitter,
Input,
OnChanges,
input,
OnInit,
Output,
signal,
SimpleChanges
signal
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatBadgeModule } from '@angular/material/badge';
@ -28,18 +27,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { MeetRecordingInfo, MeetRecordingStatus } from '@openvidu-meet/typings';
import { ViewportService } from 'openvidu-components-angular';
import { formatBytes, formatDurationToHMS } from '../../../../shared/utils/format.utils';
export interface RecordingTableAction {
recordings: MeetRecordingInfo[];
action: 'play' | 'download' | 'shareLink' | 'delete' | 'bulkDelete' | 'bulkDownload';
}
export interface RecordingTableFilter {
nameFilter: string;
statusFilter: MeetRecordingStatus | '';
sortField: 'roomName' | 'startDate' | 'duration' | 'size';
sortOrder: 'asc' | 'desc';
}
import { RecordingTableAction, RecordingTableFilter } from '../../models/recording-list.model';
/**
* Reusable component for displaying a list of recordings with filtering, selection, and bulk operations.
@ -88,23 +76,23 @@ export interface RecordingTableFilter {
templateUrl: './recording-lists.component.html',
styleUrl: './recording-lists.component.scss'
})
export class RecordingListsComponent implements OnInit, OnChanges {
// Input properties
@Input() recordings: MeetRecordingInfo[] = [];
@Input() canDeleteRecordings = false;
@Input() showSearchBox = true;
@Input() showFilters = true;
@Input() showSelection = true;
@Input() showRoomInfo = true;
@Input() showLoadMore = false;
@Input() loading = false;
@Input() roomName?: string; // Optional: if provided, shows room-specific empty state message
@Input() initialFilters: RecordingTableFilter = {
export class RecordingListsComponent implements OnInit {
recordings = input<MeetRecordingInfo[]>([]);
canDeleteRecordings = input(false);
showSearchBox = input(true);
showFilters = input(true);
showSelection = input(true);
showRoomInfo = input(true);
showLoadMore = input(false);
loading = input(false);
roomName = input<string | undefined>(undefined);
initialFilters = input<RecordingTableFilter>({
nameFilter: '',
statusFilter: '',
sortField: 'startDate',
sortOrder: 'desc'
};
});
// Output events
@Output() recordingAction = new EventEmitter<RecordingTableAction>();
@Output() filterChange = new EventEmitter<RecordingTableFilter>();
@ -161,36 +149,39 @@ export class RecordingListsComponent implements OnInit, OnChanges {
DOWNLOADABLE: [MeetRecordingStatus.COMPLETE] as readonly MeetRecordingStatus[]
} as const;
constructor(private viewportService: ViewportService) {}
protected isMobileView = computed(() => this.viewportService.isMobileView());
ngOnInit() {
this.setupFilters();
this.updateDisplayedColumns();
}
ngOnChanges(changes: SimpleChanges) {
if (changes['recordings']) {
constructor(private viewportService: ViewportService) {
effect(() => {
// Update selected recordings based on current recordings
const validIds = new Set(this.recordings.map((r) => r.recordingId));
const recordings = this.recordings();
const validIds = new Set(recordings.map((r) => r.recordingId));
const filteredSelection = new Set([...this.selectedRecordings()].filter((id) => validIds.has(id)));
this.selectedRecordings.set(filteredSelection);
this.updateSelectionState();
// Show message when no recordings match filters
this.showEmptyFilterMessage = this.recordings.length === 0 && this.hasActiveFilters();
}
this.showEmptyFilterMessage = recordings.length === 0 && this.hasActiveFilters();
});
}
ngOnInit() {
this.setupFilters();
this.updateDisplayedColumns();
// Calculate showEmptyFilterMessage based on initial state
this.showEmptyFilterMessage = this.recordings.length === 0 && this.hasActiveFilters();
}
// ===== INITIALIZATION METHODS =====
private setupFilters() {
// Set up initial filter values
this.nameFilterControl.setValue(this.initialFilters.nameFilter);
this.statusFilterControl.setValue(this.initialFilters.statusFilter);
this.currentSortField = this.initialFilters.sortField;
this.currentSortOrder = this.initialFilters.sortOrder;
// Set up initial filter values from input signal
const filters = this.initialFilters();
this.nameFilterControl.setValue(filters.nameFilter);
this.statusFilterControl.setValue(filters.statusFilter);
this.currentSortField = filters.sortField;
this.currentSortOrder = filters.sortOrder;
// Set up name filter change detection
this.nameFilterControl.valueChanges.subscribe((value) => {
@ -209,10 +200,10 @@ export class RecordingListsComponent implements OnInit, OnChanges {
private updateDisplayedColumns() {
this.displayedColumns = [];
if (this.showSelection) {
if (this.showSelection()) {
this.displayedColumns.push('select');
}
if (this.showRoomInfo) {
if (this.showRoomInfo()) {
this.displayedColumns.push('roomInfo');
}
@ -226,7 +217,7 @@ export class RecordingListsComponent implements OnInit, OnChanges {
if (this.allSelected()) {
selected.clear();
} else {
this.recordings.forEach((recording) => {
this.recordings().forEach((recording) => {
if (this.canSelectRecording(recording)) {
selected.add(recording.recordingId);
}
@ -248,7 +239,7 @@ export class RecordingListsComponent implements OnInit, OnChanges {
}
private updateSelectionState() {
const selectableRecordings = this.recordings.filter((r) => this.canSelectRecording(r));
const selectableRecordings = this.recordings().filter((r) => this.canSelectRecording(r));
const selectedCount = this.selectedRecordings().size;
const selectableCount = selectableRecordings.length;
@ -266,7 +257,7 @@ export class RecordingListsComponent implements OnInit, OnChanges {
getSelectedRecordings(): MeetRecordingInfo[] {
const selected = this.selectedRecordings();
return this.recordings.filter((r) => selected.has(r.recordingId));
return this.recordings().filter((r) => selected.has(r.recordingId));
}
// ===== ACTION METHODS =====
@ -379,7 +370,7 @@ export class RecordingListsComponent implements OnInit, OnChanges {
}
canDeleteRecording(recording: MeetRecordingInfo): boolean {
return this.canDeleteRecordings && this.isStatusInGroup(recording.status, this.STATUS_GROUPS.SELECTABLE);
return this.canDeleteRecordings() && this.isStatusInGroup(recording.status, this.STATUS_GROUPS.SELECTABLE);
}
isRecordingFailed(recording: MeetRecordingInfo): boolean {

View File

@ -1,6 +1,6 @@
export * from './components';
// export * from './models';
export * from './guards';
export * from './models';
export * from './pages';
export * from './services';

View File

@ -0,0 +1 @@
export * from './recording-list.model';

View File

@ -0,0 +1,13 @@
import { MeetRecordingInfo, MeetRecordingStatus } from '@openvidu-meet/typings';
export interface RecordingTableAction {
recordings: MeetRecordingInfo[];
action: 'play' | 'download' | 'shareLink' | 'delete' | 'bulkDelete' | 'bulkDownload';
}
export interface RecordingTableFilter {
nameFilter: string;
statusFilter: MeetRecordingStatus | '';
sortField: 'roomName' | 'startDate' | 'duration' | 'size';
sortOrder: 'asc' | 'desc';
}

View File

@ -38,7 +38,7 @@
[showFilters]="true"
[showSelection]="true"
[showLoadMore]="hasMoreRecordings"
[initialFilters]="initialFilters"
[initialFilters]="initialFilters()"
(recordingAction)="onRecordingAction($event)"
(loadMore)="loadMoreRecordings($event)"
(refresh)="refreshRecordings($event)"

View File

@ -5,11 +5,8 @@ import { ActivatedRoute } from '@angular/router';
import { MeetRecordingFilters, MeetRecordingInfo } from '@openvidu-meet/typings';
import { ILogger, LoggerService } from 'openvidu-components-angular';
import { NotificationService } from '../../../../shared/services/notification.service';
import {
RecordingListsComponent,
RecordingTableAction,
RecordingTableFilter
} from '../../components/recording-lists/recording-lists.component';
import { RecordingListsComponent } from '../../components/recording-lists/recording-lists.component';
import { RecordingTableAction, RecordingTableFilter } from '../../models/recording-list.model';
import { RecordingService } from '../../services/recording.service';
@Component({
@ -26,12 +23,12 @@ export class RecordingsComponent implements OnInit {
showInitialLoader = false;
isLoading = false;
initialFilters: RecordingTableFilter = {
initialFilters = signal<RecordingTableFilter>({
nameFilter: '',
statusFilter: '',
sortField: 'startDate',
sortOrder: 'desc'
};
});
// Pagination
hasMoreRecordings = false;
@ -46,19 +43,25 @@ export class RecordingsComponent implements OnInit {
protected route: ActivatedRoute
) {
this.log = this.loggerService.get('OpenVidu Meet - RecordingsComponent');
// Get room ID from route query params and set initial filters before component initialization
const roomId = this.route.snapshot.queryParamMap.get('room-id');
if (roomId) {
this.initialFilters.set({
nameFilter: roomId,
statusFilter: '',
sortField: 'startDate',
sortOrder: 'desc'
});
}
}
async ngOnInit() {
const roomId = this.route.snapshot.queryParamMap.get('room-id');
const delayLoader = setTimeout(() => {
this.showInitialLoader = true;
}, 200);
// If a specific room ID is provided, filter recordings by that room
if (roomId) {
this.initialFilters.nameFilter = roomId;
}
await this.loadRecordings(this.initialFilters);
await this.loadRecordings(this.initialFilters());
clearTimeout(delayLoader);
this.showInitialLoader = false;