From 1cbed33eae31d8e68e3e13b4cf23374df0007509 Mon Sep 17 00:00:00 2001 From: Afreen Misbah Date: Tue, 21 Oct 2025 22:07:46 +0530 Subject: [PATCH] mgr/dashboard: Generalized errors and validations in forms Fixes https://tracker.ceph.com/issues/73901 - added a validation directive -`cdValidate` which can be use to set [invalid] form fields - also added generic template for showing error messages in user password form - user password form updates that Signed-off-by: Afreen Misbah --- .../user-password-form.component.html | 51 ++++---- .../shared/directives/directives.module.ts | 7 +- .../directives/validate.directive.spec.ts | 123 ++++++++++++++++++ .../shared/directives/validate.directive.ts | 66 ++++++++++ 4 files changed, 218 insertions(+), 29 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html index 438aee155a8..2c0ea4461b2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html @@ -14,13 +14,15 @@
Old Password @@ -28,27 +30,31 @@
New Password
Confirm New Password
@@ -62,32 +68,23 @@
+ +@if (control.errors) { +@for (err of control.errors | keyvalue; track err.key) { +{{ INVALID_TEXTS[err.key] }} +} +} + + - {{INVALID_TEXTS['required']}} - {{INVALID_TEXTS['notmatch']}} + - {{INVALID_TEXTS['required']}} - {{INVALID_TEXTS['notmatch']}} - {{INVALID_TEXTS['passwordPolicy']}} + - {{INVALID_TEXTS['required']}} - {{INVALID_TEXTS['match']}} + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts index 576628ae70f..9bd29ebd0d4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts @@ -20,6 +20,7 @@ import { ReactiveFormsModule } from '@angular/forms'; import { OptionalFieldDirective } from './optional-field.directive'; import { DimlessBinaryPerMinuteDirective } from './dimless-binary-per-minute.directive'; import { DynamicInputComboboxDirective } from './dynamic-input-combobox.directive'; +import { ValidateDirective } from './validate.directive'; @NgModule({ imports: [ReactiveFormsModule], @@ -42,7 +43,8 @@ import { DynamicInputComboboxDirective } from './dynamic-input-combobox.directiv RequiredFieldDirective, OptionalFieldDirective, DimlessBinaryPerMinuteDirective, - DynamicInputComboboxDirective + DynamicInputComboboxDirective, + ValidateDirective ], exports: [ AutofocusDirective, @@ -63,7 +65,8 @@ import { DynamicInputComboboxDirective } from './dynamic-input-combobox.directiv RequiredFieldDirective, OptionalFieldDirective, DimlessBinaryPerMinuteDirective, - DynamicInputComboboxDirective + DynamicInputComboboxDirective, + ValidateDirective ] }) export class DirectivesModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.spec.ts new file mode 100644 index 00000000000..3211369afa5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.spec.ts @@ -0,0 +1,123 @@ +import { Component, DebugElement, ChangeDetectionStrategy } from '@angular/core'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + ReactiveFormsModule, + FormControl, + Validators, + FormGroup, + FormGroupDirective +} from '@angular/forms'; +import { ValidateDirective } from './validate.directive'; + +// A test host component to simulate directive usage with a Reactive Form +@Component({ + template: ` +
+ + +
+ `, + standalone: false, + // Using OnPush strategy to properly test cdr.markForCheck() + changeDetection: ChangeDetectionStrategy.OnPush +}) +class TestHostComponent { + form = new FormGroup({ + testField: new FormControl('', Validators.required) + }); + + onSubmit() {} +} + +describe('ValidateDirective', () => { + let fixture: ComponentFixture; + let hostComponent: TestHostComponent; + let inputElement: DebugElement; + let directiveInstance: ValidateDirective; + let formGroupDir: FormGroupDirective; + let control: FormControl; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ValidateDirective, TestHostComponent], + imports: [ReactiveFormsModule] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TestHostComponent); + hostComponent = fixture.componentInstance; + inputElement = fixture.debugElement.query(By.directive(ValidateDirective)); + + directiveInstance = inputElement.injector.get(ValidateDirective); + formGroupDir = inputElement.injector.get(FormGroupDirective); + + control = hostComponent.form.get('testField') as FormControl; + + fixture.detectChanges(); + }); + + it('should create an instance', () => { + expect(directiveInstance).toBeTruthy(); + }); + + it('should not show error initially (clean and untouched)', () => { + expect(directiveInstance.isInvalid).toBeFalsy(); + }); + + it('should show error when control is invalid and touched', () => { + control.markAsTouched(); + control.setValue(''); + fixture.detectChanges(); + expect(directiveInstance.isInvalid).toBeTruthy(); + }); + + it('should show error when control is invalid and dirty', () => { + control.markAsDirty(); + control.setValue(''); + fixture.detectChanges(); + + expect(directiveInstance.isInvalid).toBeTruthy(); + }); + + it('should show error when form is submitted, even if control is untouched/clean', () => { + control.setValue(''); + // Trigger the actual form submit event via button click + const submitButton = fixture.debugElement.query(By.css('button[type="submit"]')); + submitButton.nativeElement.click(); + fixture.detectChanges(); + expect(formGroupDir.submitted).toBeTruthy(); + expect(directiveInstance.isInvalid).toBeTruthy(); + }); + + it('should hide error when control becomes valid', () => { + // Mark form invalid + control.markAsTouched(); + control.setValue(''); + fixture.detectChanges(); + expect(directiveInstance.isInvalid).toBeTruthy(); + + // Mark form valid + control.setValue('a valid password'); + fixture.detectChanges(); + + expect(directiveInstance.isInvalid).toBeFalsy(); + }); + + it('should use cdr.markForCheck() to ensure view updates in OnPush components', () => { + const cdrSpy = spyOn((directiveInstance as any).cdr, 'markForCheck').and.callThrough(); + + // Triggers updateState() internally + control.markAsTouched(); + control.setValue(''); + + expect(cdrSpy).toHaveBeenCalled(); + }); + + it('should clean up subscriptions on ngOnDestroy', () => { + const destroySpy = spyOn((directiveInstance as any).destroy$, 'complete').and.callThrough(); + fixture.destroy(); + expect(destroySpy).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.ts new file mode 100644 index 00000000000..0211103c9a6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.ts @@ -0,0 +1,66 @@ +import { Directive, OnInit, OnDestroy, ChangeDetectorRef, Optional, Self } from '@angular/core'; +import { NgControl, FormGroupDirective } from '@angular/forms'; +import { Subject, merge } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +/** + * This directive validates form inputs for reactive forms. + * The form should have [formGroup] directive. + * + * Example: + * + * + Old Password + + */ +@Directive({ + selector: '[cdValidate]', + exportAs: 'cdValidate', + standalone: false +}) +export class ValidateDirective implements OnInit, OnDestroy { + private destroy$ = new Subject(); + isInvalid = false; + + constructor( + @Optional() @Self() private ngControl: NgControl, + @Optional() private formGroupDir: FormGroupDirective, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit() { + if (!this.ngControl?.control) return; + + const submit$ = this.formGroupDir ? this.formGroupDir.ngSubmit : new Subject(); + + merge(this.ngControl.control.statusChanges, submit$) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.updateState(); + }); + } + + private updateState() { + const control = this.ngControl.control; + if (!control) return; + + const isInvalid = control.invalid; + + const wasSubmitted = this.formGroupDir?.submitted; + const userInteracted = control.dirty || control.touched; + + this.isInvalid = !!(isInvalid && (userInteracted || wasSubmitted)); + + this.cdr.markForCheck(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} -- 2.47.3