angular / angularfire

Angular + Firebase = ❤️
https://firebaseopensource.com/projects/angular/angularfire2
MIT License
7.67k stars 2.19k forks source link

Documentation Request: provide guide on use of AngularFire in conjunction with @angular/router #624

Closed tarlepp closed 3 years ago

tarlepp commented 7 years ago

Version info

Angular: 2.0.1

Firebase: 3.5.0

AngularFire: 2.0.0-beta.5

Other (e.g. Node, browser, operating system) (if applicable):

Test case

messages.resolver.ts

import { Injectable } from '@angular/core';
import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { AngularFire, FirebaseListObservable } from 'angularfire2';

@Injectable()
export class MessagesResolver implements Resolve<any> {
  constructor(private angularFire: AngularFire) { }

  resolve(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ) {
    return this.angularFire.database.list(
      '/messages',
      {
        query: {
          limitToLast: 100
        }
      }
    );
  }
}

room.routing.ts

import { Routes } from '@angular/router';

import { RoomComponent } from './room.component';
import { MessagesResolver } from './messages.resolver';

export const RoomRoutes: Routes = [
  {
    path: 'chat',
    component: RoomComponent,
    resolve: {
      messages: MessagesResolver,
    },
  },
];

room.component.ts

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FirebaseListObservable } from 'angularfire2';

@Component({
  selector: 'app-chat-room',
  templateUrl: './room.component.html',
  styleUrls: ['./room.component.scss']
})

export class RoomComponent implements OnInit {
  private messages: FirebaseListObservable<any[]>;
  private message: string = '';

  constructor(private activatedRoute: ActivatedRoute) { }

  ngOnInit() {
    this.activatedRoute.data.subscribe(data => {
      this.messages = data['messages'];
    });
  }

  addNewMessage() {
    this.messages.push({
      message: this.message,
      createdAt: firebase.database.ServerValue.TIMESTAMP,
    });

    this.message = '';
  }
}

Debug output

\ Errors in the JavaScript console **

\ Output from firebase.database().enableLogging(true); **

\ Screenshots **

Steps to reproduce

Expected behavior

It should resolve on component ngInit

Actual behavior

Route is not activated at all.

katowulf commented 7 years ago

Hey @tarlepp, some of my rxjs and A2/router newbness is going to show through here, so bear with me. Ithink this isn't returning a promise, since angularFire.database.list is actually returning an Observable:

return this.angularFire.database.list(...)

This document seems to confirm my naïve assessment. And I think the correct answer here is that you'll want to try one of the techniques described there to get a promise.

However, this feels a bit of an XY problem. Generally speaking, the whole idea here is to let AngularFire2 manage the syncing of the data and not worry about state. When you start managing load state, you've generally started trying to force the realtime streaming into a CRUD model, which leads to a lot of complexities like these.

As a general rule of responsive web apps, you should get the UI in front of users quickly, rather than waiting for all the data to load before you even trigger the routing rule. The data will load in magically as it is downloaded.

☼, Kato

tarlepp commented 7 years ago

@katowulf actually ng2 route resolves uses Observables or Promises. https://angular.io/docs/ts/latest/api/router/index/Resolve-interface.html

resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) : Observable<any>|Promise<any>|any
katowulf commented 7 years ago

(Again, bear with me, because most of this is new territory and I'm still getting up to speed)

Spent some time on this today. While I haven't resolved it elegantly yet (I'll work on this more), I did get it working using something like the following:

import { Injectable }             from '@angular/core';
import {Router, Resolve, ActivatedRouteSnapshot} from '@angular/router';
import { AngularFire, FirebaseObjectObservable } from 'angularfire2';
import {Observable} from "rxjs";

@Injectable()
export class DetailResolver implements Resolve<FirebaseObjectObservable<any>> {
  constructor(private af: AngularFire) {}
  resolve(route: ActivatedRouteSnapshot): Promise<FirebaseObjectObservable<any>> {
    return Promise.resolve(this.af.database.object('foo'));
  }
}

I couldn't get it to work by returning the observable in resolve(), but by wrapping it in a Promise, I was able to get that working.

I also noticed a related issue with using ngOnInit(). When I didn't declare this.data within my constructor, but waited until ngOnInit() to declare it, nothing would print to the page. However, if I declared anything in the constructor (e.g. this.data = {}) and then reassigned it in my ngOnInit(), then things displayed fine.

Again, still working on a more elegant answer for using resolve, which I'll run past some of the A2 gurus before I present it back here. But some of this may get you started.

katowulf commented 7 years ago

@jeffbcross @davideast So I've been playing with promises in routers and it's a bit of a mess.

Here are some of the difficulties:

I don't see a great use case for trying to load either of our observables in resolve as opposed to just letting the data load dynamically, but based on history, this will continue to be a common request.

So I think we should probably support a loaded() method or some resolvable promise on FirebaseListObservable and FirebaseObjectObservable, as well as providing a similar way to obtain a resolvable promise on auth state.

katowulf commented 7 years ago

@tarlepp here's a working master/detail view using routing and resolve (it resolves the master list before routing): https://gist.github.com/katowulf/c493e19a932cd608b811907547be7a78

Some of the key points:

1) FirebaseListObservable and FirebaseObjectObservable never resolve.

You can't just return the Firebase*Observable in your resolve method because they never complete, and Observable.prototype.toPromise() seems to rely on the complete callback. Instead, you need to create your own promise with something like:

let list = this.af.database.list('path/to/data');
return new Promise((resolve, reject) => {
      // must import 'rxjs/add/operator/first'; before using it here
      list.first().subscribe(() => {
          resolve(list)
      }, reject);
});

*2) You can't try to establish your FirebaseObservable references in ngOnInit()**

Not sure on the cause for this, but this.books (and this.book in the detail view) must be declared in constructor; when moved into ngOnInit() they never get rendered, regardless of what magic I tried here.

tarlepp commented 7 years ago

@katowulf hmm, it seems to work if I use your example of that resolve implementation. But still I'm a bit confused why returning observable doesn't work like it should.

katowulf commented 7 years ago

I think the primary issue is #1: FirebaseListObservable and FirebaseObjectObservable don't ever "finish" (i.e. call onCompleted) like a normal observables (just call onProgress and onError), so toPromise() never resolves.

tarlepp commented 7 years ago

Hmm so this is a real bug then.

katowulf commented 7 years ago

Well, not precisely. We're observing an event stream. It doesn't resolve; it's always in progress.

As I mentioned before, waiting on "finished" data, etc, isn't really an appropriate way to think about Firebase (it's trying to turn the real-time aspects into a CRUD strategy).

While these two ideas aren't exactly incompatible, the onComplete handler, and by extension, the toPromise method won't ever be invoked (nor should they), making it difficult to use a Firebase observable in the same way you would a more traditional, short-lived Observable subscription.

tarlepp commented 7 years ago

Hmm, and I'm just more confused :D

If Angular2 resolves can return a promise or observable as in the docs https://angular.io/docs/ts/latest/api/router/index/Resolve-interface.html and this.af.database.list('path/to/data') returns a observable that should be ok - right?

So which one has the bug then? 1) angular2 route resolve handling with observables 2) angularfire2 observable 3) or something else

katowulf commented 7 years ago

It's an event stream. It's never resolved. It keeps waiting for more events. You aren't downloading a static data model, you're subscribing to a continuous event stream. So none are wrong, it's just a slightly different paradigm that doesn't fit perfectly with a promise strategy.

BirdInTheCity commented 7 years ago

+1 for finding a cleaner solution.

The Firebase paradigm of continuous event stream makes sense, but it's a pretty common use case to have initial data loaded prior to resolving a route. I'll be curious to see if any other ideas develop.

Benny739 commented 7 years ago

`resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { let id = route.params['id'];

    return Observable.from(this.ss.getSupplierById(id))
            .do(s => {
                if (s.$value === null){
                    this.router.navigate(['suppliers']);
                    return null;
                }
                return s;
            }).take(1);
}`

This is working fine.

denizcoskun commented 7 years ago

here is the full example.

` import { Injectable } from '@angular/core'; import { AngularFire, FirebaseObjectObservable } from 'angularfire2'; import { AuthService } from './auth'; import { Router, Resolve, RouterStateSnapshot, ActivatedRouteSnapshot } from '@angular/router'; import { Folder } from './models/folder.model'; import { Observable } from 'rxjs'; import 'rxjs/add/operator/first';

@Injectable() export class FolderResolver implements Resolve<FirebaseObjectObservable> {

constructor(private af: AngularFire, private auth: AuthService, private router: Router) { }

resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FirebaseObjectObservable> {

let id = route.params['id'];
console.log(id);

let folderID = `/users/${this.auth.id}/folders/${id}`;
let folder =  this.af.database.object(folderID);

return folder.do((s) => {
    if(s.$value === null) {
        this.router.navigate(['/dashboard']);
        return null;
    } else {
        return folder;
    }
}).take(1);

}`

bogacg commented 7 years ago

Doesn't .first() or .take(1) stops further reading of updates? With this, if data gets updated, we won't see any effect in our app I expect. Also, proposed solutions aren't really intuitive.

myspivey commented 7 years ago

Subscribing to this as well. A common practice for us to resolve certain pieces of data for a view.

leblancmeneses commented 7 years ago

I'm looking for a way to convert angularfire $firebaseAuth().$waitForSignIn() $firebaseAuth().$requireSignIn() to angularfire2.

example route resolve usecases: 1) /shared/:userId resolve $requireSignIn() and determine if auth.uid has access to userid profile. 2) $firebaseAuth().$waitForSignIn() ensures loaded view can synchronously access auth value with const firebaseUser = $scope.authObj.$getAuth();

mrdowden commented 7 years ago

@leblancmeneses I'm also looking for a replacement for $firebaseAuth().$requireSignIn() as this was one of the most powerful features of AngularFire IMO.

UPDATE: Actually @martine-dowden found a way to do this using the AngularFireAuth: angularFireAuth.authState.take(1) returns an Observable that works correctly when returned from a Resolver.

leblancmeneses commented 7 years ago

@mrdowden here is what I am using.

RequireSignInGuard:

import { Injectable } from '@angular/core';
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { AngularFireAuth } from 'angularfire2/auth';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class RequireSignInGuard implements CanActivate {

  constructor(private fbAuth: AngularFireAuth, private router: Router) {
  }

  canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
    return this.fbAuth.authState.map((auth) => {
       if (!auth) {
         this.router.navigateByUrl('login');
         return false;
       }
       return true;
    }).take(1);
  }
}

WaitForSignInGuard:

import { Injectable } from '@angular/core';
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { AngularFireAuth } from 'angularfire2/auth';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class WaitForSignInGuard implements CanActivate {

  constructor(private fbAuth: AngularFireAuth, private router: Router) {
  }

  canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
    return this.fbAuth.authState.map((auth) => {
       return true;
    }).take(1);
  }
}
escobara commented 6 years ago

The problem with using first() is that it will only call the object or list then automatically unsubscribe which will cause problems when reading live changes on the page. So you would need to reload the page if you want to see the changes. I ran into this problem when trying to load a object so that I could edit it.

jamesdaniels commented 3 years ago

Closing as outdated, we could do more work here but I think there's plenty of samples/content floating around now.