Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Subscribe to changes on property of component in Angular 2 for debounced auto saving?

Say I have the following Angular 2 component, where foo is an object not bound to a form, and not coming from an Input():

@Component({
    selector: 'foo',
    templateUrl: '/angular/views/foo.template.html',
    directives: [ContentEditableDirective, ROUTER_DIRECTIVES],
    providers: [FooService]
})
export class FooComponent implements OnInit {
    public foo: Foo = new Foo();

    constructor(
        private fooService: FooService,
        private route : ActivatedRoute,
        private router : Router) {}

    ngOnInit() {
        let id = +this.route.snapshot.params['id'];
        this.fooService.getFoo(id).subscribe(
            foo => {
                this.foo = foo;

                // I have this.foo, we should watch for changes here.

                Observable. // ???
                    .debounceTime(1000)
                    .subscribe(input => fooService.autoSave(this.foo));
            },
            error => console.log(error)
        );
    }
}

How can I subscribe to changes to the Foo object, and send it up to my server?

Every example I have seen so far involves either having foo come off of an Input(), or being bound to a form. I just want to watch a plain old Javascript object for changes and react to that.

Update

I have tried once again, and have been able to debounce an internal primitive property on a component here.

But, I have been unable to make this work with a complex object that has properties of its own (here); as the setter in the provided plnkr is not called by ngModel when it updates the property of the foo object.

A correct answer will demonstrate this working with a complex object.

Update 2

I believe I have it working with a complex object; but one must ensure the object is immutable and that you have setters for each property, which is kind of a letdown. It also appears to require the splitting of [(ngModel)] into [ngModel] and (ngModelChange) so you can specify custom setter logic.

You could theoretically abstract the functionality to a state service, but I'd like to see if the amount of boilerplate can be stripped down further. Creating new objects each time you would like a state change is kind of frustrating.

like image 626
marked-down Avatar asked Jul 09 '16 07:07

marked-down


4 Answers

I appear to have partially solved this for simple & complex objects. If one has no need for an entire change detector, you can implement this easily with just:

  • A subject and an observable on your component.
  • A getter and a setter for the tracked property on your component.

Below is a very minimal example of how one could accomplish this.

Component

import {Component} from '@angular/core'
import {Subject} from 'rxjs/Rx';

@Component({
  selector: 'my-app',
  providers: [],
  template: `
    <div>
      <input type="text" [(ngModel)]="foo"  />
      <div>current Foo: {{ foo }}</div>
      <div>debounced Foo: {{ debouncedFoo }}</div>
    </div>
  `,
  directives: []
})
export class App {
  private _foo: any;
  debouncedFoo: any;
  fooSubject : Subject<any> = new Subject<any>(this._foo);
  fooStream : Observable<any> = this.fooSubject.asObservable();

  get foo() {
      return this._foo;
  });

  set foo(value:any) {
    this._foo = value;
    this.fooSubject.next(value);
  });

  constructor() {
    // Manipulate your subscription stream here
    this.subscription = this.fooStream
      .debounceTime(2000)
      .subscribe(
        value => {
          this.debouncedFoo = value;
      });
  }
}

Example

http://plnkr.co/edit/HMJz6UWeZIBovMkXlR01?p=preview

Of course, realistically the component should have no knowledge of the subject or observable, so extract them to a service as you please.

Downsides

This example only works with primitives. If you would like it to work with objects, you need custom setter logic for each property, and remember to create new objects each time you alter a property of that object.

like image 54
marked-down Avatar answered Oct 09 '22 19:10

marked-down


@vjawala is very close :)

If you want to track input (annotated with @Input) properties use OnChanges:

  ngOnChanges(changes: SimpleChanges) {
    if(changes['foo']){
        triggerAutoSave();
    }
  }

If you want to track key/value object, then implement DoCheck listener and use KeyValueDiffer, example is provided in angular docs.

It might be much more efficient/simple to subscribe directly to change/input (most likely both) events:

<form (input)="triggerAutoSave()" (change)="triggerAutoSave()">
   <input>
   <input>
   <input>
</form>

Debounce is relatively easy with RxJS:

triggerAutoSave(){
     subject.next(this.foo);
}

subject.debounceTime(500).subscribe(...);

If you are saving via http then exhaustMap might help.

like image 25
kemsky Avatar answered Oct 09 '22 19:10

kemsky


What if you use the ngDoCheck() function? Something like:

@Component({
    selector: 'foo',
    templateUrl: '/angular/views/foo.template.html',
    directives: [ContentEditableDirective, ROUTER_DIRECTIVES],
    providers: [FooService]
})
export class FooComponent implements OnInit, DoCheck {
    public foo: Foo = new Foo();

    constructor(
        private fooService: FooService,
        private route : ActivatedRoute,
        private router : Router) {}

    ngOnInit() {
        let id = +this.route.snapshot.params['id'];
        this.foo = this.fooService.getFoo(id);
    }
    ngDoCheck() {
        fooService.autoSave(this.foo);
    }
}
like image 35
vjawala Avatar answered Oct 09 '22 20:10

vjawala


This is something I just came up with. It probably needs a little polishing, but it works well regardless.

Working Plunker for example usage


export class ChangeTracker {
  private _object: any;
  private _objectRetriever: () => any;
  private _objectSubject: Subject<any> = new Subject<any>();
  private _onChange: (obj: any) => void;
  private _fnCompare: (a: any, b: any) => boolean;

  constructor(objRetriever: () => any, 
              onChange: (obj: any) => void, 
              fnCompare?: (a: any, b: any) => boolean) {

    this._object = objRetriever();
    this._objectRetriever = objRetriever;
    this._onChange = onChange;
    this._fnCompare = fnCompare ? fnCompare : this.defaultComparer;

    this._objectSubject
      .debounceTime(1000)
      .subscribe((data: any) => {
          this._onChange(data);
      });

    setInterval(() => this.detectChanges(), 500);
  }

  private defaultComparer(a: any, b: any) {
    return JSON.stringify(a) == JSON.stringify(b);
  }

  private detectChanges() {
    let currentObject = this._objectRetriever();

    if (!this._fnCompare(this._object, currentObject)) {
      this._object = currentObject;

      this._objectSubject.next(currentObject);
    }
  }
}

Usage in your code sample:

@Component({
    selector: 'foo',
    templateUrl: '/angular/views/foo.template.html',
    directives: [ContentEditableDirective, ROUTER_DIRECTIVES],
    providers: [FooService]
})
export class FooComponent implements OnInit {
    public foo: Foo = new Foo();

    constructor(
        private fooService: FooService,
        private route : ActivatedRoute,
        private router : Router) {}

    ngOnInit() {
        let id = +this.route.snapshot.params['id'];
        this.fooService.getFoo(id).subscribe(
            foo => {
                this.foo = foo;

                /* just create a new ChangeTracker with a reference
                 * to the local foo variable and define the action to be
                 * taken on a detected change
                 */
                new ChangeTracker(
                    () => this.foo, 
                    (value) => {
                        this.fooService.autoSave(value);
                    }
                );
            },
            error => console.log(error)
        );
    }
}
like image 35
rinukkusu Avatar answered Oct 09 '22 18:10

rinukkusu