Search code examples
angularmeteorangular2-meteor

collectionCount not displaying value in template / meteor-rxjs in a service


That's my first submission to SO, so, if anything is wrong or not in the right place, please feel free to tell me.

Now to my question:

I'm trying to implement a service within a simple to-do application based on the Angular2 meteor-base boilerplate.

Consider the following code, where I'm trying to do two things:

  • Display a bunch of to-do lists ( <- This works )
  • Display the number of lists present with .collectionCount() ( <- This doesn't work )

todolist.service.ts:

import { Injectable } from '@angular/core';
import { Subscription, Observable } from 'rxjs';
import { MeteorObservable, ObservableCursor } from 'meteor-rxjs';

import { Todolist } from '../../../../../both/models/todolist.model';
import { Todolists } from '../../../../../both/collections/todolists.collection';

@Injectable()
export class TodolistService {

  todolistSubscription: Subscription;
  todoLists$: Observable<Todolist[]>;
  numLists$: Observable<number>;

  constructor() {
    this.initListSubscription();
  }

  initListSubscription() {
    if (!this.todolistSubscription) {
      this.todolistSubscription =     MeteorObservable.subscribe("todolists").subscribe(() => {
        // Code to be executed when the subscription is ready goes here
        // This one works
        this.todoLists$ = Todolists.find({}).zone();
        this.todoLists$.subscribe((lists) => {
          console.log(lists);
        });
        // This one doesn't
        this.numLists$ = Todolists.find({}).collectionCount();
        this.numLists$.subscribe((numberOfLists) => {
          console.log(numberOfLists);
        })
      });
    }
  }

  getLists(selector?, options?) {
    // Just in case anyone is wondering what those params are for...
    // return Todolists.find(selector || {}, options || {});
    return this.todoLists$;
  }

  getListsCount() {
    return this.numLists$;
  }

  unsubscribeFromLists() {
    this.todolistSubscription.unsubscribe();
  }

}

This one, I import in my app.module.ts and add it to the providers-array.

Then, in my list.component.ts I use the service like so:

import { Component, OnInit } from '@angular/core';
import { TodolistService } from '../../shared/todolist.service'
// + all other relevant imports, e.g. Todolist (the model), Todolists (collection)

@Component({
  selector: 'list-component',
  template,
  styles: [style]
})

export class ListComponent implements OnInit{

  lists$: Observable<Todolist[]>;
  numLists$: Observable<number>;

  constructor(private _todolistService: TodolistService){}

  ngOnInit(){
    // Again, this one works...
    this._todolistService.getLists().subscribe((lists) => {
      console.log(lists);
    });

    // ...and this does not
    this._todolistService.getListsCount().subscribe((number) => {
      console.log(number);
    });

    // This I can also use in my template, see below...
    this.lists$ = this._todolistService.getLists();

    // This one I can't
    this.numLists$ = this._todolistService.getListsCount();
  }

}

todolist.component.html:

In my template I for example do the following:

<!-- This one works... -->
<div *ngFor="let list of lists$ | async">{{list._id}}</div>

<!-- This one doesn't... -->
<span class="badge">{{ numLists$ | async }}</span>

Things I tried:

  • adding the .zone()-operator to the method defined in my service, like
      getListsCount() {
        return this.numLists$.zone();
      }
  • Tried the same (that being the addition of the .zone()-operator) in the initListSubscription()-method of the service, where I do things when the subscription is ready
  • Tried the same in my component, when I call
    // with the addition of .zone()
    this.numLists$ = this._todolistService.getListsCount().zone();

=====

Adding .zone() was, from my point of view as someone who does this as a hobby, the obvious thing to do. Sadly, to no effect whatsoever. From what I understand, this attaches the asynchronus task that's happening to angulars zone and is basically the same as saying

constructor(private _zone: NgZone){}

ngOnInit(){
  this._zone.run(() => {
  //...do stuff here that's supposed to be executed in angulars zone
  })
}

for example.

Can someone help me out? I really tried to understand what's going on, but I can't wrap my head around, why I'm not able to get the actual number of lists out of that observable.

Another thing I'm wondering:

If I were to do all of this directly in my component and I wanted my list to update automatically if I added new to-dos, I'd do the following to make things reactive:

MeteorObservable.subscribe("todolists").subscribe(() => {
  // The additional part that's to be executed, each time my data has changed
  MeteorObservable.autorun().subscribe(() => {
    this.lists$ = Todolists.find({}).zone();
    // with/without .zone() has the same result - that being no result ...
    this.listCount$ = Todolists.find({}).collectionCount();
  });
});

Here, I also can't wrap my head around how to achieve reactivity from within my service. I tried this, and again for the to-do lists it's working, but for the .collectionCount() it's not.

I'd really appreciate if some could point my in the right direction here. Maybe I'm missing something, but I feel like this, in theory, should work, since I'm able to get the lists to display (and even update reactively, when I do things from within my component).

Thanks in advance!

UPDATE:

Thanks to @ghybs I finally managed to get a working solution. Below you find the final code.

todolist.service.ts:

import { Injectable } from '@angular/core';
import { Observable, Subscription, Subject } from 'rxjs';
import { MeteorObservable, ObservableCursor } from 'meteor-rxjs';

import { Todolist, Task } from '../../../../../both/models/todolist.model';
import { Todolists } from '../../../../../both/collections/todolists.collection';

@Injectable()
export class TodolistService {

  todolistSubscription: Subscription;
  todoLists$: ObservableCursor<Todolist> = Todolists.find({});
  numLists$: Observable<number>;
  numLists: number = 0;
  subReady: Subject<boolean> = new Subject<boolean>();

  init(): void {
    if(!this.todolistSubscription){
      this.subReady.startWith(false);
      this.todolistSubscription =     MeteorObservable.subscribe("todolists").subscribe(() => {
        this.todoLists$ = Todolists.find({});
        this.numLists$ = this.todoLists$.collectionCount();
        this.numLists$.subscribe((numberOfLists) => {
          console.log(numberOfLists)
        });
        this.todoLists$.subscribe(() => {
          this.subReady.next(true);
        });
      });      
    }
  }

  isSubscriptionReady(): Subject<boolean> {
    return this.subReady;
  }

  getLists(selector?, options?): ObservableCursor<Todolist> {
    return this.todoLists$;
  }

  getListsCount(): Observable<number> {
    return this.numLists$;
  }

  addList(name: string, description: string): Observable<string> {
    return MeteorObservable.call<string>("addTodoList", name, description);
  }

  addTask(listId: string, identifier: string, description: string, priority: number, start: Date, end: Date): Observable<number> {
    return MeteorObservable.call<number>("addTask", listId, identifier, description, priority, start, end);
  }

  markTask(listId: string, task: Task, index: number) : Observable<number> {
    return MeteorObservable.call<number>("markTask", listId, task, index);
  }

  disposeSubscription() : void {
    if (this.todolistSubscription) {
      this.subReady.next(false);
      this.todolistSubscription.unsubscribe();
      this.todolistSubscription = null;
    }
  }

}

dashboard.component.ts:

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { routerTransition } from '../shared/animations'
import { Observable, Subject } from 'rxjs';
import { ObservableCursor } from 'meteor-rxjs';
import { Todolist } from '../../../../both/models/todolist.model';
import { TodolistService } from '../shared/services/todolist.service';

import template from './dashboard.component.html';
import style from './dashboard.component.scss';

@Component({
  selector: 'dashboard',
  template,
  styles: [style],
  animations: [routerTransition()]
})

export class DashboardComponent implements OnInit, OnDestroy {

  todoLists$: ObservableCursor<Todolist>;
  numTodoLists$: Observable<number>;
  numTodoLists: number = 0;

  constructor(private _router: Router, private todolistService: TodolistService) {}

  ngOnInit() {
    this.todolistService.init();
    this.todolistService.isSubscriptionReady().subscribe((isReady) => {
      if(isReady){
        this.todolistService.getListsCount().subscribe((numTodoLists) => {
          this.numTodoLists = numTodoLists;
        });            
      }
    });
  }

  sideNavShown: boolean = true;
  toggleSideNav() {
    this.sideNavShown = !this.sideNavShown;
  }

  ngOnDestroy() {
    this.todolistService.disposeSubscription();
  }

}

dashboard.component.html:

After subscribing to the Observable returned from the service and receiving the value, I assign the value to a variable and use it like that:

<span class="badge badge-accent pull-right">{{ numTodoLists }}</span>

which results in

the number of lists displayed

Also, the value is automatically updated, as soon as I add a new list - everything working as expected.

Thanks SO and especially @ghybs, you are awesome.


Solution

  • I noticed that an ObservableCursor (as returned by myCollection.find()) needs to be subscribed to before having any effect. I guess we describe it as being a Cold Observable.

    In simple situations (like passing directly the cursor to a template through the AsyncPipe), Angular does the subscription on its own (as part of the async pipe process).

    So in your case, you simply need to get a reference to the intermediate object returned by the find(), before you apply collectionCount() on it, so that you can subscribe to it:

    const cursor = Todolists.find({});
    this.numLists$ = cursor.collectionCount();
    this.numLists$.subscribe((numberOfLists) => {
      console.log(numberOfLists);
    });
    cursor.subscribe(); // The subscribe that makes it work.
    

    Then you can either use numLists$ through AsyncPipe in your template:

    {{ numLists$ | async}}

    Or you can use a simple intermediate placeholder that you assign within the numLists$.subscribe()

    private numLists: number;
    
    // ...
    this.numLists$.subscribe((numberOfLists) => {
      this.numLists = numberOfLists;
    });
    

    and in your template: {{numLists}}

    As for reactivity, you do not need MeteorObservable.autorun() to wrap functions that just re-assign an Observable: the AsyncPipe will properly use the Observable and react accordingly.

    The situation is different for findOne(), which does not return an Observable but an object directly.