Demo: https://bi-labor.github.io/angular Feedback: https://goo.gl/forms/PTYOdBNIB0MpSlzu1
- 0. Introduction
- 1. Create project sceleton
- 2. Create Questions
- 3. Implement Question deletion
- 4. Add Notifications
- 5. Add Statistics
At the end of the guide, please upload the resulting code (without the node_modules folder) in a zip file to course Moodle site.
- Typescript (+ HTML,CSS,JavaScript)
- with ts-lint and .editoconfig
- Angular
- with angular-cli
- RxJS (part of Angular)
- Bootstrap (ngx-bootstrap)
- Firebase
- with firestore repository
- ngx-toastr for notifications
- ploty.js for graph plotting
0.b Typescript 101
0.c Angular 101
- Download and install the latest LTS versino of NodeJS from https://nodejs.org/en/
Open a Terminal / Command Prompt, and type in the following commands.
npm install @angular/cli -g
ng new bi-angular
- If asked about the enforcement of strict tpye checking, answer No.
- Don't add routing.
- Use CSS
Note: It takes a while, because of it also installs the node_modules
package.json
- contains the required npm modules and start up scriptsangular.json
- angular configuration file.editogcondig
- editor configuration file (contains rules like indentation)tsconfig.json
- typescript configurationtslint.json
- typescript configuration (contains rules like no "", but only '')- src/
index.html
- start up page, conatins angularapp-root
, the app entry point- app/
app.module.ts
- list all included modules, components, pipes, services, etc.app.component.ts
- entry component: later it will only contain a router outlet*.spec.ts
- test files
npm start
& open localhost:<port>
in browser.
Add it with cli:
ng generate component votes
Creates these files:
src/app/votes/votes.component.html
src/app/votes/votes.component.spec.ts
src/app/votes/votes.component.ts
src/app/votes/votes.component.css
Routing helps us to navigate between screens like:
Between the voting screen and the statistic screen:
(Routing modul is responsible for parsing the current url and routing=rendering the application to the appropriate component)
ng generate module app-routing --flat --module=app
It creates: app-routing.module.ts
and registeres it atapp.module.ts
Update app.component.ts
with a router outlet:
@Component({
selector: 'app-root',
template: `<router-outlet></router-outlet>`
})
export class AppComponent {
title = 'bi-angular';
}
Update app-routing.module.ts
to look like this:
import { NgModule } from '@angular/core';
import {VotesComponent} from './votes/votes.component';
import {RouterModule, Routes} from '@angular/router';
const routes: Routes = [
{path: '', redirectTo: '/votes', pathMatch: 'full'},
{path: 'votes', component: VotesComponent}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
You can google for angular bootstrap and find a nice npm module. Best practice: look for the one that has the most stars at github + has a solid documentation. We will use valor-software's ngx-bootstrap:
ng add @ng-bootstrap/ng-bootstrap
Note: This installs and adds to the package.json ngx-bootstrap
and bootstrap
dependencies
Look for example designs at: https://getbootstrap.com/docs/4.2/examples/starter-template/
Add navigator and main container to votes.component.html
:
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
<a class="navbar-brand" href="https://www.aut.bme.hu/Course/VIAUMB00">Angular Lab</a>
<button class="navbar-toggler" (click)="isCollapsed = !isCollapsed"
type="button" data-toggle="collapse" data-target="#navbarsExampleDefault"
aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarsExampleDefault" [ngbCollapse]="isCollapsed">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" [routerLink]="['/votes']">Questions <span class="sr-only">(current)</span></a>
</li>
</ul>
<ul class="nav justify-content-center">
<li class="nav-item">
<button class="btn btn-primary">New Question</button>
</li>
</ul>
</div>
</nav>
<main role="main" class="container">
</main><!-- /.container -->
isCollapsed = true;
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
...
imports:[
...
BrowserAnimationsModule,
]
Your final app.module.ts should look like this:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { VotesComponent } from './votes/votes.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
declarations: [
AppComponent,
VotesComponent
],
imports: [
BrowserModule,
AppRoutingModule,
NgbModule,
BrowserAnimationsModule,
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
ng generate component votes/question
<div class="card mb-3">
<img src="..." class="card-img-top">
<div class="card-body">
<div class="row">
<div class="col-8">
<h5 class="card-title">Question placeholder</h5>
</div>
<div class="col-4">
<p class="card-text float-right">
<small class="text-muted">Created a minute ago</small>
</p>
</div>
</div>
<div class="row">
<div class="col-8">
<p class="card-text">Short Description</p>
</div>
</div>
<hr/>
<div class="form-group row">
<div class="col-12">
<div class="custom-control custom-radio custom-control-inline">
<input name="radio-0" id="radio-0"
type="radio"
class="custom-control-input" value="rabbit">
<label for="radio-0" class="custom-control-label">{{1}}) First option</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input name="radio-1" id="radio-1"
type="radio"
class="custom-control-input" value="rabbit">
<label for="radio-1" class="custom-control-label">{{2}}) First option</label>
</div>
<button type="button" class="btn btn-secondary float-right">Vote</button>
</div>
</div>
</div>
</div>
...
<main role="main" class="container">
<app-question></app-question>
</main><!-- /.container -->
Add a top margin to body (src/styles.css), this will seperate the content from the navbar.
body {
margin-top: 100px;
}
export interface QuestionOption {
label: string;
}
import {QuestionOption} from './QuestionOption';
export interface Question {
photoUrl?: string;
question: string;
description?: string;
created: number;
options: QuestionOption[];
}
export interface QuestionEntity extends Question {
id: string;
}
export interface Vote {
option: string;
timeStamp: number;
}
You can use cli:
ng generate s votes
Or create manuall in a new file at services/vote.service.ts
import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';
import {Question, QuestionEntity} from './model/Question';
import {Vote} from './model/Vote';
@Injectable()
export class VotesService {
questions: Observable<QuestionEntity[]>;
constructor() {
this.questions = this.getDummyQuestions();
}
getDummyQuestions(): Observable<QuestionEntity[]> {
return new BehaviorSubject([
{
id: '1',
photoUrl: '',
question: 'How are you?',
description: 'I\'m good too..',
created: Date.now(),
options: [{label: 'good'}, {label: 'ehh'}]
},
{
id: '2',
photoUrl: '',
question: 'How are you again?',
description: 'Just cheking...',
created: Date.now(),
options: [{label: 'good again'}, {label: 'still ehh'}]
}
]);
}
async addQuestion(q: Question) {
// TODO: implement
}
async vote(questionId: string, optionLabel: string) {
// TODO: implement
}
async delete(questionId: string) {
// TODO: implement
}
getVotes(questionId: string): Observable<Vote[]> {
// TODO: implement
return null;
}
}
Note: Don't forget to add it to the app module at app.module.ts
import {VotesService} from './votes.service';
...
providers:[
...
VotesService
]
export class VotesComponent {
constructor(public votesService: VotesService) {
}
}
<main role="main" class="container">
<app-question [question]="question"
*ngFor="let question of votesService.questions | async"></app-question>
</main><!-- /.container -->
Note: We are using the async
pipe, since votesService.questions
is an Observervable<QuestionEntity>
type.
Add question
as an input parameter of the component.
import {Component, Input, OnInit} from '@angular/core';
import {QuestionEntity} from '../../model/Question';
import {VotesService} from '../../votes.service';
@Component({
selector: 'app-question',
templateUrl: './question.component.html',
styleUrls: ['./question.component.css']
})
export class QuestionComponent {
selected: string;
@Input() question: QuestionEntity;
constructor(private votesService: VotesService) {
}
async vote() {
if (!this.selected) {
return;
}
await this.votesService.vote(this.question.id, this.selected);
// TODO: add toast
}
async deleteQuestion() {
// TODO: implement
}
}
Note: You can see that we can now use the [question]
tag in the votes/votes.componenet.html
to provide an input to the component.
<div class="card mb-3">
<img *ngIf="question.photoUrl" [src]="question.photoUrl" class="card-img-top">
<div class="card-body">
<div class="row">
<div class="col-8">
<h5 class="card-title">{{question.question}}</h5>
</div>
<div class="col-4">
<p class="card-text float-right">
<small class="text-muted">Created {{question.created | timeAgo}}</small>
</p>
</div>
</div>
<div class="row">
<div class="col-8">
<p class="card-text">{{question.description}}</p>
</div>
</div>
<hr/>
<div class="form-group row">
<div class="col-12">
<form #voteForm="ngForm">
<div class="custom-control custom-radio custom-control-inline"
*ngFor="let option of question.options; let i=index">
<input [(ngModel)]="selected"
[value]="option.label"
name="radio-{{question.id}}"
id="radio-{{question.id}}{{i}}"
type="radio"
class="custom-control-input" required>
<label for="radio-{{question.id}}{{i}}" class="custom-control-label">{{i+1}}) {{option.label}}</label>
</div>
<button (click)="vote()" [disabled]="!voteForm.form.valid" type="button" class="btn btn-secondary float-right">Vote</button>
</form>
</div>
</div>
</div>
</div>
We are using angular forms to disable the voting button if no radio button is selected
import {FormsModule} from '@angular/forms';
...
import:[
...
FormsModule
...
]
votes/question/question.component.html
uses now the timeAgo
pipe to convert the created
timeStamp to readable string.
It is not a built in pipe, we need to implement it:
Create new pipe at srcm/app/pipes/TimeAgoPipe.ts
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({
name: 'timeAgo',
pure: false
})
export class TimeAgoPipe implements PipeTransform {
public transform(time: number): string {
const delta = (Date.now() - time) / 1000;
// format string
if (delta < 60) { // sent in last minute
return `${Math.floor(delta)}s ago`;
} else if (delta < 3600) { // sent in last hour
return `${Math.floor(delta / 60)}m ago`;
} else if (delta < 86400) { // sent on last day
return `${Math.floor(delta / 3600)}h ago`;
} else { // sent more than one day ago
return `${Math.floor(delta / 86400)}d ago`;
}
}
}
Note: Don't forget to add it to the app module at app.module.ts
import {TimeAgoPipe} from './pipes/TimeAgoPipe';
...
declarations:[
TimeAgoPipe
]
You can use a form generator to create bootstrap 4 forms: https://bootstrapformbuilder.com/
<ng-template #addVoteTemplate>
<div class="modal-header">
<h4 class="modal-title pull-left">New Question Form</h4>
<button type="button" class="close pull-right" aria-label="Close" (click)="modalRef.hide()">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<form (ngSubmit)="addQuestion()" #questionForm="ngForm">
<div class="form-group row">
<label class="col-3 col-form-label" for="question">Question</label>
<div class="col-9">
<input [(ngModel)]="question.question" id="question" name="question" placeholder="How long is the...?"
required="required" type="text" class="form-control">
</div>
</div>
<div class="form-group row">
<label for="photo-url" class="col-3 col-form-label">Photo url</label>
<div class="col-9">
<input [(ngModel)]="question.photoUrl" id="photo-url" name="photo-url" placeholder="https://myurl.com/img.png"
type="text" class="form-control">
</div>
</div>
<div class="form-group row">
<label for="description" class="col-3 col-form-label">Description</label>
<div class="col-9">
<input [(ngModel)]="question.description" id="description" name="description" placeholder="Some more context"
type="text" class="form-control">
</div>
</div>
<div class="form-group row" *ngFor="let option of question.options; let i = index">
<label for="description" class="col-2 offset-3 col-form-label">{{i + 1}}.</label>
<div class="col-7">
<input [(ngModel)]="option.label"
[name]="'option-'+(i+1)"
placeholder="Option {{i+1}}"
required="required"
type="text"
class="form-control">
</div>
</div>
<div class="form-group row">
<div class="offset-3 col-4">
<button name="submit" type="button" (click)="addOption()" class="btn btn-secondary">Add Option</button>
</div>
<div class="col-5">
<button name="submit" type="submit" [disabled]="!questionForm.form.valid"
class="btn btn-primary float-right">Create Question
</button>
</div>
</div>
</form>
</div>
</ng-template>
Add click listener to the new Question
button at votes/votes.component.html
<li class="nav-item">
<button class="btn btn-primary"
(click)="openModal(addVoteTemplate)">
New Question
</button>
</li>
- Create
question
andoptions
data, that we can bind to in the html.
import {Component, TemplateRef} from '@angular/core';
import {VotesService} from '../votes.service';
import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap';
import {Question} from '../model/Question';
@Component({
selector: 'app-votes',
templateUrl: './votes.component.html',
styleUrls: ['./votes.component.css']
})
export class VotesComponent {
modalRef: NgbModalRef;
question: Question;
isCollapsed = true;
constructor(public votesService: VotesService,
private modalService: NgbModal) {
}
openModal(template: TemplateRef<any>) {
this.question = {
question: '',
created: Date.now(),
photoUrl: '',
description: '',
options: []
};
this.addOption();
this.addOption();
this.modalRef = this.modalService.open(template);
}
async addQuestion() {
this.modalRef.close();
await this.votesService.addQuestion(this.question);
}
addOption() {
this.question.options.push({label: ''});
}
}
- questions:collection
|--- id:string
|--- question:string
|--- photoUrl:string
|--- description:string
|--- created:number
|--- options:{label:string}[]
|--- votes:collection
|--- option:string
|--- timeStamp:number
npm install firebase @angular/fire --save
Update environments/environment.ts
You can create your own firebase token, but you can use these:
export const environment = {
production: false,
firebase: {
apiKey: "AIzaSyD0qWw7YgMqGd-ELV0a8ea8O0w2u29wR4M",
authDomain: "bi-labor-angular2.firebaseapp.com",
databaseURL: "https://bi-labor-angular2.firebaseio.com",
projectId: "bi-labor-angular2",
storageBucket: "bi-labor-angular2.appspot.com",
messagingSenderId: "1009646526609"
}
};
Alternative you can also you this settings:
apiKey: "AIzaSyDol8jLaHIPSfChtIyg7X36aMyVrN83K_4",
authDomain: "bi-labor-angular1.firebaseapp.com",
databaseURL: "https://bi-labor-angular1.firebaseio.com",
projectId: "bi-labor-angular1",
storageBucket: "",
messagingSenderId: "363613880788"
import {AngularFireModule} from '@angular/fire';
import {AngularFirestoreModule} from '@angular/fire/firestore';
import {environment} from '../environments/environment';
...
imports: [
...
AngularFireModule.initializeApp(environment.firebase),
AngularFirestoreModule
]
import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';
import {Question, QuestionEntity} from './model/Question';
import {Vote} from './model/Vote';
import {AngularFirestore, AngularFirestoreCollection, DocumentChangeAction} from '@angular/fire/firestore';
import {map} from 'rxjs/operators';
@Injectable()
export class VotesService {
questions: Observable<QuestionEntity[]>;
private questionCollection: AngularFirestoreCollection<Question>;
constructor(private afs: AngularFirestore) {
this.questionCollection = afs.collection<Question>('questions',
ref => ref.orderBy('created', 'desc').limit(50));
this.questions = <any>this.questionCollection.snapshotChanges().pipe(
map((actions: DocumentChangeAction<Question>[]) => {
return actions.map(a => {
const data = a.payload.doc.data() as Question;
const id = a.payload.doc.id;
return {id, ...data};
});
}));
}
getDummyQuestions(): Observable<QuestionEntity[]> {
return new BehaviorSubject([
{
id: '1',
photoUrl: '',
question: 'How are you?',
description: 'I\'m good too..',
created: Date.now(),
options: [{label: 'good'}, {label: 'ehh'}]
},
{
id: '2',
photoUrl: '',
question: 'How are you again?',
description: 'Just cheking...',
created: Date.now(),
options: [{label: 'good again'}, {label: 'still ehh'}]
}
]);
}
async addQuestion(q: Question) {
await this.questionCollection.add(q);
}
async vote(questionId: string, optionLabel: string) {
await this.questionCollection.doc(questionId)
.collection<Vote>('votes').add({timeStamp: Date.now(), option: optionLabel});
}
async delete(questionId: string) {
// TODO: implement
// do not forget to delete the votes subcollection manually
}
getVotes(questionId: string): Observable<Vote[]> {
return this.questionCollection.doc(questionId).collection<Vote>('votes').valueChanges();
}
}
3.a Add delete Button to every question in the same line with the description at votes/question/question.componenet.html
<div class="row">
<div class="col-8">
<p class="card-text">{{question.description}}</p>
</div>
--------Copy between these----------
<div class="col-4">
<button type="button" (click)="deleteQuestion()" class="btn btn-outline-danger float-right">Delete</button>
<a [routerLink]="['/statistic', question.id]"
class="btn btn-outline-primary float-right mr-2 ml-2">Statistic</a>
</div>
--------Copy between these----------
</div>
Note: Firestore does not delete subcollections automatically, but you can do it manually.
Hint: Query the firestore document that has the id questionId
and delete it.
We are using ngx-toastr: https://github.com/scttcper/ngx-toastr
npm install ngx-toastr --save
npm install @angular/animations --save
Also add the toaster stylesheet to angular.json
, read more at: https://github.com/scttcper/ngx-toastr
Read more here: scttcper/ngx-toastr#602
Add this to styles.css
#toast-container > div {
opacity:1;
}
We are using angular-ploty: https://github.com/plotly/angular-plotly.js/blob/master/README.md
npm install angular-plotly.js plotly.js --save
ng g c statistic
const routes: Routes = [
{ path: '', redirectTo: '/votes', pathMatch: 'full' },
{ path: 'votes', component: VotesComponent },
{ path: 'statistic/:id', component: StatisticComponent }
];
Use navigation bar
Crate a bootstrap card for your plot
Hint: You need to subscribe
to the ActivatedRoute:params
observable parameters list.
Hint 1: It is an Observable you need to subscribe
to it.
Hint 2: You need to process the data to the appropriate format that plotlyJS requires.
Hint 3: On updating the model plotlyJs might not detect changes, trigger detection manaully.
You can use this trick this.graphData = JSON.parse(JSON.stringify(this.graphData));