Skip to content
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

[WIP] fix(lib): disable root form at beginning #115

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Really small bundle (< 15kb) and no module to setup. Pick the class you need and

Built for **all your different forms** (tiny to extra large!), this library will deal with all the boilerplate required to use a [`ControlValueAccessor`](https://blog.angularindepth.com/never-again-be-confused-when-implementing-controlvalueaccessor-in-angular-forms-93b9eee9ee83) internally and let you manage your most complex forms in a fast and easy way.

From creating a small custom input, to breaking down a form into multiple sub components, `ngx-sub-form` will give you a lot of functionalities like better type safety to survive futur refactors (from both `TS` and `HTML`), remapping external data to the shape you need within your form, access nested errors and many more. It'll also save you from passing a `FormGroup` to an `@Input` :pray:.
From creating a small custom input, to breaking down a form into multiple sub components, `ngx-sub-form` will give you a lot of functionalities like better type safety to survive future refactors (from both `TS` and `HTML`), remapping external data to the shape you need within your form, access nested errors and many more. It'll also save you from passing a `FormGroup` to an `@Input` :pray:.

It also works particularly well with polymorphic data structures.

Expand Down Expand Up @@ -122,7 +122,7 @@ export interface OneListingForm {
})
export class ListingComponent extends NgxAutomaticRootFormComponent<OneListing, OneListingForm> {
// as we're renaming the input, it'd be impossible for ngx-sub-form to guess
// the name of your input to then check within the `ngOnChanges` hook wheter
// the name of your input to then check within the `ngOnChanges` hook whether
// it has been updated or not
// another solution would be to ask you to use a setter and call a hook but
// this is too verbose, that's why we created a decorator `@DataInput`
Expand Down Expand Up @@ -202,9 +202,9 @@ _Note the presence of disabled, this is an optional input provided by both `NgxR
Differences between:

- `NgxRootFormComponent`: Will never emit the form value automatically when it changes, to emit the value you'll have to call the method `manualSave` when needed
- `NgxAutomaticRootFormComponent`: Will emit the form value as soon as there's a change. It's possible to customize the emission rate by overidding the `handleEmissionRate` method
- `NgxAutomaticRootFormComponent`: Will emit the form value as soon as there's a change. It's possible to customize the emission rate by overriding the `handleEmissionRate` method

The method `handleEmissionRate` is available accross **all** the classes that `ngx-sub-form` offers. It takes an observable as input and expect another observable as output. One common case is to simply [`debounce`](http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#instance-method-debounce) the emission. If that's what you want to do, instead of manipulating the observable chain yourself you can just do:
The method `handleEmissionRate` is available across **all** the classes that `ngx-sub-form` offers. It takes an observable as input and expect another observable as output. One common case is to simply [`debounce`](http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#instance-method-debounce) the emission. If that's what you want to do, instead of manipulating the observable chain yourself you can just do:

```ts
// src/readme/handle-emission-rate.ts#L6-L9
Expand Down Expand Up @@ -258,7 +258,7 @@ _Simplified from the original example into src folder to keep the example as min

### Remapping Data

It is a frequent pattern to have the data that you're trying to modify in a format that is incovenient to the angular forms structural constraints. For this reason, `ngx-form-component` offers a separate class `NgxSubFormRemapComponent`
It is a frequent pattern to have the data that you're trying to modify in a format that is inconvenient to the angular forms structural constraints. For this reason, `ngx-form-component` offers a separate class `NgxSubFormRemapComponent`
which will require you to define two interfaces:

- One to model the data going into the form
Expand Down Expand Up @@ -533,7 +533,7 @@ export class CrewMemberComponent extends NgxSubFormComponent<CrewMember> {

**Properties**

- `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
- `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 across 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`)

**Hooks**
Expand Down
2 changes: 2 additions & 0 deletions projects/ngx-sub-form/src/lib/ngx-root-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export abstract class NgxRootFormComponent<ControlInterface, FormInterface = Con
protected dataValue: ControlInterface | null = null;

public ngOnInit(): void {
super.ngOnInit();

// we need to manually call registerOnChange because that function
// handles most of the logic from NgxSubForm and when it's called
// as a ControlValueAccessor that function is called by Angular itself
Expand Down
7 changes: 7 additions & 0 deletions projects/ngx-sub-form/src/lib/ngx-sub-form.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class SubComponentWithDefaultValues extends NgxSubFormComponent<Vehicle> {
describe(`Common`, () => {
it(`should call formGroup.updateValueAndValidity only if formGroup is defined`, (done: () => void) => {
const subComponent: SubComponent = new SubComponent();
subComponent.ngOnInit();

const formGroupSpy = spyOn(subComponent.formGroup, 'updateValueAndValidity');

Expand All @@ -96,8 +97,11 @@ describe(`NgxSubFormComponent`, () => {

beforeEach((done: () => void) => {
subComponent = new SubComponent();
subComponent.ngOnInit();
debouncedSubComponent = new DebouncedSubComponent();
debouncedSubComponent.ngOnInit();
subComponentWithDefaultValues = new SubComponentWithDefaultValues();
subComponentWithDefaultValues.ngOnInit();

// 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 @@ -512,6 +516,7 @@ describe(`NgxSubFormComponent`, () => {

beforeEach((done: () => void) => {
validatedSubComponent = new ValidatedSubComponent();
validatedSubComponent.ngOnInit();

// 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 @@ -580,6 +585,7 @@ describe(`NgxSubFormRemapComponent`, () => {

beforeEach((done: () => void) => {
subRemapComponent = new SubRemapComponent();
subRemapComponent.ngOnInit();

// 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 @@ -685,6 +691,7 @@ describe(`SubArrayComponent`, () => {

beforeEach((done: () => void) => {
subArrayComponent = new SubArrayComponent();
subArrayComponent.ngOnInit();

// we have to call `updateValueAndValidity` within the constructor in an async way
// and here we need to wait for it to run
Expand Down
144 changes: 89 additions & 55 deletions projects/ngx-sub-form/src/lib/ngx-sub-form.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { OnDestroy } from '@angular/core';
import { OnDestroy, OnInit } from '@angular/core';
import {
AbstractControl,
AbstractControlOptions,
Expand All @@ -9,8 +9,8 @@ import {
FormArray,
FormControl,
} from '@angular/forms';
import { merge, Observable, Subscription } from 'rxjs';
import { delay, filter, map, startWith, withLatestFrom } from 'rxjs/operators';
import { merge, Observable, Subscription, Subject, pipe, BehaviorSubject } from 'rxjs';
import { delay, filter, map, startWith, withLatestFrom, shareReplay, tap, switchMap } from 'rxjs/operators';
import {
ControlMap,
Controls,
Expand All @@ -28,7 +28,17 @@ type MapControlFunction<FormInterface, MapValue> = (ctrl: AbstractControl, key:
type FilterControlFunction<FormInterface> = (ctrl: AbstractControl, key: keyof FormInterface) => boolean;

export abstract class NgxSubFormComponent<ControlInterface, FormInterface = ControlInterface>
implements ControlValueAccessor, Validator, OnDestroy, OnFormUpdate<FormInterface> {
implements OnInit, ControlValueAccessor, Validator, OnDestroy, OnFormUpdate<FormInterface> {
private ngOnInit$$: Subject<void> = new Subject();

// if we try to call setDisabledState before `ngOnInit`
// (and before the form is initialised, which might be the case for top RootForm*)
// it won't be taken into account, therefore we use a Subject to only apply that once
// the form is ready (after `ngOnInit`)
private disabledState$$: BehaviorSubject<boolean | null> = new BehaviorSubject(null) as BehaviorSubject<
boolean | null
>;

public get formGroupControls(): ControlsType<FormInterface> {
// @note form-group-undefined we need the return null here because we do not want to expose the fact that
// the form can be undefined, it's handled internally to contain an Angular bug
Expand Down Expand Up @@ -69,19 +79,47 @@ export abstract class NgxSubFormComponent<ControlInterface, FormInterface = Cont
// 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
public formGroup: TypedFormGroup<FormInterface> = (new FormGroup(
this._getFormControls(),
this.getFormGroupControlOptions() as AbstractControlOptions,
) as unknown) as TypedFormGroup<FormInterface>;
// formGroup will only be defined after ngOnInit
// otherwise it's impossible to access class properties
// from the `getFormControls` method which is an issue with validators
// see https://github.com/cloudnc/ngx-sub-form/issues/82
public formGroup!: TypedFormGroup<FormInterface>;

protected onChange: Function | undefined = undefined;
protected onTouched: Function | undefined = undefined;
protected emitNullOnDestroy = true;
protected emitInitialValueOnInit = true;

private subscription: Subscription | undefined = undefined;
private readonly subscription: Subscription = new Subscription();

constructor() {
this.subscription.add(
this.ngOnInit$$
.pipe(
switchMap(() =>
this.disabledState$$.pipe(
filter(x => !isNullOrUndefined(x)),
delay(0),
tap(shouldDisable => {
if (shouldDisable) {
this.formGroup.disable({ emitEvent: false });
} else {
this.formGroup.enable({ emitEvent: false });
}
}),
),
),
)
.subscribe(),
);
}

public ngOnInit(): void {
this.formGroup = (new FormGroup(
this._getFormControls(),
this.getFormGroupControlOptions() as AbstractControlOptions,
) as unknown) as TypedFormGroup<FormInterface>;

// if the form has default values, they should be applied straight away
const defaultValues: Partial<FormInterface> | null = this.getDefaultValues();
if (!!defaultValues) {
Expand All @@ -98,6 +136,8 @@ export abstract class NgxSubFormComponent<ControlInterface, FormInterface = Cont
this.formGroup.updateValueAndValidity({ emitEvent: false });
}
}, 0);

this.ngOnInit$$.next();
}

// can't define them directly
Expand Down Expand Up @@ -327,61 +367,55 @@ export abstract class NgxSubFormComponent<ControlInterface, FormInterface = Cont

const lastKeyEmitted$: Observable<keyof FormInterface> = merge(...formValues.map(obs => obs.pipe(map(x => x.key))));

this.subscription = this.formGroup.valueChanges
.pipe(
// hook to give access to the observable for sub-classes
// this allow sub-classes (for example) to debounce, throttle, etc
this.handleEmissionRate(),
startWith(this.formGroup.value),
// this is required otherwise an `ExpressionChangedAfterItHasBeenCheckedError` will happen
// this is due to the fact that parent component will define a given state for the form that might
// be changed once the children are being initialized
delay(0),
filter(() => !!this.formGroup),
// detect which stream emitted last
withLatestFrom(lastKeyEmitted$),
map(([_, keyLastEmit], index) => {
if (index > 0 && this.onTouched) {
this.onTouched();
}

if (index > 0 || (index === 0 && this.emitInitialValueOnInit)) {
if (this.onChange) {
this.onChange(
this.transformFromFormGroup(
// do not use the changes passed by `this.formGroup.valueChanges` here
// as we've got a delay(0) above, on the next tick the form data might
// be outdated and might result into an inconsistent state where a form
// state is valid (base on latest value) but the previous value
// (the one passed by `this.formGroup.valueChanges` would be the previous one)
this.formGroup.value,
),
);
this.subscription.add(
this.formGroup.valueChanges
.pipe(
// hook to give access to the observable for sub-classes
// this allow sub-classes (for example) to debounce, throttle, etc
this.handleEmissionRate(),
startWith(this.formGroup.value),
// this is required otherwise an `ExpressionChangedAfterItHasBeenCheckedError` will happen
// this is due to the fact that parent component will define a given state for the form that might
// be changed once the children are being initialized
delay(0),
filter(() => !!this.formGroup),
// detect which stream emitted last
withLatestFrom(lastKeyEmitted$),
map(([_, keyLastEmit], index) => {
if (index > 0 && this.onTouched) {
this.onTouched();
}

const formUpdate: FormUpdate<FormInterface> = {};
formUpdate[keyLastEmit] = true;
this.onFormUpdate(formUpdate);
}
}),
)
.subscribe();
if (index > 0 || (index === 0 && this.emitInitialValueOnInit)) {
if (this.onChange) {
this.onChange(
this.transformFromFormGroup(
// do not use the changes passed by `this.formGroup.valueChanges` here
// as we've got a delay(0) above, on the next tick the form data might
// be outdated and might result into an inconsistent state where a form
// state is valid (base on latest value) but the previous value
// (the one passed by `this.formGroup.valueChanges` would be the previous one)
this.formGroup.value,
),
);
}

const formUpdate: FormUpdate<FormInterface> = {};
formUpdate[keyLastEmit] = true;
this.onFormUpdate(formUpdate);
}
}),
)
.subscribe(),
);
}

public registerOnTouched(fn: any): void {
this.onTouched = fn;
}

public setDisabledState(shouldDisable: boolean | undefined): void {
if (!this.formGroup) {
return;
}

if (shouldDisable) {
this.formGroup.disable({ emitEvent: false });
} else {
this.formGroup.enable({ emitEvent: false });
}
this.disabledState$$.next(shouldDisable as boolean);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/readme/listing.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface OneListingForm {
})
export class ListingComponent extends NgxAutomaticRootFormComponent<OneListing, OneListingForm> {
// as we're renaming the input, it'd be impossible for ngx-sub-form to guess
// the name of your input to then check within the `ngOnChanges` hook wheter
// the name of your input to then check within the `ngOnChanges` hook whether
// it has been updated or not
// another solution would be to ask you to use a setter and call a hook but
// this is too verbose, that's why we created a decorator `@DataInput`
Expand Down