Angular 15 directive - trim (input) on blur
In this article, I'll present the process of creating and testing Angular 15 directive. The directive trim on blur will be responsible for removing white spaces after firing the blur event on the current element. The supported elements are input and textarea. Everything will be based on Angular Forms mechanics.
Requirements
Installed libraries: NodeJs 16, NPM 8, npx
The whole project was set up in Linux environment.
Project initialization
Install Angular CLI version 15
npm install -g @angular/cli@15
Create new project
ng new app --minimal=true --defaults=true
A detailed description of the options: angular.io/cli/new
Navigate to the project directory
cd app
Install additional dependencies
npm i jest@29 @types/jest@29 @angular/material@15 jest-preset-angular@13
The how to integrate Jest with Angular can be found here: github.com/thymikee/jest-preset-angular
Create directive
The directive will be created and exported as a separate module.
Generate module:
ng generate module directives/trim-on-blur
Generate directive:
ng generate directive --export=true directives/trim-on-blur/trim-on-blur
Implement directive
Open file app/src/app/directives/trim-on-blur/trim-on-blur.directive.ts
Start with the selector's modification. The directive will only be used with textarea and input elements.
@Directive({
selector: 'textarea[appTrimOnBlur], input[appTrimOnBlur]',
})
Create a constructor and add two dependencies:
constructor(
@Optional() private formControlDir: FormControlDirective,
@Optional() private formControlName: FormControlName
) {}
formControlDir: FormControlDirective - used in conjunction with formControl,
formControlName: FormControlName - used in conjunction with formControlName,
Only one of two dependencies will be injected into our directive.
Create the onBlur method and decorate it with @HostListener('blur') decorator in order to take advantage of the native blur event.
@HostListener('blur')
onBlur(): void {
}
Implement blur event handler.
@HostListener('blur')
onBlur(): void {
const control = this.formControlDir?.control || this.formControlName?.control;
if (!control) {
return;
}
const value = control.value;
if (value == null) {
return;
}
const trimmed = value.trim();
control.patchValue(trimmed);
}
The method works as follows:
- get the control from the dependencies and if it's not present then return
const control = this.formControlDir?.control || this.formControlName?.control;
if (!control) {
return;
}
- if the value is null or undefined then return
const value = control.value;
if (value == null) {
return;
}
- remove whitespaces from the start and the end of the current value and update the control
const trimmed = value.trim();
control.patchValue(trimmed);
Use directive
Modify the file app/src/app/app.component.ts by declaring controls.
import { Component } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styles: []
})
export class AppComponent {
textAreaControl = new FormControl();
inputControl = new FormControl();
form = new FormGroup({
input: new FormControl(),
textarea: new FormControl(),
});
}
Create view app/src/app/app.component.html and add form fields.
<div style="display: flex; flex-direction: column; width: 400px">
<mat-form-field>
<mat-label>Textarea formControl</mat-label>
<textarea
[formControl]="textAreaControl"
appTrimOnBlur
matInput
></textarea>
</mat-form-field>
<mat-form-field>
<mat-label>Input formControl</mat-label>
<input [formControl]="inputControl" appTrimOnBlur matInput/>
</mat-form-field>
<form [formGroup]="form">
<mat-form-field>
<mat-label>Textarea formControlName</mat-label>
<textarea
formControlName='textarea'
appTrimOnBlur
matInput
></textarea>
</mat-form-field>
<mat-form-field>
<mat-label>Input formControlName</mat-label>
<input formControlName='input' appTrimOnBlur matInput/>
</mat-form-field>
</form>
</div>
Start the application by running:
npm start
Navigate to localhost:4200 and verify manually if the directive works.
Testing
As part of the tests, three groups of controls will be tested:
with formControl
with formControlName
without any of the above
Create file app/src/app/directives/trim-on-blur/trim-on-blur.directive.spec.ts and add the following code:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatInputHarness } from '@angular/material/input/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { CommonModule } from '@angular/common';
import { TrimOnBlurModule } from './trim-on-blur.module';
describe('TrimOnBlurModule', () => {
let fixture: ComponentFixture<TestComponent>;
let loader: HarnessLoader;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
CommonModule,
NoopAnimationsModule,
MatFormFieldModule,
ReactiveFormsModule,
MatInputModule,
TrimOnBlurModule
],
declarations: [TestComponent],
});
fixture = TestBed.createComponent(TestComponent);
loader = TestbedHarnessEnvironment.loader(fixture);
});
describe('when component is initialized', () => {
describe('test formControl', () => {
describe('test textarea', () => {
describe('and user sets "test me" value', () => {
beforeEach(async () => {
const input = await getTextareaHarness(formControlWrapperAncestor);
await input.focus();
await input.setValue('test me');
await input.blur();
fixture.detectChanges();
});
it('should not change control value', async () => {
const input = await getTextareaHarness(formControlWrapperAncestor);
const value = await input.getValue();
expect(value).toBe('test me');
});
});
describe('and user sets " test me " value', () => {
beforeEach(async () => {
const input = await getTextareaHarness(formControlWrapperAncestor);
await input.focus();
await input.setValue(' test me ');
await input.blur();
fixture.detectChanges();
});
it('should update control value', async () => {
const input = await getTextareaHarness(formControlWrapperAncestor);
const value = await input.getValue();
expect(value).toBe('test me');
});
});
});
describe('test input', () => {
describe('and user sets "test me" value', () => {
beforeEach(async () => {
const input = await getInputHarness(formControlWrapperAncestor);
await input.focus();
await input.setValue('test me');
await input.blur();
fixture.detectChanges();
});
it('should not change control value', async () => {
const input = await getInputHarness(formControlWrapperAncestor);
const value = await input.getValue();
expect(value).toBe('test me');
});
});
describe('and user sets " test me " value', () => {
beforeEach(async () => {
const input = await getInputHarness(formControlWrapperAncestor);
await input.focus();
await input.setValue(' test me ');
await input.blur();
fixture.detectChanges();
});
it('should update control value', async () => {
const input = await getInputHarness(formControlWrapperAncestor);
const value = await input.getValue();
expect(value).toBe('test me');
});
});
});
})
describe('test formControlName', () => {
describe('test textarea', () => {
describe('and user sets "test me" value', () => {
beforeEach(async () => {
const input = await getTextareaHarness(formControlNameWrapperAncestor);
await input.focus();
await input.setValue('test me');
await input.blur();
fixture.detectChanges();
});
it('should not change control value', async () => {
const input = await getTextareaHarness(formControlNameWrapperAncestor);
const value = await input.getValue();
expect(value).toBe('test me');
});
});
describe('and user sets " test me " value', () => {
beforeEach(async () => {
const input = await getTextareaHarness(formControlNameWrapperAncestor);
await input.focus();
await input.setValue(' test me ');
await input.blur();
fixture.detectChanges();
});
it('should update control value', async () => {
const input = await getTextareaHarness(formControlNameWrapperAncestor);
const value = await input.getValue();
expect(value).toBe('test me');
});
});
});
describe('test input', () => {
describe('and user sets "test me" value', () => {
beforeEach(async () => {
const input = await getInputHarness(formControlNameWrapperAncestor);
await input.focus();
await input.setValue('test me');
await input.blur();
fixture.detectChanges();
});
it('should not change control value', async () => {
const input = await getInputHarness(formControlNameWrapperAncestor);
const value = await input.getValue();
expect(value).toBe('test me');
});
});
describe('and user sets " test me " value', () => {
beforeEach(async () => {
const input = await getInputHarness(formControlNameWrapperAncestor);
await input.focus();
await input.setValue(' test me ');
await input.blur();
fixture.detectChanges();
});
it('should update control value', async () => {
const input = await getInputHarness(formControlNameWrapperAncestor);
const value = await input.getValue();
expect(value).toBe('test me');
});
});
});
});
describe('test elements without form control', () => {
describe('test textarea', () => {
describe('and user sets "test me" value', () => {
beforeEach(async () => {
const input = await getTextareaHarness(noControlWrapperAncestor);
await input.focus();
await input.setValue('test me');
await input.blur();
fixture.detectChanges();
});
it('should not change control value', async () => {
const input = await getTextareaHarness(noControlWrapperAncestor);
const value = await input.getValue();
expect(value).toBe('test me');
});
});
describe('and user sets " test me " value', () => {
beforeEach(async () => {
const input = await getTextareaHarness(noControlWrapperAncestor);
await input.focus();
await input.setValue(' test me ');
await input.blur();
fixture.detectChanges();
});
it('should not change control value', async () => {
const input = await getTextareaHarness(noControlWrapperAncestor);
const value = await input.getValue();
expect(value).toBe(' test me ');
});
});
});
describe('test input', () => {
describe('and user sets "test me" value', () => {
beforeEach(async () => {
const input = await getInputHarness(noControlWrapperAncestor);
await input.focus();
await input.setValue('test me');
await input.blur();
fixture.detectChanges();
});
it('should not change control value', async () => {
const input = await getInputHarness(noControlWrapperAncestor);
const value = await input.getValue();
expect(value).toBe('test me');
});
});
describe('and user sets " test me " value', () => {
beforeEach(async () => {
const input = await getInputHarness(noControlWrapperAncestor);
await input.focus();
await input.setValue(' test me ');
await input.blur();
fixture.detectChanges();
});
it('should not change control value', async () => {
const input = await getInputHarness(noControlWrapperAncestor);
const value = await input.getValue();
expect(value).toBe(' test me ');
});
});
});
});
});
const getTextareaHarness = (ancestor: string): Promise<MatInputHarness> => {
return loader.getHarness(
MatInputHarness.with({
ancestor,
})
);
};
const getInputHarness = (ancestor: string): Promise<MatInputHarness> => {
return loader.getHarness(
MatInputHarness.with({
ancestor,
})
);
};
});
const formControlWrapperAncestor = '.form-control-wrapper';
const formControlNameWrapperAncestor = '.form-control-name-wrapper';
const noControlWrapperAncestor = '.no-control-wrapper';
@Component({
selector: 'app-test',
template: `
<div class="form-control-wrapper">
<mat-form-field>
<mat-label>Textarea formControl</mat-label>
<textarea
[formControl]="textAreaControl"
appTrimOnBlur
matInput
></textarea>
</mat-form-field>
<mat-form-field>
<mat-label>Input formControl</mat-label>
<input [formControl]="inputControl" appTrimOnBlur matInput/>
</mat-form-field>
</div>
<div class="form-control-name-wrapper">
<form [formGroup]="form">
<mat-form-field>
<mat-label>Textarea formControlName</mat-label>
<textarea
formControlName='textarea'
appTrimOnBlur
matInput
></textarea>
</mat-form-field>
<mat-form-field>
<mat-label>Input formControlName</mat-label>
<input formControlName='input' appTrimOnBlur matInput/>
</mat-form-field>
</form>
</div>
<div class="no-control-wrapper">
<textarea appTrimOnBlur matInput></textarea>
<input appTrimOnBlur matInput/>
</div>
`,
})
export class TestComponent {
textAreaControl = new FormControl();
inputControl = new FormControl();
form = new FormGroup({
input: new FormControl(),
textarea: new FormControl(),
});
}
The initial beforeEach is responsible for initialization. Remember to import all required modules.
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
CommonModule,
NoopAnimationsModule,
MatFormFieldModule,
ReactiveFormsModule,
MatInputModule,
TrimOnBlurModule
],
declarations: [TestComponent],
});
fixture = TestBed.createComponent(TestComponent);
loader = TestbedHarnessEnvironment.loader(fixture);
});
The definition of the test component does not require any extra explanation. It's just a simple component with controls that are being tested.
@Component({
selector: 'app-test',
template: `
<div class="form-control-wrapper">
<mat-form-field>
<mat-label>Textarea formControl</mat-label>
<textarea
[formControl]="textAreaControl"
appTrimOnBlur
matInput
></textarea>
</mat-form-field>
<mat-form-field>
<mat-label>Input formControl</mat-label>
<input [formControl]="inputControl" appTrimOnBlur matInput/>
</mat-form-field>
</div>
<div class="form-control-name-wrapper">
<form [formGroup]="form">
<mat-form-field>
<mat-label>Textarea formControlName</mat-label>
<textarea
formControlName='textarea'
appTrimOnBlur
matInput
></textarea>
</mat-form-field>
<mat-form-field>
<mat-label>Input formControlName</mat-label>
<input formControlName='input' appTrimOnBlur matInput/>
</mat-form-field>
</form>
</div>
<div class="no-control-wrapper">
<textarea appTrimOnBlur matInput></textarea>
<input appTrimOnBlur matInput/>
</div>
`,
})
export class TestComponent {
textAreaControl = new FormControl();
inputControl = new FormControl();
form = new FormGroup({
input: new FormControl(),
textarea: new FormControl(),
});
}
To test angular material elements use MatHarness.
const getTextareaHarness = (ancestor: string): Promise<MatInputHarness> => {
return loader.getHarness(
MatInputHarness.with({
ancestor,
})
);
};
const getInputHarness = (ancestor: string): Promise<MatInputHarness> => {
return loader.getHarness(
MatInputHarness.with({
ancestor,
})
);
};
Run the tests:
npx jest