<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>
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],
RequiredFieldDirective,
OptionalFieldDirective,
DimlessBinaryPerMinuteDirective,
- DynamicInputComboboxDirective
+ DynamicInputComboboxDirective,
+ ValidateDirective
],
exports: [
AutofocusDirective,
RequiredFieldDirective,
OptionalFieldDirective,
DimlessBinaryPerMinuteDirective,
- DynamicInputComboboxDirective
+ DynamicInputComboboxDirective,
+ ValidateDirective
]
})
export class DirectivesModule {}
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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();
+ }
+}