]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Generalized errors and validations in forms 66378/head
authorAfreen Misbah <afreen@ibm.com>
Tue, 21 Oct 2025 16:37:46 +0000 (22:07 +0530)
committerAfreen Misbah <afreen@ibm.com>
Thu, 8 Jan 2026 22:56:17 +0000 (04:26 +0530)
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 <afreen@ibm.com>
src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/validate.directive.ts [new file with mode: 0644]

index 438aee155a8ae4dde83e7decde896ff1bb7d23dd..2c0ea4461b23a4398b6172e2a8cb7ff8d50ffe4c 100644 (file)
       <div cdsRow
            class="form-item">
         <cds-password-label
-          [invalid]="!userForm.controls.oldpassword.valid && userForm.controls.oldpassword.dirty"
+          [invalid]="oldPwd.isInvalid"
           [invalidText]="oldPasswordInvalid">
           Old Password
           <input cdsPassword
+                 cdValidate
+                 #oldPwd="cdValidate"
                  formControlName="oldpassword"
                  autocomplete="oldpassword"
-                 [invalid]="!userForm.controls.oldpassword.valid && userForm.controls.oldpassword.dirty"
+                 [invalid]="oldPwd.isInvalid"
                  id="oldpassword"
                  autofocus>
         </cds-password-label>
       <div cdsRow
            class="form-item">
         <cds-password-label
-          [invalid]="!userForm.controls.newpassword.valid && userForm.controls.newpassword.dirty"
+          [invalid]="newPwd.isInvalid"
           [invalidText]="newPasswordInvalid"
           [helperText]="passwordPolicyHelpText">
           New Password
           <input cdsPassword
+                 cdValidate
+                 #newPwd="cdValidate"
                  formControlName="newpassword"
                  autocomplete="newpassword"
-                 [invalid]="!userForm.controls.newpassword.valid && userForm.controls.newpassword.dirty"
+                 [invalid]="newPwd.isInvalid"
                  id="newpassword">
         </cds-password-label>
       </div>
       <div cdsRow
            class="form-item">
         <cds-password-label
-          [invalid]="!userForm.controls.confirmnewpassword.valid && userForm.controls.confirmnewpassword.dirty"
+          [invalid]="confirmPwd.isInvalid"
           [invalidText]="confirmNewPasswordInvalid">
           Confirm New Password
           <input cdsPassword
+                 cdValidate
+                 #confirmPwd="cdValidate"
                  formControlName="confirmnewpassword"
                  autocomplete="confirmnewpassword"
-                 [invalid]="!userForm.controls.confirmnewpassword.valid && userForm.controls.confirmnewpassword.dirty"
+                 [invalid]="confirmPwd.isInvalid"
                  id="confirmnewpassword">
         </cds-password-label>
       </div>
   </div>
 </form>
 
+<ng-template #validationErrors
+             let-control="control">
+@if (control.errors) {
+@for (err of control.errors | keyvalue; track err.key) {
+<span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
+}
+}
+</ng-template>
+
 <ng-template #oldPasswordInvalid>
-  <span class="invalid-feedback"
-        *ngIf="userForm.showError('oldpassword', null, 'required')"
-        i18n>{{INVALID_TEXTS['required']}}</span>
-  <span class="invalid-feedback"
-        *ngIf="userForm.showError('oldpassword', null, 'notmatch')"
-        i18n>{{INVALID_TEXTS['notmatch']}}</span>
+  <ng-container *ngTemplateOutlet="validationErrors; context: { control: userForm.get('oldpassword') }"></ng-container>
 </ng-template>
 
 <ng-template #newPasswordInvalid>
-  <span class="invalid-feedback"
-        *ngIf="userForm.showError('newpassword', null, 'required')"
-        i18n>{{INVALID_TEXTS['required']}}</span>
-  <span class="invalid-feedback"
-        *ngIf="userForm.showError('newpassword', null, 'notmatch')"
-        i18n>{{INVALID_TEXTS['notmatch']}}</span>
-  <span class="invalid-feedback"
-        *ngIf="userForm.showError('newpassword', null, 'passwordPolicy')"
-        i18n>{{INVALID_TEXTS['passwordPolicy']}}</span>
+  <ng-container *ngTemplateOutlet="validationErrors; context: { control: userForm.get('newpassword') }"></ng-container>
 </ng-template>
 
 <ng-template #confirmNewPasswordInvalid>
-  <span class="invalid-feedback"
-        *ngIf="userForm.showError('confirmnewpassword', null, 'required')"
-        i18n>{{INVALID_TEXTS['required']}}</span>
-  <span class="invalid-feedback"
-        *ngIf="userForm.showError('confirmnewpassword', null, 'match')"
-        i18n>{{INVALID_TEXTS['match']}}</span>
+  <ng-container *ngTemplateOutlet="validationErrors; context: { control: userForm.get('confirmnewpassword') }"></ng-container>
 </ng-template>
index 576628ae70f4d7876ad52229bfb8539b5511078c..9bd29ebd0d43aa848bbfa049d45c7e14f2e65c9f 100644 (file)
@@ -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 (file)
index 0000000..3211369
--- /dev/null
@@ -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: `
+    <form [formGroup]="form" (ngSubmit)="onSubmit()">
+      <input formControlName="testField" cdValidate #cdValidateRef="cdValidate" />
+      <button type="submit">Submit</button>
+    </form>
+  `,
+  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<TestHostComponent>;
+  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 (file)
index 0000000..0211103
--- /dev/null
@@ -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:
+ *
+ *  <cds-password-label
+      [invalid]="oldPasswordRef.showError">
+      Old Password
+      <input cdsPassword
+             cdValidate
+             #oldPasswordRef="cdValidate"
+             formControlName="oldpassword"
+             [invalid]="oldPasswordRef.showError">
+ */
+@Directive({
+  selector: '[cdValidate]',
+  exportAs: 'cdValidate',
+  standalone: false
+})
+export class ValidateDirective implements OnInit, OnDestroy {
+  private destroy$ = new Subject<void>();
+  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();
+  }
+}