frontend: add room members list component with filtering and selection features
- Implemented RoomMembersListComponent for displaying room members in a Material Design table. - Added SCSS styles for the room members list and its associated elements. - Created AddRoomMemberComponent for adding new members to a room with role and permission configuration. - Integrated user search functionality with autocomplete in the AddRoomMemberComponent. - Updated RoomDetailComponent to utilize the new RoomMembersListComponent for displaying members. - Defined routing for adding room members. - Enhanced overall user experience with loading states and error handling.
This commit is contained in:
parent
fe71d07242
commit
5b9fa3149c
@ -2,6 +2,7 @@ import { Route } from '@angular/router';
|
||||
import { DomainRouteConfig } from '../../../shared/models/domain-routes.model';
|
||||
import { checkUserAuthenticatedGuard } from '../../auth/guards/auth.guard';
|
||||
import { recordingsConsoleRoutes } from '../../recordings/routes/recordings.routes';
|
||||
import { roomMembersConsoleRoutes } from '../../room-members/routes/room-members.routes';
|
||||
import { roomsConsoleRoutes } from '../../rooms/routes/rooms.routes';
|
||||
import { usersConsoleRoutes } from '../../users/routes/users.routes';
|
||||
import { clearRoomSessionGuard } from '../guards/clear-room-session.guard';
|
||||
@ -24,6 +25,7 @@ export const consoleChildRoutes: DomainRouteConfig[] = [
|
||||
}
|
||||
},
|
||||
...roomsConsoleRoutes,
|
||||
...roomMembersConsoleRoutes,
|
||||
...recordingsConsoleRoutes,
|
||||
...usersConsoleRoutes,
|
||||
{
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from './room-members-list/room-members-list.component';
|
||||
@ -0,0 +1,302 @@
|
||||
@if (!loading() && members().length === 0 && !showEmptyFilterMessage) {
|
||||
<!-- Empty State -->
|
||||
<div class="no-members-state">
|
||||
<div class="empty-content">
|
||||
<mat-icon class="empty-icon">group_off</mat-icon>
|
||||
<h3>No members yet</h3>
|
||||
<p>This room doesn't have any members assigned.</p>
|
||||
<div class="getting-started-actions">
|
||||
<button mat-flat-button color="primary" class="primary-button" (click)="addMember.emit()">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
Add Member
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Members Toolbar -->
|
||||
<mat-toolbar class="members-toolbar" id="members-toolbar">
|
||||
<!-- Left Section: Search -->
|
||||
<div class="toolbar-left" id="members-toolbar-left">
|
||||
@if (showSearchBox()) {
|
||||
<mat-form-field class="search-field" appearance="outline" id="members-search-field">
|
||||
<mat-label>Search members</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[formControl]="nameFilterControl"
|
||||
placeholder="Search by member name"
|
||||
id="members-search-input"
|
||||
(keydown.enter)="triggerSearch()"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
class="search-btn"
|
||||
(click)="triggerSearch()"
|
||||
[disabled]="loading() || !nameFilterControl.value"
|
||||
matTooltip="Search"
|
||||
id="members-search-btn"
|
||||
>
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Center Section: Batch Actions (visible when items selected) -->
|
||||
@if (showSelection() && selectedMembers().size > 0) {
|
||||
<div class="toolbar-center" id="members-toolbar-center">
|
||||
<div class="batch-actions" id="members-batch-actions">
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="bulkDeleteSelected()"
|
||||
[disabled]="loading()"
|
||||
matTooltip="Delete selected members"
|
||||
id="members-bulk-delete-btn"
|
||||
>
|
||||
<mat-icon [matBadge]="selectedMembers().size" matBadgePosition="below after">delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Right Section: Actions -->
|
||||
<div class="toolbar-right" id="members-toolbar-right">
|
||||
@if (hasActiveFilters()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="clear-btn"
|
||||
(click)="clearFilters()"
|
||||
[disabled]="loading()"
|
||||
matTooltip="Clear all filters"
|
||||
id="members-clear-filters-btn"
|
||||
>
|
||||
<mat-icon>filter_alt_off</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
class="refresh-btn"
|
||||
(click)="refreshMembers()"
|
||||
[disabled]="loading()"
|
||||
matTooltip="Refresh members"
|
||||
id="members-refresh-btn"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</mat-toolbar>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>Loading members...</span>
|
||||
</div>
|
||||
} @else if (members().length === 0 && showEmptyFilterMessage) {
|
||||
<!-- No members match the current filters -->
|
||||
<div class="no-members-state">
|
||||
<div class="empty-content">
|
||||
<h3>No members match your search</h3>
|
||||
<p>Try adjusting or clearing your filters to see more members.</p>
|
||||
<div class="getting-started-actions">
|
||||
<button mat-button (click)="clearFilters()" class="primary-button">
|
||||
<mat-icon>filter_alt_off</mat-icon>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Members Table -->
|
||||
<div class="table-container" id="members-table-container">
|
||||
<table
|
||||
mat-table
|
||||
[dataSource]="members()"
|
||||
matSort
|
||||
matSortDisableClear
|
||||
[matSortActive]="currentSortField"
|
||||
[matSortDirection]="currentSortOrder"
|
||||
(matSortChange)="onSortChange($event)"
|
||||
class="members-table"
|
||||
id="members-table"
|
||||
>
|
||||
<!-- Selection Column -->
|
||||
@if (showSelection()) {
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef id="members-select-header">
|
||||
<mat-checkbox
|
||||
[checked]="allSelected()"
|
||||
[indeterminate]="someSelected()"
|
||||
(change)="toggleAllSelection()"
|
||||
(click)="$event.stopPropagation()"
|
||||
[disabled]="loading()"
|
||||
id="members-select-all-checkbox"
|
||||
>
|
||||
</mat-checkbox>
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let member" id="members-select-cell-{{ member.memberId }}">
|
||||
<mat-checkbox
|
||||
[checked]="isMemberSelected(member)"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="toggleMemberSelection(member)"
|
||||
[disabled]="loading()"
|
||||
id="select-member-{{ member.memberId }}"
|
||||
>
|
||||
</mat-checkbox>
|
||||
</td>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<!-- Name Column -->
|
||||
<ng-container matColumnDef="name">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
mat-sort-header="name"
|
||||
class="member-name-header"
|
||||
id="members-name-header"
|
||||
>
|
||||
Member
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let member" class="member-cell" id="member-name-cell-{{ member.memberId }}">
|
||||
<div class="member-info" id="member-info-{{ member.memberId }}">
|
||||
<div class="member-avatar" id="member-avatar-{{ member.memberId }}">
|
||||
<span>{{ getMemberInitials(member) }}</span>
|
||||
</div>
|
||||
<div class="member-details">
|
||||
<span class="member-name" id="member-name-{{ member.memberId }}">{{ member.name }}</span>
|
||||
<span class="member-id" id="member-id-{{ member.memberId }}">{{ member.memberId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Role Column -->
|
||||
<ng-container matColumnDef="role">
|
||||
<th mat-header-cell *matHeaderCellDef id="members-role-header">Role</th>
|
||||
<td mat-cell *matCellDef="let member" id="members-role-cell-{{ member.memberId }}">
|
||||
<span
|
||||
class="role-badge"
|
||||
[class.moderator]="member.baseRole === MeetRoomMemberRole.MODERATOR"
|
||||
[class.speaker]="member.baseRole === MeetRoomMemberRole.SPEAKER"
|
||||
id="role-badge-{{ member.memberId }}"
|
||||
>
|
||||
{{ member.baseRole }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Member Type Column -->
|
||||
<ng-container matColumnDef="memberType">
|
||||
<th mat-header-cell *matHeaderCellDef id="members-type-header">Type</th>
|
||||
<td mat-cell *matCellDef="let member" id="members-type-cell-{{ member.memberId }}">
|
||||
<div class="member-type" id="member-type-{{ member.memberId }}">
|
||||
<mat-icon class="type-icon">{{ getMemberTypeIcon(member) }}</mat-icon>
|
||||
<span>{{ getMemberTypeLabel(member) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Membership Date Column -->
|
||||
<ng-container matColumnDef="membershipDate">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
mat-sort-header="membershipDate"
|
||||
id="members-date-header"
|
||||
>
|
||||
Added
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let member" id="members-date-cell-{{ member.memberId }}">
|
||||
@if (member.membershipDate) {
|
||||
<div class="date-info" id="members-date-info-{{ member.memberId }}">
|
||||
<span class="date">{{ member.membershipDate | date: 'mediumDate' }}</span>
|
||||
<span class="time">{{ member.membershipDate | date: 'shortTime' }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="no-data">-</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef class="actions-header" id="members-actions-header">
|
||||
Actions
|
||||
</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let member"
|
||||
class="actions-cell"
|
||||
id="members-actions-cell-{{ member.memberId }}"
|
||||
>
|
||||
<div class="action-buttons" id="member-action-buttons-{{ member.memberId }}">
|
||||
<!-- Copy Access URL -->
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Copy access URL"
|
||||
(click)="$event.stopPropagation(); copyMemberLink(member)"
|
||||
[disabled]="loading()"
|
||||
class="copy-link-btn"
|
||||
id="copy-member-link-btn-{{ member.memberId }}"
|
||||
>
|
||||
<mat-icon>link</mat-icon>
|
||||
</button>
|
||||
|
||||
<!-- More Actions Menu -->
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="memberActionsMenu"
|
||||
matTooltip="More Actions"
|
||||
(click)="$event.stopPropagation()"
|
||||
[disabled]="loading()"
|
||||
id="member-more-actions-btn-{{ member.memberId }}"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-menu #memberActionsMenu="matMenu" id="member-actions-menu-{{ member.memberId }}">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="$event.stopPropagation(); deleteMember(member)"
|
||||
class="delete-action"
|
||||
id="delete-member-btn-{{ member.memberId }}"
|
||||
>
|
||||
<mat-icon>person_remove</mat-icon>
|
||||
<span>Remove member</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns" id="members-table-header-row"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
[class.selected-row]="isMemberSelected(row)"
|
||||
id="members-table-row-{{ row.memberId }}"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Load More Section -->
|
||||
@if (showLoadMore()) {
|
||||
<div class="load-more-container" id="members-load-more-container">
|
||||
<button
|
||||
mat-button
|
||||
class="load-more-btn"
|
||||
(click)="loadMoreMembers()"
|
||||
[disabled]="loading()"
|
||||
id="members-load-more-btn"
|
||||
>
|
||||
<mat-icon>expand_more</mat-icon>
|
||||
<span>Load More Members</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
@use '../../../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
.members-toolbar {
|
||||
@extend .ov-data-toolbar;
|
||||
|
||||
.toolbar-left {
|
||||
::ng-deep .search-btn {
|
||||
padding: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
|
||||
::ng-deep .refresh-btn {
|
||||
padding: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
|
||||
::ng-deep .clear-btn {
|
||||
padding: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-field {
|
||||
@extend .ov-search-field;
|
||||
}
|
||||
|
||||
.batch-actions {
|
||||
@extend .ov-batch-actions;
|
||||
::ng-deep button {
|
||||
padding: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@extend .ov-loading-container;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
@extend .ov-table-container;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.members-toolbar + .table-container {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top: none;
|
||||
box-shadow: var(--ov-meet-shadow-sm);
|
||||
}
|
||||
|
||||
:host:not(:has(.members-toolbar)) .table-container {
|
||||
margin-top: var(--ov-meet-spacing-md);
|
||||
}
|
||||
|
||||
.members-table {
|
||||
@extend .ov-data-table;
|
||||
|
||||
.mat-mdc-header-cell {
|
||||
&.member-name-header {
|
||||
@extend .primary-header;
|
||||
}
|
||||
|
||||
&.actions-header {
|
||||
@extend .actions-header;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-cell {
|
||||
&.member-cell {
|
||||
@extend .primary-cell;
|
||||
}
|
||||
|
||||
&.actions-cell {
|
||||
@extend .actions-cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
|
||||
.member-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--ov-meet-color-primary);
|
||||
color: var(--ov-meet-color-on-primary, white);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
font-weight: var(--ov-meet-font-weight-semibold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
@extend .primary-text;
|
||||
}
|
||||
|
||||
.member-id {
|
||||
@extend .secondary-text, .monospace-text;
|
||||
}
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
|
||||
border-radius: var(--ov-meet-radius-sm);
|
||||
font-size: var(--ov-meet-font-size-xs);
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
background-color: var(--ov-meet-surface-variant);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
|
||||
&.moderator {
|
||||
background-color: color-mix(in srgb, var(--ov-meet-color-primary), transparent 85%);
|
||||
color: var(--ov-meet-color-primary);
|
||||
}
|
||||
|
||||
&.speaker {
|
||||
background-color: color-mix(in srgb, var(--ov-meet-color-success), transparent 85%);
|
||||
color: var(--ov-meet-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
.member-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-xs);
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
|
||||
.type-icon {
|
||||
@include design-tokens.ov-icon(sm);
|
||||
color: var(--ov-meet-text-hint);
|
||||
}
|
||||
}
|
||||
|
||||
.date-info {
|
||||
@extend .ov-date-info;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
@extend .ov-no-data;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@extend .ov-action-buttons;
|
||||
|
||||
::ng-deep button {
|
||||
padding: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
|
||||
.mat-mdc-icon-button {
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.copy-link-btn {
|
||||
color: var(--ov-meet-text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-action {
|
||||
@extend .ov-delete-action;
|
||||
}
|
||||
|
||||
.no-members-state {
|
||||
@extend .ov-empty-state;
|
||||
|
||||
.empty-icon {
|
||||
@include design-tokens.ov-icon(lg);
|
||||
color: var(--ov-meet-text-hint);
|
||||
}
|
||||
box-shadow: none;
|
||||
&:hover {
|
||||
color: var(--ov-meet-text-hint);
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--ov-meet-spacing-md);
|
||||
border-top: 1px solid var(--ov-meet-border-color);
|
||||
|
||||
.load-more-btn {
|
||||
color: var(--ov-meet-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-checkbox,
|
||||
.mat-mdc-icon-button,
|
||||
.mat-mdc-button {
|
||||
@extend .ov-focus-visible;
|
||||
}
|
||||
@ -0,0 +1,286 @@
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { Component, effect, EventEmitter, HostBinding, input, OnInit, Output, signal, untracked } from '@angular/core';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
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 { MeetRoomMember, MeetRoomMemberRole, MeetRoomMemberSortField, SortOrder } from '@openvidu-meet/typings';
|
||||
import { setsAreEqual } from '../../../../shared/utils/array.utils';
|
||||
|
||||
export interface MemberTableAction {
|
||||
members: MeetRoomMember[];
|
||||
action: 'copyLink' | 'delete' | 'bulkDelete';
|
||||
}
|
||||
|
||||
export interface MemberTableFilter {
|
||||
nameFilter: string;
|
||||
sortField: MeetRoomMemberSortField;
|
||||
sortOrder: SortOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable component for displaying a list of room members with filtering, selection, and bulk operations.
|
||||
*
|
||||
* Features:
|
||||
* - Display room members in a Material Design table
|
||||
* - Filter by member name
|
||||
* - Multi-selection for bulk operations
|
||||
* - Individual member actions (copy link, delete)
|
||||
* - Responsive design with mobile optimization
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <ov-room-members-lists
|
||||
* [members]="members"
|
||||
* [loading]="isLoading"
|
||||
* [showFilters]="true"
|
||||
* [showSelection]="true"
|
||||
* (memberAction)="handleMemberAction($event)"
|
||||
* (filterChange)="handleFilterChange($event)"
|
||||
* (refresh)="refreshMembers()">
|
||||
* </ov-room-members-lists>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ov-room-members-list',
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatTableModule,
|
||||
MatCheckboxModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatToolbarModule,
|
||||
MatBadgeModule,
|
||||
MatDividerModule,
|
||||
MatSortModule,
|
||||
DatePipe
|
||||
],
|
||||
templateUrl: './room-members-list.component.html',
|
||||
styleUrl: './room-members-list.component.scss'
|
||||
})
|
||||
export class RoomMembersListsComponent implements OnInit {
|
||||
members = input<MeetRoomMember[]>([]);
|
||||
showSearchBox = input(true);
|
||||
showFilters = input(false);
|
||||
showSelection = input(true);
|
||||
showLoadMore = input(false);
|
||||
loading = input(false);
|
||||
initialFilters = input<MemberTableFilter>({
|
||||
nameFilter: '',
|
||||
sortField: 'membershipDate',
|
||||
sortOrder: SortOrder.DESC
|
||||
});
|
||||
|
||||
// Host binding for styling when members are selected
|
||||
@HostBinding('class.has-selections')
|
||||
get hasSelections(): boolean {
|
||||
return this.selectedMembers().size > 0;
|
||||
}
|
||||
|
||||
// Output events
|
||||
@Output() memberAction = new EventEmitter<MemberTableAction>();
|
||||
@Output() filterChange = new EventEmitter<MemberTableFilter>();
|
||||
@Output() loadMore = new EventEmitter<MemberTableFilter>();
|
||||
@Output() refresh = new EventEmitter<MemberTableFilter>();
|
||||
@Output() addMember = new EventEmitter<void>();
|
||||
|
||||
// Filter controls
|
||||
nameFilterControl = new FormControl('');
|
||||
|
||||
// Sort state
|
||||
currentSortField: MeetRoomMemberSortField = 'membershipDate';
|
||||
currentSortOrder: SortOrder = SortOrder.DESC;
|
||||
|
||||
showEmptyFilterMessage = false;
|
||||
|
||||
// Selection state
|
||||
selectedMembers = signal<Set<string>>(new Set());
|
||||
allSelected = signal(false);
|
||||
someSelected = signal(false);
|
||||
|
||||
// Table configuration
|
||||
displayedColumns: string[] = ['select', 'name', 'role', 'memberType', 'membershipDate', 'actions'];
|
||||
|
||||
// Expose enum to template
|
||||
protected readonly MeetRoomMemberRole = MeetRoomMemberRole;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const members = this.members();
|
||||
const validMemberIds = new Set(members.map((m) => m.memberId));
|
||||
|
||||
const currentSelection = untracked(() => this.selectedMembers());
|
||||
const filteredSelection = new Set([...currentSelection].filter((id) => validMemberIds.has(id)));
|
||||
|
||||
if (!setsAreEqual(filteredSelection, currentSelection)) {
|
||||
this.selectedMembers.set(filteredSelection);
|
||||
this.updateSelectionState();
|
||||
}
|
||||
|
||||
this.showEmptyFilterMessage = members.length === 0 && this.hasActiveFilters();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.setupFilters();
|
||||
this.updateDisplayedColumns();
|
||||
}
|
||||
|
||||
// ===== INITIALIZATION METHODS =====
|
||||
|
||||
private setupFilters() {
|
||||
this.nameFilterControl.setValue(this.initialFilters().nameFilter);
|
||||
this.currentSortField = this.initialFilters().sortField;
|
||||
this.currentSortOrder = this.initialFilters().sortOrder;
|
||||
|
||||
this.nameFilterControl.valueChanges.subscribe((value) => {
|
||||
if (!value) {
|
||||
this.emitFilterChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateDisplayedColumns() {
|
||||
this.displayedColumns = [];
|
||||
|
||||
if (this.showSelection()) {
|
||||
this.displayedColumns.push('select');
|
||||
}
|
||||
|
||||
this.displayedColumns.push('name', 'role', 'memberType', 'membershipDate', 'actions');
|
||||
}
|
||||
|
||||
// ===== SELECTION METHODS =====
|
||||
|
||||
toggleAllSelection() {
|
||||
const selected = this.selectedMembers();
|
||||
if (this.allSelected()) {
|
||||
selected.clear();
|
||||
} else {
|
||||
this.members().forEach((member) => selected.add(member.memberId));
|
||||
}
|
||||
this.selectedMembers.set(new Set(selected));
|
||||
this.updateSelectionState();
|
||||
}
|
||||
|
||||
toggleMemberSelection(member: MeetRoomMember) {
|
||||
const selected = this.selectedMembers();
|
||||
if (selected.has(member.memberId)) {
|
||||
selected.delete(member.memberId);
|
||||
} else {
|
||||
selected.add(member.memberId);
|
||||
}
|
||||
this.selectedMembers.set(new Set(selected));
|
||||
this.updateSelectionState();
|
||||
}
|
||||
|
||||
private updateSelectionState() {
|
||||
const memberCount = this.members().length;
|
||||
const selectedCount = this.selectedMembers().size;
|
||||
|
||||
this.allSelected.set(selectedCount > 0 && selectedCount === memberCount);
|
||||
this.someSelected.set(selectedCount > 0 && selectedCount < memberCount);
|
||||
}
|
||||
|
||||
isMemberSelected(member: MeetRoomMember): boolean {
|
||||
return this.selectedMembers().has(member.memberId);
|
||||
}
|
||||
|
||||
getSelectedMembers(): MeetRoomMember[] {
|
||||
const selected = this.selectedMembers();
|
||||
return this.members().filter((m) => selected.has(m.memberId));
|
||||
}
|
||||
|
||||
// ===== ACTION METHODS =====
|
||||
|
||||
copyMemberLink(member: MeetRoomMember) {
|
||||
this.memberAction.emit({ members: [member], action: 'copyLink' });
|
||||
}
|
||||
|
||||
deleteMember(member: MeetRoomMember) {
|
||||
this.memberAction.emit({ members: [member], action: 'delete' });
|
||||
}
|
||||
|
||||
bulkDeleteSelected() {
|
||||
const selectedMembers = this.getSelectedMembers();
|
||||
if (selectedMembers.length > 0) {
|
||||
this.memberAction.emit({ members: selectedMembers, action: 'bulkDelete' });
|
||||
}
|
||||
}
|
||||
|
||||
refreshMembers() {
|
||||
const nameFilter = this.nameFilterControl.value || '';
|
||||
this.refresh.emit({
|
||||
nameFilter,
|
||||
sortField: this.currentSortField,
|
||||
sortOrder: this.currentSortOrder
|
||||
});
|
||||
}
|
||||
|
||||
loadMoreMembers() {
|
||||
const nameFilter = this.nameFilterControl.value || '';
|
||||
this.loadMore.emit({
|
||||
nameFilter,
|
||||
sortField: this.currentSortField,
|
||||
sortOrder: this.currentSortOrder
|
||||
});
|
||||
}
|
||||
|
||||
onSortChange(sortState: Sort) {
|
||||
this.currentSortField = sortState.active as MeetRoomMemberSortField;
|
||||
this.currentSortOrder = sortState.direction as SortOrder;
|
||||
this.emitFilterChange();
|
||||
}
|
||||
|
||||
// ===== FILTER METHODS =====
|
||||
|
||||
triggerSearch() {
|
||||
this.emitFilterChange();
|
||||
}
|
||||
|
||||
private emitFilterChange() {
|
||||
this.filterChange.emit({
|
||||
nameFilter: this.nameFilterControl.value || '',
|
||||
sortField: this.currentSortField,
|
||||
sortOrder: this.currentSortOrder
|
||||
});
|
||||
}
|
||||
|
||||
hasActiveFilters(): boolean {
|
||||
return !!this.nameFilterControl.value;
|
||||
}
|
||||
|
||||
clearFilters() {
|
||||
this.nameFilterControl.setValue('');
|
||||
}
|
||||
|
||||
// ===== UTILS =====
|
||||
|
||||
getMemberTypeLabel(member: MeetRoomMember): string {
|
||||
return member.memberId.startsWith('ext-') ? 'External' : 'Registered';
|
||||
}
|
||||
|
||||
getMemberTypeIcon(member: MeetRoomMember): string {
|
||||
return member.memberId.startsWith('ext-') ? 'person_outline' : 'verified_user';
|
||||
}
|
||||
|
||||
getMemberInitials(member: MeetRoomMember): string {
|
||||
return member.name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
export * from './components';
|
||||
export * from './interceptor-handlers';
|
||||
export * from './services';
|
||||
|
||||
|
||||
@ -0,0 +1,172 @@
|
||||
<div class="ov-page-container ov-mb-xxl">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="title">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
<h1>Add Room Member</h1>
|
||||
</div>
|
||||
<p class="subtitle">Assign a registered user to this room and configure their role and permissions.</p>
|
||||
</div>
|
||||
|
||||
<!-- Form Card Wrapper -->
|
||||
<div class="form-card-wrapper">
|
||||
<mat-card class="section-card add-member-card">
|
||||
<mat-card-header>
|
||||
<div mat-card-avatar class="section-avatar">
|
||||
<mat-icon class="section-icon">group_add</mat-icon>
|
||||
</div>
|
||||
<mat-card-title>Member Configuration</mat-card-title>
|
||||
<mat-card-subtitle>Select a user and configure their access role</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form [formGroup]="form" class="create-form">
|
||||
|
||||
<!-- User ID (Autocomplete) -->
|
||||
<mat-form-field subscriptSizing="dynamic" appearance="outline">
|
||||
<mat-label>User ID</mat-label>
|
||||
<mat-icon matPrefix class="field-prefix-icon">badge</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
formControlName="userId"
|
||||
placeholder="Search for a user by ID..."
|
||||
[matAutocomplete]="userAutocomplete"
|
||||
autofocus
|
||||
/>
|
||||
@if (isLoadingUsers()) {
|
||||
<mat-spinner matSuffix diameter="18" class="search-spinner"></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon matSuffix class="field-suffix-icon">search</mat-icon>
|
||||
}
|
||||
<mat-autocomplete
|
||||
#userAutocomplete="matAutocomplete"
|
||||
[displayWith]="displayUserFn"
|
||||
(optionSelected)="onUserSelected($event.option.value)"
|
||||
>
|
||||
@for (user of filteredUsers(); track user.userId) {
|
||||
<mat-option [value]="user.userId">
|
||||
<div class="user-option">
|
||||
<mat-icon class="user-option-icon">person</mat-icon>
|
||||
<div class="user-option-info">
|
||||
<span class="user-option-id">{{ user.userId }}</span>
|
||||
@if (user.name) {
|
||||
<span class="user-option-name">{{ user.name }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
@if (!isLoadingUsers() && filteredUsers().length === 0 && form.get('userId')?.value) {
|
||||
<mat-option disabled>
|
||||
<span class="no-results">No users found</span>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
<mat-hint>Start typing to search registered users</mat-hint>
|
||||
@if (getFieldError('userId')) {
|
||||
<mat-error>{{ getFieldError('userId') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Role -->
|
||||
<mat-form-field subscriptSizing="dynamic" appearance="outline">
|
||||
<mat-label>Role</mat-label>
|
||||
<mat-icon matPrefix class="field-prefix-icon">manage_accounts</mat-icon>
|
||||
<mat-select formControlName="role">
|
||||
@for (role of availableRoles; track role) {
|
||||
<mat-option [value]="role">
|
||||
<div class="role-option">
|
||||
<mat-icon class="role-option-icon">{{ getRoleIcon(role) }}</mat-icon>
|
||||
<span>{{ getRoleLabel(role) }}</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>The base role determines default permissions</mat-hint>
|
||||
@if (getFieldError('role')) {
|
||||
<mat-error>{{ getFieldError('role') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Custom Permissions Accordion -->
|
||||
<div class="permissions-section">
|
||||
<mat-accordion class="permissions-accordion">
|
||||
<mat-expansion-panel class="permissions-panel">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title class="permissions-panel-title">
|
||||
<mat-icon class="permissions-panel-icon">admin_panel_settings</mat-icon>
|
||||
<span>Custom Permissions</span>
|
||||
</mat-panel-title>
|
||||
<mat-panel-description class="permissions-panel-description">
|
||||
Override the default role permissions
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<div class="permissions-content" [formGroup]="permissionsForm">
|
||||
<p class="permissions-hint">
|
||||
These settings override the defaults for the selected role. Leave toggles in
|
||||
their default position to inherit role permissions.
|
||||
</p>
|
||||
|
||||
@for (group of permissionGroups; track group.label) {
|
||||
<div class="permission-group">
|
||||
<div class="permission-group-header">
|
||||
<mat-icon class="permission-group-icon">{{ group.icon }}</mat-icon>
|
||||
<span class="permission-group-label">{{ group.label }}</span>
|
||||
</div>
|
||||
|
||||
@for (permission of group.permissions; track permission.key) {
|
||||
<div class="permission-row">
|
||||
<div class="permission-info">
|
||||
<mat-icon class="permission-icon material-symbols-outlined">{{
|
||||
permission.icon
|
||||
}}</mat-icon>
|
||||
<div class="permission-text">
|
||||
<span class="permission-label">{{ permission.label }}</span>
|
||||
<span class="permission-description">{{
|
||||
permission.description
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<mat-slide-toggle
|
||||
[formControlName]="permission.key"
|
||||
color="primary"
|
||||
class="permission-toggle"
|
||||
>
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
|
||||
<!-- Card Footer Actions -->
|
||||
<mat-card-actions class="card-actions">
|
||||
<button mat-button class="cancel-button" type="button" (click)="onCancel()" [disabled]="isSaving()">
|
||||
<mat-icon>close</mat-icon>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
class="primary-button"
|
||||
type="button"
|
||||
[disabled]="form.invalid || isSaving()"
|
||||
(click)="onSubmit()"
|
||||
>
|
||||
@if (isSaving()) {
|
||||
<mat-spinner diameter="18"></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>person_add</mat-icon>
|
||||
}
|
||||
Add Member
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,263 @@
|
||||
@use '../../../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
// ─── Page Layout ──────────────────────────────────────────────────────────────
|
||||
|
||||
.form-card-wrapper {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ov-meet-spacing-lg);
|
||||
}
|
||||
|
||||
// ─── Card ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
.add-member-card {
|
||||
@include design-tokens.ov-section-card;
|
||||
}
|
||||
|
||||
// ─── Form ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ov-meet-spacing-lg);
|
||||
padding-top: var(--ov-meet-spacing-sm);
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.field-prefix-icon {
|
||||
@include design-tokens.ov-icon(md);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
margin-right: var(--ov-meet-spacing-xs);
|
||||
}
|
||||
|
||||
.field-suffix-icon {
|
||||
@include design-tokens.ov-icon(md);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
}
|
||||
|
||||
.search-spinner {
|
||||
margin-right: var(--ov-meet-spacing-xs);
|
||||
}
|
||||
|
||||
// ─── Autocomplete Options ─────────────────────────────────────────────────────
|
||||
|
||||
.user-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
|
||||
.user-option-icon {
|
||||
@include design-tokens.ov-icon(md);
|
||||
color: var(--ov-meet-icon-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-option-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.user-option-id {
|
||||
font-size: var(--ov-meet-font-size-md);
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
color: var(--ov-meet-text-primary);
|
||||
}
|
||||
|
||||
.user-option-name {
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ─── Role Select Options ──────────────────────────────────────────────────────
|
||||
|
||||
.role-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
|
||||
.role-option-icon {
|
||||
@include design-tokens.ov-icon(md);
|
||||
color: var(--ov-meet-icon-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Permissions Section ──────────────────────────────────────────────────────
|
||||
|
||||
.permissions-section {
|
||||
padding-top: var(--ov-meet-spacing-sm);
|
||||
}
|
||||
|
||||
.permissions-accordion {
|
||||
border: 1px solid var(--ov-border-color-light);
|
||||
border-radius: var(--ov-meet-radius-md) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.permissions-panel {
|
||||
box-shadow: none !important;
|
||||
|
||||
::ng-deep .mat-expansion-panel-header {
|
||||
padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-lg);
|
||||
background: var(--ov-meet-surface-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--ov-meet-surface-hover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .mat-expansion-panel-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.permissions-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
color: var(--ov-meet-text-primary);
|
||||
|
||||
.permissions-panel-icon {
|
||||
@include design-tokens.ov-icon(md);
|
||||
color: var(--ov-meet-icon-settings);
|
||||
}
|
||||
}
|
||||
|
||||
.permissions-panel-description {
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
}
|
||||
|
||||
.permissions-content {
|
||||
padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-lg) var(--ov-meet-spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ov-meet-spacing-lg);
|
||||
}
|
||||
|
||||
.permissions-hint {
|
||||
margin: 0 0 var(--ov-meet-spacing-sm) 0;
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
line-height: var(--ov-meet-line-height-normal);
|
||||
padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-md);
|
||||
background: var(--ov-meet-surface-secondary);
|
||||
border-radius: var(--ov-meet-radius-sm);
|
||||
border-left: 3px solid var(--ov-meet-color-primary);
|
||||
}
|
||||
|
||||
.permission-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.permission-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-md);
|
||||
margin-bottom: var(--ov-meet-spacing-xs);
|
||||
|
||||
.permission-group-icon {
|
||||
@include design-tokens.ov-icon(sm);
|
||||
color: var(--ov-meet-icon-primary);
|
||||
}
|
||||
|
||||
.permission-group-label {
|
||||
font-size: var(--ov-meet-font-size-xs);
|
||||
font-weight: var(--ov-meet-font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ov-meet-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.permission-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-md);
|
||||
border-radius: var(--ov-meet-radius-sm);
|
||||
transition: background-color var(--ov-meet-transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--ov-meet-surface-hover);
|
||||
}
|
||||
|
||||
.permission-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.permission-icon {
|
||||
@include design-tokens.ov-icon(md);
|
||||
color: var(--ov-meet-icon-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.permission-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
.permission-label {
|
||||
font-size: var(--ov-meet-font-size-md);
|
||||
font-weight: var(--ov-meet-font-weight-medium);
|
||||
color: var(--ov-meet-text-primary);
|
||||
line-height: var(--ov-meet-line-height-tight);
|
||||
}
|
||||
|
||||
.permission-description {
|
||||
font-size: var(--ov-meet-font-size-sm);
|
||||
color: var(--ov-meet-text-secondary);
|
||||
line-height: var(--ov-meet-line-height-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.permission-toggle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Card Footer Actions ──────────────────────────────────────────────────────
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--ov-meet-spacing-sm);
|
||||
padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-lg) !important;
|
||||
border-top: 1px solid var(--ov-border-color-light);
|
||||
margin: 0 !important;
|
||||
|
||||
.cancel-button {
|
||||
color: var(--ov-meet-text-secondary);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
min-width: 140px;
|
||||
|
||||
mat-spinner {
|
||||
margin-right: var(--ov-meet-spacing-xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,189 @@
|
||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MeetRoomMemberOptions, MeetRoomMemberPermissions, MeetRoomMemberRole, MeetUserDTO } from '@openvidu-meet/typings';
|
||||
import { Subject, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs';
|
||||
import { NavigationService } from '../../../../shared/services/navigation.service';
|
||||
import { NotificationService } from '../../../../shared/services/notification.service';
|
||||
import { UserService } from '../../../users/services/user.service';
|
||||
import { PERMISSION_GROUPS } from '../../../rooms/pages/room-wizard/steps/role-permissions/role-permissions.component';
|
||||
import { RoomMemberService } from '../../services/room-member.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-add-room-member',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatAutocompleteModule,
|
||||
MatExpansionModule,
|
||||
MatSlideToggleModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule
|
||||
],
|
||||
templateUrl: './add-room-member.component.html',
|
||||
styleUrl: './add-room-member.component.scss'
|
||||
})
|
||||
export class AddRoomMemberComponent implements OnInit, OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
private roomMemberService = inject(RoomMemberService);
|
||||
private userService = inject(UserService);
|
||||
private navigationService = inject(NavigationService);
|
||||
private notificationService = inject(NotificationService);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
roomId = signal<string>('');
|
||||
isSaving = signal(false);
|
||||
isLoadingUsers = signal(false);
|
||||
filteredUsers = signal<MeetUserDTO[]>([]);
|
||||
|
||||
readonly availableRoles: MeetRoomMemberRole[] = [MeetRoomMemberRole.MODERATOR, MeetRoomMemberRole.SPEAKER];
|
||||
readonly permissionGroups = PERMISSION_GROUPS;
|
||||
|
||||
form = new FormGroup({
|
||||
userId: new FormControl<string>('', [Validators.required]),
|
||||
role: new FormControl<MeetRoomMemberRole>(MeetRoomMemberRole.SPEAKER, [Validators.required]),
|
||||
permissions: new FormGroup(
|
||||
Object.fromEntries(
|
||||
PERMISSION_GROUPS.flatMap((g) => g.permissions.map((p) => [p.key, new FormControl<boolean>(false)]))
|
||||
) as Record<keyof MeetRoomMemberPermissions, FormControl<boolean | null>>
|
||||
)
|
||||
});
|
||||
|
||||
get permissionsForm(): FormGroup {
|
||||
return this.form.get('permissions') as FormGroup;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const roomId = this.route.snapshot.paramMap.get('roomId');
|
||||
if (!roomId) {
|
||||
this.notificationService.showSnackbar('Room ID is required');
|
||||
this.navigationService.navigateTo('/rooms');
|
||||
return;
|
||||
}
|
||||
this.roomId.set(roomId);
|
||||
|
||||
// Set up autocomplete search reactive to userId input changes
|
||||
this.form.get('userId')!.valueChanges
|
||||
.pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$))
|
||||
.subscribe((value) => {
|
||||
if (value && value.length >= 1) {
|
||||
this.searchUsers(value);
|
||||
} else {
|
||||
this.filteredUsers.set([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private async searchUsers(query: string): Promise<void> {
|
||||
this.isLoadingUsers.set(true);
|
||||
try {
|
||||
const response = await this.userService.listUsers({ userId: query, maxItems: 20 });
|
||||
this.filteredUsers.set(response.users);
|
||||
} catch {
|
||||
this.filteredUsers.set([]);
|
||||
} finally {
|
||||
this.isLoadingUsers.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
getRoleLabel(role: MeetRoomMemberRole): string {
|
||||
switch (role) {
|
||||
case MeetRoomMemberRole.MODERATOR:
|
||||
return 'Moderator';
|
||||
case MeetRoomMemberRole.SPEAKER:
|
||||
return 'Speaker';
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
getRoleIcon(role: MeetRoomMemberRole): string {
|
||||
switch (role) {
|
||||
case MeetRoomMemberRole.MODERATOR:
|
||||
return 'manage_accounts';
|
||||
case MeetRoomMemberRole.SPEAKER:
|
||||
return 'record_voice_over';
|
||||
default:
|
||||
return 'person';
|
||||
}
|
||||
}
|
||||
|
||||
displayUserFn(user: MeetUserDTO | string | null): string {
|
||||
if (!user) return '';
|
||||
if (typeof user === 'string') return user;
|
||||
return user.userId;
|
||||
}
|
||||
|
||||
onUserSelected(userId: string): void {
|
||||
this.form.get('userId')!.setValue(userId);
|
||||
}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const { userId, role, permissions } = this.form.getRawValue();
|
||||
|
||||
// Only include customPermissions if the user explicitly modified them
|
||||
let customPermissions: Partial<MeetRoomMemberPermissions> | undefined;
|
||||
if (this.permissionsForm.dirty && permissions) {
|
||||
customPermissions = {};
|
||||
for (const [key, val] of Object.entries(permissions)) {
|
||||
(customPermissions as any)[key] = val as boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const options: MeetRoomMemberOptions = {
|
||||
userId: userId!,
|
||||
baseRole: role!,
|
||||
customPermissions
|
||||
};
|
||||
|
||||
this.isSaving.set(true);
|
||||
try {
|
||||
await this.roomMemberService.createRoomMember(this.roomId(), options);
|
||||
this.notificationService.showSnackbar('Member added successfully');
|
||||
await this.navigationService.navigateTo(`/rooms/${this.roomId()}`);
|
||||
} catch (error: any) {
|
||||
const msg = error?.error?.message ?? 'Failed to add member';
|
||||
this.notificationService.showSnackbar(msg);
|
||||
} finally {
|
||||
this.isSaving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async onCancel(): Promise<void> {
|
||||
await this.navigationService.navigateTo(`/rooms/${this.roomId()}`);
|
||||
}
|
||||
|
||||
getFieldError(field: string): string | null {
|
||||
const control = this.form.get(field);
|
||||
if (!control?.errors || !control.touched) return null;
|
||||
if (control.errors['required']) return 'This field is required';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { DomainRouteConfig } from '../../../shared/models/domain-routes.model';
|
||||
|
||||
/**
|
||||
* Room Members domain route configurations
|
||||
*/
|
||||
export const roomMembersDomainRoutes: DomainRouteConfig[] = [];
|
||||
|
||||
/**
|
||||
* Console child routes for room members domain
|
||||
*/
|
||||
export const roomMembersConsoleRoutes: DomainRouteConfig[] = [
|
||||
{
|
||||
route: {
|
||||
path: 'rooms/:roomId/members/new',
|
||||
loadComponent: () =>
|
||||
import('../pages/add-room-member/add-room-member.component').then((m) => m.AddRoomMemberComponent)
|
||||
}
|
||||
}
|
||||
];
|
||||
@ -138,71 +138,18 @@
|
||||
</ng-template>
|
||||
|
||||
<div class="tab-content">
|
||||
@if (loadingMembers()) {
|
||||
<div class="loading-state">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<p>Loading members...</p>
|
||||
</div>
|
||||
} @else if (roomMembers().length === 0) {
|
||||
<div class="empty-state">
|
||||
<mat-icon>person_off</mat-icon>
|
||||
<h3>No members yet</h3>
|
||||
<p>This room doesn't have any members assigned.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Members List - Simple table for now -->
|
||||
<div class="members-list">
|
||||
<div class="members-header">
|
||||
<h3>Members ({{ roomMembers().length }})</h3>
|
||||
</div>
|
||||
@for (member of roomMembers(); track member.memberId) {
|
||||
<div class="member-item">
|
||||
<div class="member-info">
|
||||
<div class="member-avatar">
|
||||
<span>{{ member.name.substring(0, 2).toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="member-details">
|
||||
<p class="member-name">{{ member.name }}</p>
|
||||
<p class="member-id">{{ member.memberId }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-meta">
|
||||
<div class="member-type">
|
||||
<mat-icon class="meta-icon">
|
||||
{{
|
||||
member.memberId.startsWith('ext-')
|
||||
? 'person_outline'
|
||||
: 'verified_user'
|
||||
}}
|
||||
</mat-icon>
|
||||
<span>{{
|
||||
member.memberId.startsWith('ext-') ? 'External' : 'Registered'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="member-role">
|
||||
<span
|
||||
class="role-badge"
|
||||
[class.moderator]="member.baseRole === 'moderator'"
|
||||
>
|
||||
{{ member.baseRole }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-actions">
|
||||
<button mat-icon-button matTooltip="Copy access URL">
|
||||
<mat-icon>link</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button matTooltip="Edit permissions">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button matTooltip="Remove member" color="warn">
|
||||
<mat-icon>person_remove</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<ov-room-members-list
|
||||
[members]="roomMembers()"
|
||||
[loading]="loadingMembers()"
|
||||
[showSearchBox]="true"
|
||||
[showSelection]="true"
|
||||
[showLoadMore]="hasMoreMembers"
|
||||
(memberAction)="onMemberAction($event)"
|
||||
(loadMore)="loadMoreRoomMembers($event)"
|
||||
(refresh)="refreshRoomMembers($event)"
|
||||
(filterChange)="refreshRoomMembers($event)"
|
||||
(addMember)="onAddMember()"
|
||||
></ov-room-members-list>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user