Reputation: 410
I'm currently building an Angular 2 app using both Redux (ngrx) and RxJS (mainly for learning purposes), however it's still a bit (to say the least) confusing to me.
I'm trying to implement a "/projects" route, as well as a "/projects/:id" route. On both of these the behaviour is that I make an HTTP request to fetch the required data.
Currently, if I navigate to "projects" (either by URL or ajax call via navigation) it will get all 15 or so projects back from the server and add them to the "projects" store on Redux. Now, if I currently try to get in from a specific project (from the browser's searchbar -> "localhost:3000/projects/2", for example) it will only fetch the one, which is what I want and place it in the store, however if I then navigate to the "projects" section from there, it will only print the one project that is located in the store.
What I want to accomplish is the following:
I want to accomplish this on an efficient, performant and elegant way.
I am currently, I believe, subscribed to the same Observable from at least two places and I do not believe that is the right approach. On top of that, I'm still not able to get the results I want if I get in from the "/projects:/id" route first and then navigate to the "/projects" route.
Here's the code that I consider relevant:
projects.directive.ts
import { Component, OnInit } from '@angular/core';
import { ProjectsService } from '../shared/services/projects.service';
import { Observable } from 'rxjs/Observable';
import { Project } from '../../models/project.model';
@Component({
selector: 'projects',
templateUrl: './projects.html'
})
export class Projects implements OnInit {
private projects$: Observable<Project[]>
constructor(private projectsService: ProjectsService) {}
ngOnInit() {
this.projectsService.findProjects();
}
}
projectOne.directive.ts
import { Component, OnInit } from '@angular/core';
import { Params, ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { ProjectsService } from '../../shared/services/projects.service';
import { Project } from '../../../models/project.model';
@Component({
selector: 'projectOne',
templateUrl: './projectOne.html'
})
export class ProjectOneComponent implements OnInit {
private projects$: Observable<Project[]>
constructor(private route: ActivatedRoute, private projectsService: ProjectsService) {}
ngOnInit() {
this.route.params.subscribe((params: Params) => {
this.projectsService.findProjects(params['id'])
});
}
}
*Some things to note here: I am subscribing to this.route.params, which subscribes to yet another Observable, do I need to flatten that at all or nay? The concept still beats me
projects.html
<section>
<article *ngFor="let project of projectsService.projects$ | async">
<p>{{project?._id}}</p>
<p>{{project?.name}}</p>
<img src="{{project?.img}}" />
<a routerLink="{{project?._id}}">See more</a>
</article>
</section>
*Here I'd like to make note that I'm also using projectsService.projects$ | async to print the results on the iteration which I'm quite positive also affects...
projects.service.ts
import { Http } from '@angular/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';
import { Store } from '@ngrx/store';
import { Project } from '../../../models/project.model';
import { AppStore } from '../../app.store';
import { ADD_PROJECTS } from '../../../reducers/projects.reducer';
@Injectable()
export class ProjectsService {
public projects$: Observable<Project[]>;
constructor(private _http: Http, private store: Store<AppStore>){
this.projects$ = store.select<Project[]>('projects');
}
fetchProjects(id) {
return this._http.get(`/api/projects?id=${id}`)
.map(res => res.json())
.map(({projectsList}) => ({ type: ADD_PROJECTS, payload: projectsList }))
.subscribe(action => this.store.dispatch(action));
}
findProjects(id: Number = 0) {
this.projects$.subscribe(projects => {
if (projects.length) {
if (projects.length === 1) {
return this.fetchProjects();
}
} else {
return this.fetchProjects(id ? id : '')
}
})
}
}
*I'm guessing every time I call that "findProjects" function I'm subscribing to the Observable. No good, huh?
*Also, with this current setup whenever I go directly into "/projects/:id" it seems to be executing the fetchProjects function twice (I figured that much by console logging). Essentially, the this.projects$ subscription within findProjects jumps in and fetches the project with the corresponding id, but then it goes in again and fetches every other project and lastly it just "goes away"? Why is it calling itself, or where is the second call coming from?
projects.reducer.ts
import { Project } from '../models/project.model';
import { ActionReducer, Action } from '@ngrx/store';
export const ADD_PROJECTS = 'ADD_PROJECTS';
export const projects: ActionReducer<Project[]> = (state: Project[] = [], action: Action) => {
switch (action.type) {
case ADD_PROJECTS:
return action.payload;
default:
return state;
}
};
*This is all the reducer has for the time being because I'm still super stuck on the rest.
Anyways, I'd like to thank you all in advance. If anything is not clear at all or you need any more info, please let me know. I know this covers more than just one thing and might be super easy or not at all but I'm really eager to get as much help as possible because I'm really stuck here... Thanks again!
Upvotes: 4
Views: 2347
Reputation: 16882
In general your code looks "okay". There are a few things, that I've noticed though:
allProjects: Projects[]
which is not refetched and selectedProject: Project
which is only fetched if not found within allProjects
ngrx
for your store, you might want to have a look at the ngrx/effects as an alternative to dispatching actions from within your service -> this part would be very optional, but in the perfect ngrx-app the data-service would not even know that there is a store.That being said, here are some code-improvements bringing it more towards a ngrx-oriented app - however I'd still suggest that you take a look at the official ngrx-example-app which is very good:
projects.component.ts
@Component({
selector: 'projects',
templateUrl: './projects.html'
})
export class Projects {
private projects$: Observable<Project[]> = his.store
.select<Project[]>('projects')
.map(projects => projects.all)
constructor(private store: Store<AppStore>) {
store.dispatch({type: ProjectActions.LOAD_ALL});
}
}
projects.component.html
<section>
<article *ngFor="let project of projects$ | async">
<!-- you don't need to use the questionmark here (project?.name) if you have something like "undefined" or "null" in your array, then the problem lies somewhere else -->
<p>{{project._id}}</p>
<p>{{project.name}}</p>
<img src="{{project.img}}" />
<a routerLink="{{project._id}}">See more</a>
</article>
</section>
project.component.ts
@Component({
selector: 'projectOne',
templateUrl: './projectOne.html'
})
export class ProjectOneComponent implements OnInit {
// project$ is only used with the async-pipe
private project$: Observable<Project[]> = this.route.params
.map(params => params['id'])
.switchMap(id => this.store
.select<Project[]>('projects')
.map(projects => projects.byId[id])
.filter(project => !!project) // filter out undefined & null
)
.share(); // sharing because it is probably used multiple times in the template
constructor(private route: ActivatedRoute,
private store: Store<AppStore>) {}
ngOnInit() {
this.route.params
.take(1)
.map(params => params['id'])
.do(id => this.store.dispatch({type: ProjectActions.LOAD_PROJECT, payload: id})
.subscribe();
}
}
project.service.ts => doesn't know about the store
@Injectable()
export class ProjectsService {
constructor(private _http: Http){}
fetchAll() {
return this._http.get(`/api/projects`)
.map(res => res.json());
}
fetchBy(id) {
return this._http.get(`/api/projects?id=${id}`)
.map(res => res.json());
}
}
project.effects.ts
@Injectable()
export class ProjectEffects {
private projects$: Observable<Project[]> = his.store
.select<Project[]>('projects')
.map(projects => projects.all);
constructor(private actions$: Actions,
private store: Store<AppStore>,
private projectsService: ProjectsService){}
@Effect()
public loadAllProjects$: Observable<Action> = this.actions$
.ofType(ProjectActions.LOAD_ALL)
.switchMap(() => this.projectsService.fetchAll()
.map(payload => {type: ProjectActions.ADD_PROJECTS, payload})
);
@Effect()
public loadSingleProject$: Observable<Action> = this.actions$
.ofType(ProjectActions.LOAD_PROJECT)
.map((action: Action) => action.payload)
.withLatestFrom(
this.projects$,
(id, projects) => ({id, projects})
)
.flatMap({id, projects} => {
let project = projects.find(project => project._id === id);
if (project) {
// project is already available, we don't need to fetch it again
return Observable.empty();
}
return this.projectsService.fetchBy(id);
})
.map(payload => {type: ProjectActions.ADD_PROJECT, payload});
}
projects.reducer.ts
export interface ProjectsState {
all: Project[];
byId: {[key: string]: Project};
}
const initialState = {
all: [],
byId: {}
};
export const projects: ActionReducer<ProjectsState> = (state: ProjectsState = initialState, action: Action) => {
switch (action.type) {
case ADD_PROJECTS:
const all: Project[] = action.payload.slice();
const byId: {[key: string]: Project} = {};
all.forEach(project => byId[project._id] = project);
return {all, byId};
case ADD_PROJECT:
const newState: ProjectState = {
all: state.slice(),
byId: Object.assing({}, state.byId)
};
const project: Project = action.payload;
const idx: number = newState.all.findIndex(p => p._id === project._id);
if (idx >= 0) {
newState.all.splice(idx, 1, project);
} else {
newState.all.push(project);
}
newState.byId[project._id] = project;
return newState;
default:
return state;
}
};
As you can see this mightbe slightly more code, but only in central places, where the code can be reused easily - the components got a lot leaner.
In an ideal app you would also have an additional layer to ProjectsComponent
and ProjectOneComponent
, something like ProjectsRouteComponent
and SingleProjectRoute
, which would contain only a template like this: <projectOne project="project$ | async"></projectOne>
this would free the ProjectOneComponent
from any knowledge of the store or anything else, and it would just contain a simple input:
@Component({
selector: 'projectOne',
templateUrl: './projectOne.html'
})
export class ProjectOneComponent implements OnInit {
@Input("project")
project: Project;
}
Upvotes: 4