Skip to content

Commit

Permalink
feat(lib): formGroup validator oneOf
Browse files Browse the repository at this point in the history
This closes #91
  • Loading branch information
maxime1992 committed Aug 29, 2019
1 parent 6ecdb20 commit fa497bf
Show file tree
Hide file tree
Showing 9 changed files with 241 additions and 6 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ export class CrewMemberComponent extends NgxSubFormComponent<CrewMember> {

- `emitNullOnDestroy`: By default is set to `true` for `NgxSubFormComponent`, `NgxSubFormRemapComponent` and to `false` for `NgxRootFormComponent` and `NgxAutomaticRootFormComponent`. When set to `true`, if the sub form component is being destroyed, it will emit one last value: `null`. It might be useful to set it to `false` for e.g. when you've got a form accross multiple tabs and once a part of the form is filled you want to destroy it
- `emitInitialValueOnInit`: By default is set to `true` for `NgxSubFormComponent`, `NgxSubFormRemapComponent` and to `false` for `NgxRootFormComponent` and `NgxAutomaticRootFormComponent`. When set to `true`, the sub form component will emit the first value straight away (default one unless the component above as a value already set on the `formControl`)
- `ngxSubFormValidators`: An object containing validator methods. Currently, only`oneOf`is available. It lets you specify on a`formGroup`using`getFormGroupControlOptions` that amongst some properties, exactly one should be defined (nothing more, nothing less)

**Hooks**

Expand Down Expand Up @@ -553,7 +554,7 @@ class PasswordSubFormComponent extends NgxSubFormComponent<PasswordForm> {
};
}

public getFormGroupControlOptions(): FormGroupOptions<PasswordForm> {
protected getFormGroupControlOptions(): FormGroupOptions<PasswordForm> {
return {
validators: [
formGroup => {
Expand Down
13 changes: 13 additions & 0 deletions projects/ngx-sub-form/src/lib/ngx-sub-form-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ export class MissingFormControlsError<T extends string> extends Error {
}
}

export class OneOfValidatorRequiresMoreThanOneFieldError extends Error {
constructor() {
super(`"oneOf" validator requires to have at least 2 keys`);
}
}

export class OneOfValidatorUnknownFieldError extends Error {
constructor(field: string) {
super(`"oneOf" validator requires to keys from the FormInterface and "${field}" is not`);
}
}

export const NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES = {
debounce: <T, U>(time: number): ReturnType<NgxSubFormComponent<T, U>['handleEmissionRate']> => obs =>
obs.pipe(debounce(() => timer(time))),
Expand All @@ -85,6 +97,7 @@ export const NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES = {
* If the component already has a `ngOnDestroy` method defined, it will call this first.
* Note that the component *must* implement OnDestroy for this to work (the typings will enforce this anyway)
*/
/** @internal */
export function takeUntilDestroyed<T>(component: OnDestroy): (source: Observable<T>) => Observable<T> {
return (source: Observable<T>): Observable<T> => {
const onDestroy = new Subject();
Expand Down
122 changes: 121 additions & 1 deletion projects/ngx-sub-form/src/lib/ngx-sub-form.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="jasmine" />

import { FormControl, Validators, FormArray } from '@angular/forms';
import { FormControl, Validators, FormArray, ValidatorFn } from '@angular/forms';
import {
FormGroupOptions,
NgxSubFormComponent,
Expand All @@ -11,6 +11,8 @@ import {
ArrayPropertyKey,
ArrayPropertyValue,
NgxFormWithArrayControls,
OneOfValidatorRequiresMoreThanOneFieldError,
OneOfValidatorUnknownFieldError,
} from '../public_api';
import { Observable } from 'rxjs';

Expand Down Expand Up @@ -442,10 +444,37 @@ describe(`NgxSubFormComponent`, () => {
}
}

interface DroidForm {
assassinDroid: { type: 'Assassin' };
medicalDroid: { type: 'Medical' };
}

class DroidFormComponent extends NgxSubFormComponent<DroidForm> {
protected getFormControls() {
return {
assassinDroid: new FormControl(null),
medicalDroid: new FormControl(null),
};
}

public getFormGroupControlOptions(): FormGroupOptions<DroidForm> {
return {
validators: [this.ngxSubFormValidators.oneOf([['assassinDroid', 'medicalDroid']])],
};
}

// testing utility
public setValidatorOneOf(keysArray: (keyof DroidForm)[][]): void {
this.formGroup.setValidators([(this.ngxSubFormValidators.oneOf(keysArray) as unknown) as ValidatorFn]);
}
}

let validatedSubComponent: ValidatedSubComponent;
let droidFormComponent: DroidFormComponent;

beforeEach((done: () => void) => {
validatedSubComponent = new ValidatedSubComponent();
droidFormComponent = new DroidFormComponent();

// we have to call `updateValueAndValidity` within the constructor in an async way
// and here we need to wait for it to run
Expand Down Expand Up @@ -473,6 +502,97 @@ describe(`NgxSubFormComponent`, () => {
}, 0);
}, 0);
});

describe('ngxSubFormValidators', () => {
it('oneOf should throw an error if no value or only one in the array', () => {
expect(() => droidFormComponent.setValidatorOneOf(undefined as any)).toThrow(
new OneOfValidatorRequiresMoreThanOneFieldError(),
);

expect(() => droidFormComponent.setValidatorOneOf([])).toThrow(
new OneOfValidatorRequiresMoreThanOneFieldError(),
);

expect(() => droidFormComponent.setValidatorOneOf([[]])).toThrow(
new OneOfValidatorRequiresMoreThanOneFieldError(),
);

expect(() => droidFormComponent.setValidatorOneOf([['assassinDroid']])).toThrow(
new OneOfValidatorRequiresMoreThanOneFieldError(),
);

expect(() => droidFormComponent.setValidatorOneOf([['assassinDroid', 'medicalDroid']])).not.toThrow();
});

it('oneOf should throw an error if there is an unknown key', () => {
droidFormComponent.setValidatorOneOf([['unknown 1' as any, 'unknown 2' as any]]);
expect(() => droidFormComponent.formGroup.updateValueAndValidity()).toThrow(
new OneOfValidatorUnknownFieldError('unknown 1'),
);

droidFormComponent.setValidatorOneOf([['assassinDroid', 'unknown 2' as any]]);
expect(() => droidFormComponent.formGroup.updateValueAndValidity()).toThrow(
new OneOfValidatorUnknownFieldError('unknown 2'),
);
});

it('oneOf should return an object (representing the error) if all the values are null', (done: () => void) => {
const spyOnChange = jasmine.createSpy();
droidFormComponent.registerOnChange(spyOnChange);

droidFormComponent.formGroup.patchValue({ assassinDroid: null, medicalDroid: null });

setTimeout(() => {
expect(droidFormComponent.validate()).toEqual({ formGroup: { oneOf: [['assassinDroid', 'medicalDroid']] } });
expect(droidFormComponent.formGroupErrors).toEqual({
formGroup: { oneOf: [['assassinDroid', 'medicalDroid']] },
});

droidFormComponent.formGroup.patchValue({
assassinDroid: { type: 'Assassin' },
medicalDroid: null,
});
setTimeout(() => {
expect(droidFormComponent.validate()).toEqual(null);
expect(droidFormComponent.formGroupErrors).toEqual(null);
done();
}, 0);
}, 0);
});

it('oneOf should return an object (error) if more than one value are not [null or undefined]', (done: () => void) => {
const spyOnChange = jasmine.createSpy();
droidFormComponent.registerOnChange(spyOnChange);

droidFormComponent.formGroup.patchValue({ assassinDroid: null, medicalDroid: null });

setTimeout(() => {
expect(droidFormComponent.validate()).toEqual({ formGroup: { oneOf: [['assassinDroid', 'medicalDroid']] } });

droidFormComponent.formGroup.patchValue({
assassinDroid: { type: 'Assassin' },
medicalDroid: { type: 'Medical' },
});

setTimeout(() => {
expect(droidFormComponent.validate()).toEqual({
formGroup: { oneOf: [['assassinDroid', 'medicalDroid']] },
});

droidFormComponent.formGroup.patchValue({
assassinDroid: null,
medicalDroid: { type: 'Medical' },
});

setTimeout(() => {
expect(droidFormComponent.validate()).toEqual(null);

done();
}, 0);
}, 0);
}, 0);
});
});
});
});

Expand Down
67 changes: 66 additions & 1 deletion projects/ngx-sub-form/src/lib/ngx-sub-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@ import {
isNullOrUndefined,
ControlsType,
ArrayPropertyKey,
OneOfValidatorRequiresMoreThanOneFieldError,
OneOfValidatorUnknownFieldError,
} from './ngx-sub-form-utils';
import { FormGroupOptions, NgxFormWithArrayControls, OnFormUpdate, TypedFormGroup } from './ngx-sub-form.types';
import {
FormGroupOptions,
NgxFormWithArrayControls,
OnFormUpdate,
TypedFormGroup,
TypedValidatorFn,
} from './ngx-sub-form.types';

type MapControlFunction<FormInterface, MapValue> = (ctrl: AbstractControl, key: keyof FormInterface) => MapValue;
type FilterControlFunction<FormInterface> = (ctrl: AbstractControl, key: keyof FormInterface) => boolean;
Expand Down Expand Up @@ -66,6 +74,63 @@ export abstract class NgxSubFormComponent<ControlInterface, FormInterface = Cont

private controlKeys: (keyof FormInterface)[] = [];

// instead of having the validators defined in the utils file
// we define them here to have access to the form types

// `ngxSubFormValidators` should be used at a group level validation
// with the `getFormGroupControlOptions` hook
protected ngxSubFormValidators = {
oneOf(keysArray: (keyof FormInterface)[][]): TypedValidatorFn<FormInterface> {
if (!keysArray || !keysArray.length || keysArray.some(keys => !keys || keys.length < 2)) {
throw new OneOfValidatorRequiresMoreThanOneFieldError();
}

return (formGroup: TypedFormGroup<FormInterface>) => {
const oneOfErrors: (keyof FormInterface)[][] = keysArray.reduce(
(acc, keys) => {
if (!keys.length) {
return acc;
}

let nbNotNull = 0;
let cpt = 0;

while (cpt < keys.length && nbNotNull < 2) {
const key: keyof FormInterface = keys[cpt];

const control: AbstractControl | null = formGroup.get(key as string);

if (!control) {
throw new OneOfValidatorUnknownFieldError(key as string);
}

if (!isNullOrUndefined(control.value)) {
nbNotNull++;
}

cpt++;
}

if (nbNotNull !== 1) {
acc.push(keys);
}

return acc;
},
[] as (keyof FormInterface)[][],
);

if (oneOfErrors.length === 0) {
return null;
}

return {
oneOf: oneOfErrors,
};
};
},
};

// when developing the lib it's a good idea to set the formGroup type
// to current + `| undefined` to catch a bunch of possible issues
// see @note form-group-undefined
Expand Down
15 changes: 15 additions & 0 deletions src/app/app.spec.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ context(`EJawa demo`, () => {
DOM.createNewButton.click();

DOM.form.errors.obj.should('eql', {
formGroup: {
oneOf: [['vehicleProduct', 'droidProduct']],
},
listingType: {
required: true,
},
Expand All @@ -98,7 +101,13 @@ context(`EJawa demo`, () => {
DOM.form.elements.selectListingTypeByType(ListingType.VEHICLE);

DOM.form.errors.obj.should('eql', {
formGroup: {
oneOf: [['vehicleProduct', 'droidProduct']],
},
vehicleProduct: {
formGroup: {
oneOf: [['speeder', 'spaceship']],
},
vehicleType: {
required: true,
},
Expand Down Expand Up @@ -180,7 +189,13 @@ context(`EJawa demo`, () => {
DOM.form.elements.selectListingTypeByType(ListingType.DROID);

DOM.form.errors.obj.should('eql', {
formGroup: {
oneOf: [['vehicleProduct', 'droidProduct']],
},
droidProduct: {
formGroup: {
oneOf: [['assassinDroid', 'astromechDroid', 'protocolDroid', 'medicalDroid']],
},
droidType: {
required: true,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { Controls, NgxSubFormRemapComponent, subformComponentProviders } from 'ngx-sub-form';
import { Controls, NgxSubFormRemapComponent, subformComponentProviders, FormGroupOptions } from 'ngx-sub-form';
import {
AssassinDroid,
AstromechDroid,
Expand Down Expand Up @@ -64,4 +64,12 @@ export class DroidProductComponent extends NgxSubFormRemapComponent<OneDroid, On
throw new UnreachableCase(formValue.droidType);
}
}

protected getFormGroupControlOptions(): FormGroupOptions<OneDroidForm> {
return {
validators: [
this.ngxSubFormValidators.oneOf([['assassinDroid', 'astromechDroid', 'protocolDroid', 'medicalDroid']]),
],
};
}
}
7 changes: 7 additions & 0 deletions src/app/main/listing/listing-form/listing-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
// NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES,
DataInput,
NgxRootFormComponent,
FormGroupOptions,
} from 'ngx-sub-form';
import { tap } from 'rxjs/operators';
import { ListingType, OneListing } from 'src/app/interfaces/listing.interface';
Expand Down Expand Up @@ -89,4 +90,10 @@ export class ListingFormComponent extends NgxRootFormComponent<OneListing, OneLi
...commonValues,
};
}

protected getFormGroupControlOptions(): FormGroupOptions<OneListingForm> {
return {
validators: [this.ngxSubFormValidators.oneOf([['vehicleProduct', 'droidProduct']])],
};
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { Controls, NgxSubFormRemapComponent, subformComponentProviders } from 'ngx-sub-form';
import { Controls, NgxSubFormRemapComponent, subformComponentProviders, FormGroupOptions } from 'ngx-sub-form';
import { OneVehicle, Spaceship, Speeder, VehicleType } from 'src/app/interfaces/vehicle.interface';
import { UnreachableCase } from 'src/app/shared/utils';

Expand Down Expand Up @@ -47,4 +47,10 @@ export class VehicleProductComponent extends NgxSubFormRemapComponent<OneVehicle
throw new UnreachableCase(formValue.vehicleType);
}
}

protected getFormGroupControlOptions(): FormGroupOptions<OneVehicleForm> {
return {
validators: [this.ngxSubFormValidators.oneOf([['speeder', 'spaceship']])],
};
}
}
2 changes: 1 addition & 1 deletion src/readme/password-sub-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class PasswordSubFormComponent extends NgxSubFormComponent<PasswordForm> {
};
}

public getFormGroupControlOptions(): FormGroupOptions<PasswordForm> {
protected getFormGroupControlOptions(): FormGroupOptions<PasswordForm> {
return {
validators: [
formGroup => {
Expand Down

0 comments on commit fa497bf

Please sign in to comment.