diff --git a/packages/angular/material-error/package.json b/packages/angular/material-error/package.json index 39d50cb..a4921fe 100644 --- a/packages/angular/material-error/package.json +++ b/packages/angular/material-error/package.json @@ -6,10 +6,12 @@ "@angular/common": ">=14.1.0 < 18.0.0", "@angular/core": ">=14.1.0 < 18.0.0", "@angular/forms": ">=14.1.0 < 18.0.0", - "@angular/material": ">=14.1.0 < 18.0.0", "@angular/platform-browser": ">=14.1.0 < 18.0.0", "rxjs": ">=6.0.0 < 8.0.0" }, + "optionalDependencies": { + "@angular/material": ">=14.1.0 < 18.0.0" + }, "dependencies": { "tslib": "^2.3.0" }, diff --git a/packages/angular/material-error/src/lib/dynamic-error.directive.spec.ts b/packages/angular/material-error/src/lib/dynamic-error.directive.spec.ts index edf024d..8fefcaf 100644 --- a/packages/angular/material-error/src/lib/dynamic-error.directive.spec.ts +++ b/packages/angular/material-error/src/lib/dynamic-error.directive.spec.ts @@ -5,96 +5,312 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { Component, ViewChild } from '@angular/core'; import { FormControl, + FormGroup, + FormsModule, ReactiveFormsModule, ValidationErrors, Validators, } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { fireEvent } from '@testing-library/dom'; import { ErrorMessageProvider } from './error-message-provider'; import { DynamicErrorDirective } from './dynamic-error.directive'; -@Component({ - template: ` - - - `, - standalone: true, - imports: [ - DynamicErrorDirective, - MatInputModule, - MatFormFieldModule, - ReactiveFormsModule, - ], -}) -class DirectiveTestComponent { - @ViewChild(DynamicErrorDirective, { static: true }) directive: - | DynamicErrorDirective - | undefined; - public readonly formControl = new FormControl('', Validators.required); -} - -const render = async () => { - await TestBed.configureTestingModule({ - imports: [DirectiveTestComponent, NoopAnimationsModule], - providers: [ - { - provide: ErrorMessageProvider, - useValue: { - getErrorMessagesFor: (error: ValidationErrors) => - Object.keys(error)[0], - }, - }, - ], - }).compileComponents(); - - const fixture = TestBed.createComponent(DirectiveTestComponent); - fixture.detectChanges(); - const loader = TestbedHarnessEnvironment.loader(fixture); - return { fixture, loader }; -}; - describe('DynamicErrorDirective', () => { - it('should create', async () => { - const { fixture } = await render(); - expect(fixture.componentInstance).toBeTruthy(); - }); + describe('Reactive form with material input', () => { + @Component({ + template: `
+ + + + +
`, + standalone: true, + imports: [ + DynamicErrorDirective, + MatInputModule, + MatFormFieldModule, + ReactiveFormsModule, + ], + }) + class DirectiveTestComponent { + @ViewChild(DynamicErrorDirective, { static: true }) directive: + | DynamicErrorDirective + | undefined; + public readonly formControl = new FormControl('', Validators.required); + public readonly formGroup = new FormGroup({ + formControl: this.formControl, + }); + } + + const render = async () => { + await TestBed.configureTestingModule({ + imports: [DirectiveTestComponent, NoopAnimationsModule], + providers: [ + { + provide: ErrorMessageProvider, + useValue: { + getErrorMessagesFor: (error: ValidationErrors) => + Object.keys(error)[0], + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(DirectiveTestComponent); + fixture.detectChanges(); + const loader = TestbedHarnessEnvironment.loader(fixture); + return { fixture, loader }; + }; + it('should create', async () => { + const { fixture } = await render(); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should show error message', async () => { + const { fixture, loader } = await render(); + const input = await loader.getHarness(MatInputHarness); + + await input.focus(); + await input.setValue('a'); + await input.setValue(''); + await input.blur(); + + expect(fixture.nativeElement.textContent).toContain('required'); + }); + + it('should hide error message', async () => { + const { fixture, loader } = await render(); + const input = await loader.getHarness(MatInputHarness); + + await input.focus(); + await input.setValue('a'); + await input.setValue(''); + await input.setValue('a'); + await input.blur(); + + expect(fixture.nativeElement.textContent).not.toContain('required'); + }); - it('should show error message', async () => { - const { fixture, loader } = await render(); - const input = await loader.getHarness(MatInputHarness); + it('should unsubscribe in on destroy', async () => { + const { fixture, loader } = await render(); + const input = await loader.getHarness(MatInputHarness); - await input.focus(); - await input.setValue('a'); - await input.setValue(''); - await input.blur(); + fixture.componentInstance.directive?.ngOnDestroy(); + await input.focus(); + await input.setValue('a'); + await input.setValue(''); + await input.blur(); - expect(fixture.nativeElement.textContent).toContain('required'); + expect(fixture.nativeElement.textContent).not.toContain('required'); + }); + + it('should show error message on submit', async () => { + const { fixture } = await render(); + + const form = fixture.nativeElement.querySelector('form'); + fireEvent.submit(form); + + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('required'); + }); }); - it('should hide error message', async () => { - const { fixture, loader } = await render(); - const input = await loader.getHarness(MatInputHarness); + describe('Form with material input', () => { + @Component({ + template: `
+ + + + +
`, + standalone: true, + imports: [ + DynamicErrorDirective, + MatInputModule, + MatFormFieldModule, + FormsModule, + ], + }) + class DirectiveTestComponent { + @ViewChild(DynamicErrorDirective, { static: true }) directive: + | DynamicErrorDirective + | undefined; + public value = ''; + } + + const render = async () => { + await TestBed.configureTestingModule({ + imports: [DirectiveTestComponent, NoopAnimationsModule], + providers: [ + { + provide: ErrorMessageProvider, + useValue: { + getErrorMessagesFor: (error: ValidationErrors) => + Object.keys(error)[0], + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(DirectiveTestComponent); + fixture.detectChanges(); + const loader = TestbedHarnessEnvironment.loader(fixture); + return { fixture, loader }; + }; + it('should create', async () => { + const { fixture } = await render(); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should show error message', async () => { + const { fixture, loader } = await render(); + const input = await loader.getHarness(MatInputHarness); + + await input.focus(); + await input.setValue('a'); + await input.setValue(''); + await input.blur(); + + expect(fixture.nativeElement.textContent).toContain('required'); + }); + + it('should hide error message', async () => { + const { fixture, loader } = await render(); + const input = await loader.getHarness(MatInputHarness); - await input.focus(); - await input.setValue('a'); - await input.setValue(''); - await input.setValue('a'); - await input.blur(); + await input.focus(); + await input.setValue('a'); + await input.setValue(''); + await input.setValue('a'); + await input.blur(); - expect(fixture.nativeElement.textContent).not.toContain('required'); + expect(fixture.nativeElement.textContent).not.toContain('required'); + }); + + it('should unsubscribe in on destroy', async () => { + const { fixture, loader } = await render(); + const input = await loader.getHarness(MatInputHarness); + + fixture.componentInstance.directive?.ngOnDestroy(); + await input.focus(); + await input.setValue('a'); + await input.setValue(''); + await input.blur(); + + expect(fixture.nativeElement.textContent).not.toContain('required'); + }); + + it('should show error message on submit', async () => { + const { fixture } = await render(); + + const form = fixture.nativeElement.querySelector('form'); + fireEvent.submit(form); + + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('required'); + }); }); - it('should unsubscribe in on destroy', async () => { - const { fixture, loader } = await render(); - const input = await loader.getHarness(MatInputHarness); + describe('Form control bare', () => { + @Component({ + template: `
+ +
+
`, + standalone: true, + imports: [DynamicErrorDirective, ReactiveFormsModule], + }) + class DirectiveTestComponent { + @ViewChild(DynamicErrorDirective, { static: true }) directive: + | DynamicErrorDirective + | undefined; + public readonly formControl = new FormControl('', Validators.required); + public readonly formGroup = new FormGroup({ + formControl: this.formControl, + }); + } + + const render = async () => { + await TestBed.configureTestingModule({ + imports: [DirectiveTestComponent, NoopAnimationsModule], + providers: [ + { + provide: ErrorMessageProvider, + useValue: { + getErrorMessagesFor: (error: ValidationErrors) => + Object.keys(error)[0], + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(DirectiveTestComponent); + fixture.detectChanges(); + return { fixture }; + }; + it('should create', async () => { + const { fixture } = await render(); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should show error message', async () => { + const { fixture } = await render(); + const input = fixture.nativeElement.querySelector('input'); + + fireEvent.focus(input); + fireEvent.input(input, { target: { value: 'a' } }); + fixture.detectChanges(); + fireEvent.input(input, { target: { value: '' } }); + fireEvent.blur(input); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('required'); + }); + + it('should hide error message', async () => { + const { fixture } = await render(); + const input = fixture.nativeElement.querySelector('input'); + + fireEvent.focus(input); + fireEvent.input(input, { target: { value: 'a' } }); + fixture.detectChanges(); + fireEvent.input(input, { target: { value: '' } }); + fixture.detectChanges(); + fireEvent.input(input, { target: { value: 'a' } }); + fireEvent.blur(input); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).not.toContain('required'); + }); + + it('should unsubscribe in on destroy', async () => { + const { fixture } = await render(); + const input = fixture.nativeElement.querySelector('input'); + + fixture.componentInstance.directive?.ngOnDestroy(); + fireEvent.focus(input); + fireEvent.input(input, { target: { value: 'a' } }); + fixture.detectChanges(); + fireEvent.input(input, { target: { value: '' } }); + fireEvent.blur(input); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).not.toContain('required'); + }); + + it('should show error message on submit', async () => { + const { fixture } = await render(); + + const form = fixture.nativeElement.querySelector('form'); + fireEvent.submit(form); - fixture.componentInstance.directive?.ngOnDestroy(); - await input.focus(); - await input.setValue('a'); - await input.setValue(''); - await input.blur(); + fixture.detectChanges(); - expect(fixture.nativeElement.textContent).not.toContain('required'); + expect(fixture.nativeElement.textContent).toContain('required'); + }); }); }); diff --git a/packages/angular/material-error/src/lib/dynamic-error.directive.ts b/packages/angular/material-error/src/lib/dynamic-error.directive.ts index a989cfe..cf9d257 100644 --- a/packages/angular/material-error/src/lib/dynamic-error.directive.ts +++ b/packages/angular/material-error/src/lib/dynamic-error.directive.ts @@ -2,13 +2,20 @@ import { AfterContentInit, ChangeDetectorRef, Directive, + Input, OnDestroy, Renderer2, TemplateRef, ViewContainerRef, inject, + isDevMode, } from '@angular/core'; -import { FormGroupDirective, NgForm, ValidationErrors } from '@angular/forms'; +import { + AbstractControl, + FormGroupDirective, + NgForm, + ValidationErrors, +} from '@angular/forms'; import { MatFormField } from '@angular/material/form-field'; import { merge, Subscription } from 'rxjs'; import { ErrorMessageProvider } from './error-message-provider'; @@ -18,12 +25,16 @@ import { ErrorMessageProvider } from './error-message-provider'; standalone: true, }) export class DynamicErrorDirective implements AfterContentInit, OnDestroy { + @Input({ + alias: 'schamanDynamicError', + }) + private formControl: AbstractControl | undefined; private text?: Text; private subscription?: Subscription; private readonly templateReference = inject(TemplateRef); private readonly viewContainer = inject(ViewContainerRef); private readonly renderer = inject(Renderer2); - private readonly matFormField = inject(MatFormField); + private readonly matFormField = inject(MatFormField, { optional: true }); private readonly errorMessageProvider = inject(ErrorMessageProvider); private readonly ngForm = inject(NgForm, { optional: true }); private readonly formGroupDirective = inject(FormGroupDirective, { @@ -31,9 +42,22 @@ export class DynamicErrorDirective implements AfterContentInit, OnDestroy { }); private readonly changeDetectorReference = inject(ChangeDetectorRef); public ngAfterContentInit(): void { - const matFormFieldControl = this.matFormField._control; - const ngControl = matFormFieldControl.ngControl; - const observables = [matFormFieldControl.stateChanges]; + const observables = []; + if (!this.matFormField && !this.formControl) { + if (isDevMode()) { + console.warn( + 'No form control provided. Please provide a form control or a mat form field.', + ); + } + } + const matFormFieldControl = this.matFormField?._control; + const ngControl = matFormFieldControl?.ngControl; + if (matFormFieldControl) { + observables.push(matFormFieldControl.stateChanges); + } + if (this.formControl) { + observables.push(this.formControl.statusChanges); + } if (this.ngForm) { observables.push(this.ngForm.ngSubmit); } @@ -41,8 +65,9 @@ export class DynamicErrorDirective implements AfterContentInit, OnDestroy { observables.push(this.formGroupDirective.ngSubmit); } this.subscription = merge(...observables).subscribe(() => { - if (matFormFieldControl.errorState && ngControl?.errors) { - this.addText(ngControl.errors); + const errors = this.formControl?.errors ?? ngControl?.errors; + if ((matFormFieldControl?.errorState ?? true) && errors) { + this.addText(errors); } else if (this.text) { this.removeText(); }