Skip to content

feat(material-error): add generic form error messages #16

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 21, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/angular/material-error/package.json
Original file line number Diff line number Diff line change
@@ -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"
},
356 changes: 286 additions & 70 deletions packages/angular/material-error/src/lib/dynamic-error.directive.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `<mat-form-field>
<input matInput [formControl]="formControl" />
<mat-error *schamanDynamicError></mat-error>
</mat-form-field>`,
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: `<form [formGroup]="formGroup">
<mat-form-field>
<input matInput [formControl]="formControl" />
<mat-error *schamanDynamicError></mat-error>
</mat-form-field>
</form>`,
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: `<form>
<mat-form-field>
<input matInput [(ngModel)]="value" required name="test" />
<mat-error *schamanDynamicError></mat-error>
</mat-form-field>
</form>`,
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: `<form [formGroup]="formGroup">
<input [formControl]="formControl" />
<div *schamanDynamicError="formControl"></div>
</form>`,
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');
});
});
});
39 changes: 32 additions & 7 deletions packages/angular/material-error/src/lib/dynamic-error.directive.ts
Original file line number Diff line number Diff line change
@@ -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,31 +25,49 @@ 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, {
optional: true,
});
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);
}
if (this.formGroupDirective) {
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();
}