frontend: refactor wizard state service to use signals for state management and enhance all related components by removing unnecessary params and methods. Configured app to show error messages in the stepper for better user feedback.

This commit is contained in:
juancarmore 2025-07-07 16:15:23 +02:00
parent ca22bd8a01
commit 0c7b1ae1c1
20 changed files with 491 additions and 961 deletions

View File

@ -13,7 +13,7 @@
>
<!-- Pro Badge -->
@if (option.isPro && showProBadge) {
<ov-pro-feature-badge badgeText="PRO"></ov-pro-feature-badge>
<ov-pro-feature-badge [badgeText]="proBadgeText" [badgeIcon]="proBadgeIcon"></ov-pro-feature-badge>
} @else if (showSelectionIndicator) {
<!-- Selection Indicator -->
<div class="selection-indicator">
@ -27,7 +27,7 @@
<div class="card-content">
<!-- Image Section -->
@if (shouldShowImage()) {
<div class="card-image" [style.aspect-ratio]="getImageAspectRatio()">
<div class="card-image" [style.aspect-ratio]="imageAspectRatio">
<img [src]="option.imageUrl" [alt]="option.title + ' preview'" loading="lazy" />
</div>
}

View File

@ -16,7 +16,6 @@ export interface SelectableOption {
isPro?: boolean;
disabled?: boolean;
badge?: string;
value?: any; // Additional data associated with the option
}
/**
@ -25,7 +24,7 @@ export interface SelectableOption {
export interface SelectionEvent {
optionId: string;
option: SelectableOption;
previousSelection?: string;
previousSelection?: string | string[];
}
@Component({
@ -45,7 +44,7 @@ export class SelectableCardComponent {
* Currently selected value(s)
* Can be a string (single select) or string[] (multi select)
*/
@Input() selectedValue: string | string[] | null = null;
@Input() selectedValue: string | string[] | undefined;
/**
* Whether multiple options can be selected simultaneously
@ -53,23 +52,6 @@ export class SelectableCardComponent {
*/
@Input() allowMultiSelect: boolean = false;
/**
* Whether the card should show hover effects
* @default true
*/
@Input() enableHover: boolean = true;
/**
* Whether the card should show selection animations
* @default true
*/
@Input() enableAnimations: boolean = true;
/**
* Custom CSS classes to apply to the card
*/
@Input() customClasses: string = '';
/**
* Whether to show the selection indicator (radio button)
* @default true
@ -82,6 +64,18 @@ export class SelectableCardComponent {
*/
@Input() showProBadge: boolean = true;
/**
* Custom icon for the PRO badge
* @default 'crown'
*/
@Input() proBadgeIcon: string = 'crown';
/**
* Custom text for the PRO badge
* @default 'PRO'
*/
@Input() proBadgeText: string = 'PRO';
/**
* Whether to show the recommended badge
* @default true
@ -107,27 +101,27 @@ export class SelectableCardComponent {
@Input() imageAspectRatio: string = '16/9';
/**
* Custom icon for the PRO badge
* @default 'star'
* Whether the card should show hover effects
* @default true
*/
@Input() proBadgeIcon: string = 'star';
@Input() enableHover: boolean = true;
/**
* Custom text for the PRO badge
* @default 'PRO'
* Whether the card should show selection animations
* @default true
*/
@Input() proBadgeText: string = 'PRO';
@Input() enableAnimations: boolean = true;
/**
* Custom CSS classes to apply to the card
*/
@Input() customClasses: string = '';
/**
* Event emitted when an option is selected
*/
@Output() optionSelected = new EventEmitter<SelectionEvent>();
/**
* Event emitted when the card is clicked (even if selection doesn't change)
*/
@Output() cardClicked = new EventEmitter<SelectableOption>();
/**
* Event emitted when the card is hovered
*/
@ -157,48 +151,17 @@ export class SelectableCardComponent {
return;
}
// Emit card clicked event
this.cardClicked.emit(this.option);
// Handle selection logic
const wasSelected = this.isOptionSelected(optionId);
let newSelection: string | string[] | null;
let previousSelection: string | undefined;
if (this.allowMultiSelect) {
// Multi-select logic
const currentArray = Array.isArray(this.selectedValue) ? [...this.selectedValue] : [];
if (wasSelected) {
// Remove from selection
newSelection = currentArray.filter((id) => id !== optionId);
if (newSelection.length === 0) {
newSelection = null;
}
} else {
// Add to selection
newSelection = [...currentArray, optionId];
}
} else {
// Single-select logic
if (wasSelected) {
// Deselect (optional behavior)
newSelection = null;
previousSelection = optionId;
} else {
// Select new option
previousSelection = Array.isArray(this.selectedValue) ? undefined : this.selectedValue || undefined;
newSelection = optionId;
}
if (!this.allowMultiSelect && wasSelected) {
return; // No change if already selected
}
// Emit selection event
const selectionEvent: SelectionEvent = {
optionId,
option: this.option,
previousSelection
previousSelection: this.selectedValue
};
this.optionSelected.emit(selectionEvent);
}
@ -229,27 +192,21 @@ export class SelectableCardComponent {
if (this.isOptionSelected(this.option.id)) {
classes.push('selected');
}
if (this.option.recommended && this.showRecommendedBadge) {
classes.push('recommended');
}
if (this.option.isPro && this.showProBadge) {
classes.push('pro-feature');
}
if (this.option.disabled) {
classes.push('disabled');
}
if (!this.enableHover) {
classes.push('no-hover');
}
if (!this.enableAnimations) {
classes.push('no-animations');
}
if (this.customClasses) {
classes.push(this.customClasses);
}
@ -278,21 +235,17 @@ export class SelectableCardComponent {
if (this.option.recommended) {
statusParts.push('Recommended');
}
if (this.option.isPro) {
statusParts.push('PRO feature');
}
if (this.option.disabled) {
statusParts.push('Disabled');
}
if (this.isOptionSelected(this.option.id)) {
statusParts.push('Selected');
}
const statusLabel = statusParts.length > 0 ? `. ${statusParts.join(', ')}` : '';
return `${baseLabel}${statusLabel}`;
}
@ -312,11 +265,4 @@ export class SelectableCardComponent {
}
return !this.shouldShowImage() && !!this.option.icon;
}
/**
* Get image aspect ratio styles
*/
getImageAspectRatio(): string {
return this.imageAspectRatio;
}
}

View File

@ -1,29 +1,22 @@
<div class="step-indicator-wrapper" [attr.data-layout]="layoutType$ | async">
<mat-stepper
#stepper
[selectedIndex]="safeCurrentStepIndex"
[selectedIndex]="currentStepIndex()"
[orientation]="(stepperOrientation$ | async)!"
[linear]="false"
class="wizard-stepper"
[attr.data-layout]="layoutType$ | async"
(selectionChange)="onStepClick($event)"
>
@for (step of visibleSteps; track step.id; let i = $index) {
@for (step of visibleSteps(); track step.id; let i = $index) {
<mat-step
[stepControl]="step.validationFormGroup"
[stepControl]="step.formGroup"
[state]="getStepState(step)"
errorMessage="Invalid fields"
[editable]="true"
[label]="step.label"
[completed]="step.isCompleted"
>
<!-- Custom step icon template -->
<ng-template matStepperIcon="edit">
<mat-icon>edit</mat-icon>
</ng-template>
<ng-template matStepperIcon="done">
<mat-icon>check</mat-icon>
</ng-template>
</mat-step>
}
</mat-stepper>

View File

@ -1,10 +1,8 @@
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper';
import { StepperOrientation, StepperSelectionEvent, StepState } from '@angular/cdk/stepper';
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { Component, computed, input, output } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatStepperModule } from '@angular/material/stepper';
import { WizardStep } from '@lib/models';
import { Observable } from 'rxjs';
@ -13,29 +11,28 @@ import { map } from 'rxjs/operators';
@Component({
selector: 'ov-step-indicator',
standalone: true,
imports: [CommonModule, MatStepperModule, MatIcon, MatButtonModule, ReactiveFormsModule],
imports: [CommonModule, MatStepperModule, ReactiveFormsModule],
templateUrl: './step-indicator.component.html',
styleUrl: './step-indicator.component.scss'
})
export class StepIndicatorComponent implements OnChanges {
@Input() steps: WizardStep[] = [];
@Input() allowNavigation: boolean = false;
@Input() editMode: boolean = false; // New input for edit mode
@Input() currentStepIndex: number = 0;
@Output() stepClick = new EventEmitter<{ step: WizardStep; index: number }>();
@Output() layoutChange = new EventEmitter<'vertical-sidebar' | 'horizontal-compact' | 'vertical-compact'>();
export class StepIndicatorComponent {
steps = input.required<WizardStep[]>();
currentStepIndex = input.required<number>();
allowNavigation = input<boolean>(true);
editMode = input<boolean>(false);
stepClick = output<{ index: number; step: WizardStep }>();
visibleSteps = computed<WizardStep[]>(() => this.steps().filter((step) => step.isVisible));
visibleSteps: WizardStep[] = [];
stepperOrientation$: Observable<StepperOrientation>;
layoutType$: Observable<'vertical-sidebar' | 'horizontal-compact' | 'vertical-compact'>;
stepControls: { [key: string]: FormControl } = {};
constructor(private breakpointObserver: BreakpointObserver) {
// Enhanced responsive strategy:
// - Large desktop (>1200px): Vertical sidebar for space efficiency
// - Medium desktop (768-1200px): Horizontal compact
// - Tablet/Mobile (<768px): Vertical compact
const breakpointState$ = this.breakpointObserver.observe([
'(min-width: 1200px)',
'(min-width: 768px)',
@ -58,128 +55,34 @@ export class StepIndicatorComponent implements OnChanges {
return layoutType === 'horizontal-compact' ? 'horizontal' : 'vertical';
})
);
// Emit layout changes for parent component
this.layoutType$.subscribe((layoutType) => {
this.layoutChange.emit(layoutType);
});
}
ngOnChanges(changes: SimpleChanges) {
if (changes['steps']) {
this.updateVisibleSteps();
this.createStepControls();
}
if (changes['currentStepIndex'] || changes['steps']) {
this.updateStepControls();
}
}
private updateVisibleSteps() {
this.visibleSteps = this.steps.filter((step) => step.isVisible);
}
private createStepControls() {
this.stepControls = {};
this.visibleSteps.forEach((step) => {
this.stepControls[step.id] = new FormControl({
value: step.isCompleted,
disabled: !step.isCompleted && !step.isActive
});
});
}
private updateStepControls() {
this.visibleSteps.forEach((step) => {
const control = this.stepControls[step.id];
if (control) {
control.setValue(step.isCompleted);
if (step.isCompleted || step.isActive) {
control.enable();
} else {
control.disable();
}
}
});
}
getStepControl(step: WizardStep): FormGroup {
return step.validationFormGroup;
}
get safeCurrentStepIndex(): number {
if (this.visibleSteps.length === 0) {
console.warn('No visible steps available. Defaulting to index 0.');
return 0;
}
// In edit mode, ensure the index is valid for visible steps
let adjustedIndex = this.currentStepIndex;
// If we are in edit mode and the current index is greater than available visible steps
if (this.editMode && this.currentStepIndex >= this.visibleSteps.length) {
// Find the first active step in the visible steps
const activeStepIndex = this.visibleSteps.findIndex((step) => step.isActive);
adjustedIndex = activeStepIndex >= 0 ? activeStepIndex : 0;
}
const safeIndex = Math.min(Math.max(0, adjustedIndex), this.visibleSteps.length - 1);
console.log('Safe current step index:', safeIndex, 'for visibleSteps length:', this.visibleSteps.length);
return safeIndex;
}
onStepClick(event: StepperSelectionEvent) {
if (this.allowNavigation) {
const step = this.visibleSteps[event.selectedIndex];
this.stepClick.emit({ step, index: event.selectedIndex });
if (this.allowNavigation()) {
const index = event.selectedIndex;
if (index < 0 || index >= this.visibleSteps().length) {
console.warn('Invalid step index:', index);
return;
}
const step = this.visibleSteps()[index];
this.stepClick.emit({ index, step });
} else {
console.warn('Navigation is not allowed. Step click ignored:', event.selectedIndex);
}
}
isStepClickable(step: WizardStep): boolean {
if (!this.allowNavigation) {
return false;
}
if (this.editMode) {
// In edit mode, allow clicking on any step
return true;
}
return step.isActive || step.isCompleted;
}
isStepEditable(step: WizardStep): boolean {
return this.isStepClickable(step);
}
getStepState(step: WizardStep): 'done' | 'edit' | 'error' | 'number' {
getStepState(step: WizardStep): StepState {
if (step.isCompleted && !step.isActive) {
return 'done';
}
if (step.isActive && step.validationFormGroup?.invalid) {
if (step.isActive && step.formGroup?.invalid) {
return 'error';
}
if (step.isActive) {
return 'edit';
}
if (step.isCompleted) {
return 'done';
}
return 'number';
}
getStepIcon(step: WizardStep): string {
if (step.isCompleted) {
return 'check';
}
if (step.isActive) {
return 'edit';
}
return '';
}
}

View File

@ -1,4 +1,4 @@
<nav class="wizard-navigation" [class.loading]="config.isLoading" [class.compact]="config.isCompact" id="wizard-navigation">
<nav class="wizard-navigation" [class.loading]="false" [class.compact]="false" id="wizard-navigation">
<div class="nav-buttons">
<!-- Cancel Button -->
@if (config.showCancel) {
@ -24,7 +24,6 @@
mat-stroked-button
class="prev-btn"
id="wizard-previous-btn"
[disabled]="config.isPreviousDisabled"
(click)="onPrevious()"
[attr.aria-label]="'Go to previous step'"
>
@ -33,50 +32,46 @@
</button>
}
@if (config.showQuickCreate) {
@if (config.showSkipAndFinish) {
<!-- Skip Wizard Button -->
<button
mat-raised-button
class="skip-btn"
class="finish-btn"
id="wizard-quick-create-btn"
[disabled]="config.disableFinish ?? false"
(click)="skipAndFinish()"
type="button"
[attr.aria-label]="'Skip wizard and finish'"
>
<mat-icon>bolt</mat-icon>
Create with defaults
{{ config.skipAndFinishLabel || 'Skip and Finish' }}
</button>
}
<!-- Next/Continue Button -->
<!-- Next Button -->
@if (config.showNext) {
<button
mat-raised-button
class="next-btn"
id="wizard-next-btn"
[disabled]="config.isNextDisabled"
(click)="onNext()"
[attr.aria-label]="'Continue to next step'"
>
{{ config.nextLabel || 'Next' }}
<mat-icon class="trailing-icon">
{{ config.isLoading ? 'hourglass_empty' : 'chevron_right' }}
</mat-icon>
<mat-icon class="trailing-icon">chevron_right</mat-icon>
</button>
}
<!-- Finish/Save Button -->
<!-- Finish Button -->
@if (config.showFinish) {
<button
mat-raised-button
class="finish-btn"
id="wizard-finish-btn"
[disabled]="config.isFinishDisabled"
[disabled]="config.disableFinish ?? false"
(click)="onFinish()"
[attr.aria-label]="'Complete wizard and save changes'"
>
<mat-icon class="leading-icon">
{{ config.isLoading ? 'hourglass_empty' : 'check' }}
</mat-icon>
<mat-icon class="leading-icon">check</mat-icon>
{{ config.finishLabel || 'Finish' }}
</button>
}

View File

@ -1,5 +1,4 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import type { WizardNavigationConfig, WizardNavigationEvent } from '@lib/models';
@ -7,30 +6,25 @@ import type { WizardNavigationConfig, WizardNavigationEvent } from '@lib/models'
@Component({
selector: 'ov-wizard-nav',
standalone: true,
imports: [CommonModule, MatButton, MatIcon],
imports: [MatButton, MatIcon],
templateUrl: './wizard-nav.component.html',
styleUrl: './wizard-nav.component.scss'
})
export class WizardNavComponent implements OnInit, OnChanges {
export class WizardNavComponent {
/**
* Navigation configuration with default values
*/
@Input() config: WizardNavigationConfig = {
showPrevious: true,
showPrevious: false,
showNext: true,
showCancel: true,
showFinish: false,
showQuickCreate: true,
showSkipAndFinish: false,
disableFinish: false,
nextLabel: 'Next',
previousLabel: 'Previous',
cancelLabel: 'Cancel',
finishLabel: 'Finish',
isNextDisabled: false,
isPreviousDisabled: false,
isFinishDisabled: false,
isLoading: false,
isCompact: false,
ariaLabel: 'Wizard navigation'
finishLabel: 'Finish'
};
/**
@ -51,90 +45,56 @@ export class WizardNavComponent implements OnInit, OnChanges {
*/
@Output() navigate = new EventEmitter<WizardNavigationEvent>();
ngOnInit() {
this.validateConfig();
}
ngOnChanges(changes: SimpleChanges) {
if (changes['config']) {
this.validateConfig();
}
}
/**
* Validates navigation configuration
*/
private validateConfig() {
if (!this.config.nextLabel) this.config.nextLabel = 'Next';
if (!this.config.previousLabel) this.config.previousLabel = 'Previous';
if (!this.config.cancelLabel) this.config.cancelLabel = 'Cancel';
if (!this.config.finishLabel) this.config.finishLabel = 'Finish';
}
/**
* Handle previous step navigation
*/
onPrevious() {
if (!this.config.isPreviousDisabled && !this.config.isLoading) {
const event: WizardNavigationEvent = {
action: 'previous',
currentStepId: this.currentStepId
};
if (!this.config.showPrevious) return;
this.previous.emit(event);
this.navigate.emit(event);
}
const event: WizardNavigationEvent = {
action: 'previous',
currentStepIndex: this.currentStepId
};
this.previous.emit(event);
this.navigate.emit(event);
}
/**
* Handle next step navigation
*/
onNext() {
if (!this.config.isNextDisabled && !this.config.isLoading) {
const event: WizardNavigationEvent = {
action: 'next',
currentStepId: this.currentStepId
};
if (!this.config.showNext) return;
this.next.emit(event);
this.navigate.emit(event);
}
const event: WizardNavigationEvent = {
action: 'next',
currentStepIndex: this.currentStepId
};
this.next.emit(event);
this.navigate.emit(event);
}
/**
* Handle wizard cancellation
*/
onCancel() {
if (!this.config.isLoading) {
const event: WizardNavigationEvent = {
action: 'cancel',
currentStepId: this.currentStepId
};
if (!this.config.showCancel) return;
this.cancel.emit(event);
this.navigate.emit(event);
}
const event: WizardNavigationEvent = {
action: 'cancel',
currentStepIndex: this.currentStepId
};
this.cancel.emit(event);
this.navigate.emit(event);
}
/**
* Handle wizard completion
*/
onFinish() {
if (!this.config.isFinishDisabled && !this.config.isLoading) {
const event: WizardNavigationEvent = {
action: 'finish',
currentStepId: this.currentStepId
};
if (!this.config.showFinish) return;
this.finish.emit(event);
this.navigate.emit(event);
}
const event: WizardNavigationEvent = {
action: 'finish',
currentStepIndex: this.currentStepId
};
this.finish.emit(event);
this.navigate.emit(event);
}
skipAndFinish() {
if (!this.config.showSkipAndFinish) return;
const event: WizardNavigationEvent = {
action: 'finish',
currentStepId: this.currentStepId
currentStepIndex: this.currentStepId
};
this.finish.emit(event);
this.navigate.emit(event);

View File

@ -9,42 +9,27 @@ export interface WizardStep {
isCompleted: boolean;
isActive: boolean;
isVisible: boolean;
isOptional?: boolean;
order: number;
validationFormGroup: FormGroup;
description?: string;
icon?: string;
formGroup: FormGroup;
}
/**
* Configuration interface for wizard navigation controls
* Supports theming and responsive behavior
*/
export interface WizardNavigationConfig {
// Button visibility
// Button visibility flags
showPrevious: boolean;
showNext: boolean;
showCancel: boolean;
showFinish: boolean;
showQuickCreate: boolean; // Optional for quick create functionality
showSkipAndFinish: boolean; // Used for quick create actions
disableFinish?: boolean;
// Button labels (customizable)
// Button labels
nextLabel?: string;
previousLabel?: string;
cancelLabel?: string;
finishLabel?: string;
// Button states
isNextDisabled: boolean;
isPreviousDisabled: boolean;
isFinishDisabled?: boolean;
// UI states
isLoading?: boolean;
isCompact?: boolean;
// Accessibility
ariaLabel?: string;
skipAndFinishLabel?: string;
}
/**
@ -52,7 +37,5 @@ export interface WizardNavigationConfig {
*/
export interface WizardNavigationEvent {
action: 'next' | 'previous' | 'cancel' | 'finish';
currentStepId?: number;
targetStepId?: string;
data?: any;
currentStepIndex?: number;
}

View File

@ -1,52 +1,53 @@
<div class="wizard-container ov-theme-transition">
<header class="wizard-header">
<!-- <div class="title">
<mat-icon class="ov-room-icon">meeting_room</mat-icon>
<h2>Room Creation Wizard</h2>
</div> -->
<p class="subtitle">{{ editMode ? 'Edit your room settings' : 'Create and configure your video room in a few simple steps' }}</p>
<ov-step-indicator
[steps]="steps"
[allowNavigation]="true"
[editMode]="editMode"
[currentStepIndex]="currentStepIndex"
(stepClick)="onStepClick($event)"
(layoutChange)="onLayoutChange($event)"
>
</ov-step-indicator>
</header>
@if (steps().length !== 0) {
<header class="wizard-header">
<p class="subtitle">
{{
editMode ? 'Edit your room settings' : 'Create and configure your video room in a few simple steps'
}}
</p>
<ov-step-indicator
[steps]="steps()"
[currentStepIndex]="currentStepIndex()"
[allowNavigation]="true"
[editMode]="editMode"
(stepClick)="onStepClick($event)"
>
</ov-step-indicator>
</header>
<main class="wizard-content">
<section class="step-content ov-surface">
@switch (currentStep?.id) {
@case ('basic') {
<ov-room-wizard-basic-info [editMode]="editMode"></ov-room-wizard-basic-info>
<main class="wizard-content">
<section class="step-content ov-surface">
@switch (currentStep()?.id) {
@case ('basic') {
<ov-room-wizard-basic-info></ov-room-wizard-basic-info>
}
@case ('recording') {
<ov-recording-preferences></ov-recording-preferences>
}
@case ('recordingTrigger') {
<ov-recording-trigger></ov-recording-trigger>
}
@case ('recordingLayout') {
<ov-recording-layout></ov-recording-layout>
}
@case ('preferences') {
<ov-room-preferences></ov-room-preferences>
}
}
@case ('recording') {
<ov-recording-preferences></ov-recording-preferences>
}
@case ('recordingTrigger') {
<ov-recording-trigger></ov-recording-trigger>
}
@case ('recordingLayout') {
<ov-recording-layout></ov-recording-layout>
}
@case ('preferences') {
<ov-room-preferences></ov-room-preferences>
}
}
</section>
</main>
</section>
</main>
<footer class="wizard-footer">
<ov-wizard-nav
[config]="navigationConfig"
[currentStepId]="currentStepIndex"
(previous)="onPrevious()"
(next)="onNext()"
(cancel)="onCancel()"
(finish)="onFinish($event)"
>
</ov-wizard-nav>
</footer>
<footer class="wizard-footer">
<ov-wizard-nav
[config]="navigationConfig()"
[currentStepId]="currentStepIndex()"
(previous)="onPrevious()"
(next)="onNext()"
(cancel)="onCancel()"
(finish)="onFinish()"
>
</ov-wizard-nav>
</footer>
}
</div>

View File

@ -1,14 +1,13 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, computed, OnInit, Signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { ActivatedRoute } from '@angular/router';
import { StepIndicatorComponent, WizardNavComponent } from '@lib/components';
import { WizardNavigationConfig, WizardNavigationEvent, WizardStep } from '@lib/models';
import { NavigationService, RoomService, RoomWizardStateService } from '@lib/services';
import { MeetRoom, MeetRoomOptions } from '@lib/typings/ce';
import { Subject, takeUntil } from 'rxjs';
import { WizardNavigationConfig, WizardStep } from '@lib/models';
import { NavigationService, NotificationService, RoomService, RoomWizardStateService } from '@lib/services';
import { MeetRoomOptions } from '@lib/typings/ce';
import { RoomWizardBasicInfoComponent } from './steps/basic-info/basic-info.component';
import { RecordingLayoutComponent } from './steps/recording-layout/recording-layout.component';
import { RecordingPreferencesComponent } from './steps/recording-preferences/recording-preferences.component';
@ -34,79 +33,40 @@ import { RoomPreferencesComponent } from './steps/room-preferences/room-preferen
templateUrl: './room-wizard.component.html',
styleUrl: './room-wizard.component.scss'
})
export class RoomWizardComponent implements OnInit, OnDestroy {
export class RoomWizardComponent implements OnInit {
editMode: boolean = false;
roomId: string | null = null;
existingRoomData: MeetRoomOptions | null = null;
roomId?: string;
existingRoomData?: MeetRoomOptions;
private destroy$ = new Subject<void>();
steps: WizardStep[] = [];
currentStep: WizardStep | null = null;
currentStepIndex: number = 0;
currentLayout: 'vertical-sidebar' | 'horizontal-compact' | 'vertical-compact' = 'horizontal-compact';
navigationConfig: WizardNavigationConfig = {
showPrevious: false,
showNext: true,
showCancel: true,
showFinish: false,
showQuickCreate: true,
nextLabel: 'Next',
previousLabel: 'Previous',
finishLabel: 'Create Room',
isNextDisabled: false,
isPreviousDisabled: true
};
wizardData: MeetRoomOptions = {};
steps: Signal<WizardStep[]>;
currentStep: Signal<WizardStep | undefined>;
currentStepIndex: Signal<number>;
navigationConfig: Signal<WizardNavigationConfig>;
constructor(
private wizardState: RoomWizardStateService,
private wizardService: RoomWizardStateService,
protected roomService: RoomService,
protected notificationService: NotificationService,
private navigationService: NavigationService,
private route: ActivatedRoute
) {}
) {
this.steps = this.wizardService.steps;
this.currentStep = this.wizardService.currentStep;
this.currentStepIndex = this.wizardService.currentStepIndex;
this.navigationConfig = computed(() => this.wizardService.getNavigationConfig());
}
async ngOnInit() {
console.log('RoomWizard ngOnInit - starting');
// Detect edit mode from route
this.detectEditMode();
// If in edit mode, load room data
if (this.editMode && this.roomId) {
this.navigationConfig.showQuickCreate = false;
await this.loadRoomData();
}
// Initialize wizard with edit mode and existing data
this.wizardState.initializeWizard(this.editMode, this.existingRoomData || undefined);
this.wizardState.steps$.pipe(takeUntil(this.destroy$)).subscribe((steps) => {
// Only update current step info after steps are available
if (steps.length > 0) {
this.steps = steps;
this.currentStep = this.wizardState.getCurrentStep();
this.currentStepIndex = this.wizardState.getCurrentStepIndex();
this.navigationConfig = this.wizardState.getNavigationConfig();
// Update navigation config for edit mode
if (this.editMode) {
this.navigationConfig.finishLabel = 'Update Room';
}
}
});
this.wizardState.roomOptions$.pipe(takeUntil(this.destroy$)).subscribe((options) => {
this.wizardData = options;
});
this.wizardState.currentStepIndex$.pipe(takeUntil(this.destroy$)).subscribe((index) => {
// Only update if we have visible steps
if (this.steps.filter((s) => s.isVisible).length > 0) {
this.currentStepIndex = index;
}
});
this.wizardService.initializeWizard(this.editMode, this.existingRoomData);
}
private detectEditMode() {
@ -116,7 +76,7 @@ export class RoomWizardComponent implements OnInit, OnDestroy {
// Get roomId from route parameters when in edit mode
if (this.editMode) {
this.roomId = this.route.snapshot.paramMap.get('roomId');
this.roomId = this.route.snapshot.paramMap.get('roomId') || undefined;
}
}
@ -124,15 +84,8 @@ export class RoomWizardComponent implements OnInit, OnDestroy {
if (!this.roomId) return;
try {
// Fetch room data from the service
const room: MeetRoom = await this.roomService.getRoom(this.roomId);
// Convert MeetRoom to MeetRoomOptions
this.existingRoomData = {
roomIdPrefix: room.roomIdPrefix,
autoDeletionDate: room.autoDeletionDate,
preferences: room.preferences
};
const { roomIdPrefix, autoDeletionDate, preferences } = await this.roomService.getRoom(this.roomId);
this.existingRoomData = { roomIdPrefix, autoDeletionDate, preferences };
} catch (error) {
console.error('Error loading room data:', error);
// Navigate back to rooms list if room not found
@ -140,56 +93,42 @@ export class RoomWizardComponent implements OnInit, OnDestroy {
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
onPrevious() {
this.wizardState.goToPreviousStep();
this.currentStep = this.wizardState.getCurrentStep();
this.navigationConfig = this.wizardState.getNavigationConfig();
this.wizardService.goToPreviousStep();
}
onNext() {
this.wizardState.goToNextStep();
this.currentStep = this.wizardState.getCurrentStep();
this.navigationConfig = this.wizardState.getNavigationConfig();
this.wizardService.goToNextStep();
}
onCancel() {
this.navigationService.navigateTo('rooms', undefined, true);
this.wizardState.resetWizard();
onStepClick(event: { index: number; step: WizardStep }) {
this.wizardService.goToStep(event.index);
}
onStepClick(event: { step: WizardStep; index: number }) {
this.wizardState.goToStep(event.index);
this.currentStep = this.wizardState.getCurrentStep();
this.navigationConfig = this.wizardState.getNavigationConfig();
async onCancel() {
this.wizardService.resetWizard();
await this.navigationService.navigateTo('rooms', undefined, true);
}
onLayoutChange(layout: 'vertical-sidebar' | 'horizontal-compact' | 'vertical-compact') {
this.currentLayout = layout;
}
async onFinish(event: WizardNavigationEvent) {
const roomOptions = this.wizardState.getRoomOptions();
console.log('Wizard completed with data:', event, roomOptions);
async onFinish() {
const roomOptions = this.wizardService.roomOptions();
console.log('Wizard completed with data:', roomOptions);
try {
if (this.editMode && this.roomId && roomOptions.preferences) {
await this.roomService.updateRoom(this.roomId, roomOptions.preferences);
//TODO: Show success notification
this.notificationService.showSnackbar('Room updated successfully');
} else {
// Create new room
await this.roomService.createRoom(roomOptions);
console.log('Room created successfully');
// TODO: Show error notification
this.notificationService.showSnackbar('Room created successfully');
}
await this.navigationService.navigateTo('rooms', undefined, true);
} catch (error) {
console.error(`Failed to ${this.editMode ? 'update' : 'create'} room:`, error);
const errorMessage = `Failed to ${this.editMode ? 'update' : 'create'} room`;
this.notificationService.showSnackbar(errorMessage);
console.error(errorMessage, error);
}
}
}

View File

@ -16,9 +16,12 @@
<!-- Room Prefix Field -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Room Name Prefix</mat-label>
<input matInput formControlName="roomIdPrefix" placeholder="e.g. demo-room" maxlength="50" />
<input matInput formControlName="roomIdPrefix" placeholder="e.g. demo-room" />
<mat-icon matSuffix class="ov-settings-icon">label</mat-icon>
<mat-hint>Optional prefix for room names. Leave empty for default naming.</mat-hint>
@if (basicInfoForm.get('roomIdPrefix')?.hasError('maxlength')) {
<mat-error> Room name prefix cannot exceed 50 characters </mat-error>
}
</mat-form-field>
<!-- Deletion Date Field -->
@ -39,6 +42,7 @@
matSuffix
mat-icon-button
type="button"
[disabled]="basicInfoForm.get('autoDeletionDate')?.disabled"
(click)="clearDeletionDate()"
matTooltip="Clear date selection"
class="clear-date-button"
@ -85,10 +89,14 @@
<mat-icon matSuffix class="ov-settings-icon">access_time</mat-icon>
</mat-form-field>
</div>
<div class="time-hint">
<mat-icon class="hint-icon material-symbols-outlined material-icons">auto_delete</mat-icon>
<span>Room will be deleted at {{ getFormattedDateTime() }}</span>
</div>
@if (!basicInfoForm.hasError('minFutureDateTime')) {
<div class="time-hint">
<mat-icon class="hint-icon material-symbols-outlined material-icons">auto_delete</mat-icon>
<span>Room will be deleted at {{ getFormattedDateTime() }}</span>
</div>
} @else {
<mat-error> Deletion date and time must be at least one hour in the future </mat-error>
}
</div>
}
</form>

View File

@ -1,6 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Component, OnDestroy } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatNativeDateModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
@ -17,7 +16,6 @@ import { Subject, takeUntil } from 'rxjs';
selector: 'ov-room-wizard-basic-info',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatButtonModule,
MatIconModule,
@ -31,37 +29,18 @@ import { Subject, takeUntil } from 'rxjs';
templateUrl: './basic-info.component.html',
styleUrl: './basic-info.component.scss'
})
export class RoomWizardBasicInfoComponent implements OnInit, OnDestroy {
@Input() editMode: boolean = false; // Input to control edit mode from parent component
export class RoomWizardBasicInfoComponent implements OnDestroy {
basicInfoForm: FormGroup;
private destroy$ = new Subject<void>();
// Arrays for time selection
hours = Array.from({ length: 24 }, (_, i) => ({ value: i, display: i.toString().padStart(2, '0') }));
minutes = Array.from({ length: 60 }, (_, i) => ({ value: i, display: i.toString().padStart(2, '0') }));
constructor(
private fb: FormBuilder,
private wizardState: RoomWizardStateService
) {
this.basicInfoForm = this.fb.group({
roomIdPrefix: ['', [Validators.maxLength(50)]],
autoDeletionDate: [null],
autoDeletionHour: [23],
autoDeletionMinute: [59]
});
}
private destroy$ = new Subject<void>();
ngOnInit() {
// Disable form controls in edit mode
if (this.editMode) {
this.basicInfoForm.get('roomIdPrefix')?.disable();
this.basicInfoForm.get('autoDeletionDate')?.disable();
this.basicInfoForm.get('autoDeletionHour')?.disable();
this.basicInfoForm.get('autoDeletionMinute')?.disable();
}
this.loadExistingData();
constructor(private wizardService: RoomWizardStateService) {
const currentStep = this.wizardService.currentStep();
this.basicInfoForm = currentStep!.formGroup;
this.basicInfoForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => {
this.saveFormData(value);
@ -73,24 +52,6 @@ export class RoomWizardBasicInfoComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private loadExistingData() {
const roomOptions = this.wizardState.getRoomOptions();
if (roomOptions.autoDeletionDate) {
const date = new Date(roomOptions.autoDeletionDate);
this.basicInfoForm.patchValue({
roomIdPrefix: roomOptions.roomIdPrefix || '',
autoDeletionDate: date,
autoDeletionHour: date.getHours(),
autoDeletionMinute: date.getMinutes()
});
} else {
this.basicInfoForm.patchValue({
roomIdPrefix: roomOptions.roomIdPrefix || ''
});
}
}
private saveFormData(formValue: any) {
let autoDeletionDateTime: number | undefined = undefined;
@ -110,27 +71,13 @@ export class RoomWizardBasicInfoComponent implements OnInit, OnDestroy {
};
// Always save to wizard state (including when values are cleared)
this.wizardState.updateStepData('basic', stepData);
}
clearForm() {
this.basicInfoForm.reset();
this.wizardState.updateStepData('basic', {
roomIdPrefix: '',
autoDeletionDate: undefined
});
this.wizardService.updateStepData('basic', stepData);
}
get minDate(): Date {
return new Date();
}
clearDeletionDate() {
this.basicInfoForm.patchValue({
autoDeletionDate: null,
autoDeletionHour: 23, // Reset to default values
autoDeletionMinute: 59
});
const now = new Date();
now.setHours(now.getHours() + 1, 0, 0, 0); // Set to 1 hour in the future
return now;
}
get hasDateSelected(): boolean {
@ -160,4 +107,12 @@ export class RoomWizardBasicInfoComponent implements OnInit, OnDestroy {
hour12: false
});
}
clearDeletionDate() {
this.basicInfoForm.patchValue({
autoDeletionDate: null,
autoDeletionHour: 23,
autoDeletionMinute: 59
});
}
}

View File

@ -18,8 +18,8 @@
[option]="option"
[selectedValue]="selectedOption"
[showSelectionIndicator]="true"
[showProBadge]="true"
[showRecommendedBadge]="false"
[showProBadge]="option.isPro ?? false"
[showRecommendedBadge]="option.recommended ?? false"
[showImage]="true"
[imageAspectRatio]="'1/1'"
[showImageAndIcon]="false"

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { Component, OnDestroy } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
@ -24,18 +24,14 @@ import { Subject, takeUntil } from 'rxjs';
templateUrl: './recording-layout.component.html',
styleUrl: './recording-layout.component.scss'
})
export class RecordingLayoutComponent implements OnInit, OnDestroy {
export class RecordingLayoutComponent implements OnDestroy {
layoutForm: FormGroup;
private destroy$ = new Subject<void>();
layoutOptions: SelectableOption[] = [
{
id: 'grid',
title: 'Grid Layout',
description: 'Show all participants in a grid view with equal sized tiles',
imageUrl: './assets/layouts/grid.png',
recommended: false,
isPro: false
imageUrl: './assets/layouts/grid.png'
},
{
id: 'speaker',
@ -43,7 +39,8 @@ export class RecordingLayoutComponent implements OnInit, OnDestroy {
description: 'Highlight the active speaker with other participants below',
imageUrl: './assets/layouts/speaker.png',
isPro: true,
disabled: true
disabled: true,
recommended: true
},
{
id: 'single-speaker',
@ -55,26 +52,15 @@ export class RecordingLayoutComponent implements OnInit, OnDestroy {
}
];
constructor(
private fb: FormBuilder,
private wizardState: RoomWizardStateService
) {
this.layoutForm = this.fb.group({
layoutType: ['grid'] // default to grid
});
}
private destroy$ = new Subject<void>();
ngOnInit() {
// Load existing data if available
this.loadExistingData();
constructor(private wizardService: RoomWizardStateService) {
const currentStep = this.wizardService.currentStep();
this.layoutForm = currentStep!.formGroup;
// Subscribe to form changes for auto-save
this.layoutForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => {
this.saveFormData(value);
});
// Save initial default value if no existing data
this.saveInitialDefaultIfNeeded();
}
ngOnDestroy() {
@ -82,30 +68,9 @@ export class RecordingLayoutComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private loadExistingData() {
// Note: This component doesn't need to store data in MeetRoomOptions
// Recording layout settings are typically stored as metadata or used for UI state only
this.layoutForm.patchValue({
layoutType: 'grid' // Always default to grid
});
}
private saveInitialDefaultIfNeeded() {
// Always ensure grid is selected as default
if (!this.layoutForm.value.layoutType) {
this.layoutForm.patchValue({
layoutType: 'grid'
});
}
}
private saveFormData(formValue: any) {
// Note: Recording layout type is not part of MeetRoomOptions
// This is UI state that affects recording layout but not stored in room options
// We could extend this to store in a metadata field if needed in the future
// For now, just keep the form state - this affects UI behavior but not the final room creation
console.log('Recording layout type selected:', formValue.layoutType);
// For now, just keep the form state
}
onOptionSelect(event: SelectionEvent): void {
@ -115,6 +80,6 @@ export class RecordingLayoutComponent implements OnInit, OnDestroy {
}
get selectedOption(): string {
return this.layoutForm.value.layoutType || 'grid'; // Default to grid if not set
return this.layoutForm.value.layoutType || 'grid';
}
}

View File

@ -17,6 +17,9 @@
<ov-selectable-card
[option]="option"
[selectedValue]="selectedValue"
[allowMultiSelect]="false"
[showSelectionIndicator]="true"
[showRecommendedBadge]="option.recommended ?? false"
(optionSelected)="onOptionSelect($event)"
></ov-selectable-card>
}

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { Component, OnDestroy } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -34,27 +34,26 @@ interface RecordingAccessOption {
templateUrl: './recording-preferences.component.html',
styleUrl: './recording-preferences.component.scss'
})
export class RecordingPreferencesComponent implements OnInit, OnDestroy {
export class RecordingPreferencesComponent implements OnDestroy {
recordingForm: FormGroup;
private destroy$ = new Subject<void>();
isAnimatingOut = false;
recordingOptions: SelectableOption[] = [
{
id: 'disabled',
title: 'No Recording',
description: 'Room will not be recorded. Participants can join without recording concerns.',
icon: 'videocam_off'
},
{
id: 'enabled',
title: 'Allow Recording',
description:
'Enable recording capabilities for this room. Recordings can be started manually or automatically.',
icon: 'video_library'
icon: 'video_library',
recommended: true
},
{
id: 'disabled',
title: 'No Recording',
description: 'Room will not be recorded. Participants can join without recording concerns.',
icon: 'videocam_off'
}
];
recordingAccessOptions: RecordingAccessOption[] = [
{
value: MeetRecordingAccess.ADMIN,
@ -70,25 +69,15 @@ export class RecordingPreferencesComponent implements OnInit, OnDestroy {
}
];
constructor(
private fb: FormBuilder,
private wizardState: RoomWizardStateService
) {
this.recordingForm = this.fb.group({
recordingEnabled: ['disabled'], // default to no recording
allowAccessTo: ['admin'] // default access level
});
}
private destroy$ = new Subject<void>();
ngOnInit() {
this.loadExistingData();
constructor(private wizardState: RoomWizardStateService) {
const currentStep = this.wizardState.currentStep();
this.recordingForm = currentStep!.formGroup;
this.recordingForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => {
this.saveFormData(value);
});
// Save initial default value if no existing data
this.saveInitialDefaultIfNeeded();
}
ngOnDestroy() {
@ -96,18 +85,6 @@ export class RecordingPreferencesComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private loadExistingData() {
const roomOptions = this.wizardState.getRoomOptions();
const recordingPrefs = roomOptions.preferences?.recordingPreferences;
if (recordingPrefs !== undefined) {
this.recordingForm.patchValue({
recordingEnabled: recordingPrefs.enabled ? 'enabled' : 'disabled',
allowAccessTo: recordingPrefs.allowAccessTo || MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER
});
}
}
private saveFormData(formValue: any) {
const enabled = formValue.recordingEnabled === 'enabled';
@ -123,16 +100,6 @@ export class RecordingPreferencesComponent implements OnInit, OnDestroy {
this.wizardState.updateStepData('recording', stepData);
}
private saveInitialDefaultIfNeeded() {
const roomOptions = this.wizardState.getRoomOptions();
const recordingPrefs = roomOptions.preferences?.recordingPreferences;
// If no existing data, save the default value
if (recordingPrefs === undefined) {
this.saveFormData(this.recordingForm.value);
}
}
onOptionSelect(event: SelectionEvent): void {
const previouslyEnabled = this.isRecordingEnabled;
const willBeEnabled = event.optionId === 'enabled';
@ -155,36 +122,15 @@ export class RecordingPreferencesComponent implements OnInit, OnDestroy {
}
}
isOptionSelected(optionId: 'disabled' | 'enabled'): boolean {
return this.recordingForm.value.recordingEnabled === optionId;
}
get selectedValue(): string {
return this.recordingForm.value.recordingEnabled;
return this.recordingForm.value.recordingEnabled || 'disabled';
}
get isRecordingEnabled(): boolean {
return this.recordingForm.value.recordingEnabled === 'enabled';
return this.selectedValue === 'enabled';
}
get shouldShowAccessSection(): boolean {
return this.isRecordingEnabled || this.isAnimatingOut;
}
setRecommendedOption() {
this.recordingForm.patchValue({
recordingEnabled: 'enabled'
});
}
setDefaultOption() {
this.recordingForm.patchValue({
recordingEnabled: 'disabled'
});
}
get currentSelection(): SelectableOption | undefined {
const selectedId = this.recordingForm.value.recordingEnabled;
return this.recordingOptions.find((option) => option.id === selectedId);
}
}

View File

@ -19,8 +19,8 @@
[selectedValue]="selectedOption"
[allowMultiSelect]="false"
[showSelectionIndicator]="true"
[showProBadge]="true"
[showRecommendedBadge]="false"
[showProBadge]="option.isPro ?? false"
[showRecommendedBadge]="option.recommended ?? false"
[enableHover]="!option.disabled"
(optionSelected)="onOptionChange($event)"
></ov-selectable-card>

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { Component, OnDestroy } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
@ -24,18 +24,15 @@ import { Subject, takeUntil } from 'rxjs';
templateUrl: './recording-trigger.component.html',
styleUrl: './recording-trigger.component.scss'
})
export class RecordingTriggerComponent implements OnInit, OnDestroy {
export class RecordingTriggerComponent implements OnDestroy {
triggerForm: FormGroup;
private destroy$ = new Subject<void>();
triggerOptions: SelectableOption[] = [
{
id: 'manual',
title: 'Manual Recording',
description: 'Start recording manually when needed',
icon: 'touch_app',
recommended: true,
isPro: false
recommended: true
},
{
id: 'auto1',
@ -55,26 +52,15 @@ export class RecordingTriggerComponent implements OnInit, OnDestroy {
}
];
constructor(
private fb: FormBuilder,
private wizardState: RoomWizardStateService
) {
this.triggerForm = this.fb.group({
triggerType: ['manual'] // default to manual
});
}
private destroy$ = new Subject<void>();
ngOnInit() {
// Load existing data if available
this.loadExistingData();
constructor(private wizardService: RoomWizardStateService) {
const currentStep = this.wizardService.currentStep();
this.triggerForm = currentStep!.formGroup;
// Subscribe to form changes for auto-save
this.triggerForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => {
this.saveFormData(value);
});
// Save initial default value if no existing data
this.saveInitialDefaultIfNeeded();
}
ngOnDestroy() {
@ -82,31 +68,9 @@ export class RecordingTriggerComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private loadExistingData() {
// Note: This component doesn't need to store data in MeetRoomOptions
// Recording trigger settings are typically stored as metadata or used for UI state only
// For now, we'll use form state only
this.triggerForm.patchValue({
triggerType: 'manual' // Always default to manual
});
}
private saveFormData(formValue: any) {
// Note: Recording trigger type is not part of MeetRoomOptions
// This is UI state that affects how recording is initiated but not stored in room options
// We could extend this to store in a metadata field if needed in the future
// For now, just keep the form state - this affects UI behavior but not the final room creation
console.log('Recording trigger type selected:', formValue.triggerType);
}
private saveInitialDefaultIfNeeded() {
// Always ensure manual is selected as default
if (!this.triggerForm.value.triggerType) {
this.triggerForm.patchValue({
triggerType: 'manual'
});
}
// For now, just keep the form state
}
/**

View File

@ -1,7 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { Component, OnDestroy } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
@ -11,43 +9,22 @@ import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'ov-room-preferences',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, MatCardModule, MatButtonModule, MatIconModule, MatSlideToggleModule],
imports: [ReactiveFormsModule, MatCardModule, MatIconModule, MatSlideToggleModule],
templateUrl: './room-preferences.component.html',
styleUrl: './room-preferences.component.scss'
})
export class RoomPreferencesComponent implements OnInit, OnDestroy {
export class RoomPreferencesComponent implements OnDestroy {
preferencesForm: FormGroup;
private destroy$ = new Subject<void>();
constructor(
private fb: FormBuilder,
private roomWizardStateService: RoomWizardStateService
) {
this.preferencesForm = this.fb.group({
chatEnabled: [true],
virtualBackgroundsEnabled: [true]
});
}
constructor(private wizardService: RoomWizardStateService) {
const currentStep = this.wizardService.currentStep();
this.preferencesForm = currentStep!.formGroup;
ngOnInit(): void {
// Load existing data from wizard state
const roomOptions = this.roomWizardStateService.getRoomOptions();
const preferences = roomOptions.preferences;
if (preferences) {
this.preferencesForm.patchValue({
chatEnabled: preferences.chatPreferences?.enabled ?? true,
virtualBackgroundsEnabled: preferences.virtualBackgroundPreferences?.enabled ?? true
});
}
// Auto-save form changes
this.preferencesForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => {
this.saveFormData(value);
});
// Save initial default values if no existing data
this.saveInitialDefaultIfNeeded();
}
ngOnDestroy(): void {
@ -67,17 +44,7 @@ export class RoomPreferencesComponent implements OnInit, OnDestroy {
}
};
this.roomWizardStateService.updateStepData('preferences', stepData);
}
private saveInitialDefaultIfNeeded(): void {
const roomOptions = this.roomWizardStateService.getRoomOptions();
const preferences = roomOptions.preferences;
// If no existing preferences data, save the default values
if (!preferences?.chatPreferences || !preferences?.virtualBackgroundPreferences) {
this.saveFormData(this.preferencesForm.value);
}
this.wizardService.updateStepData('preferences', stepData);
}
onChatToggleChange(event: any): void {
@ -91,10 +58,10 @@ export class RoomPreferencesComponent implements OnInit, OnDestroy {
}
get chatEnabled(): boolean {
return this.preferencesForm.value.chatEnabled;
return this.preferencesForm.value.chatEnabled || false;
}
get virtualBackgroundsEnabled(): boolean {
return this.preferencesForm.value.virtualBackgroundsEnabled;
return this.preferencesForm.value.virtualBackgroundsEnabled || false;
}
}

View File

@ -1,8 +1,17 @@
import { inject, Injectable } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { computed, Injectable, signal } from '@angular/core';
import { AbstractControl, FormBuilder, ValidationErrors, Validators } from '@angular/forms';
import { WizardNavigationConfig, WizardStep } from '@lib/models';
import { MeetRecordingAccess, MeetRoomOptions, MeetRoomPreferences } from '@lib/typings/ce';
import { BehaviorSubject } from 'rxjs';
// Default room preferences following the app's defaults
const DEFAULT_PREFERENCES: MeetRoomPreferences = {
recordingPreferences: {
enabled: true,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER
},
chatPreferences: { enabled: true },
virtualBackgroundPreferences: { enabled: true }
};
/**
* Service to manage the state of the room creation wizard.
@ -12,35 +21,29 @@ import { BehaviorSubject } from 'rxjs';
providedIn: 'root'
})
export class RoomWizardStateService {
private readonly _formBuilder = inject(FormBuilder);
// Observables for reactive state management
private readonly _currentStepIndex = new BehaviorSubject<number>(0);
private readonly _steps = new BehaviorSubject<WizardStep[]>([]);
private readonly _roomOptions = new BehaviorSubject<MeetRoomOptions>({
preferences: {
recordingPreferences: {
enabled: false,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER
},
chatPreferences: { enabled: true },
virtualBackgroundPreferences: { enabled: true }
}
// Signals for reactive state management
private _steps = signal<WizardStep[]>([]);
private _visibleSteps = computed(() => this._steps().filter((step) => step.isVisible));
private _currentStepIndex = signal<number>(0);
private _roomOptions = signal<MeetRoomOptions>({
preferences: DEFAULT_PREFERENCES
});
public readonly currentStepIndex$ = this._currentStepIndex.asObservable();
public readonly steps$ = this._steps.asObservable();
public readonly roomOptions$ = this._roomOptions.asObservable();
public readonly steps = computed(() => this._steps());
public readonly currentStepIndex = computed(() => this._currentStepIndex());
public readonly currentStep = computed<WizardStep | undefined>(() => {
const visibleSteps = this._visibleSteps();
const currentIndex = this._currentStepIndex();
// Default room preferences following the platform's defaults
private readonly DEFAULT_PREFERENCES: MeetRoomPreferences = {
recordingPreferences: {
enabled: false,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER
},
chatPreferences: { enabled: true },
virtualBackgroundPreferences: { enabled: true }
};
if (currentIndex < 0 || currentIndex >= visibleSteps.length) {
return undefined;
}
return visibleSteps[currentIndex];
});
public readonly roomOptions = computed(() => this._roomOptions());
constructor(private formBuilder: FormBuilder) {}
/**
* Initializes the wizard with base steps and default room options.
@ -49,22 +52,17 @@ export class RoomWizardStateService {
*/
initializeWizard(editMode: boolean = false, existingData?: MeetRoomOptions): void {
// Initialize room options with defaults merged with existing data
console.log('Initializing wizard - editMode:', editMode, 'existingData:', existingData);
const mergedPreferences: MeetRoomPreferences = {
...this.DEFAULT_PREFERENCES,
...existingData?.preferences
};
const initialRoomOptions: MeetRoomOptions = {
...existingData,
preferences: mergedPreferences
preferences: {
...DEFAULT_PREFERENCES,
...(existingData?.preferences || {})
}
};
console.log('Initial room options created:', initialRoomOptions);
this._roomOptions.next(initialRoomOptions);
this._roomOptions.set(initialRoomOptions);
// Define base wizard steps
// Define wizard steps
const baseSteps: WizardStep[] = [
{
id: 'basic',
@ -72,14 +70,62 @@ export class RoomWizardStateService {
isCompleted: editMode, // In edit mode, mark as completed but not editable
isActive: !editMode, // Start with basic step active in create mode
isVisible: true,
validationFormGroup: this._formBuilder.group({
roomPrefix: [
{ value: initialRoomOptions.roomIdPrefix || '', disabled: editMode },
editMode ? [] : [Validators.minLength(2)]
],
deletionDate: [{ value: initialRoomOptions.autoDeletionDate || '', disabled: editMode }]
}),
order: 1
formGroup: this.formBuilder.group(
{
roomIdPrefix: [
{ value: initialRoomOptions.roomIdPrefix || '', disabled: editMode },
editMode ? [] : [Validators.maxLength(50)]
],
autoDeletionDate: [
{
value: initialRoomOptions.autoDeletionDate
? new Date(initialRoomOptions.autoDeletionDate)
: undefined,
disabled: editMode
}
],
autoDeletionHour: [
{
value: initialRoomOptions.autoDeletionDate
? new Date(initialRoomOptions.autoDeletionDate).getHours()
: 23,
disabled: editMode
},
editMode ? [] : [Validators.min(0), Validators.max(23)]
],
autoDeletionMinute: [
{
value: initialRoomOptions.autoDeletionDate
? new Date(initialRoomOptions.autoDeletionDate).getMinutes()
: 59,
disabled: editMode
},
editMode ? [] : [Validators.min(0), Validators.max(59)]
]
},
{
// Apply future date-time validation only if not in edit mode
validators: editMode
? []
: [
(control: AbstractControl): ValidationErrors | null => {
const date = control.get('autoDeletionDate')?.value as Date | null;
const hour = control.get('autoDeletionHour')?.value as number | null;
const minute = control.get('autoDeletionMinute')?.value as number | null;
if (!date) return null;
const selected = new Date(date);
selected.setHours(hour ?? 0, minute ?? 0, 0, 0);
const now = new Date();
now.setMinutes(now.getMinutes() + 61, 0, 0); // Ensure at least 1 hour in the future
return selected.getTime() < now.getTime() ? { minFutureDateTime: true } : null;
}
]
}
)
},
{
id: 'recording',
@ -87,13 +133,32 @@ export class RoomWizardStateService {
isCompleted: editMode, // In edit mode, all editable steps are completed
isActive: editMode, // Only active in edit mode
isVisible: true,
validationFormGroup: this._formBuilder.group({
enabled: [
initialRoomOptions.preferences?.recordingPreferences?.enabled ??
this.DEFAULT_PREFERENCES.recordingPreferences.enabled
]
}),
order: 2
formGroup: this.formBuilder.group({
recordingEnabled: initialRoomOptions.preferences!.recordingPreferences.enabled
? 'enabled'
: 'disabled',
allowAccessTo: initialRoomOptions.preferences!.recordingPreferences.allowAccessTo
})
},
{
id: 'recordingTrigger',
label: 'Recording Trigger',
isCompleted: editMode, // In edit mode, all editable steps are completed
isActive: false,
isVisible: false, // Initially hidden, will be shown based on recording settings
formGroup: this.formBuilder.group({
triggerType: 'manual'
})
},
{
id: 'recordingLayout',
label: 'Recording Layout',
isCompleted: editMode, // In edit mode, all editable steps are completed
isActive: false,
isVisible: false, // Initially hidden, will be shown based on recording settings
formGroup: this.formBuilder.group({
layoutType: 'grid'
})
},
{
id: 'preferences',
@ -101,35 +166,19 @@ export class RoomWizardStateService {
isCompleted: editMode, // In edit mode, all editable steps are completed
isActive: false,
isVisible: true,
validationFormGroup: this._formBuilder.group({
chatPreferences: this._formBuilder.group({
enabled: [
initialRoomOptions.preferences?.chatPreferences?.enabled ??
this.DEFAULT_PREFERENCES.chatPreferences.enabled
]
}),
virtualBackgroundPreferences: this._formBuilder.group({
enabled: [
initialRoomOptions.preferences?.virtualBackgroundPreferences?.enabled ??
this.DEFAULT_PREFERENCES.virtualBackgroundPreferences.enabled
]
})
}),
order: 5
formGroup: this.formBuilder.group({
chatEnabled: initialRoomOptions.preferences!.chatPreferences.enabled,
virtualBackgroundsEnabled: initialRoomOptions.preferences!.virtualBackgroundPreferences.enabled
})
}
];
this._steps.next(baseSteps);
this._steps.set(baseSteps);
const initialStepIndex = editMode ? 1 : 0; // Skip basic step in edit mode
this._currentStepIndex.set(initialStepIndex);
// Set the initial step index after a short delay to ensure steps are processed
setTimeout(() => {
const initialStepIndex = editMode ? 1 : 0; // Skip basic step in edit mode
console.log('Setting initial step index:', initialStepIndex);
this._currentStepIndex.next(initialStepIndex);
// Update step visibility after index is set
this.updateStepVisibility();
}, 0);
// Update step visibility after index is set
this.updateStepsVisibility();
}
/**
@ -139,8 +188,7 @@ export class RoomWizardStateService {
* @param stepData - The data to update in the room options
*/
updateStepData(stepId: string, stepData: Partial<MeetRoomOptions>): void {
console.log(`updateStepData called - stepId: '${stepId}', stepData:`, stepData);
const currentOptions = this._roomOptions.value;
const currentOptions = this._roomOptions();
let updatedOptions: MeetRoomOptions;
switch (stepId) {
@ -148,6 +196,7 @@ export class RoomWizardStateService {
updatedOptions = {
...currentOptions
};
// Only update fields that are explicitly provided
if ('roomIdPrefix' in stepData) {
updatedOptions.roomIdPrefix = stepData.roomIdPrefix;
@ -155,8 +204,8 @@ export class RoomWizardStateService {
if ('autoDeletionDate' in stepData) {
updatedOptions.autoDeletionDate = stepData.autoDeletionDate;
}
break;
break;
case 'recording':
updatedOptions = {
...currentOptions,
@ -169,13 +218,11 @@ export class RoomWizardStateService {
} as MeetRoomPreferences
};
break;
case 'recordingTrigger':
case 'recordingLayout':
// These steps don't directly modify room options but could store additional metadata
// These steps don't update room options
updatedOptions = { ...currentOptions };
break;
case 'preferences':
updatedOptions = {
...currentOptions,
@ -196,77 +243,41 @@ export class RoomWizardStateService {
} as MeetRoomPreferences
};
break;
default:
console.warn(`Unknown step ID: ${stepId}`);
updatedOptions = currentOptions;
}
this._roomOptions.next(updatedOptions);
console.log(`Updated room options for step '${stepId}':`, updatedOptions);
this.updateStepVisibility();
this._roomOptions.set(updatedOptions);
this.updateStepsVisibility();
}
/**
* Updates the visibility of wizard steps based on current room options.
* For example, recording-related steps are only visible when recording is enabled.
*/
private updateStepVisibility(): void {
const currentSteps = this._steps.value;
const currentOptions = this._roomOptions.value;
const recordingEnabled = currentOptions.preferences?.recordingPreferences?.enabled ?? false;
private updateStepsVisibility(): void {
const currentSteps = this._steps();
const currentOptions = this._roomOptions();
const recordingEnabled = currentOptions.preferences?.recordingPreferences.enabled ?? false;
// Determine if we're in edit mode by checking if basic step is completed and disabled
const basicStep = currentSteps.find((step) => step.id === 'basic');
const isEditMode = !!basicStep?.validationFormGroup?.disabled;
// Remove recording-specific steps if they exist
const filteredSteps = currentSteps.filter((step) => !['recordingTrigger', 'recordingLayout'].includes(step.id));
if (recordingEnabled) {
// Add recording-specific steps
const recordingSteps: WizardStep[] = [
{
id: 'recordingTrigger',
label: 'Recording Trigger',
isCompleted: isEditMode, // In edit mode, mark as completed
isActive: false,
isVisible: true,
validationFormGroup: this._formBuilder.group({
type: ['manual']
}),
order: 3
},
{
id: 'recordingLayout',
label: 'Recording Layout',
isCompleted: isEditMode, // In edit mode, mark as completed
isActive: false,
isVisible: true,
validationFormGroup: this._formBuilder.group({
layout: ['GRID'],
access: ['moderator-only']
}),
order: 4
}
];
// Insert recording steps at the correct position (after recording step)
filteredSteps.splice(2, 0, ...recordingSteps);
}
// Reorder steps based on visibility
filteredSteps.forEach((step, index) => {
step.order = index + 1;
// Update recording steps visibility based on recordingEnabled
const updatedSteps = currentSteps.map((step) => {
if (step.id === 'recordingTrigger' || step.id === 'recordingLayout') {
return {
...step,
isVisible: recordingEnabled // Only show if recording is enabled
};
}
return step;
});
this._steps.next(filteredSteps);
this._steps.set(updatedSteps);
}
goToNextStep(): boolean {
const currentIndex = this._currentStepIndex.value;
const steps = this._steps.value;
const visibleSteps = steps.filter((step) => step.isVisible);
const currentIndex = this._currentStepIndex();
const visibleSteps = this._visibleSteps();
if (currentIndex < visibleSteps.length - 1) {
// Mark current step as completed
const currentStep = visibleSteps[currentIndex];
@ -277,17 +288,18 @@ export class RoomWizardStateService {
const nextStep = visibleSteps[currentIndex + 1];
nextStep.isActive = true;
this._currentStepIndex.next(currentIndex + 1);
this._steps.next([...steps]);
this._currentStepIndex.set(currentIndex + 1);
const steps = this._steps();
this._steps.set([...steps]); // Trigger reactivity
return true;
}
return false;
}
goToPreviousStep(): boolean {
const currentIndex = this._currentStepIndex.value;
const steps = this._steps.value;
const visibleSteps = steps.filter((step) => step.isVisible);
const currentIndex = this._currentStepIndex();
const visibleSteps = this._visibleSteps();
if (currentIndex > 0) {
// Deactivate current step
@ -298,34 +310,26 @@ export class RoomWizardStateService {
const previousStep = visibleSteps[currentIndex - 1];
previousStep.isActive = true;
this._currentStepIndex.next(currentIndex - 1);
this._steps.next([...steps]);
this._currentStepIndex.set(currentIndex - 1);
const steps = this._steps();
this._steps.set([...steps]); // Trigger reactivity
return true;
}
return false;
}
getCurrentStep(): WizardStep | null {
const steps = this._steps.value;
const visibleSteps = steps.filter((step) => step.isVisible);
const currentIndex = this._currentStepIndex.value;
return visibleSteps[currentIndex] || null;
}
getCurrentStepIndex(): number {
return this._currentStepIndex.value;
}
getVisibleSteps(): WizardStep[] {
return this._steps.value.filter((step) => step.isVisible);
}
goToStep(targetIndex: number): boolean {
const visibleSteps = this.getVisibleSteps();
const currentIndex = this._currentStepIndex();
if (targetIndex === currentIndex) {
return false; // No change if the target index is the same as current
}
const visibleSteps = this._visibleSteps();
if (targetIndex >= 0 && targetIndex < visibleSteps.length) {
// Deactivate current step
const currentStep = this.getCurrentStep();
const currentStep = this.currentStep();
if (currentStep) {
currentStep.isActive = false;
}
@ -334,54 +338,48 @@ export class RoomWizardStateService {
const targetStep = visibleSteps[targetIndex];
targetStep.isActive = true;
this._currentStepIndex.next(targetIndex);
this._steps.next([...this._steps.value]);
this._currentStepIndex.set(targetIndex);
const steps = this._steps();
this._steps.set([...steps]); // Trigger reactivity
return true;
}
return false;
}
getNavigationConfig(): WizardNavigationConfig {
const currentIndex = this._currentStepIndex.value;
const steps = this._steps.value;
const visibleSteps = steps.filter((step) => step.isVisible);
const isLastStep = currentIndex === visibleSteps.length - 1;
const currentIndex = this._currentStepIndex();
const visibleSteps = this._visibleSteps();
const isFirstStep = currentIndex === 0;
const isLastStep = currentIndex === visibleSteps.length - 1;
// Determine if we're in edit mode
const basicStep = steps.find((step) => step.id === 'basic');
const isEditMode = !!(basicStep?.isCompleted && basicStep.validationFormGroup?.disabled);
const isEditMode = this.isEditMode();
const isSomeStepInvalid = visibleSteps.some((step) => step.formGroup.invalid);
return {
showPrevious: !isFirstStep,
showNext: !isLastStep,
showCancel: true,
showFinish: isLastStep,
showQuickCreate: !isEditMode && isFirstStep,
showSkipAndFinish: !isEditMode && isFirstStep,
disableFinish: isSomeStepInvalid,
nextLabel: 'Next',
previousLabel: 'Previous',
finishLabel: isEditMode ? 'Update Room' : 'Create Room',
isNextDisabled: false,
isPreviousDisabled: isFirstStep
skipAndFinishLabel: 'Create with defaults'
};
}
/**
* Gets the current room options configured in the wizard.
* @returns The current MeetRoomOptions object
* Checks if the wizard is in edit mode.
* Edit mode is determined by whether the basic step is completed and its form is disabled.
* @returns True if in edit mode, false otherwise
*/
getRoomOptions(): MeetRoomOptions {
const options = this._roomOptions.getValue();
console.log('Getting room options:', options);
return options;
}
/**
* Checks if the wizard was skipped (user is still on the first step).
* @returns True if the wizard was skipped, false otherwise
*/
isWizardSkipped(): boolean {
return this._currentStepIndex.getValue() === 0;
private isEditMode(): boolean {
const visibleSteps = this._visibleSteps();
const basicStep = visibleSteps.find((step) => step.id === 'basic');
const isEditMode = !!basicStep && basicStep.isCompleted && basicStep.formGroup.disabled;
return isEditMode;
}
/**
@ -389,11 +387,10 @@ export class RoomWizardStateService {
*/
resetWizard(): void {
const defaultOptions: MeetRoomOptions = {
preferences: this.DEFAULT_PREFERENCES
preferences: DEFAULT_PREFERENCES
};
this._roomOptions.next(defaultOptions);
this._currentStepIndex.next(0);
this.initializeWizard();
this._roomOptions.set(defaultOptions);
this._steps.set([]);
this._currentStepIndex.set(0);
}
}

View File

@ -1,11 +1,12 @@
import { ApplicationConfig, importProvidersFrom, provideZoneChangeDetection } from '@angular/core';
import { STEPPER_GLOBAL_OPTIONS } from '@angular/cdk/stepper';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, importProvidersFrom, provideZoneChangeDetection } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter } from '@angular/router';
import { OpenViduComponentsModule, OpenViduComponentsConfig } from 'openvidu-components-angular';
import { environment } from '@environment/environment';
import { routes } from '@app/app.routes';
import { environment } from '@environment/environment';
import { httpInterceptor } from '@lib/interceptors/index';
import { OpenViduComponentsConfig, OpenViduComponentsModule } from 'openvidu-components-angular';
const ovComponentsconfig: OpenViduComponentsConfig = {
production: environment.production
@ -17,6 +18,10 @@ export const appConfig: ApplicationConfig = {
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideAnimationsAsync(),
provideHttpClient(withInterceptors([httpInterceptor]))
provideHttpClient(withInterceptors([httpInterceptor])),
{
provide: STEPPER_GLOBAL_OPTIONS,
useValue: { showError: true } // Show error messages in stepper
}
]
};