2

Topic:

md-table ui that implements the cdk-table in Angular Material 2

Problem:

unable to get connect to emit after a user-invoked http call returns a response

Approach:

create a hot observable out of a behavior subject in a service. parent component invokes a method in the service that feeds an array of objects into the behavior subject. the child component subscribes to the behavior subject's hot observable in it's constructor. the child component recreates the reference to the datasource in the subscription method with the newly received array of objects

Expected Behavior:

each time the behavior subject is fed new data via .next(), connect should fire

Observed Behavior:

the connect method fires only on initialization of the child component


Parent Component:

import { Component }                 from '@angular/core';

import { InboundMessagesService }       from '../messages/services/inbound/inbound.service';
import { Message }                      from '../messages/model/message';

@Component({
    selector: 'search-bar',
    templateUrl: './search-bar.component.html',
    styleUrls: [ './search-bar.component.css'],
    providers: [ InboundMessagesService ]
})

export class SearchBarComponent {
    hac: string = "";

    constructor( private inboundMessagesService: InboundMessagesService ) { }

    onSubmit( event: any ): void {
        this.hac = event.target.value;
        this.inboundMessagesService.submitHac( this.hac );
    }
}

Service:

import { Injectable }                 from '@angular/core';
import { Headers, 
         Http, 
         RequestMethod, 
         RequestOptions, 
         Response }                   from '@angular/http';
import { HttpErrorResponse }          from "@angular/common/http";
import { Observable }                 from 'rxjs/Rx';
import { Subject }                    from 'rxjs/Subject';
import { BehaviorSubject }            from 'rxjs/BehaviorSubject';
import { ReplaySubject }              from 'rxjs/ReplaySubject';
import { Subscription }               from 'rxjs/Subscription';
import "rxjs/add/operator/mergeMap";
import { Message }                    from '../../model/message'; 
import { LookupService }         from '../../../lookup/lookup.service';
@Injectable()
export class InboundMessagesService {
    dataChange: BehaviorSubject<Message[]> = new BehaviorSubject<Message[]>([]);
    dataChangeObservable = Observable.from( this.dataChange ).publish();
    messages: Message[];
    get data(): Message[] { 
        return this.dataChange.value; 
    }
    baseUrl: string = 'http://foobar/query?parameter=';
    headers = new Headers();
    options = new RequestOptions({ headers: this.headers });
    response: Observable<Response>;

    constructor( private http: Http, 
                 private lookupService: LookupService ) {
        console.log( "inboundService constructor - dataChange: ", this.dataChange );
        this.dataChangeObservable.connect()        
    }
    submitHac( hac: string ) {
        console.log( "submitHac received: ", hac );    

        this.getMessages( hac )
            .subscribe( ( messages: any ) => {
                this.dataChange.next( messages )
            }),
            ( err: HttpErrorResponse ) => {
                if ( err.error instanceof Error ) {
                    // A client-side or network error occurred. Handle it accordingly.
                    console.log( 'An error occurred:', err.error.message );
                } else {
                    // The backend returned an unsuccessful response code.
                    // The response body may contain clues as to what went wrong,
                    console.log( `Backend returned code ${ err.status }, body was: ${ err.error }` );
                    console.log( "full error: ", err );
                }
            };
    }
    getMessages( hac: string ) {
        console.log( "inboundService.getMessages( hac ) got: ", hac );
        return this.lookupService
            .getMailboxUuids( hac )
            .switchMap( 
                ( mailboxUuidsInResponse: Response ) => {
                    console.log( "lookup service returned: ", mailboxUuidsInResponse );
                    return this.http.get( this.baseUrl + mailboxUuidsInResponse.json(), this.options )
                })
            .map(
                ( messagesInResponse: any ) => {
                    console.log( "request returned these messages: ", messagesInResponse );
                    messagesInResponse.forEach( 
                        (message: any ) => {
                            this.messages.push( 
                                this.createMessage( message )
                    )});

                    return this.messages;
            })
    }
    createMessage( message: any ): Message {
        return new Message(
            message.name,
            message.type,
            message.contentType,
            message.state,
            message.source,
            message.target,
            message.additionalData
        )
    }
}

Child Component:

import { Component }                  from '@angular/core';
import { HttpErrorResponse }          from "@angular/common/http";
import { DataSource, CdkTable }       from '@angular/cdk';
import { Observable }                 from 'rxjs/Observable';

import { Message }                    from '../../../messages/model/message';
import { InboundMessagesService }     from '../../../messages/services/inbound/inbound.service';
import { SearchBarComponent }         from '../../../search_bar/search-bar.component';

@Component({
    selector: 'inbound-messages',
    templateUrl: './../inbound-messages.component.html',
    styleUrls: [ 
        'app/mailboxes/mailboxes-layout.css',
        './../inbound-messages.component.css'      
    ],
    providers: [ InboundMessagesService ]
})

export class InboundMessagesComponent {
    dataSource: InboundDataSource | null;
    displayedColumns = [ 'name', 'type', 'contentType', 'state', 'source', 'target', 'additionalData' ];

    constructor( private inboundMessagesService: InboundMessagesService ) { 
        console.log( "inbound component constructor (this): ", this );
        this.inboundMessagesService.dataChangeObservable.connect();
    } 

    ngOnInit() {
        console.log( "inbound component ngOnInit()" );
        this.dataSource = new InboundDataSource( this.inboundMessagesService );        
    }
}

export class InboundDataSource extends DataSource<Message> {
        constructor( private inboundMessagesService: InboundMessagesService ) {
            super();
            console.log( "InboundDataSource constructor" );
        }

        connect(): Observable<Message[]> {
            console.log( "CONNECT called" );
            return this.inboundMessagesService.dataChangeObservable
        }

        disconnect() {}
    }
tyler2cr
  • 73
  • 1
  • 1
  • 9
  • I have to admit, there is a lot going on here that could be cleaned up. However, the place that seems most suspicious is the `this.dataChange.next(messagesObservable.switch())`. In your last `flatMap` in `getMessages` you should just `return messages` which will remove the need for `switch()`. Then you should do `messagesObservable.subscribe(messages => {this.dataChange.next(messages);});` The `next()` method is supposed to take in an item, not a stream. In this case it should be receiving a `Message[]`. However, you are passing in `Observable`. – Pace Jul 28 '17 at 23:12
  • I have same problem. Could you find a solution? – Luiz Mitidiero Jul 29 '17 at 22:35
  • @Pace thanks for your help! I cleaned the code up a bit and refactored the returned object from get messages, but i'm not sure where you mean to subscribe to the hot observable. I've posted the updated code – tyler2cr Jul 31 '17 at 16:08
  • @LuizMitidiero I think it has to do with using publish() and connect(), or maybe replay() and connect(). This thread gave me some understanding: https://stackoverflow.com/questions/40164752/rxjs-subscribe-vs-publish-connect – tyler2cr Jul 31 '17 at 16:09
  • @tyler2cr there is another bug in `getMessages()`. The first `flatMap` should be `switchMap`. And the second `flatMap` should just be `map` since it is returning new values, not a new Observable. – Will Howell Jul 31 '17 at 16:35
  • @WillHowell thanks! that fixed my http chain and now `getMessages(hac).subscribe( (messages:any) => { this.dataChange.next( messages ); })` does fire, but connect: in the component still doesn't – tyler2cr Jul 31 '17 at 16:57
  • @tyler2cr you're not instantiating your `dataSource`. Look back at the examples and you'll see that you need something like: `this.dataSource = new InboundDataSource(this.inboundMessagesService)` in your OnInit – Will Howell Jul 31 '17 at 17:37
  • @WillHowell I had been doing that initially based on the examples and I realize that a new reference to my `dataSource` is considered an event that invokes the connect function, but the cdk-table docs suggest that any type of event can be used to invoke the dataSource's connect function. Do i need to setup a factory for my component and render an entirely new component just so that the dataSource gets a new reference? – tyler2cr Jul 31 '17 at 17:57
  • @WillHowell I just posted my refactor that includes dataSource instantiation in the OnInit() method, and Connect does fire on load, but I don't need it to until a user event invokes the http request for the table data – tyler2cr Jul 31 '17 at 18:03
  • @tyler2cr I think I understand and can answer this in full with a little refactoring. To be clear, what would you like the state of InboundMessagesComponent to be until the user action? Empty table or no table? – Will Howell Jul 31 '17 at 18:22
  • @WillHowell Thank you so much!! I would like the table to be shown but empty at first – tyler2cr Jul 31 '17 at 19:06

1 Answers1

0

I've simplified some of the details specific to your application, but this shows how to render the table immediately with an empty data set, and then fetch new data from the service when onSubmit is called in SearchBarComponent.

Search component

@Component({ ... })
export class SearchBarComponent {

  constructor(private inboundMessagingService: InboundMessagingService) { }

  onSubmit(event): void {
    this.inboundMessagingService.submitHac(event.target.value);
  }
}

Service

@Injectable()
export class InboundMessagingService {

  messageDataSubject$ = new BehaviorSubject<Message[]>([]);

  get data$(): Observable<Message[]> {
    return this.messageDataSubject$.asObservable();
  }

  constructor(
    private http: Http,
    private addressBookService: AddressBookService
  ) { }

  submitHac(hac: string): void {
    this.getMessages(hac)
      .subscribe((messages: Message[]) => this.messageDataSubject$.next(messages));
  }

  getMessages(hac: string): Observable<Message[]> {
    return this.addressBookService
      .getMailboxUuids(hac)
      .switchMap(x => this.http.get(x))
      .map(messagesInResponse => messagesInResponse.map(m => this.createMessage(m)))
  }


}

Table component

@Component({ ... })
export class InboundMessagesComponent {

  dataSource: InboundDataSource | null;

  displayedColumns = [ ... ];

  constructor(private inboundMessagesService: InboundMessagesService) { }

  ngOnInit() {
    this.dataSource = new InboundDataSource(this.inboundMessagesService);
  }
}

export class InboundDataSource extends DataSource<Message> {

  constructor(private inboundMessagesService: InboundMessagesService) { }

  /**
  * This is only called once, when `dataSource` is provided to the md/cdk-table. To
  * update the table rows, you must make sure the source observable emits again.
  * The way it is setup, this will emit whenever `messageDataSubject$.next()` is called
  * in the service.
  */
  connect(): Observable<Message[]> {
    // Since messageDataSubject$ is a behavior subject, it will immediately emit an empty array
    // when subscribed to. This will show as an empty table.
    return this.inboundMessagesService.data$;
  }

  diconnect() { }
}

Other notes

  • Lots of people like to add $ to the end of observable variable names to distinguish them. I've used that convention here.
  • Since you are adding InboundMessagesService to each of your component providers, you will end up with multiple instances of the service. You should providing this service at the module level, and if you want to make sure this service exists only once in the lifetime of the app, add it to the root module providers.
Will Howell
  • 3,585
  • 2
  • 21
  • 32
  • Thanks Will!! Unfortunately this still only invokes the connect function on initial load. The http fetch succeeds and feeds the messages into .next(), but the table doesn't subscribe to that emission and remains empty – tyler2cr Aug 01 '17 at 15:09
  • That's expected! The `connect()` method should only be called once during initialization. As long a you are feeding values into `messageDataSubject$` with `next()`, new emissions will propagate through the observable chain and through `connect()` to the table. If you want to check at various points in the rxjs chain whether values are coming through, (as a troubleshooting measure) use `.do(val => console.log(val))` – Will Howell Aug 01 '17 at 15:52
  • Also did you move your service to be provided only once? If you don't, then the BehaviorSubject that your next'ing your http response into will be _different_ from the BehaviorSubject your DataSource is passing along to the table. – Will Howell Aug 01 '17 at 15:55
  • Thank you so much for your help Will. It's alive!!! I wasn't returning an array of the constructed messages in the .map() of getMessages() so .next() was being provided an undefined object. I did move the inboundMessagesService to be provided at the module level and the app loads much faster now. I'll adhere to the $ symbol notation for observables from here on out as well – tyler2cr Aug 01 '17 at 16:13
  • by they way, i up-voted your answer but my reputation is under 15 since i'm new so it isn't showing yet. Thanks again for your help! – tyler2cr Aug 01 '17 at 16:14
  • Cheers! Can you mark it as correct? Also I hope things are a little clearer now. The table is remarkable simple if you can only focus on getting your array observable into `connect()` – Will Howell Aug 01 '17 at 16:17
  • Marked as correct :) A coworker and I are thrilled now that we have this working - we really enjoy Material 2. It's much clearer now that I'm understanding the prerequisite of knowing how to properly use the ReactiveX framework. Just to be sure that I understand - the DataSource and the messageDataSubject.asObservable() are hot observables, with the DataSource being a Connectable Observable as well? – tyler2cr Aug 01 '17 at 16:34
  • I'll admit, my understanding of the temperature of observables hasn't quite solidified. Don't confuse the `connect()` method of the DataSource with Connectable Observables. Because `messageDataSubject` is a BehaviorSubject, as soon as something subscribes, it will get the most recent value. AFAIK appending `asObservable()` just means that consumers of `data$` don't have access to the `.next()` method of `messageDataSubject`. Only `InboundMessagingService` should have the capability to add push new values through it – Will Howell Aug 01 '17 at 18:06
  • Ok that clears things up for me - I was confusing the `connect()` method to mean that the DataSource performs as a ConnectableObservable. After reading this article: (https://medium.com/@benlesh/hot-vs-cold-observables-f8094ed53339), I began trying to separate the Behavior subject into it's contained observable and observer, which is what the `.asObservable()` method does in part, but I wasn't able to get a handle on the observer until now - the `.next()` method tells the messageDataSubject$ to subscribe to it's inner BehaviorSubject. Again, I can't thank you enough for your help and guidance! – tyler2cr Aug 01 '17 at 19:41