Poglavje 3 P1.2 Angular SPA in preverjanje istovetnosti uporabnika
P1.2
3.1 Kompleksnejši pogledi s parametri usmerjevanja
V okviru tega poglavja bomo nadaljevali z implementacijo Angular SPA aplikacije, kot to prikazuje slika 3.1. Dodali bomo manjkajoče funkcionalnosti, prikazali uporabo parametrov usmerjanja in posredovali zahteve za dodajanje podatkov na strežnik.
Slika 3.1: Nadaljevanje pretvorbe obstoječe aplikacije v Angular SPA aplikacijo
Najprej bomo implementirali prikaz podrobnosti izbrane lokacije v okviru Angular SPA. Pri tem moramo dostopati do parametra usmerjanja (ID lokacije) in ga uporabiti za pridobitev podatkov iz podatkovne baze, s pomočjo REST API-ja.
3.1.1 Sprememba strukture strani
Stran s podrobnostmi izbrane lokacije je sestavljena iz več komponent, kot to prikazuje slika 3.2.
Slika 3.2: Razdelitev vsebine strani s podrobnostmi lokacije
Najprej kreirajmo manjkajoči komponenti location-details in details-page, medtem ko lahko komponente framework, header in sidebar ponovno uporabimo.
$ ng generate component location-details
CREATE src/app/location-details/location-details.component.ts (221 bytes)
UPDATE src/app/app.module.ts (1322 bytes)
$ ng generate component details-page
CREATE src/app/details-page/details-page.component.ts (209 bytes)
UPDATE src/app/app.module.ts (1426 bytes)Nato poskrbimo za opredelitev strukture strani s podrobnostmi izbrane lokacije tako, da dopolnimo HTML predlogo v details-page.component.ts in pripravimo zahteveane parametre, ki jih HTML predloga potrebuje.
angular/src/app/details-page/details-page.component.ts
import { Component } from "@angular/core";
@Component({
selector: "app-details-page",
template: `<p>details-page works!</p>
<app-header [content]="header"></app-header>
<div class="row">
<div class="col-12 col-lg-9">
<app-location-details></app-location-details>
</div>
<app-sidebar
class="col-12 col-lg-3 mt-4"
[content]="header.sidebar"
></app-sidebar>
</div>`,
styles: [],
})
export class DetailsPageComponent {
header = {
title: "Cultural heritage name",
subtitle: "",
sidebar:
"is on our Demo app because it is a fascinating cultural heritage nearby. If you've visited and you like it - or if you don't - please review it to help other people just like you.",
};
}3.2 Dodajanje usmerjanja z URL parametri
Dodajanje usmerjanja z URL parametri v okviru Angular aplikacije je prav tako enostavno kot pri strežniški Express aplikaciji, celo sintaksa je zelo podobna. Preslikovalna tabela usmerjanja je v Angular aplikaciji opredeljena v datoteki app.module.ts, kjer dodamo novo pot locations/:locationId s pričakovanim parametrom locationId.
angular/src/app/app.module.ts
@NgModule({
...
imports: [
...
RouterModule.forRoot([
{ path: "", component: HomepageComponent },
{ path: "about", component: AboutComponent },
{ path: "locations/:locationId", component: DetailsPageComponent },
]),
],
...
})Če poskušamo dostopati do podrobnosti izbrane lokacije, opazimo, da funkcionalnost delno deluje. Sedaj poskrbimo še, da bo za prehajanje med stranmi zadolžen Angular, tako da zamenjamo obstoječi href atribut z atributom routerLink v HTML predlogi location-list.component.html.
angular/src/app/location-list/location-list.component.html
<a
href="/locations/{{ location._id }}"
[routerLink]="['/locations', location._id]"
class="link-primary text-decoration-none"
><i class="fa-solid fa-location-dot me-2"></i
>{{ location.name }}</a
>V nadaljevanju bi želeli s pomočjo pridobljenega parametra locationId poklicati REST API storitev, pridobiti podrobnosti lokacije in jih prikazati uporabniku. V okviru Angularja za to skrbijo storitve, zato bomo obstoječi storitvi demo-data dodali novo metodo getLocationDetails.
angular/src/app/demo-data.service.ts
export class DemoDataService {
constructor(private http: HttpClient) {}
private apiUrl = "http://localhost:3000/api";
public getLocations(
lng: number,
lat: number,
distance: number,
nResults: number
): Observable<Location[]> {
const url: string = `${this.apiUrl}/locations/distance?lng=${lng}&lat=${lat}&distance=${distance}&nResults=${nResults}`;
return this.http
.get<Location[]>(url)
.pipe(retry(1), catchError(this.handleError));
}
public getLocationDetails(locationId: string): Observable<Location> {
const url: string = `${this.apiUrl}/locations/${locationId}`;
return this.http
.get<Location>(url)
.pipe(retry(1), catchError(this.handleError));
}
private handleError(error: HttpErrorResponse) {
return throwError(() => error.error.message || error.statusText);
}
}Storitev DemoDataService, ki vrača objekt razreda Location bomo uporabili v okviru komponente details-page, zato poskrbimo za ustrezne odvisnosti in ponastavimo parametre vsebine strani.
angular/src/app/details-page/details-page.component.ts
import { Component } from "@angular/core";
import { DemoDataService } from "../demo-data.service";
@Component({
selector: "app-details-page",
template: `<app-header [content]="header"></app-header>
<div class="row">
<div class="col-12 col-lg-9">
<app-location-details></app-location-details>
</div>
<app-sidebar
class="col-12 col-lg-3 mt-4"
[content]="header.sidebar"
></app-sidebar>
</div>`,
styles: [],
})
export class DetailsPageComponent {
constructor(private demoDataService: DemoDataService) {}
header = {
title: "Cultural heritage name",
subtitle: "",
sidebar:
"is on our Demo app because it is a fascinating cultural heritage nearby. If you've visited and you like it - or if you don't - please review it to help other people just like you.",
};
}Glede na enostavno in pogosto zahtevo v Angular aplikaciji po prenosu parametrov med stranmi, je implementacija dokaj zapletena in je prikazana v nadaljevanju izvorne kode krmilnika komponente details-page. Treba je izvesti naslednje korake:
- S pomočjo
ActivatedRouteiz Angular usmerjevalnika pridobimo trenutno pot v okviru komponente. ParamMapnam omogoča dostop do URL parametrov pri trenutno aktivni poti.- Uporabi
SwitchMapiz modulaRxJSza pridobitev vrednosti iz URL parametrov ter uporaba pri API klicu.
Ključni del delovanja je uporaba metode pipe, ki pripravi Observable2, kjer pridobimo vrednost parametra locationId in pokličemo storitev za pridobitev podrobnosti lokacije. Nato se z metodo subscribe prijavimo na rezultate API klica, kjer pričakujemo objekt razreda Location.
angular/src/app/details-page/details-page.component.ts
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, ParamMap } from "@angular/router";
import { switchMap } from "rxjs/operators";
import { DemoDataService } from "../demo-data.service";
import { Location } from "../location-list/location-list.component";
@Component({
selector: "app-details-page",
template: `<app-header [content]="header"></app-header>
<div class="row">
<div class="col-12 col-lg-9">
<app-location-details></app-location-details>
</div>
<app-sidebar
class="col-12 col-lg-3 mt-4"
[content]="header.sidebar"
></app-sidebar>
</div>`,
styles: [],
})
export class DetailsPageComponent implements OnInit {
constructor(
private demoDataService: DemoDataService,
private route: ActivatedRoute
) {}
ngOnInit(): void {
this.route.paramMap
.pipe(
switchMap((params: ParamMap) => {
let locationId: string = params.get("locationId") ?? "";
return this.demoDataService.getLocationDetails(locationId);
})
)
.subscribe((location: Location) => {
this.header = {
title: location.name,
subtitle: "",
sidebar: `${location.name} is on our Demo app because it is a fascinating cultural heritage nearby. If you've visited and you like it - or if you don't - please review it to help other people just like you.`,
};
});
}
header = {
title: "",
subtitle: "",
sidebar: "",
};
}3.2.1 Prenos podatkov do komponente s podrobnostmi lokacije
Ko preko storitve demoDataService pridobimo podatke o lokaciji, poskrbimo še za prenos parametra location podrejeni komponenti location-details.
angular/src/app/details-page/details-page.component.ts
@Component({
selector: "app-details-page",
template: `<app-header [content]="header"></app-header>
<div class="row">
<div class="col-12 col-lg-9">
<app-location-details [location]="location"></app-location-details>
</div>
<app-sidebar
class="col-12 col-lg-3 mt-4"
[content]="header.sidebar"
></app-sidebar>
</div>`,
styles: [],
})
export class DetailsPageComponent implements OnInit {
constructor(
private demoDataService: DemoDataService,
private route: ActivatedRoute
) {}
location!: Location;
ngOnInit(): void {
this.route.paramMap
.pipe(
switchMap((params: ParamMap) => {
let locationId: string = params.get("locationId") || "";
return this.demoDataService.getLocationDetails(locationId);
})
)
.subscribe((location: Location) => {
this.location = location;
this.header = {
title: location.name,
subtitle: "",
sidebar: `${location.name} is on our Demo app because it is a fascinating cultural heritage nearby. If you've visited and you like it - or if you don't - please review it to help other people just like you.`,
};
});
}
...
}Pri komponenti location-details je treba poskrbeti še za pričakovani vhodni parameter location in opredelitev razreda Location.
angular/src/app/location-details/location-details.component.ts
3.2.2 Pogled podrobnosti lokacije
Ker bo HTML predloga za prikaz podrobnosti lokacije nekoliko bolj kompleksna, jo bomo opredelili v ločeni datoteki location-details.component.html ter poskrbeli za spremembo v obstoječem krmilniku.
angular/src/app/location-details/location-details.component.ts
@Component({
selector: "app-location-details",
template: `<p>location-details works!</p>`,
templateUrl: "location-details.component.html",
styles: [],
})Pri opredelitvi predloge s prikazom podrobnosti lokacije smo uporabili številne funkcionalnosti, ki nam jih nudi ogrodje Angular, in sicer:
- ponovna uporaba obstoječe komponente
app-stars, ki prikazuje povprečno oceno lokacije, kot tudi oceno posameznega komentarja, - pogojno prikazovanje HTML gradnikov s pomočjo direktive
*ngIf,- ker podatke pridobivamo asinhrono, bomo podrobnosti o lokaciji prikazali šele takrat, ko so le-ti (
location) na voljo, - določeni podatki (npr.
synonyms,heritage,comments) so opcijski oz. neznani in jih prikažemo le, če so pri izbrani lokaciji na voljo,
- ker podatke pridobivamo asinhrono, bomo podrobnosti o lokaciji prikazali šele takrat, ko so le-ti (
- prikaz večvrednostnih podatkov iz tabele s pomočjo direktive
*ngFor,- npr.
keywords,fields,comments,
- npr.
- pogojno vključevanje CSS razredov s pomočjo
[ngClass],- pri komentarjih želimo pri prikazu vključiti medsebojni presledek, vendar ne pri zadnjem komentarju.
angular/src/app/location-details/location-details.component.html
<div class="row" *ngIf="location">
<div class="col-12 col-md-6">
<div class="card mt-4">
<div class="card-header bg-light">
<h4 class="mt-1 mb-1">
<i class="fa-regular fa-file-lines pe-2"></i>Description
</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-12 col-md-6">
<h6>Rating</h6>
<p><app-stars [rating]="location.rating || 0"></app-stars></p>
</div>
<div class="col-12 col-md-6">
<div *ngIf="location.synonyms && location.synonyms.length > 0">
<h6>Synonyms</h6>
<p>{{ location.synonyms.join(", ") }}</p>
</div>
</div>
<div class="col-12 col-md-6">
<h6>Category</h6>
<p>{{ location.category }}</p>
</div>
<div class="col-12 col-md-6">
<h6>Type</h6>
<p>{{ location.type }}</p>
</div>
<div class="col-12 col-md-6">
<h6>Datation</h6>
<p>{{ location.datation }}</p>
</div>
<div class="col-12 col-md-6">
<div *ngIf="location.heritage != 'neznano'">
<h6>Heritage</h6>
<p>{{ location.heritage }}</p>
</div>
</div>
<div class="col-12">
<h6>Summary</h6>
<p>{{ location.description }}</p>
</div>
<div class="col-12 col-md-6">
<h6>Institution</h6>
<p class="mb-3 mb-md-0">{{ location.institution }}</p>
</div>
<div class="col-12 col-md-6">
<h6>Municipality</h6>
<p class="mb-0">{{ location.municipality }}</p>
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header bg-light">
<h4 class="mt-1 mb-1"><i class="fa-solid fa-tags pe-2"></i>Tags</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-12 col-md-6">
<h6>Keywords</h6>
<p class="mb-3 mb-md-0">
<span
class="badge text-bg-light me-1"
*ngFor="let keyword of location.keywords"
><i class="fa-solid fa-tag me-1"></i>{{ keyword }}</span
>
</p>
</div>
<div class="col-12 col-md-6">
<h6>Fields</h6>
<p class="mb-0">
<span
class="badge text-bg-light me-1"
*ngFor="let field of location.fields"
><i class="fa-solid fa-tag me-1"></i>{{ field }}</span
>
</p>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="card mt-4">
<div class="card-header bg-light">
<h4 class="mt-1 mb-1"><i class="fas fa-map-signs pe-2"></i>Location</h4>
</div>
<div class="card-body">
<div class="mb-1">
<small class="text-muted">{{ location.location }}</small>
</div>
<div class="ratio ratio-4x3">
<iframe
title="Zamljevid"
src="https://maps.google.com/maps?q={{ location.coordinates[1] }},{{
location.coordinates[0]
}}&z=15&output=embed"
class="rounded-3"
></iframe>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card mt-4">
<div class="card-header bg-light">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4 class="mt-1 mb-1">
<i class="fa-regular fa-comments pe-2"></i>User's comments
</h4>
</div>
<div>
<a
href="/locations/{{ location._id }}/comments/new"
class="btn btn-primary btn-sm"
><i class="fa-regular fa-comment-dots pe-2"></i
><span>Add comment</span></a
>
</div>
</div>
</div>
<div class="card-body">
<div *ngIf="!location.comments || location.comments.length == 0">
No comments yet, please add yours.
</div>
<div
class="mt-2"
[ngClass]="!last ? 'mb-4' : ''"
*ngFor="let comment of location.comments; last as last"
>
<div
class="d-flex justify-content-between align-items-baseline bg-light rounded-3 p-1"
>
<div class="ms-1">
<app-stars [rating]="comment.rating"></app-stars
><i class="fa-regular fa-user ps-4 pe-2"></i>{{ comment.author }}
</div>
<div>
<small>
{{ comment.createdOn
}}<i class="fa-regular fa-calendar ps-2 pe-1"></i>
</small>
</div>
</div>
<div>
<div class="d-flex flex-row align-items-baseline">
<div class="p-2"><i class="far fa-comment-dots"></i></div>
<div class="p-2">{{ comment.comment }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>Ker med podatki lokacije sedaj pričakujemo tudi (opcijske) komentarje, dopolnimo opredelitev razreda Location ter dodajmo razred Comment.
angular/src/location-list/location-list.components.ts
export class Comment {
_id?: string;
author!: string;
rating!: number;
comment!: string;
createdOn?: Date;
}
export class Location {
_id!: string;
name!: string;
category!: string;
type!: string;
keywords!: string[];
description!: string;
location!: string;
institution!: string;
heritage!: string;
municipality!: string;
coordinates!: number[];
synonyms?: string[];
datation!: string;
authors!: string;
fields!: string[];
rating?: number;
distance!: number;
comments?: Comment[];
}3.2.3 Varnostne izjeme pri prikazovanju zunanjih podatkov
Opazimo lahko, da se zemljevid v HTML gradniku iframe ne prikazuje, in sicer Angular to obravnava kot varnostno težavo, kjer bomo morali dodati izjemo. Uporabili bomo cev transformacije allow-url, kjer za podan URL naslov ne bomo upoštevali varnostnih zahtev.
$ ng generate pipe allow-url
CREATE src/app/allow-url.pipe.ts (221 bytes)
UPDATE src/app/app.module.ts (1567 bytes)angular/src/app/allow-url.pipe.ts
import { Pipe, PipeTransform } from "@angular/core";
import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser";
@Pipe({
name: "allowUrl",
})
export class AllowUrlPipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}
transform(value: unknown, ...args: unknown[]url: string): unknownSafeResourceUrl {
return nullthis.sanitizer.bypassSecurityTrustResourceUrl(url);
}
}Pri določitvi atributa src gradnika iframe zdaj uporabimo cev transformacije allowUrl.
angular/src/app/location-details/location-details.component.html
3.2.4 Sprememba vrstnega reda prikaza komentarjev
Trenuten prikaz komentarjev na strani je v takšnem vrstnem redu, kot jih najdemo v podatkovni bazi, mi pa bi želeli komentarje urejene od najnovejšega do najstarejšega. Za takšen prikaz bomo pripravili novo cev most-recent-first.
$ ng generate pipe most-recent-first
CREATE src/app/most-recent-first.pipe.ts (235 bytes)
UPDATE src/app/app.module.ts (1655 bytes)Transformacija bo zgolj poskrbela za vrstni red elementov v polju komentarjev, kjer izvedemo primerjavo glede na atribut createdOn.
angular/src/app/most-recent-first.pipe.ts
import { Pipe, PipeTransform } from "@angular/core";
import { Comment } from "./location-list/location-list.component";
@Pipe({
name: "mostRecentFirst",
})
export class MostRecentFirstPipe implements PipeTransform {
transform(
value: unknown, ...args: unknown[]comments: Comment[] | undefined
): unknownComment[] | undefined {
if (comments && comments.length > 0) {
comments = comments.sort((a, b) => {
let order = 0;
if (b.createdOn && a.createdOn) {
if (b.createdOn > a.createdOn) order = 1;
else if (b.createdOn < a.createdOn) order = -1;
}
return order;
});
}
return nullcomments;
}
}Na HTML predlogi location-details cev filtriranja mostRecentFirst vključimo na ravni dostopa do komentarjev.
angular/src/app/location-details.component.html
3.2.5 Formatiranje datuma
Pri prikazu podrobnosti lokacije moramo poskrbeti zgolj še za ustrezen prikaz datuma komentarja.
angular/src/app/location-details/location-details.component.html
<div>
<small
>{{ comment.createdOn | date: "d. MMMM, yyyy"
}}<i class="fa-regular fa-calendar ps-2 pe-1"></i
></small>
</div>Ker se imena mesecev privzeto prikažejo v angleškem jeziku, lahko v Angular aplikacijo v okviru modula s pomočjo registerLocaleData vključimo slovenski jezik.
app_public/src/app/app.module.ts
import { NgModule } from "@angular/core";
import { registerLocaleData } from "@angular/common";
import localeSl from "@angular/common/locales/sl";
registerLocaleData(localeSl, "sl");
import { BrowserModule } from "@angular/platform-browser";
import { HttpClientModule } from "@angular/common/http";
import { RouterModule } from "@angular/router";
...Sedaj pri klicu cevi uporabimo dodatna parametra za časovni pas (CEST) in jezik (sl) in imena mesecev se prikažejo v slovenskem jeziku.
angular/src/app/location-details/location-details.component.html
<small
>{{ comment.createdOn | date: "d. MMMM, yyyy":"CEST":"sl"
}}<i class="fa-regular fa-calendar ps-2 pe-1"></i
></small>Opomba: Za celostni prevod aplikacije se uporabljajo drugi pristopi za internacionalizacijo in lokalizacijo i18n (npr.
ngx-translate), ki jih bomo spoznali v nadaljevanju.
3.3 Delo s spletnimi obrazci in pošiljanje podatkov
V nadaljevanju bomo v aplikacijo vključili spletni obrazec za vnos novih komentarjev in posredovanje le-teh preko REST API-ja na strežnik. Ob kliku na gumb Add comment bomo z uporabo Bootstrap modalnega okna prikazali spletni obrazec za vnos podatkov komentarja. Ko bodo podatki posredovani na strežnik, bo Angular poskrbel za prikaz novega komentarja na vrhu seznama.
3.3.1 Priprava modalnega okna
Za lažjo vključitev Bootstrap gradnikov v Angular aplikacijo bomo uporabili obstoječi odprtokodni projekt ngx-bootstrap, kjer so Bootstrap gradniki na voljo v obliki Angular komponent.
Opomba: Poleg projekta
ngx-bootstrap, ki je pri razvijalcih bolj priljubljen, obstaja tudi projektng-bootstrap, ki ponuja podobno funkcionalnost, vendar ni tako ažuren pri podpori zadnjih verzij ogrodja Bootstrap.
Podporo ng-bootstrap v obstoječo Angular aplikacijo dodamo s pomočjo ukaza ng add.
$ ng add ngx-bootstrap
ℹ Using package manager: npm
✔ Found compatible package version: ngx-bootstrap@11.0.2.
✔ Package information loaded.
The package ngx-bootstrap@11.0.2 will be installed and executed.
Would you like to proceed? Yes
✔ Packages successfully installed.
✅️ Added "bootstrap
✅️ Added "ngx-bootstrap
UPDATE package.json (838 bytes)
UPDATE angular.json (3219 bytes)
UPDATE src/app/app.module.ts (1906 bytes)
✔ Packages installed successfully.Z dodajanjem podpore za ng-bootstrap so se vključili tudi Bootstrap stili, ki pa jih v našem primeru ne potrebujemo, saj smo jih predhodno že uvozili, zato bomo te na novo dodane reference v angular.json odstranili.
angular/angular.json
{
...
"projects": {
"Demo": {
...
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
...
"styles": [
"./node_modules/ngx-bootstrap/datepicker/bs-datepicker.css",
"./node_modules/bootstrap/dist/css/bootstrap.min.css",
"src/styles.css"
],
...
},
...
},
...
}
}
}
}V aplikaciji uvozimo modul ModalModule ter poskrbimo za storitev BsModalService, kar potrebujemo za funkcionalnost modalnega okna, v okviru katerega bomo dodajali komentarje.
angular/src/app/app.module.ts
import { ModalModule, BsModalService } from "ngx-bootstrap/modal";
@NgModule({
...,
imports: [
...
ModalModule,
],
providers: [BsModalService],
bootstrap: [FrameworkComponent],
})Klic modalnega okna potrebujemo v komponenti za prikaz podrobnosti lokacije. Najprej pripravimo referenco na modalno okno in metodo openModal, s katero zahtevamo prikaz.
angular/src/app/location-details/location-details.component.ts
import { Component, Input, TemplateRef } from "@angular/core";
import { BsModalService, BsModalRef } from "ngx-bootstrap/modal";
import { Location } from "../location-list/location-list.component";
@Component({
selector: "app-location-details",
templateUrl: "location-details.component.html",
styles: [],
})
export class LocationDetailsComponent {
modalRef?: BsModalRef;
constructor(private modalService: BsModalService) {}
@Input() location!: Location;
protected openModal(form: TemplateRef<any>) {
this.modalRef = this.modalService.show(form, {
class: "modal-dialog-centered",
keyboard: false,
ignoreBackdropClick: true
});
}
}angular/src/app/location-details/location-details.component.html
<div class="row" *ngIf="location">
...
<div class="col-12">
<div class="card mt-4">
<div class="card-header bg-light">
<div class="d-flex justify-content-between align-items-center">
...
<div>
<a
href="/locations/{{ location._id }}/comments/new"
class="btn btn-primary btn-sm"
(click)="openModal(commentForm)"
><i class="fa-regular fa-comment-dots pe-2"></i
><span>Add comment</span></a
>
</div>
</div>
</div>
...
</div>
</div>
</div>
<ng-template #commentForm>
<div class="modal-header">
<h4 class="modal-title pull-left">
<i class="fa-regular fa-comment-dots pe-2"></i>Add comment
</h4>
<button
type="button"
class="btn-close close pull-right"
aria-label="Close"
(click)="modalRef?.hide()"
>
<span aria-hidden="true" class="visually-hidden">×</span>
</button>
</div>
<div class="modal-body">Comment's information.</div>
</ng-template>3.3.2 Dodajanje spletnega obrazca
Pri dodajanju novega komentarja od uporabnika zahtevamo njegovo ime, oceno in besedilo komentarja. Za ime in besedilo komentarja bomo uporabili klasične HTML gradnike, medtem ko bomo za oceno uporabili obstoječ ngx-bootstrap gradnik Rating.
V aplikacijo najprej dodamo referenco na RatingModule, kar bomo potem uporabili na spletnemu obrazcu za dodajanje komentarja.
angular/src/app/app.module.ts
import { ModalModule, BsModalService } from "ngx-bootstrap/modal";
import { RatingModule } from "ngx-bootstrap/rating";
@NgModule({
...,
imports: [
...,
ModalModule,
RatingModule,
],
...
})V HTML predlogo dodamo vse zahtevane gradnike, in sicer:
- ngx-bootstrap gradnik
ratingza oceno lokacije, - HTML gradnik
inputza ime avtorja in - HTML gradnik
textareaza vsebino komentarja.
Dodamo tudi gumba za shranjevanje in preklic dodajanje komentarja. Ob preklicu se modalno okno skrije.
angular/src/app/location-details/location-details.component.html
<ng-template #commentForm>
<div class="modal-header">
...
</div>
<div class="modal-body">
Comment's information.
<div class="form-group">
<label for="rating" class="form-label mb-1">Rating</label>
<div><rating [max]="5"></rating></div>
</div>
<div class="form-group">
<label for="name" class="form-label mb-1 mt-2">Name</label>
<input
type="text"
id="name"
class="form-control form-control-sm"
placeholder="Enter author's name"
/>
</div>
<div class="form-group">
<label for="comment" class="form-label mt-2">Comment</label>
<textarea
id="comment"
rows="3"
class="form-control form-control-sm"
placeholder="Enter comment"
></textarea>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-sm btn-secondary"
(click)="modalRef?.hide()"
>
<i class="fa-regular fa-circle-xmark me-1"></i>Cancel
</button>
<button type="button" class="btn btn-sm btn-primary">
<i class="fa-regular fa-circle-check me-2"></i>Save
</button>
</div>
</ng-template>Obrazec za vnos komentarja s pomočjo modalnega okna prikazuje slika 3.3, kjer so vidna vsa vnosna polja in gumba za potrjevanje oz. preklic akcije.
Slika 3.3: Vključen obrazec za vnos novih komentarjev nad seznamom komentarjev
3.3.3 Povezovanje podatkov in vnosnih polj obrazca
Pri Angular SPA aplikaciji uporabnik ostane na isti strani, zato moramo podatke vnosnega obrazca povezati s podatki komponente, ki bo podatke preverila in potem poskrbela za posredovanje le-teh na REST API. To dosežemo z uporabo dvosmernega povezovanja ngModel.
Na ravni celotne aplikacije bomo potrebovali komponento FormsModule, ki jo uvozimo v app.module.ts.
angular/src/app/app.module.ts
import { RouterModule } from "@angular/router";
import { FormsModule } from "@angular/forms";
...
@NgModule({
...,
imports: [
...
RatingModule,
FormsModule,
],
...
})V okviru komponente location-details bomo vse podatke, povezane z novim komentarjem shranili v skupni objekt newComment, zaradi lažjega pošiljanja podatkov. Pri opredelitvi novega objekta moramo vključiti podatkovni tip, kot tudi vse privzete vrednosti, kot je prikazano v nadaljevanju.
angular/src/app/location-details/location-details.component.ts
import { Component, Input, OnInit, TemplateRef } from "@angular/core";
import { BsModalService, BsModalRef } from "ngx-bootstrap/modal";
import { Location, Comment } from "../location-list/location-list.component";
@Component({
selector: "app-location-details",
templateUrl: "location-details.component.html",
styles: [],
})
export class LocationDetailsComponent implements OnInit {
...
@Input() location!: Location;
protected newComment: Comment = {
author: "",
rating: 0,
comment: ""
};
...
}Sedaj lahko vnosna polja na spletnem obrazcu s pomočjo ngModel povežemo z atributi objekta newComment. Pomembno je tudi izpostaviti uporabo pravilnih podatkovnih tipov pri spletnih obrazcih. Privzeto so namreč vrednosti HTML vnosnih polj v obliki niza, medtem ko v našem primeru pri oceni potrebujemo številčno vrednost. To lahko dosežemo z Angular funkcionalnostjo [ngValue], ki predstavlja Angular izraz in se izvede, zato je v našem primeru posamezna ocena, ki jo uporabnik izbere, številčna vrednost.
angular/src/app/location-details/location-details.component.html
<ng-template #commentForm>
...
<div class="modal-body">
<div class="form-group">
<label for="rating" class="form-label mb-1">Rating</label>
<div><rating [max]="5" [(ngModel)]="newComment.rating"></rating></div>
</div>
<div class="form-group">
<label for="name" class="form-label mb-1 mt-2">Name</label>
<input
type="text"
id="name"
[(ngModel)]="newComment.author"
class="form-control form-control-sm"
placeholder="Enter author's name"
/>
</div>
<div class="form-group">
<label for="comment" class="form-label mt-2">Comment</label>
<textarea
id="comment"
[(ngModel)]="newComment.comment"
rows="3"
class="form-control form-control-sm"
placeholder="Enter comment"
></textarea>
</div>
</div>
...
</ng-template>3.3.4 Posredovanje podatkov spletnega obrazca na REST API
Želeli bi zaključiti funkcionalnost spletnega obrazca, kjer se podatki novega komentarja posredujejo na strežnik. Pri tem je potrebno kar nekaj korakov:
- Priprava nove metode v storitvi za interakcijo z REST API-jem.
- Ob zaključku vnosa podatkov mora Angular prevzeti podatke spletnega obrazca.
- Preverjanje ustreznosti podatkov, saj sprejmemo le celovite podatke.
- Posredovanje podatkov storitvi.
- Dodajanje novega komentarja na vrh seznama pri prikazu komentarjev.
3.3.4.1 Nova metoda v storitvi
V storitev demo-data bomo dodali novo metodo, kjer bomo pred klicem pripravili ustrezne podatke, kot so url, body in headers.
angular/src/app/demo-data.service.ts
import { Injectable } from "@angular/core";
import {
HttpClient,
HttpErrorResponse,
HttpHeaders,
HttpParams,
} from "@angular/common/http";
import { Observable, throwError } from "rxjs";
import { catchError, retry } from "rxjs/operators";
import { Location, Comment } from "./location-list/location-list.component";
@Injectable({
providedIn: "root",
})
export class DemoDataService {
...
public addCommentToLocation(
locationId: string,
comment: Comment
): Observable<Comment> {
const url: string = `${this.apiUrl}/locations/${locationId}/comments`;
let body = new HttpParams()
.set("author", comment.author)
.set("rating", comment.rating)
.set("comment", comment.comment);
let headers = new HttpHeaders().set(
"Content-Type",
"application/x-www-form-urlencoded"
);
return this.http
.post<Comment>(url, body, { headers })
.pipe(retry(1), catchError(this.handleError));
}
...
}3.3.4.2 Prevzem podatkov spletnega obrazca
V krmilniku komponente location-details.component.ts pripravimo novo metodo, ki se bo klicala ob kliku na gumb Add comment.
angular/src/app/location-details/location-details.component.ts
export class LocationDetailsComponent implements OnInit {
...
protected newComment: Comment = {
author: "",
rating: 0,
comment: ""
};
protected addNewComment() {
console.log(this.newComment);
}
...
}Sedaj z dogodkom click poskrbimo, da akcijo pošiljanja podatkov spletnega obrazca prevzame metoda addNewComment.
angular/src/app/location-details/location-details.component.html
3.3.4.3 Preverjanje ustreznosti podatkov
Za validacijo podatkov bomo pripravili metodo isFormDataValid, ki bo pred posredovanjem podatkov na REST API preverila ali so prisotni vsi zahtevani podatki in so le-ti ustrezni.
angular/src/app/location-details.component.ts
export class LocationDetailsComponent implements OnInit {
...
protected addNewComment() {
this.formDataError = "";
if (this.isFormDataValid()) {
console.log(this.newComment);
} else {
this.formDataError =
"All fields required, including rating between 1 and 5.";
}
}
protected formDataError!: string;
private isFormDataValid(): boolean {
let isValid = false;
if (
this.newComment.author &&
this.newComment.rating &&
this.newComment.comment &&
this.newComment.rating >= 1 &&
this.newComment.rating <= 5
) {
isValid = true;
}
return isValid;
}
...
}Na HTML predlogo location-details.component.html dodamo še prikaz napake na obrazcu, če je le-ta prisotna, tj. v primeru, ko uporabnik ne vnese vseh zahtevanih podatkov.
angular/src/app/location-details/location-details.component.html
<div class="modal-body">
<div class="alert alert-dark p-2" [hidden]="!formDataError">
<i class="fa-solid fa-triangle-exclamation pe-2"></i>{{ formDataError }}
</div>
<div class="form-group">
<label for="rating" class="form-label mb-1">Rating</label>
<div><rating [max]="5" [(ngModel)]="newComment.rating"></rating></div>
</div>
...
</div>Poskrbeli bomo tudi za izbris začasno vnesenih vrednosti, če se uporabnik med vnosom komentarja premisli in akcijo prekliče oz. zapre modalno okno.
Najprej pripravimo metodo v krmilniku, ki ponastavi vrednosti spremenljivk newComment in formDataError ter šele nato zapre modalno okno.
angular/src/app/location-details/location-details.component.ts
export class LocationDetailsComponent {
...
protected closeModal() {
this.newComment = {
author: "",
rating: 0,
comment: "",
};
this.formDataError = "";
this.modalRef?.hide();
}
}V HTML predlogi poskrbimo, da se ob preklicu akcije oz. ob zapiranju modalnega okna pokliče prej omenjena metoda.
angular/src/app/location-details/location-details.component.html
<ng-template #commentForm>
<div class="modal-header">
...
<button
type="button"
class="btn-close close pull-right"
aria-label="Close"
(click)="modalRef?.hide()closeModal()"
>
<span aria-hidden="true" class="visually-hidden">×</span>
</button>
</div>
...
<div class="modal-footer">
<button
type="button"
class="btn btn-sm btn-secondary"
(click)="modalRef?.hide()closeModal()"
>
<i class="fa-regular fa-circle-xmark me-1"></i>Cancel
</button>
...
</div>
</ng-template>3.3.4.4 Posredovanje podatkov storitvi
Sedaj bomo v okviru komponente location-details najprej uvozili storitev demo-data in metodi addCommentToLocation posredovali podatke spletnega obrazca.
V primeru napake pri dodajanju novega komentarja bomo o tem uporabnika tudi obvestili. Če pa je bilo dodajanje komentarja uspešno, bomo le-tega dodali na vrh seznama uporabniških komentarjev na spletni strani, brez potrebe po ponovnem klicu REST API-ja, kjer bi poleg ostalih komentarjev pridobili tudi tega novega. Prav tako na strani odjemalca izračunamo spremenjeno povprečno oceno lokacije.
angular/src/app/location-details/location-details.component.ts
import { Component, Input, OnInit, TemplateRef } from "@angular/core";
import { BsModalService, BsModalRef } from "ngx-bootstrap/modal";
import { DemoDataService } from "../demo-data.service";
import { Location, Comment } from "../location-list/location-list.component";
...
export class LocationDetailsComponent {
modalRef?: BsModalRef;
constructor(
private modalService: BsModalService,
private demoDataService: DemoDataService
) {}
...
protected addNewComment() {
this.formDataError = "";
if (this.isFormDataValid()) {
console.log(this.newComment);
this.demoDataService
.addCommentToLocation(this.location._id, this.newComment)
.subscribe({
next: (comment: Comment) => {
this.location?.comments?.unshift(comment);
this.updateAverageRating();
this.closeModal();
},
error: (err) => {
this.formDataError = err || "Error adding comment.";
},
});
} else {
this.formDataError =
"All fields required, including rating between 1 and 5.";
}
}
private updateAverageRating() {
if (this.location && this.location.comments) {
const count: number = this.location.comments.length;
const total: number = this.location.comments.reduce((acc, { rating }) => {
return acc + rating;
}, 0);
this.location.rating = Math.floor(total / count);
} else {
this.location.rating = 0;
}
}
...
}3.3.5 Brisanje komentarjev
Uporabnikom bomo ponudili možnost brisanja komentarjev. Zaenkrat se z varnostjo ne bomo ukvarjali (tj. vsak uporabnik lahko izbriše vsak komentar), kar pa bomo nadgradili v naslednjih predavanjih, kjer bo lahko uporabnik izbrisal zgolj svoje komentarje.
Najprej pri storitvi demo-data dodamo novo metodo, s katero bomo zahtevo za brisanje preko REST API-ja posredovali na strežnik.
angular/src/app/demo-data.service.ts
export class DemoDataService {
...
public deleteCommentFromLocation(
locationId: string,
commentId: string
): Observable<any> {
const url: string = `${this.apiUrl}/locations/${locationId}/comments/${commentId}`;
return this.http.delete(url).pipe(retry(1), catchError(this.handleError));
}
...
}Funkcionalnost brisanja komentarjev bomo potrebovali v okviru komponente location-details, zato pripravimo metodo, ki se bo izvedla ob kliku na gumb za brisanje komentarja in poklicala prej pripravljeno metodo storitve. V primeru uspešnega brisanja na strani strežnika, komentar na strani odjemalca preprosto izbrišemo.
angular/src/app/location-details/location-details.component.ts
export class LocationDetailsComponent implements OnInit {
...
protected deleteComment(commentId: string | undefined): void {
if (commentId) {
this.demoDataService
.deleteCommentFromLocation(this.location._id, commentId)
.subscribe({
next: () => {
this.location.comments = this.location.comments?.filter(
(comment) => comment._id !== commentId
);
this.updateAverageRating();
},
error: (err) => {
console.log(err);
},
});
}
}
...
}Na koncu na spletno stran, poleg imena avtorja, dodajmo gumb za brisanje komentarja , kjer ob kliku pokličemo prej pripravljeno metodo.
angular/src/app/location-details/location-details.component.html
<div class="card-body">
...
<div
class="mt-2"
[ngClass]="!last ? 'mb-4' : ''"
*ngFor="
let comment of location.comments | mostRecentFirst;
last as last
"
>
<div
class="d-flex justify-content-between align-items-baseline bg-light rounded-3 p-1"
>
<div class="ms-1">
<app-stars [rating]="comment.rating"></app-stars
><i class="fa-regular fa-user ps-4 pe-2"></i>{{ comment.author
}}<i
class="fa-solid fa-trash-can text-primary show-pointer ms-4"
(click)="deleteComment(comment._id)"
></i>
</div>
...
</div>
...
</div>
</div>V celotni aplikaciji bomo potrebovali tudi CSS razred show-pointer, ki smo ga prvotno opredelili v komponenti location-list, zato ga prenesemo v styles.css, ki ga uporabljajo vse komponente.
angular/src/app/location-list/location-list.component.ts
@Component({
selector: "app-location-list",
templateUrl: "./location-list.component.html",
styles: [".show-pointer { cursor: pointer; }"],
})angular/src/styles.css
3.4 Izboljšava arhitekture
Pred zamenjavo obstoječe strežniške aplikacije v okolju Express z Angular SPA aplikacijo bomo izboljšali nekatere arhitekturne vidike aplikacije, kot je ločitev usmerjanja in opredelitev razredov v lastne datoteke ter organizacija izvorne kode v mape.
3.4.1 Ločitev usmerjanja
Pri razvoju Angular SPA aplikacij je dobra praksa ločitev usmerjanja iz osrednje datoteke app.module.ts v ločeno datoteko. Če imamo v naši aplikaciji večje število poti, potem lahko to predstavlja večji del datoteke app.module.ts, kar pa zagotovo ne želimo.
S pomočjo Angular CLI zahtevamo kreiranje novega modula app-routing.
Opredelitev novega modula se nahaja v datoteki app-routing.module.ts, ki ga bomo dopolnili v skladu z zahtevami naše aplikacije in prenesli del funkcionalnosti usmerjanja iz datoteke app.module.ts.
angular/src/app/app-routing/app-routing.module.ts
import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { RouterModule, Routes } from "@angular/router";
import { HomepageComponent } from "../homepage/homepage.component";
import { AboutComponent } from "../about/about.component";
import { DetailsPageComponent } from "../details-page/details-page.component";
const routes: Routes = [
{ path: "", component: HomepageComponent },
{ path: "about", component: AboutComponent },
{ path: "locations/:locationId", component: DetailsPageComponent },
];
@NgModule({
declarations: [],
imports: [CommonModule, RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}Skladno z novim modulom za usmerjanje moramo še dopolniti osrednjo datoteko naše aplikacije v app.module.ts.
app_public/src/app/app.module.ts
import { RouterModule } from "@angular/router";
...
import { AppRoutingModule } from "./app-routing/app-routing.module";
@NgModule({
...,
imports: [
...,
AppRoutingModule,
RouterModule.forRoot([
{ path: "", component: HomepageComponent },
{ path: "about", component: AboutComponent },
{ path: "locations/:locationId", component: DetailsPageComponent },
]),
...,
],
...
})
export class AppModule {}Aplikacija deluje tako kot prej, zgolj usmerjevalna tabela je prestavljena v ločen modul v datoteki app-routing.module.ts.
3.4.2 Opredelitev razredov
Opredelitev razredov Location in Comment se trenutno nahaja v okviru komponente location-list, saj smo jih na tem mestu najprej potrebovali. Kasneje uvažamo te opredelitve razredov v različne komponente in tako postajajo ključen del aplikacije, zato je smiselno, da sta opredeljena v lastni datoteki.
Z uporabo Angular CLI zahtevamo kreiranje novih razredov.
$ ng generate class Location
CREATE src/app/location.ts (26 bytes)
$ ng generate class Comment
CREATE src/app/comment.ts (25 bytes)Opredelitev razredov v location.ts in comment.ts preprosto prenesemo iz obstoječe komponente v location-list.component.ts.
angular/src/app/comment.ts
export class Comment {
_id?: string;
author!: string;
rating!: number;
comment!: string;
createdOn?: Date;
}angular/src/app/location.ts
import { Comment } from "./comment";
export class Location {
_id!: string;
name!: string;
category!: string;
type!: string;
keywords!: string[];
description!: string;
location!: string;
institution!: string;
heritage!: string;
municipality!: string;
coordinates!: number[];
synonyms?: string[];
datation!: string;
authors!: string;
fields!: string[];
rating?: number;
distance!: number;
comments?: Comment[];
}Omenjene opredelitve razredov nato odstranimo iz komponente location-list.
angular/src/app/location-list/location-list.component.ts
import { Location } from "../location";
...
export class LocationListComponent implements OnInit {
...
}
export class Comment {
_id?: string;
author!: string;
rating!: number;
comment!: string;
createdOn?: Date;
}
export class Location {
_id!: string;
name!: string;
category!: string;
type!: string;
keywords!: string[];
description!: string;
location!: string;
institution!: string;
heritage!: string;
municipality!: string;
coordinates!: number[];
synonyms?: string[];
datation!: string;
authors!: string;
fields!: string[];
rating?: number;
distance!: number;
comments?: Comment[];
}Prav tako posodobimo vse obstoječe reference na opredelitev razredov Location in Comment.
angular/src/app/location-details/location-details.component.ts
import { Location, Comment } from "../location-list/location-list.component";
import { Comment } from "../comment";
import { Location } from "../location";angular/src/app/details-page/details-page.component.ts
angular/src/app/demo-data.service.ts
import { Location, Comment } from "./location-list/location-list.component";
import { Comment } from "./comment";
import { Location } from "./location";angular/src/app/most-recent-first.ts
3.4.3 Organizacija izvorne kode v mape
Pri večji Angular aplikaciji postane privzeta delitev izvorne kode težje obvladljiva. Obstaja kar nekaj priporočil, kjer bomo sledili dobri praksi v razvoju. Vse dodatne module Angular aplikacije shranimo v podmapo modules, medtem ko skupne gradnike shranimo v mapo shared, kot to prikazuje spodnji prikaz. Mapa shared je nadalje razdeljena po tipu gradnikov, in sicer pipes, components, classes, services itd.
-
angular
- …
-
src
-
app
-
modules -
app-routing
- app-routing.module.ts
-
app-routing
-
shared -
classes - comment.ts
- location.ts
-
components -
about
- about.component.ts
-
details-page
- details-page.component.ts
-
framework
- framework.component.html
- framework.component.ts
-
header
- header.component.ts
-
homepage
- homepage.component.ts
-
location-details
- location-details.component.html
- location-details.component.ts
-
location-list
- location-list.component.html
- location-list.component.ts
-
sidebar
- sidebar.component.ts
-
stars
- stars.component.ts
-
about
-
pipes - allow-url.pipe.ts
- distance.pipe.ts
- most-recent-first.pipe.ts
-
services - demo-data.service.ts
- geolocation.service.ts
- teaching-data.service.ts
-
- app.module.ts
-
-
app
Ob organizaciji datotek v mape moramo poskrbeti za ustrezne popravke medsebojnih referenc posameznih gradnikov.
3.5 Priprava storitve avtentikacije v Angular
Ker bomo avtentikacijo v Angular aplikaciji potrebovali na večih mestih, je smiselno funkcionalnost razviti v obliki storitve. Storitev bo skrbela za vse vidike povezane z avtentikacijo, kar vključuje shranjevanje in branje žetona JWT, vračanje informacij o trenutnem uporabniku in dostop do API dostopnih točk registracije in prijave.
3.5.1 Obvladovanje seje uporabnika v Angular
Tradicionalen pristop shranjevanja uporabniških podatkov je v obliki piškotkov (angl. cookies) in tudi v našem primeru bi lahko izbrali to možnost. Vendar se moramo zavedati, da se piškotki uporabljajo predvsem zaradi strežniške aplikacije, kjer jih vključimo pri vsaki zahtevi v obliki podatka v HTTP glavi. Pri SPA aplikaciji na eni strani ne potrebujemo takšne podpore, saj so API dostopne točke brez stanja in ne nastavljajo oz. uporabljajo piškotkov.
Zato bomo v našem primeru uporabili lokalno shrambo podatkov, ki smo jo že predstavili v enem izmed prejšnjih predavanj.
Za ta namen bomo najprej z Angular CLI zahtevali kreiranje novega razreda Storage.
V shrambi bomo opredelili spremenljivko BROWSER_STORAGE, ki bo povezana z lokalno shrambo podatkov v brskalniku in jo bomo kasneje uporabljali v komponentah Angular SPA aplikacije.
angular/src/app/shared/classes/storage.ts
import { InjectionToken } from "@angular/core";
export const BROWSER_STORAGE = new InjectionToken<Storage>("Browser storage", {
providedIn: "root",
factory: () => localStorage,
});
export class Storage {}Za shranjevanje in branje žetona JWT iz lokalne shrambe brskalnika bomo pripravili storitev za avtentikacijo, kjer bomo opredelili metodi saveToken in getToken. Funkcionalnosti bosta zelo preprosti, saj bosta zgolj shranili oz. brali vrednost demo-token iz shrambe, ki smo jo predhodno opredelili v razredu.
$ ng generate service shared/services/authentication
CREATE src/app/shared/services/authentication.service.ts (143 bytes)angular/src/app/shared/services/authentication.service.ts
import { Inject, Injectable } from "@angular/core";
import { BROWSER_STORAGE } from "../classes/storage";
@Injectable({
providedIn: "root",
})
export class AuthenticationService {
constructor(@Inject(BROWSER_STORAGE) private storage: Storage) {}
public getToken(): string | null {
return this.storage.getItem("demo-token");
}
public saveToken(token: string): void {
this.storage.setItem("demo-token", token);
}
}3.5.2 Omogočanje registracije, prijave in odjave uporabnikov
Za pomoč pri obvladovanju podatkov o uporabniku in njegovem JWT žetonu bomo pripravili dva nova razreda User in AuthResponse.
$ ng generate class shared/classes/user
CREATE src/app/shared/classes/user.ts (22 bytes)
$ ng generate class shared/classes/auth-response
CREATE src/app/shared/classes/auth-response.ts (30 bytes)Pri uporabnikih bomo shranili njihovo ime (name), elektronski naslov (email) in opcijsko geslo (password), pri rezultatu avtentikacije pa JWT žeton (token).
angular/src/app/shared/classes/user.ts
angular/src/app/shared/classes/auth-response.ts
V obstoječo storitev authentication moramo dodati še druge metode, ki jih bomo uporabljali za registracijo (register), prijavo (login) in odjavo (logout).
angular/src/app/shared/services/authentication.service.ts
import { Inject, Injectable } from "@angular/core";
import { BROWSER_STORAGE } from "../classes/storage";
import { Observable } from "rxjs";
import { tap } from "rxjs/operators";
import { User } from "../classes/user";
import { AuthResponse } from "../classes/auth-response";
import { DemoDataService } from "./demo-data.service";
@Injectable({
providedIn: "root",
})
export class AuthenticationService {
constructor(
@Inject(BROWSER_STORAGE) private storage: Storage,
private demoDataService: DemoDataService
) {}
public login(user: User): Observable<AuthResponse> {
return this.demoDataService.login(user).pipe(
tap((authResponse: AuthResponse) => {
this.saveToken(authResponse.token);
})
);
}
public register(user: User): Observable<AuthResponse> {
return this.demoDataService.register(user).pipe(
tap((authResponse: AuthResponse) => {
this.saveToken(authResponse.token);
})
);
}
public logout(): void {
this.storage.removeItem("demo-token");
}
public getToken(): string | null {
return this.storage.getItem("demo-token");
}
public saveToken(token: string): void {
this.storage.setItem("demo-token", token);
}
}V zgornjem primeru smo uporabili
tap, ki iz odgovora REST API-ja izlušči žeton JWT in ga shrani v lokalno shrambo brskalnika.
Pri registraciji in prijavi bo šlo zgolj za klic REST API storitev, ki jih moramo še implementirati v storitvi demo-data, pri odjavi pa moramo zgolj zbrisati JWT žeton iz lokalne shrambe brskalnika.
angular/src/app/shared/services/demo-data.service.ts
...
import { User } from "../classes/user";
import { AuthResponse } from "../classes/auth-response";
@Injectable({
providedIn: "root",
})
export class DemoDataService {
constructor(private http: HttpClient) {}
private apiUrl = environment.apiUrl;
public login(user: User): Observable<AuthResponse> {
return this.makeAuthApiCall("login", user);
}
public register(user: User): Observable<AuthResponse> {
return this.makeAuthApiCall("register", user);
}
private makeAuthApiCall(
urlPath: string,
user: User
): Observable<AuthResponse> {
const url: string = `${this.apiUrl}/${urlPath}`;
let body = new HttpParams().set("email", user.email).set("name", user.name);
if (user.password) body = body.set("password", user.password);
let headers = new HttpHeaders().set(
"Content-Type",
"application/x-www-form-urlencoded"
);
return this.http
.post<AuthResponse>(url, body, { headers })
.pipe(retry(1), catchError(this.handleError));
}
...
}3.5.3 Uporaba podatkov iz JWT v Angular storitvi
JWT žeton bo shranjen v lokalni shrambi brskalnika in ga bomo uporabljali za obvladovanje uporabniške seje. S pomočjo njegove vsebine bomo preverjali, ali je uporabnik prijavljen, aplikacija pa bo lahko pridobila tudi druge podrobnosti o uporabniku.
Najprej bomo med metode storitve authentication dodali preverjanje, ali je uporabnik že prijavljen isLoggedIn. Najprej je treba pridobiti žeton iz lokalne shrambe brskalnika, nato iz njega izluščimo koristno vsebino. Če žeton še ni potekel, potem je uporabnik prijavljen.
angular/src/app/shared/services/authentication.service.ts
export class AuthenticationService {
...
public isLoggedIn(): boolean {
const token: string | null = this.getToken();
if (token) {
const payload = JSON.parse(window.atob(token.split(".")[1]));
return payload.exp > Date.now() / 1000;
} else return false;
}
}Naša aplikacija bo prav tako dostopala do elektronskega naslova (email) in polnega imena uporabnika (name), ki sta tudi shranjena v JWT. Za ta namen bomo implementirali metodo getCurrentUser, ki bo dostopala do žetona v lokalni shrambi brskalnika in iz njega izluščila omenjena podatka ter ju vrnila v rezultatu.
angular/src/app/shared/services/authentication.service.ts
3.6 Priprava strani za registracijo in prijavo uporabnika
Funkcionalnost, ki smo jo implementirali do tega trenutka, je brez strani za registracijo in prijavo uporabnika neuporabna. Zato želimo v nadaljevanju razviti spletno stran, ki bo novim uporabnikom omogočala registracijo in prijavo v sistem za obstoječe uporabnike.
Po končani registraciji oz. prijavi bomo uporabnika preusmerili nazaj na stran, na kateri je bil pred začetkom registracije oz. prijave.
3.6.1 Priprava strani za registracijo
S pomočjo Angular CLI kreirajmo novo komponento register.
$ ng generate component shared/components/register
CREATE src/app/shared/components/register/register.component.ts (198 bytes)
UPDATE src/app/app.module.ts (2286 bytes)Najprej posodobimo usmerjevalno tabelo v modulu app-routing, kjer bo komponenta RegisterComponent poskrbela za obvladovanje funkcionalnosti na URL naslovu /register.
angular/src/app/modules/app-routing/app-routing.module.ts
import { HomepageComponent } from "../../shared/components/homepage/homepage.component";
import { AboutComponent } from "../../shared/components/about/about.component";
import { DetailsPageComponent } from "../../shared/components/details-page/details-page.component";
import { RegisterComponent } from "src/app/shared/components/register/register.component";
const routes: Routes = [
{ path: "", component: HomepageComponent },
{ path: "about", component: AboutComponent },
{ path: "locations/:locationId", component: DetailsPageComponent },
{ path: "register", component: RegisterComponent },
];Pri HTML predlogi bomo dodali spletni obrazec z vnosnimi polji imena in priimka (name), elektronskega naslova (email) ter gesla (password). Nad spletnim obrazcem bomo vključili tudi povezavo na prijavo, če gre za obstoječega uporabnika.
V krmilniku moramo poskrbeti za nastavitev osnovnih podatkov, ki jih pričakuje predloga pogleda. Pri tem bomo potrebovali tudi storitev authentication. Prijavne podatke uporabnika bomo hranili v spremenljivki credentials in jih bomo na začetku nastavili na prazne vrednosti.
angular/src/app/shared/components/register/register.component.ts
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { User } from "../../classes/user";
import { AuthenticationService } from "../../services/authentication.service";
@Component({
selector: "app-register",
template: `<p>register works!</p>
<app-header [content]="header"></app-header>
<div class="row">
<div class="col-12 col-md-8">
<p>
Already a member? Please
<a routerLink="/login" class="link-primary">login</a> instead.
</p>
<form autocomplete="off">
<div *ngIf="formError" class="form-group">
<div class="alert alert-dark p-2 mt-4" role="alert">
<i class="fas fa-exclamation-triangle pe-2"></i>{{ formError }}
</div>
</div>
<div class="form-group">
<label for="name" class="form-label mb-1">Full name</label>
<input
type="text"
class="form-control form-control-sm"
id="name"
name="name"
placeholder="Enter your name"
[(ngModel)]="credentials.name"
/>
</div>
<div class="form-group">
<label for="email" class="form-label mb-1 mt-3"
>E-mail address</label
>
<input
type="text"
class="form-control form-control-sm"
id="email"
name="email"
placeholder="Enter e-mail address"
[(ngModel)]="credentials.email"
/>
</div>
<div class="form-group">
<label for="password" class="form-label mb-1 mt-3">Password</label>
<input
type="password"
class="form-control form-control-sm"
id="password"
name="password"
placeholder="Enter password"
[(ngModel)]="credentials.password"
/>
</div>
<div class="form-group mt-3">
<button type="submit" class="btn btn-sm btn-primary me-2">
<i class="fa-regular fa-circle-check pe-2"></i>Register
</button>
</div>
</form>
</div>
<app-sidebar
class="col-12 col-lg-3 mt-4"
[content]="header.sidebar"
></app-sidebar>
</div>`,
styles: [],
})
export class RegisterComponent {
constructor(
private router: Router,
private authenticationService: AuthenticationService
) {}
protected formError!: string;
protected credentials: User = {
name: "",
email: "",
password: "",
};
public header = {
title: "Create a new account",
subtitle: "",
sidebar: "",
};
}Za posredovanje podatkov spletnega obrazca novega uporabnika do strežnika preko REST API dostopne točke bo poskrbela metoda onRegisterSubmit.
Če uporabnik ne vnese vseh zahtevanih podatkov, neustrezen elektronski naslov ali geslo, potem na strani prikažemo napako, sicer za nadaljnje obvladovanje posredovanja podatkov pokličemo metodo doRegister.
V okviru metode doRegister pokličemo metodo register storitve authentication. V primeru uspešnega klica uporabnika preusmerimo na začetno stran.
angular/src/app/shared/components/register/register.component.ts
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { HttpErrorResponse } from "@angular/common/http";
import { throwError } from "rxjs";
import { catchError } from "rxjs/operators";
import { User } from "../../classes/user";
import { AuthenticationService } from "../../services/authentication.service";
@Component({
selector: "app-register",
template: `...
<div class="row">
<div class="col-12 col-md-8">
...
<form (ngSubmit)="onRegisterSubmit()" autocomplete="off">
...
</form>
</div>
...
</div>`,
styles: [],
})
export class RegisterComponent {
...
public onRegisterSubmit() {
this.formError = "";
if (
!this.credentials.name ||
!this.credentials.email ||
!this.credentials.password
)
this.formError = "All fields are required, please try again.";
else if (
!/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(
this.credentials.email
)
)
this.formError = "Please enter a valid e-mail address.";
else if (this.credentials.password.length < 3)
this.formError = "Password must be at least 3 characters long.";
else this.doRegister();
}
private doRegister() {
this.authenticationService
.register(this.credentials)
.pipe(
catchError((error: HttpErrorResponse) => {
this.formError = error.toString();
return throwError(() => error);
})
)
.subscribe(() => {
this.router.navigateByUrl("/");
});
}
}Delovanje obstoječe funkcionalnosti registracije novega uporabnika preverimo tako, da obiščemo spletno stran /register in izpolnimo potrebne podatke (glej sliko 3.4).
Slika 3.4: Obrazec za registracijo uporabnika
Ker smo uporabnika z elektronskim naslovom dejan@lavbic.net predhodno že kreirali in le-ta obstaja, nam strežnik vrne napako There was a 500 error 'E11000 duplicate key error collection: Demo.Users index: email_1 dup key: { email: "dejan@lavbic.net" }'. Obvestilo o napaki za uporabnika vsebuje preveč tehničnih podrobnosti, zato bomo na strani strežnika poskrbeli za manj podroben odgovor, prav tako bomo tudi nastavil ustrezno kodo odgovora, in sicer 409.
api/controllers/authentication.js
/**
* @openapi
* /register:
* post:
* ...
* responses:
* ...
* '409':
* description: <b>Conflict</b>, with error message.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorMessage'
* example:
* message: User with given e-mail address already registered.
* ...
*/
const register = async (req, res) => {
if (!req.body.name || !req.body.email || !req.body.password)
return res.status(400).json({ message: "All fields required." });
const user = new User();
user.name = req.body.name;
user.email = req.body.email;
user.setPassword(req.body.password);
try {
await user.save();
res.status(200).json({ token: user.generateJwt() });
} catch (err) {
if (err.code === 11000)
res.status(409).json({
message: "User with given e-mail address already registered.",
});
else res.status(500).json({ message: err.message });
}
};Spletna aplikacija bo nato poskrbela za kreiranje JWT žetona in shranjevanje le-tega v lokalno shrambo brskalnika, kot to prikazuje slika 3.5.
Slika 3.5: JWT žeton, shranjen v localStorage brskalnika, po registraciji uporabnika
Namig: Do lokalne shrambe brskalnika Chrome lahko dostopate preko JavaScript razvijalske konzole s pomočjo naslednjega zaporedja ukazov:
View→Developer→JavaScript Console→Application→Storage→Local Storage.
3.6.2 Priprava strani za prijavo
Podobno kot pri registraciji je tudi pri prijavi najprej treba s pomočjo Angular CLI kreirati novo komponento v login.
$ ng generate component shared/components/login
CREATE src/app/shared/components/login/login.component.ts (189 bytes)
UPDATE src/app/app.module.ts (2382 bytes)Sedaj posodobimo usmerjevalno tabelo v modulu app-routing, kjer bo komponenta LoginComponent poskrbela za obvladovanje funkcionalnosti na URL naslovu /login.
angular/src/app/modules/app-routing/app-routing.module.ts
import { HomepageComponent } from "../../shared/components/homepage/homepage.component";
import { AboutComponent } from "../../shared/components/about/about.component";
import { DetailsPageComponent } from "../../shared/components/details-page/details-page.component";
import { RegisterComponent } from "../../shared/components/register/register.component";
import { LoginComponent } from "../../shared/components/login/login.component";
const routes: Routes = [
{ path: "", component: HomepageComponent },
{ path: "about", component: AboutComponent },
{ path: "locations/:locationId", component: DetailsPageComponent },
{ path: "register", component: RegisterComponent },
{ path: "login", component: LoginComponent },
];Predloga pogleda prijave uporabnika je skoraj enaka predlogi pogleda registracije s to razliko, da ne potrebujemo vnosnega polja za polno ime uporabnika ter povezave na registracijo uporabnika, če le-ta še nima uporabniškega imena.
Tudi izvorna koda krmilnika za obvladovanje prijave uporabnika je zelo podobna izvorni kodi krmilnika za obvladovanje registracije. Pri pošiljanju podatkov v okviru metode onLoginSubmit ne preverjamo več polnega imena uporabnika, ostala funkcionalnost pa se izvede podobno.
angular/src/app/shared/components/login/login.component.ts
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { HttpErrorResponse } from "@angular/common/http";
import { throwError } from "rxjs";
import { catchError } from "rxjs/operators";
import { User } from "../../classes/user";
import { AuthenticationService } from "../../services/authentication.service";
@Component({
selector: "app-login",
template: `<p>login works!</p>
<app-header [content]="header"></app-header>
<div class="row">
<div class="col-12 col-md-8">
<p>
Not a member? Please
<a routerLink="/register" class="link-primary">register</a> first.
</p>
<form (ngSubmit)="onLoginSubmit()" autocomplete="off">
<div *ngIf="formError" class="form-group">
<div class="alert alert-dark p-2 mt-4" role="alert">
<i class="fas fa-exclamation-triangle pe-2"></i>{{ formError }}
</div>
</div>
<div class="form-group">
<label for="email" class="form-label mb-1 mt-3"
>E-mail address</label
>
<input
type="text"
class="form-control form-control-sm"
id="email"
name="email"
placeholder="Enter e-mail address"
[(ngModel)]="credentials.email"
/>
</div>
<div class="form-group">
<label for="password" class="form-label mb-1 mt-3">Password</label>
<input
type="password"
class="form-control form-control-sm"
id="password"
name="password"
placeholder="Enter password"
[(ngModel)]="credentials.password"
/>
</div>
<div class="form-group mt-3">
<button type="submit" class="btn btn-sm btn-primary me-2">
<i class="fa-regular fa-circle-check pe-2"></i>Login
</button>
</div>
</form>
</div>
<app-sidebar
class="col-12 col-lg-3 mt-4"
[content]="header.sidebar"
></app-sidebar>
</div>`,
styles: [],
})
export class LoginComponent {
constructor(
private router: Router,
private authenticationService: AuthenticationService
) {}
protected formError!: string;
protected credentials: User = {
name: "",
email: "",
password: "",
};
public header = {
title: "Sign in",
subtitle: "",
sidebar: "",
};
public onLoginSubmit(): void {
this.formError = "";
if (
!this.credentials.email ||
!this.credentials.password
)
this.formError = "All fields are required, please try again.";
else if (
!/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(
this.credentials.email
)
)
this.formError = "Please enter a valid e-mail address.";
else if (this.credentials.password.length < 3)
this.formError = "Password must be at least 3 characters long.";
else this.doLogin();
}
private doLogin(): void {
this.authenticationService
.login(this.credentials)
.pipe(
catchError((error: HttpErrorResponse) => {
this.formError = error.toString();
return throwError(() => error);
})
)
.subscribe(() => {
this.router.navigateByUrl("/");
});
}
}3.7 Uporaba avtentikacije v Angular aplikaciji
Ko imamo podprto funkcionalnost avtentikacije uporabnikov, lahko v nadaljevanju te podatke uporabimo v aplikaciji. Z vidika funkcionalnosti bomo podprli:
- posodobitev navigacije glede na to, ali je uporabnik prijavljen (glej poglavje 3.7.1),
- podporo zgodovini brskanja (glej poglavje 3.7.2),
- uporabo imena uporabnika pri dodajanju komentarjev (glej poglavje 3.7.3),
- omogočeno brisanje lastnih komentarjev (glej poglavje 3.7.4).
3.7.2 Podpora zgodovini brskanja
Funkcionalnost prijave in odjave v tem trenutku deluje, vendar bi želeli našo Angular SPA aplikacijo izboljšati z vidika uporabniške izkušnje. Trenutno delovanje ob uspešni prijavi in registraciji uporabnika preusmeri na začetno stran naše aplikacije, kar ni najbolj primerno, saj bi ga želeli vrniti nazaj na mesto, kjer je bil pred poskusom prijave oz. registracije.
Za obvladovanje zgodovine brskanja uporabnika bomo s pomočjo Angular CLI kreirali novo storitev history.
$ ng generate service shared/services/history
CREATE src/app/shared/services/history.service.ts (136 bytes)Storitev history vključimo v obstoječo komponento framework.
angular/src/app/shared/components/framework/framework.component.ts
import { Component } from "@angular/core";
import { User } from "../../classes/user";
import { AuthenticationService } from "../../services/authentication.service";
import { HistoryService } from "../../services/history.service";
@Component({
selector: "app-framework",
templateUrl: "framework.component.html",
styles: [],
})
export class FrameworkComponent {
constructor(
private authenticationService: AuthenticationService,
private historyService: HistoryService
) {}
...
}Nato poskrbimo za delovanje storitve history, kjer bomo uporabili sistemski Router modul, se prijavili na dogodke usmerjevalnika in pripravili metodo getPreviousUrl, s katero bomo dostopali do zgodovine brskanja.
Metoda events v okviru usmerjevalnika vrača številne dogodke, vendar nas v primeru zgodovine zanimajo zgolj NavigationEnd dogodki. S subscribe se prijavimo na vse takšne dogodke in v povratnem klicu posodabljamo seznam obiskanih URL naslovov v urls.
angular/src/app/shared/services/history.service.ts
import { Injectable } from "@angular/core";
import { Router, NavigationEnd } from "@angular/router";
@Injectable({
providedIn: "root",
})
export class HistoryService {
private urls: string[] = [];
constructor(private router: Router) {
this.router.events.subscribe((routerEvent) => {
if (routerEvent instanceof NavigationEnd)
this.urls.push(routerEvent.urlAfterRedirects);
});
}
public getPreviousUrl(): string {
this.urls.pop();
if (this.urls.length > 0) return this.urls.slice(-1).toString();
else return "/";
}
}Storitev history sedaj vključimo v komponento register.
angular/src/app/shared/components/register/register.component.ts
import { AuthenticationService } from "../../services/authentication.service";
import { HistoryService } from "../../services/history.service";
...
export class RegisterComponent implements OnInit {
constructor(
private router: Router,
private authenticationService: AuthenticationService,
private historyService: HistoryService
) {}
...
private doRegister(): void {
this.authenticationService
.register(this.credentials)
.pipe(
catchError((error: HttpErrorResponse) => {
this.formError = error.toString();
return throwError(() => error);
})
)
.subscribe(() => {
this.router.navigateByUrl(this.historyService.getPreviousUrl()"/");
});
}
...
}Po testiranju funkcionalnosti ugotovimo, da nas Angular SPA po uspešni registraciji preusmeri nazaj na stran s prijavo. To delovanje je pravilno, zato bomo pripravili novo metodo getLastNonLoginUrl, ki bo vračala zgodovino brskanja, vendar brez /login in /register.
angular/src/app/shared/services/history.service.ts
export class HistoryService {
...
public getLastNonLoginUrl(): string {
const exclude: string[] = ["/register", "/login"];
this.urls.pop();
const filtererd = this.urls.filter((url) => !exclude.includes(url));
if (filtererd.length > 0) return filtererd.slice(-1).toString();
else return "/";
}
}Sedaj to novo metodo tudi vključimo pri uspešno zaključeni registraciji.
angular/src/app/shared/components/register/register.component.ts
export class RegisterComponent {
...
private doRegister(): void {
this.authenticationService
.register(this.credentials)
.pipe(
catchError((error: HttpErrorResponse) => {
this.formError = error.toString();
return throwError(() => error);
})
)
.subscribe(() => {
this.router.navigateByUrl(this.historyService.getPreviousUrlgetLastNonLoginUrl());
});
}
...
}Podobno kot pri registraciji, storitev history vključimo še pri komponenti login.
angular/src/app/shared/components/login/login.component.ts
import { AuthenticationService } from "../../services/authentication.service";
import { HistoryService } from "../../services/history.service";
...
export class LoginComponent {
constructor(
private router: Router,
private authenticationService: AuthenticationService,
private historyService: HistoryService
) {}
...
private doLogin(): void {
this.authenticationService
.login(this.credentials)
.pipe(
catchError((error: HttpErrorResponse) => {
this.formError = error.toString();
return throwError(() => error);
})
)
.subscribe(() => {
this.router.navigateByUrl("/"this.historyService.getLastNonLoginUrl());
});
}
...
}3.7.3 Dodajanje uporabniških podatkov v komentar
Ključna dodana vrednost vpeljave avtentikacije v aplikacijo je sposobnost dodajanja komentarjev lokacij zgolj za registrirane in prijavljene uporabnike. Omenjena funkcionalnost se nahaja v okviru komponente location-details, zato bomo najprej dodali odvisnost od storitve authentication in poskrbeli za metodi isLoggedIn ter getCurrentUser.
angular/src/app/shared/components/location-details/location-details.component.ts
import { AuthenticationService } from "../../services/authentication.service";
import { User } from "../../classes/user";
...
export class LocationDetailsComponent {
...
constructor(
private modalService: BsModalService,
private demoDataService: DemoDataService,
private authenticationService: AuthenticationService
) {}
...
public isLoggedIn(): boolean {
return this.authenticationService.isLoggedIn();
}
public getCurrentUser(): string {
const user: User | null = this.authenticationService.getCurrentUser();
return user ? user.name : "Guest";
}
}Glede na to ali je uporabnik prijavljen, želimo vplivati na prikaz gumba za dodajanje komentarjev, kot to prikazuje slika 3.6.
Slika 3.6: Različni stanji gumba za dodajanje komentarja, ki sta odvisni od tega, ali je uporabnik prijavljen ali ne
Na predlogo pogleda location-details.component.html dodamo gumb Login to post comment, če uporabnik ni prijavljen, sicer ostane gumb Add comment.
angular/src/app/shared/components/location-details/location-details.component.html
<div [ngSwitch]="isLoggedIn()">
<a
*ngSwitchCase="true"
class="btn btn-primary btn-sm"
(click)="openModal(commentForm)"
><i class="fa-regular fa-comment-dots pe-2"></i
><span>Add comment</span></a
>
<a
*ngSwitchDefault
routerLink="/login"
class="btn btn-primary btn-sm"
><i class="fa-solid fa-right-to-bracket pe-2"></i>Login to post
comment</a
>
</div>Z vsemi spremembami imamo sedaj delujočo funkcionalnost dinamičnega gumba, ki se spreminja glede na to, ali je uporabnik prijavljen v našo aplikacijo (glej sliko 3.6).
Sedaj moramo poskrbeti še za odstranjevanje vnosnega polja imena na obrazcu za dodajanje komentarjev na pogledu location-details.component.html, saj bomo ta podatek pridobili iz žetona JWT in ga samodejno vključili pri shranjevanju komentarja.
angular/src/app/shared/components/location-details/location-details.component.html
<ng-template #commentForm>
...
<div class="modal-body">
...
<div class="form-group">
<label for="name" class="form-label mb-1 mt-2">Name</label>
<input
type="text"
id="name"
[(ngModel)]="newComment.author"
class="form-control form-control-sm"
placeholder="Enter author's name"
/>
</div>
...
</div>
...
</ng-template>Brez omenjenega vnosnega polja je potrebna tudi manjša posodobitev krmilnika location-details.component.ts.
angular/src/app/shared/components/location-details/location-details.component.ts
export class LocationDetailsComponent {
...
protected addNewComment(): void {
this.formDataError = "";
this.newComment.author = this.getCurrentUser();
if (this.isFormDataValid()) {
this.demoDataService
.addCommentToLocation(this.location._id, this.newComment)
.subscribe({
next: (comment: Comment) => {
this.location?.comments?.unshift(comment);
this.updateAverageRating();
this.closeModal();
},
error: (err) => {
this.formDataError = err || "Error adding comment.";
},
});
} else {
this.formDataError =
"All fields required, including rating between 1 and 5.";
}
}
...
}Končna različica spletnega obrazca za dodajanje komentarja prijavljenega uporabnika je prikazana na sliki 3.7. Razvidno je, da ni več vnosnega polja za vnos polnega imena uporabnika, ki se bo sedaj prenesel iz JWT žetona.
Slika 3.7: Posodobljen obrazec za dodajanje komentarjev prijavljenega uporabnika brez vnosnega polja za polno ime, ki se sedaj samodejno izpolni s podatki prijavljenega uporabnika (polno ime uporabnika desno zgoraj v navigacijski vrstici)
Če poskušamo dodati komentar, to še vedno ni mogoče, ker pride do napake 401 Unauthorized pri klicu REST API-ja. To stanje je pričakovano, saj smo nekatere (POST, PUT in DELETE) REST API dostopne točke na strani strežnika zaščitili tako, da lahko do njih dostopajo zgolj prijavljeni uporabniki.
Najprej moramo poskrbeti, da se v okviru POST zahteve na REST API dostopno točko zraven posreduje tudi HTTP glava Authorization z vsebino Bearer <JWT>. To funkcionalnost bomo implementirali kar v storitvi pridobivanja podatkov demo-data.service.ts, kjer najprej dodamo potrebne odvisnosti in nato v okviru metode addCommentToLocation pri posredovanju POST zahteve dodamo še HTTP glavo Authorization.
angular/src/app/shared/services/demo-data.service.ts
import { Inject, Injectable } from "@angular/core";
...
import { BROWSER_STORAGE } from "../classes/storage";
@Injectable({
providedIn: "root",
})
export class DemoDataService {
constructor(
private http: HttpClient,
@Inject(BROWSER_STORAGE) private storage: Storage
) {}
...
public addCommentToLocation(
locationId: string,
comment: Comment
): Observable<Comment> {
const url: string = `${this.apiUrl}/locations/${locationId}/comments`;
let body = new HttpParams()
.set("author", comment.author)
.set("rating", comment.rating)
.set("comment", comment.comment);
let headers = new HttpHeaders()
.set("Content-Type", "application/x-www-form-urlencoded")
.set("Authorization", `Bearer ${this.storage.getItem("demo-token")}`);
return this.http
.post<Comment>(url, body, { headers })
.pipe(retry(1), catchError(this.handleError));
}
...
}3.7.4 Brisanje lastnih komentarjev
Trenutno brisanje komentarjev preko spletne aplikacije ne deluje, saj pri REST API zahtevi ne vključimo žetona JWT, zato to dopolnimo.
angular/src/app/shared/services/demo-data.service.ts
export class DemoDataService {
...
public deleteCommentFromLocation(
locationId: string,
commentId: string
): Observable<any> {
const url: string = `${this.apiUrl}/locations/${locationId}/comments/${commentId}`;
let headers = new HttpHeaders().set(
"Authorization",
`Bearer ${this.storage.getItem("demo-token")}`
);
return this.http
.delete(url, { headers })
.pipe(retry(1), catchError(this.handleError));
}
...
}Ikono za brisanje posameznega komentarja bomo prikazali zgolj pri tistih komentarjih, katerih avtor je prijavljeni uporabnik. Za ta namen pripravimo metodo canDeleteComment, ki jo nato vključimo v HTML predlogo preko *ngIf.
angular/src/app/shared/components/location-details/location-details.component.ts
export class LocationDetailsComponent {
...
public canDeleteComment(comment: Comment): boolean {
return this.isLoggedIn() && this.getCurrentUser() === comment.author;
}
}angular/src/app/shared/components/location-details/location-details.component.html
3.8 Shranjevanje uporabniških nastavitev v brskalniku
Za boljšo uporabniško izkušnjo si uporabniki pogosto želijo nastaviti določene parametre aplikacije, ki bi ostali nastavljeni tudi ob njihovem naslednjem obisku. Za ta namen lahko uporabimo lokalno shrambo localStorage podatkov v brskalniku.
Funkcionalnost, ki bi jo želeli shraniti, je uporaba geolokacije oz. vnaprej določene lokacije UL FRI.
angular/src/app/shared/components/location-list/location-list.component.ts
import { Component, OnInit, Output, EventEmitter, Inject } from "@angular/core";
import { DemoDataService } from "../../services/demo-data.service";
import { GeolocationService } from "../../services/geolocation.service";
import { TeachingDataService } from "../../services/teaching-data.service";
import { Location } from "../../classes/location";
import { BROWSER_STORAGE } from "../../classes/storage";
@Component({
selector: "app-location-list",
templateUrl: "./location-list.component.html",
styles: [],
})
export class LocationListComponent implements OnInit {
constructor(
private demoDataService: DemoDataService,
private teachingDataService: TeachingDataService,
private geoLocationService: GeolocationService,
@Inject(BROWSER_STORAGE) private storage: Storage
) {}
ngOnInit() {
if (!this.storage.getItem("use-geolocation"))
this.storage.setItem("use-geolocation", this.useGeolocation.toString());
else
this.useGeolocation =
this.storage.getItem("use-geolocation") === "true" ? true : false;
this.getCurrentLocation();
}
...
protected toggleGeolocation(): void {
this.useGeolocation = !this.useGeolocation;
if (!this.useGeolocation) this.currentLocation = this.ULFRILocation;
this.storage.setItem("use-geolocation", this.useGeolocation.toString());
this.getCurrentLocation();
}
...
}Sedaj se ob vsakem ponovnem obisku strani nastavitev uporabe geolokacije prebere iz lokalne shrambe, če le-ta obstaja, sicer se jo nastavi.
3.9 Zamenjava strežniške aplikacije s SPA aplikacijo
Ko je Angular SPA aplikacija končana, bomo strežniško Express aplikacijo s Handlebars predlogami zamenjali s produkcijsko Angular SPA verzijo.
Najprej moramo poskrbeti za spremenljivke v razvojnem (environment.ts) in produkcijskem (environment.prod.ts) okolju.
angular/src/environments/environment.ts
angular/src/environments/environment.prod.ts
Prav tako v konfiguracijski datoteki angular.json vključimo zamenjavo datoteke environment.ts z datoteko environment.prod.ts ob izgradnji produkcijske aplikacije, tj. ob ukazu ng build.
angular.json
{
...
"projects": {
"Demo": {
...
"architect": {
"build": {
...
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
...
},
...
},
...
},
...
}
}
}
}Pri klicu REST API-ja v okviru demo-data poskrbimo za uporabo okoljske spremenljivke, namesto vnaprej določenega URL naslova.
angular/src/app/shared/services/demo-data.service.ts
import { Comment } from "../classes/comment";
import { Location } from "../classes/location";
import { environment } from '../../../environments/environment';
@Injectable({
providedIn: "root",
})
export class DemoDataService {
constructor(private http: HttpClient) {}
private apiUrl = environment.apiUrl"http://localhost:3000/api";
...
}Ob izgradnji Angular SPA aplikacije s pomočjo Angular CLI bo proces samodejno zamenjal datoteki z okoljskimi spremenljivkami in poskrbel za ustrezne vrednosti v produkcijskem okolju.
$ ng build --output-path build
✔ Browser application bundle generation complete.
✔ Copying assets complete.
✔ Index html generation complete.
Initial Chunk Files | Names | Raw Size | Estimated Transfer Size
main.e4aebb96f6f1e0e7.js | main | 403.37 kB | 104.55 kB
polyfills.e29c00c2bfd805ea.js | polyfills | 33.00 kB | 10.60 kB
runtime.f8cdbef653ede801.js | runtime | 886 bytes | 513 bytes
styles.ae0d61c1bb4441f8.css | styles | 30 bytes | 29 bytes
| Initial Total | 437.27 kB | 115.68 kB
Build at: 2023-09-15T08:31:34.657Z - Hash: fbf3bde95fc04fb6 - Time: 7341ms3.9.1 Preusmeritev zahtev na Angular SPA
Na strani Express strežnika v datoteki app.js zakomentiramo oz. odstranimo usmerjevalnik hbsRouter, ki je povezan s strežbo aplikacije, medtem ko usmerjevalnik apiRouter za funkcionalnost REST API-ja obdržimo. Sedaj tudi ne potrebujemo več mape public s statično vsebino, saj je celotna aplikacija na voljo v okviru Angular SPA. Prej smo Angular SPA aplikacijo posredovali iz mape angular (zamenjali smo le del strežniške aplikacije), sedaj pa jo bomo uporabljali v celoti, zato jo posredujemo iz mape angular/build.
server.js
/**
* Database connection
*/
require("./api/models/db.js");
const hbsRouter = require("./hbs/routes/hbs");
const apiRouter = require("./api/routes/api");
/**
* Create server
*/
const port = process.env.PORT || 3000;
const app = express();
/**
* CORS
*/
app.use(cors());
/**
* Static pages
*/
app.use(express.static(path.join(__dirname, "public")));
app.use(express.static(path.join(__dirname, "angular", "build")));
/**
* Body parser (application/x-www-form-urlencoded)
*/
app.use(bodyParser.urlencoded({ extended: false }));
/**
* View engine (HBS) setup
*/
app.set("views", path.join(__dirname, "hbs", "views"));
app.set("view engine", "hbs");
require("./hbs/views/helpers/hbsh.js");
/**
* HBS routing
*/
app.use("/", hbsRouter);
/**
* API routing
*/
app.use("/api", apiRouter);
/**
* Angular routing
*/
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "angular", "build", "index.html"));
});Ko pride do zahteve na Express strežniku, le-ta postreže prvi vir, ki se ujema z zahtevo, ki je v mapi angular/build. To deluje, ko zahtevamo začetno stran aplikacije in tudi če pride neposredna zahteva do npr. strani z informacijami o aplikaciji na /about. V tem primeru Express poskuša poiskati ujemanje z obstoječimi potmi in če jih ne najde, posreduje zahtevo na angular/build/index.html, kjer se nahaja Angular SPA aplikacija.
Pri pripravi Docker slike poskrbimo tudi, da se v produkcijsko verzijo prenese zgolj prevedena produkcijska verzija Angular SPA aplikacije in ne celotna izvorna koda.
.dockerignore
.git*
dockerfile
docker-compose*
.dockerignore
.jshintrc
package-lock.json
node_modules
*.md
data
angular/**
!angular/build/**Za ponovno nameščanje produkcijske verzije v okolje Fly pa uporabimo naslednji ukaz.
3.10 Dodatni gradniki Angular aplikacije
Angular je celostno ogrodje z vsemi podpornimi orodji in veliko dobrimi praksami uporabe. Podobno kot React in Vue, tudi Angular razvijalce spodbuja k ponovni uporabi komponent, kjer je aplikacija smiselno razdeljena in sestavljena iz ponovno uporabnih delov.
Na voljo je kar nekaj zelo priljubljenih in pogosto uporabljenih dobrih praks in dodatnih komponent, ki razširjajo osnovno Angular funkcionalnost:
- NGX Bootstrap je knjižnica, ki v okolje Angular prinaša podporo za vse osnovne in tudi dodatne Bootstrap komponente, ki smo jo uporabili tudi pri naši aplikaciji.
- NG Bootstrap je evolucija ogrodja Angular UI, ki je omogočal uporabo Bootstrap komponent v okviru predhodne verzije AngularJS. Knjižnica ima veliko stopnjo pokritosti s testi in nima dodatnih odvisnosti od drugih JavaScript knjižnic.
- NG2 Charts prinaša podporo 6 različnim tipom grafov, ki so podprti v okviru chart.js. Knjižnica je primerna tudi za vizualizacijo večje količine podatkov.
Dodatno gradivo
|
|
|
Observable je poenostavljeni potisni mehanizem za posredovanje več vrednosti.↩︎
