Skip to content
This repository has been archived by the owner on May 3, 2024. It is now read-only.

Commit

Permalink
feat: 馃幐 dependentValidator
Browse files Browse the repository at this point in the history
  • Loading branch information
DmitryEfimenko committed Jan 19, 2021
1 parent 618b4cb commit b49b82f
Show file tree
Hide file tree
Showing 11 changed files with 270 additions and 20 deletions.
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ The design of this library promotes less boilerplate code, which keeps your temp
- [Handling form submission](#handling_form_submission)
- [Getting error details](#getting_error_details)
- [Styling](#styling)
- [Miscellaneous](#miscellaneous)
- [Development](#development)

## How it works
Expand Down Expand Up @@ -236,6 +237,64 @@ Include something similar to the following in global CSS file:
}
```

## Miscellaneous

ngx-errors library provides a couple of misc function that ease your work with forms.

### **dependentValidator**
Makes it easy to trigger validation on the control, that depends on a value of a different control

Example with using `FormBuilder`:
```ts
import { dependentValidator } from '@ngspot/ngx-errors';

export class LazyComponent {
constructor(fb: FormBuilder) {
this.form = fb.group({
password: ['', Validators.required],
confirmPassword: ['', dependentValidator<string>({
watchControl: f => f!.get('password')!,
validator: (passwordValue) => isEqualToValidator(passwordValue)
})],
});
}
}

function isEqualToValidator<T>(compareVal: T): ValidatorFn {
return function(control: AbstractControl): ValidationErrors | null {
return control.value === compareVal
? null
: { match: { expected: compareVal, actual: control.value } };
}
}
```

The `dependentValidator` may also take `condition`. If provided, it needs to return true for the validator to be used.

```ts
const controlA = new FormControl('');
const controlB = new FormControl('', dependentValidator<string>({
watchControl: () => controlA,
validator: () => Validators.required,
condition: (val) => val === 'fire'
}));
```
In the example above, the `controlB` will only be required when `controlA` value is `'fire'`

### **extractTouchedChanges**
As of today, the FormControl does not provide a way to subscribe to the changes of `touched` status. This function lets you do just that:

```ts
* const touchedChanged$ = extractTouchedChanges(formControl);
```

### **markDescendantsAsDirty**
As of today, the FormControl does not provide a way to mark the control and all its children as `dirty`. This function lets you do just that:

```ts
markDescendantsAsDirty(formControl);
```

## Development

### Basic Workflow
Expand Down
4 changes: 2 additions & 2 deletions projects/ngx-errors/package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"name": "@ngspot/ngx-errors",
"version": "2.0.0",
"version": "2.0.1",
"description": "Handle error messages in Angular forms with ease",
"peerDependencies": {
"@angular/core": "^9.0.0",
"@angular/core": ">= 9.0.0",
"tslib": "^1.10.0"
},
"author": {
Expand Down
30 changes: 29 additions & 1 deletion projects/ngx-errors/src/lib/misc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AbstractControl } from '@angular/forms';
import { AbstractControl, FormArray, FormGroup } from '@angular/forms';
import { Observable, Subject } from 'rxjs';

/**
Expand Down Expand Up @@ -48,3 +48,31 @@ export const extractTouchedChanges = (

return touchedChanges$.asObservable();
};

/**
* Marks the provided control as well as all of its children as dirty
* @param options to be passed into control.markAsDirty() call
*/
export function markDescendantsAsDirty(
control: AbstractControl,
options?: {
onlySelf?: boolean;
emitEvent?: boolean;
}
) {
control.markAsDirty(options);

if (control instanceof FormGroup || control instanceof FormArray) {
let controls = Object.keys(control.controls).map(
(controlName) => control.get(controlName)!
);

controls.forEach((control) => {
control.markAsDirty(options);

if ((control as FormGroup | FormArray).controls) {
markDescendantsAsDirty(control, options);
}
});
}
}
93 changes: 93 additions & 0 deletions projects/ngx-errors/src/lib/validators.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
AbstractControl,
FormControl,
ValidationErrors,
ValidatorFn,
} from '@angular/forms';
import { dependentValidator } from './validators';

function matchValidator<T>(compareVal: T): ValidatorFn {
return function (control: AbstractControl): ValidationErrors | null {
return control.value === compareVal
? null
: { match: { expected: compareVal, actual: control.value } };
};
}

describe('dependentValidator', () => {
let controlA: FormControl;
let controlB: FormControl;

let controlAValue: string;
let controlBValue: string;

// let matchValidatorSpy: jasmine.Spy<typeof matchValidator>;
let condition: ((val?: any) => boolean) | undefined;

Given(() => {
controlAValue = '';
controlBValue = '';
condition = undefined;
// matchValidatorSpy = jasmine.createSpy('matchValidator', matchValidator).and.callThrough();
});

When(() => {
controlA = new FormControl(controlAValue);
controlB = new FormControl(
controlBValue,
dependentValidator<string>({
watchControl: () => controlA,
validator: (val) => matchValidator(val),
condition,
})
);
});

describe('controlA.value === controlB.value', () => {
Given(() => (controlAValue = ''));
Then('Control B is valid', () => expect(controlB.valid).toBe(true));
});

describe('controlA.value !== controlB.value', () => {
Given(() => (controlAValue = 'asd'));
Then('Control B is invalid', () => expect(controlB.valid).toBe(false));
});

describe('controlA.value !== controlB.value, then updated to match', () => {
Given(() => {
controlAValue = 'asd';
controlBValue = 'qwe';
});

Then('Control B is valid', () => {
controlA.setValue(controlBValue);
expect(controlB.valid).toBe(true);
});
});

describe('condition is provided', () => {
describe('GIVEN: condition returns false', () => {
Given(() => {
controlAValue = 'not Dima';
controlBValue = 'two';
condition = (val) => val === 'Dima';
});

Then('Control B is valid', () => {
expect(controlB.valid).toBe(true);
});
});

describe('GIVEN: condition returns true', () => {
Given(() => {
controlAValue = 'Dima';
controlBValue = 'two';
condition = (val) => val === 'Dima';
});

Then('Control B is invalid', () => {
expect(controlB.valid).toBe(false);
});
});
});
});
61 changes: 61 additions & 0 deletions projects/ngx-errors/src/lib/validators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { isDevMode } from '@angular/core';
import { AbstractControl, ValidatorFn } from '@angular/forms';

export interface DependentValidatorOptions<T> {
/**
* Function that returns AbstractControl to watch
* @param form - the root FormGroup of the control being validated
*/
watchControl: (form?: AbstractControl) => AbstractControl;
/**
* @param watchControlValue - the value of the control being watched
* @returns ValidatorFn. Ex: Validators.required
*/
validator: (watchControlValue?: T) => ValidatorFn;
/**
* If the condition is provided, it must return true in order for the
* validator to be applied.
* @param watchControlValue - the value of the control being watched
*/
condition?: (watchControlValue?: T) => boolean;
}

/**
* Makes it easy to trigger validation on the control, that depends on
* a value of a different control
*/
export function dependentValidator<T = any>(
opts: DependentValidatorOptions<T>
) {
let subscribed = false;

return (formControl: AbstractControl) => {
const form = formControl.root;
const { watchControl, condition, validator } = opts;
const controlToWatch = watchControl(form);

if (!controlToWatch) {
if (isDevMode()) {
console.warn(
`dependentValidator could not find specified watchControl`
);
}
return null;
}

if (!subscribed) {
subscribed = true;

controlToWatch.valueChanges.subscribe(() => {
formControl.updateValueAndValidity();
});
}

if (condition === undefined || condition(controlToWatch.value)) {
const validatorFn = validator(controlToWatch.value);
return validatorFn(formControl);
}

return null;
};
}
2 changes: 2 additions & 0 deletions projects/ngx-errors/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export * from './lib/errors-configuration';
export * from './lib/errors.directive';
export * from './lib/errors.module';
export * from './lib/ngx-errors';
export * from './lib/validators';
export * from './lib/misc';
15 changes: 2 additions & 13 deletions projects/playground/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,8 @@ import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule
],
declarations: [
AppComponent
],
imports: [RouterTestingModule],
declarations: [AppComponent],
}).compileComponents();
}));

Expand All @@ -25,11 +21,4 @@ describe('AppComponent', () => {
const app = fixture.componentInstance;
expect(app.title).toEqual('playground');
});

it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.content span').textContent).toContain('playground app is running!');
});
});
12 changes: 10 additions & 2 deletions projects/playground/src/app/lazy/lazy.component.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<form [formGroup]="form">
<input type="text" formControlName="firstName" />
<label>
First Name
<input type="text" formControlName="firstName" />
</label>

<pre>
Dirty: {{ form.controls.firstName.dirty }}
Expand All @@ -11,7 +14,12 @@
</div>

<div formGroupName="address">
<input type="text" formControlName="street" />
<label>
Street
<input type="text" formControlName="street" />
</label>

<pre>{{ form.get('address.street')?.errors | json }}</pre>

<div ngxErrors="street">
<div ngxError="required">Street name is required</div>
Expand Down
2 changes: 2 additions & 0 deletions projects/playground/src/app/lazy/lazy.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';

import { LazyComponent } from './lazy.component';

Expand All @@ -8,6 +9,7 @@ describe('LazyComponent', () => {

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [LazyComponent],
}).compileComponents();
}));
Expand Down
10 changes: 9 additions & 1 deletion projects/playground/src/app/lazy/lazy.component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { dependentValidator } from '@ngspot/ngx-errors';

@Component({
selector: 'app-lazy',
Expand All @@ -13,7 +14,14 @@ export class LazyComponent implements OnInit {
this.form = fb.group({
firstName: ['', Validators.required],
address: fb.group({
street: ['', Validators.required],
street: [
'',
dependentValidator<string>({
watchControl: (f) => f!.get('firstName')!,
condition: (val) => !!val,
validator: () => Validators.required,
}),
],
}),
});
}
Expand Down
2 changes: 1 addition & 1 deletion projects/playground/src/app/lazy/lazy.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { LazyComponent } from './lazy.component';
LazyRoutingModule,
ReactiveFormsModule,
NgxErrorsModule.configure({
showErrorsWhenInput: 'dirty',
showErrorsWhenInput: 'touched',
}),
],
})
Expand Down

0 comments on commit b49b82f

Please sign in to comment.