Skip to content

Instantly share code, notes, and snippets.

@frederikaalund
Created June 10, 2017 14:20
Show Gist options
  • Save frederikaalund/daab1a6da85f9d6df28050ae9c01c8fd to your computer and use it in GitHub Desktop.
Save frederikaalund/daab1a6da85f9d6df28050ae9c01c8fd to your computer and use it in GitHub Desktop.
A Directive to Bind @angular-redux/store with both Template-driven and Reactive Forms
import { EventEmitter, Directive, Input, AfterViewInit, Output, OnDestroy } from '@angular/core';
import { NgForm, FormGroup } from '@angular/forms';
import { NgRedux } from '@angular-redux/store';
import { fromJS, is } from 'immutable';
import { AppState, ActionTypes } from '../store';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
function isSame(a: any, b: any) {
return is(fromJS(a), fromJS(b));
}
function isSubset(subset: any, superset: any) {
for (const key in subset) {
if (!subset.hasOwnProperty(key)) {
continue;
}
// Key must exist in the superset
if (!superset.propertyIsEnumerable(key)) {
return false;
}
// Values must match. Resolve for equality by recursion until direct
// comparison is possible (e.g., for simple types).
if (typeof subset[key] === 'object' && subset[key] !== null) {
if (!isSubset(subset[key], superset[key])) {
return false;
}
} else if (subset[key] !== superset[key]) {
return false;
}
}
return true;
}
abstract class BaseDirective implements OnDestroy, AfterViewInit {
// Input and output attributes must be defined on the derived classes for
// AOT compatability. Therefore, said attributes are declared as abstract
// here in the base class.
abstract autoDispatch: boolean;
abstract initiateStoreFromForm: boolean;
abstract formChanged = new EventEmitter();
abstract form: FormGroup;
abstract storePath: string[];
protected view: Observable<any>;
protected ngUnsubscribe = new Subject<void>();
constructor(protected redux: NgRedux<AppState>) {
}
ngAfterViewInit() {
this.view = this.redux.select(this.storePath);
// Updates from the store goes into the form
this.view
.takeUntil(this.ngUnsubscribe)
// The state will be undefined if the storePath points to
// a state that doesn't exist yet.
.map((state) => (undefined !== state) ? state.toJS() : {})
// TODO: Temporary filter to circumvent unnecessary form updates.
// Awaiting resolution of: https://github.com/angular/angular/issues/13835
// Future change to: this.form.form.patchValue(state, { notEqual: true });
.filter((state) => !isSubset(this.form.value, state))
.subscribe((state) => {
this.form.patchValue(state);
});
// Updates from the form goes into the "formChanged" output and
// optionally the store too
this.form.statusChanges
.takeUntil(this.ngUnsubscribe)
.filter((status_) => 'VALID' === status_)
.withLatestFrom(this.form.valueChanges, (valid, value) => value)
.distinctUntilChanged((lhs, rhs) => isSame(lhs, rhs))
.subscribe((formData) => {
this.formChanged.next(formData);
if (undefined !== this.autoDispatch) {
this.dispatchFormChanged(formData);
}
});
// Initiate the redux store from the form data
if (undefined !== this.initiateStoreFromForm) {
this.dispatchFormChanged(this.form.value);
}
}
private dispatchFormChanged(formData: any) {
this.redux.dispatch({
type: ActionTypes.formChanged,
payload: {
path: this.storePath,
formData
}
});
}
ngOnDestroy() {
this.ngUnsubscribe.next();
this.ngUnsubscribe.complete();
}
}
/*
* The NgForm directive is implicitly added to all form
* components *except* for form components with either
* the ngNoForm or formGroup attribute. Therefore, we need
* two different labStore directives (StoreDirective and
* StoreGroupDirective).
*/
// For template forms (with implicit NgForm)
@Directive({ selector: 'form[labStore]:not([formGroup])' })
export class StoreDirective extends BaseDirective implements AfterViewInit {
@Input() autoDispatch: boolean;
@Input() initiateStoreFromForm: boolean;
@Output() formChanged = new EventEmitter();
// tslint:disable:no-input-rename
@Input('labStore') storePath: string[];
// tslint:enable:no-input-rename
get form() {
return this.ngForm.form;
}
constructor(protected ngForm: NgForm,
redux: NgRedux<AppState>) {
super(redux);
}
// Must be redefined in the derived class for AOT compatability
ngAfterViewInit() {
// Wait a tick for the control tree to be built.
// Reference: https://angular.io/docs/ts/latest/guide/reactive-forms.html
// section "Async vs. sync".
setTimeout(() => super.ngAfterViewInit(), 0);
}
}
// For reactive forms (without implicit NgForm)
@Directive({ selector: 'form[labStore][formGroup]' })
export class StoreGroupDirective extends BaseDirective implements AfterViewInit {
@Input() autoDispatch: boolean;
@Input() initiateStoreFromForm: boolean;
@Output() formChanged = new EventEmitter();
// tslint:disable:no-input-rename
@Input('labStore') storePath: string[];
@Input('formGroup') form: FormGroup;
// tslint:enable:no-input-rename
constructor(redux: NgRedux<AppState>) {
super(redux);
}
// Must be redefined in the derived class for AOT compatability
ngAfterViewInit() {
super.ngAfterViewInit();
}
}
@nuba
Copy link

nuba commented Aug 1, 2017

Thank you, this was really handy!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment