Applicazione Angular - esempio

In questo articolo vedrai come realizzare un’applicazione Angular attraverso un esempio completo e funzionante.

Svilupperemo insieme, passo passo, un’applicazione semplice, ma abbastanza completa da permetterti di imparare alcuni elementi base come Angular Routing, RxJS e Angular Material.

 

Creare una nuova applicazione Angular

Abbiamo già visto come creare una nuova applicazione Angular, daremo all’esempio il nome Tutorial demo Angular, simulando un’applicazione in grado di gestire semplici messaggi di testo. Scegliamo di utilizzare Angular routing e SCSS come stile.

ng new tutorial-demo-angular

Apri la cartella appena creata in un editor, ad esempio Visual Studio Code.

 

Dettagli dell'esempio

Come detto, realizzeremo un’applicazione per la gestione, quindi l’inserimento, la visualizzazione e la cancellazione, di messaggi di testo.

Le pagine che andremo a gestire:

  • login (simulazione)
  • visualizzare la lista dei messaggi inseriti
  • visualizzare il dettaglio di un messaggio
  • form di creazione e inserimento del messaggio
 

Utilizzare Angular Material

Per quanto riguarda l’interfaccia, utilizzeremo la libreria Angular Material, che, di per sé, comprende già un allestimento di componenti. Quello che faremo è aggiungere questi componenti alle nostre pagine facendo riferimento alla documentazione.

Aggiungere il modulo Material

Aggiungiamo Angular Material al progetto scegliendo, tra le opzioni, un tema pre-configurato (Indigo/Pink ad esempio) e di impostare gli stili Material globalmente e browser animation.

ng add @angular/material

Creiamo ora un modulo dedicato material attraverso il quale condivideremo i singoli moduli della libreria con i componenti dell’applicazione.

ng g module shared/material -m app.module --flat=true

Con il comando precedente abbiamo creato il file material.module.ts, indicando con l’opzione -m app.module di utilizzare app.module come modulo base e con –flat di non voler creare un’ulteriore cartella material contenente il modulo ma di inserire il file direttamente all’interno della cartella /shared.

nota: tutti i file generati con il comando ng generate .. (abbreviato ng g) verranno inseriti all’interno della cartella app, a meno dell’utilizzo di opzioni – documentazione.

Il modulo MaterialModule verrà aggiunto automaticamente agli imports del modulo principale AppModule (opzione: -m app.module), se così non fosse, dovrai aggiungerlo manualmente.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';

@NgModule({
  imports: [
    CommonModule
  ],
  exports: [
    CommonModule
  ]
})
export class MaterialModule { }
 

Il template principale

La prima cosa da fare è definire un template principale per l’applicazione. Sto parlando di impostare la struttura dell’interfaccia grafica dell’applicazione e da quali componenti questa deve essere composta.

Template principale

Il template visualizzato inizialmente è definito all’interno del file app.component.html. AppComponent è il componente principale di default per l’applicazione. Potremmo definire il nostro nuovo template di navigazione direttamente all’interno del file html del componente App ma, personalmente, preferisco lasciarlo più pulito possibile aggiungendo un nuovo componente per la navigazione dell’APP. 

Creiamo quindi un componente di nome NavigationComponent sulla quale definire il nostro nuovo template.

ng g c navigation

Ora dobbiamo pulire il file app.component.html sostituendo tutto il contenuto con l’unica riga

<router-outlet></router-outlet>

Questa direttiva della libreria @angular/routercomporta che l’applicazione funzioni come una single-page application, il quale, piuttosto che caricare una nuova pagina quando si cambia percorso ne modifica il contenuto html. 

Nel nostro esempio, vogliamo visualizzare a tutto schermo il template principale del componente Navigation appena creato e il contenuto delle pagine all’interno dello spazio centrale dedicato “Contenuto pagina”.

Per ottenere questo risultato, abbiamo bisogno di configurare correttamente i percorsi dell’applicazione andando a modificare il file di routing app-routing.module.ts.

const routes: Routes = [
  {
    path: '',
    component: NavigationComponent
  }
];

In questo modo abbiamo istruito l’applicazione ad utilizzare il componente Navigation sul percorso principale (‘/’),  che, per quanto visto fino ad ora, equivale a dire: aggiungere il contenuto del componente Navigation, all’interno della direttiva <router-outlet>.

 

Il componente Navigation

Iniziamo la costruzione del template di navigazione.

Il primo passo è quello di aggiornare il modulo Material aggiungendo i componenti di cui abbiamo bisogno, inizialmente MatToolbar. Facendo riferimento alla documentazione, importiamo il modulo descritto nella sezione API e aggiungiamolo agli imports ed exports del nostro modulo.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatToolbarModule } from '@angular/material/toolbar';

@NgModule({
  imports: [
    CommonModule,
    MatToolbarModule
  ],
  exports: [
    CommonModule,
    MatToolbarModule
  ]
})
export class MaterialModule { }

Aggiorniamo il file navigation.component.html come segue:

<mat-toolbar>
    <!-- TODO -->
</mat-toolbar>

<div class="page-wrapper">
    <router-outlet></router-outlet>
</div>

Aggiungiamo la classe page-wrapper al foglio di stile navigation.component.scss:

.page-wrapper {
    max-width: 1024px;
    margin: 0 auto;
    padding: 1.5em;
}

Si noti la presenza della direttiva router-outlet. Abbiamo aggiunto una barra di navigazione nella parte alta della schermata, mentre stiamo dicendo all’applicazione, di utilizzare la parte restante della pagina per visualizzare il contenuto del componente indicato dal percorso attuale. 

Dopo l’aggiunta dei componenti che rappresentano le pagine dell’applicazione avremo bisogno di modificare nuovamente il file di Routing. Continua a leggere.

 

Le pagine dell'applicazione

In questa parte, creeremo le pagine principali del nostro esempio, ovvero, la lista dei messaggi e il dettaglio di un singolo messaggio, ed andremo a modificare il file di Routing per l’assegnazione dei percorsi.

Per distinguere i componenti “pagine” dagli altri, utilizzeremo una nuova cartella pages.

ng g c pages/messages

ng g c pages/message-detail

I comandi precedenti, da eseguire in successione, creano i nuovi componenti direttamente all’interno della cartella pages aggiungendo la loro dichiarazione nel modulo principale.

Possiamo subito modificare il file di Routing app-routing.module.ts aggiungendo i percorsi:

import { MessageDetailComponent } from './pages/message-detail/message-detail.component';
import { MessagesComponent } from './pages/messages/messages.component';
    
const routes: Routes = [
  {
    path: '',
    component: NavigationComponent,
    children: [
      {
        path: '',
        component: MessagesComponent
      },
      {
        path: 'message/:id',
        component: MessageDetailComponent
      }
    ]
  }
];

Abbiamo definito le nuove rotte come “figli” del percorso definito per il componente Navigation poiché, come accennato poco fa, vogliamo sostituire il contenuto della direttiva router-outlet all’interno del template Navigation con il contenuto delle nuove pagine. Da notare che la prima rotta definita, quella “padre” Navigation, è egli stessa la prima figlia del componente principale di default App (ricordi la direttiva router-outlet nel file app.component.html?)

 

Pagina dei messaggi

La pagina dei messaggi dovrà mostrare la lista di tutti i messaggi inseriti.

Per prima cosa dobbiamo definire un’interfaccia Message per rappresentare le informazioni del messaggio, dopo di che, costruiamo un mock di messaggi. In questo modo, avremo una lista iniziale di messaggi (predefinita) fin tanto che non avremo implementato l’inserimento di un nuovo messaggio. 

Creiamo l’interfaccia Message sotto la cartella model e modifichiamola:

ng g interface model/message
export interface Message {
    id: number;
    title: string;
    message: string;
}

Ora creiamo un nuovo file mock-messages.ts sotto la cartella mock e modifichiamolo:

import { Message } from "../model/message";

export const MOCK_MESSAGES: Message[] = [
    { id: 1, title: 'Messaggio 1', message: 'Questo è un messaggio di testo' },
    { id: 2, title: 'Messaggio 2', message: 'Questo è un messaggio di testo' },
    { id: 3, title: 'Messaggio 3', message: 'Questo è un messaggio di testo' },
    { id: 4, title: 'Messaggio 4', message: 'Questo è un messaggio di testo' },
    { id: 5, title: 'Messaggio 5', message: 'Questo è un messaggio di testo' }
];

Una volta creato il modello dei dati, modifichiamo la pagina Messages per mostrare la lista.

Utilizziamo il modulo MatList di Material aggiungendolo al nostro MaterialModule, come fatto per la toolbar in precedenza.

Modifichiamo quindi i file del componente Messages, rispettivamente messages.component.ts e messages.component.html, come segue:

import { Component, OnInit } from '@angular/core';
import { MOCK_MESSAGES } from 'src/app/mock/mock-messages';
import { Message } from 'src/app/model/message';

@Component({
  selector: 'app-messages',
  templateUrl: './messages.component.html',
  styleUrls: ['./messages.component.scss']
})
export class MessagesComponent implements OnInit {

  messages: Message[];

  constructor() {
    this.messages = MOCK_MESSAGES;
  }

  ngOnInit(): void {
  }

}
<mat-nav-list>
    <a mat-list-item [routerLink]="['message', message.id]" *ngFor="let message of messages">
        <p matLine>{{ message.title }}</p>
        <p matLine class="extract">{{ message.message }}</p>
    </a>
</mat-nav-list>

RouterLink è la direttiva di Angular Router che si applica direttamente su un elemento nel template (html) per navigare verso un’altra rotta. Nel nostro esempio, ci permette di visualizzare la pagina di dettaglio del messaggio cliccando sull’elemento della lista.

In dettaglio, [routerLink]="['message', message.id] corrisponde alla navigazione verso la rotta /message/:id, la stessa che avevamo definito nel file di Routing. Se vuoi approfondire l’argomento, puoi dare un’occhiata a questo articolo che ho scritto sul funzionamento di Angular Routing.

Aggiorniamo anche il foglio di stile messages.component.scss:

.extract {
    max-width: 100%;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

mat-nav-list {
    margin: 1em -1.5em;
}
 
 

Pagina di dettaglio del messaggio

Come appena visto, cliccando su di un elemento della lista si vuole visualizzare il dettaglio del messaggio scelto. 

Come prima cosa dobbiamo poter recuperare il messaggio attraverso il suo ID che ci viene fornito all’interno della URL. Angular Routing permette di recuperare tutti i parametri del percorso tramite l’oggetto ActivatedRoute della libreria.

Quello che faremo è, in ordine:

  • recuperare l’ID del messaggio dalla Route
  • recuperare il messaggio, se presente, dalla lista definita MOCK_MESSAGES (mock)
  • assegnare l’oggetto recuperato ad una variabile di tipo Message per visualizzarlo nel template

Aggiorniamo i file message-detail.component.tsmessage-detail.component.html:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs/operators';
import { MOCK_MESSAGES } from 'src/app/mock/mock-messages';
import { Message } from 'src/app/model/message';

@Component({
  selector: 'app-message-detail',
  templateUrl: './message-detail.component.html',
  styleUrls: ['./message-detail.component.scss']
})
export class MessageDetailComponent implements OnInit {

  message?: Message;

  constructor(
    private readonly route: ActivatedRoute
  ) { }

  ngOnInit(): void {
      this.route.params
          .pipe(
            map(params => this.message = this.getMessage(+params.id))
          )
          .subscribe();
  }

  private getMessage(id: number): Message | undefined {
    return MOCK_MESSAGES.find(m => m.id === id);
  }

}

dove getMessage è il metodo utilizzato per ricercare il messaggio con un certo ID, mentre recuperiamo l’ID del messaggio facendo uso dell’oggetto ActivatedRoute accennato poco fa. Da notare come il campo params di questo oggetto restituisca un Observable di Params alla quale sottoscriversi, permettendoci di recuperare il nostro messaggio in modo asincrono.

<div *ngIf="message">
    <h4>{{ message.title }}</h4>
    <small>messaggio: </small>
    <p>{{ message.message }}</p>
</div>

dove *ngIf serve come verifica nel caso in cui l’oggetto message sia ‘undefined’.

 

Utilizzo delle classi Service

Fino a questo momento, abbiamo utilizzato una lista di messaggi predefinita (mock), richiamandola all’occorrenza all’interno del componente. Lo abbiamo fatto per la pagina messages, recuperando l’intera lista e per la pagina message-detail, andando a cercare il messaggio con un determinato ID all’interno della lista.

Ma nella realtà da dove vengono questi messaggi?

Angular è un framework adatto allo sviluppo Client, l’interfaccia per intendersi, di una applicazione web e, solitamente, questa comunica via HTTP tramite API REST con un’altra applicazione lato Server. In questa situazione, è il Server a fornire i dati presenti sul database, mentre è compito dell’applicazione Client presentare le richieste. 

In Angular, le richieste HTTP vengono effettuate utilizzando il l’oggetto HttpClient del modulo HttpClientModule.

Questo cosa c’entra con i Service?

Direttamente dalla documentazione ufficiale, si legge:

Un servizio è tipicamente una classe con uno scopo ristretto e ben definito. Dovrebbe fare qualcosa di specifico e farlo bene. …

… Idealmente, il compito di un componente è abilitare l’esperienza dell’utente e nient’altro. … può delegare determinate attività ai servizi, come ad esempio il recupero dei dati dal server …

 

Detto questo, nel nostro esempio non useremo un server ne eseguiremo chiamate HTTP esterne, ma piuttosto seguiremo questi principi per semplificare la logica dell’applicazione all’interno di Servizi, e rendondoli disponibili ai componenti. 

 

Message Service

Creiamo il servizio MessageService avente come unico compito la gestione dei messaggi.

ng g service services/message
import { Injectable } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { MOCK_MESSAGES } from '../mock/mock-messages';
import { Message } from '../model/message';

@Injectable({
  providedIn: 'root'
})
export class MessageService {

  messages: Message[] = [];

  constructor() {
    this.messages = MOCK_MESSAGES;
  }

  getAll(): Observable<Message[]> {
    return of(this.messages);
  }

  get(id: number): Observable<Message> {
    const message = MOCK_MESSAGES.find(m => m.id === id);
    return message ? of(message) : throwError(`Messaggio con id ${id} non trovato!`);
  }
  
}

All’avvio, la lista dei messaggi è equivalente a MOCK_MESSAGES.

Il metodo getAll restituisce la lista completa dei messaggi mentre get riceve in ingresso l’ID del messaggio, lo ricerca nella lista e restituisce il messaggio se questo viene trovato, altrimenti un errore.

Aggiorniamo i nostri componenti, rispettivamente messages.component.ts e message-detail.component.ts, facendo uso del servizio appena creato, recuperandolo con il meccanismo di dependency injection all’interno del costruttore:

import { Component, OnInit } from '@angular/core';
import { map } from 'rxjs/operators';
import { Message } from 'src/app/model/message';
import { MessageService } from 'src/app/services/message.service';

@Component({
  selector: 'app-messages',
  templateUrl: './messages.component.html',
  styleUrls: ['./messages.component.scss']
})
export class MessagesComponent implements OnInit {

  messages: Message[] = [];

  constructor(
    private readonly messageService: MessageService
  ) { }

  ngOnInit(): void {
    this.messageService.getAll()
      .pipe(
        map((messages: Message[]) => this.messages = messages)
      )
      .subscribe();
  }

}
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { catchError, map, switchMap } from 'rxjs/operators';
import { Message } from 'src/app/model/message';
import { MessageService } from 'src/app/services/message.service';

@Component({
  selector: 'app-message-detail',
  templateUrl: './message-detail.component.html',
  styleUrls: ['./message-detail.component.scss']
})
export class MessageDetailComponent implements OnInit {

  message?: Message;

  constructor(
    private readonly route: ActivatedRoute,
    private readonly messageService: MessageService,
    private readonly router: Router
  ) { }

  ngOnInit(): void {
    this.route.params
      .pipe(
        switchMap(params => this.messageService.get(+params.id)),
        catchError(err => {
          this.router.navigate(['/']);
          throw err;
        }),
        map((message: Message) => this.message = message)
      )
      .subscribe();
  }

}

Da sottolineare l’utilizzo dell’oggetto Router: se la chiamata get di MessageService fallisce, ovvero non viene trovato nessun messaggio con l’ID specificato, si torna alla pagina iniziale (lista di messaggi). 

Nota: Si ottiene lo stesso comportamento aggiungendo un servizio Resolver all’interno della rotta, come vedrai più avanti in questo articolo.

 

Operazioni di inserimento e cancellazione

Completiamo le operazioni da eseguire sui messaggi aggiungendo a MessageService i metodi rispettivamente di inserimento e cancellazione di un messaggio:

add(message: Message): Observable<Message> {
  this.messages.push(message);
  return of(message);
}

remove(id: number): Observable<void> {
  const messageIndex = this.messages.findIndex(m => m.id === id);
  if (messageIndex !== -1) {
    this.messages.splice(messageIndex, 1);
    return of(undefined);
  }
  return throwError(`Errore: messaggio con id ${id} non trovato!`);
}

Il metodo add riceve un nuovo oggetto Message e lo aggiunge alla lista, ritornando un Observable contenente lo stesso oggetto. Il metodo remove ricerca il messaggio richiesto e, se questo viene trovato, lo rimuove dalla lista.

Aggiungiamo subito la funzionalità di cancellazione alla pagina di dettaglio del messaggio, mentre tratteremo l’inserimento subito dopo aver discusso l’argomento template-driven form che utilizzeremo per la creazione di un nuovo messaggio.

Aggiungiamo la funzione delete in MessageDetailComponent e aggiorniamo il template html e lo stile:

delete(message: Message): void {
  this.messageService.remove(message.id)
    .subscribe(
      () => {
        console.log(`${message.title} rimosso!`);
        this.router.navigate(['/']);
      },
      err => console.error(err)
    );
}
<div *ngIf="message">
    <button mat-mini-fab color="warn" (click)="delete(message)">
        <mat-icon>delete</mat-icon>
    </button>

    <h4>{{ message.title }}</h4>
    <small>messaggio: </small>
    <p>{{ message.message }}</p>
</div>
button {
    margin-bottom: 1em;
}

Avendo inserito nuovi componenti Material (MatButton e MatIcon) dobbiamo aggiornare il file material.module.ts importando i moduli:

...
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';

@NgModule({
  imports: [
    ...
    MatButtonModule,
    MatIconModule
  ],
  exports: [
    ...
    MatButtonModule,
    MatIconModule
  ]
})
export class MaterialModule { }
 

Utilizzo di localStorage

Per quanto fatto fino ad ora, si osserva che ad ogni refresh della pagina e ad ogni riavvio dell’applicazione, otteniamo la stessa lista di messaggi definita nel nostro Mock. Questo avviene anche dopo aver eseguito le operazioni di inserimento e cancellazione di un messaggio. Perche?

Non abbiamo definito nessun ambiente di persistenza ne usiamo un server esterno per fornirci le risorse. Quello che abbiamo fatto è assegnare alla variabile messages il contenuto di MOCK_MESSAGES, costante, all’interno del costruttore del nostro MessageService. Ed ecco che otteniamo il risultato citato.

Vogliamo poter tenere traccia degli aggiornamenti fatti dall’utente ad ogni riavvio e lo facciamo utilizzando l’oggetto localStorage. Questo ci permette di salvare i dati utilizzando il browser.

 

Prima di effettuare modifiche ai componenti, vediamo alcuni dettagli sull’uso di localStorage:

  • memorizza dati come mappa chiave-valore
  • accetta solo stringhe come valori, che vuol dire dover convertire il nostro oggetto in stringa e viceversa

Detto questo, aggiorniamo il file message.service.ts come segue:

import { Injectable } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { finalize } from 'rxjs/operators';
import { MOCK_MESSAGES } from '../mock/mock-messages';
import { Message } from '../model/message';

export const DEMO_MESSAGES_STORE = 'demo_messages_store';

@Injectable({
  providedIn: 'root'
})
export class MessageService {

  messages: Message[] = [];

  constructor() {
    const stored: string | null = localStorage.getItem(DEMO_MESSAGES_STORE);
    this.messages = stored ? JSON.parse(stored) : this.save(MOCK_MESSAGES);
  }

  getAll(): Observable<Message[]> {
    return of(this.messages);
  }

  get(id: number): Observable<Message> {
    const message = this.messages.find(m => m.id === id);
    return message ? of(message) : throwError(`Messaggio con id ${id} non trovato!`);
  }

  add(message: Message): Observable<Message> {
    this.messages.push(message);
    return of(message)
      .pipe(finalize(() => this.save(this.messages)));
  }

  remove(id: number): Observable<void> {
    const messageIndex = this.messages.findIndex(m => m.id === id);
    if (messageIndex !== -1) {
      this.messages.splice(messageIndex, 1)
      return of(undefined)
        .pipe(finalize(() => this.save(this.messages)));
    }
    return throwError(`Messaggio con id ${id} non trovato!`);
  }

  private save(messages: Message[]): Message[] {
    localStorage.setItem(DEMO_MESSAGES_STORE, JSON.stringify(messages));
    return messages;
  }

}

Ottimo! Ora il browser memorizzerà la nostra lista di messaggi rendendoli disponibili e invariati ad ogni riavvio.

 

Creare un nuovo messaggio tramite form

I moduli (form) sono molto comuni all’interno delle applicazioni e dei siti web, poiché permettono di raccogliere dati inseriti dall’utente.

Nel nostro esempio, utilizzeremo due campi di input per raccogliere le informazioni necessarie a creare un messaggio: titolo e testo del messaggio. 

Angular fornisce due approcci per gestire gli input dell’utente nei moduli: Reactive forms e Template-driven forms. Il primo approccio è più robusto e viene utilizzato per moduli complessi o di grandi dimensioni, il secondo invece è facile da utilizzare e viene impiegato in piccole forms, come il nostro esempio.

Per poter utilizzare template-driven forms dobbiamo includere il modulo FormsModule in AppModule:

...
import { FormsModule } from '@angular/forms';

...
  imports: [
    ...
    FormsModule
  ]
 

Finestra di dialogo

Utilizzeremo una finestra di dialogo (popup) contenente i campi di input e un pulsante per salvare il contenuto prendendo i componenti necessari dalla libreria MaterialMatDialogMatFormField e MatInput. Aggiungiamoli all’interno di MaterialModule:

...
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';

...
  imports: [
    ...
    MatDialogModule,
    MatFormFieldModule,
    MatInputModule
  ],
  exports: [
    ...
    MatDialogModule,
    MatFormFieldModule,
    MatInputModule
  ]

Fatto questo, creiamo un nuovo componente Angular all’interno di una cartella components, cosi da distinguerlo dai componenti che rappresentano le pagine, col nome CreateMessageDialogComponent e modifichiamo i file rispettivamente .ts.html e .scss:

ng g c components/create-message-dialog
import { Component, OnInit } from '@angular/core';
import { MatDialogRef } from '@angular/material/dialog';
import { Message } from 'src/app/model/message';

@Component({
  selector: 'app-create-message-dialog',
  templateUrl: './create-message-dialog.component.html',
  styleUrls: ['./create-message-dialog.component.scss']
})
export class CreateMessageDialogComponent implements OnInit {

  message: Message;

  constructor(
    private readonly ref: MatDialogRef
  ) {
    this.message = { id: new Date().getTime(), title: '', message: '' };
  }

  ngOnInit(): void {
  }

  close(): void {
    this.ref.close(this.message);
  }

}
<h2 mat-dialog-title>Crea un nuovo messaggio</h2>
<mat-dialog-content>
    <mat-form-field>
        <mat-label>Titolo</mat-label>
        <input matInput [(ngModel)]="message.title" placeholder="Aggiungi un titolo">
    </mat-form-field>
    <mat-form-field>
        <mat-label>Messaggio</mat-label>
        <textarea matInput [(ngModel)]="message.message" placeholder="Scrivi qualcosa ..."></textarea>
    </mat-form-field>

</mat-dialog-content>
<mat-dialog-actions align="end">
    <button mat-button mat-dialog-close cdkFocusInitial>Annulla</button>
    <button mat-button (click)="close()" [disabled]="!(message.title && message.message)">Salva</button>
</mat-dialog-actions>
mat-form-field {
    width: 100%;
}

Template-driven forms utilizza la direttiva NgModel per effettuare un’associazione dei dati bidirezionale (two-way data binding) che comporta l’aggiornamento del modello dei dati nel componente man mano che vengono apportate modifiche nel template e viceversa.

Quando entrambi i campi di input saranno riempiti, il click sul pulsante Salva chiuderà la finestra di dialogo fornendo in output l’oggetto message valorizzato (modello dati), come vedremo adesso.

Prima di tutto, dobbiamo aggiungere il pulsante per aprire la finestra di creazione del messaggio subito sopra la lista nella pagina dei messaggi. Aggiorniamo il file messages.component.html e il foglio di stile messages.component.scss:

<button mat-mini-fab color="primary" (click)="create()">
    <mat-icon>add</mat-icon>
</button>

<mat-nav-list>
    <a mat-list-item [routerLink]="['message', message.id]" *ngFor="let message of messages">
        <p matLine>{{ message.title }}</p>
        <p matLine class="extract">{{ message.message }}</p>
    </a>
</mat-nav-list>
.extract {
    max-width: 100%;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

mat-nav-list {
    margin: 1em -1.5em;
}

Ora, possiamo implementare il metodo create, chiamato dal pulsante, all’interno del componente MessagesComponent:

import { Component, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { CreateMessageDialogComponent } from 'src/app/components/create-message-dialog/create-message-dialog.component';
import { Message } from 'src/app/model/message';
import { MessageService } from 'src/app/services/message.service';

@Component({
  selector: 'app-messages',
  templateUrl: './messages.component.html',
  styleUrls: ['./messages.component.scss']
})
export class MessagesComponent implements OnInit {

  messages: Message[] = [];

  constructor(
    private readonly messageService: MessageService,
    private readonly dialog: MatDialog
  ) { }

  ngOnInit(): void {
    this.messageService.getAll()
      .pipe(
        map((messages: Message[]) => this.messages = messages)
      )
      .subscribe();
  }

  create(): void {
    this.dialog.open(CreateMessageDialogComponent)
      .afterClosed()
      .pipe(
        switchMap((message?: Message) => message ? this.messageService.add(message) : new Observable(sub => sub.complete()))
      )
      .subscribe(
        (message: any) => console.log(`Messaggio creato: ${message.id}`)
      );
  }

}

Chiamando il metodo create si aprirà la finestra di dialogo con CreateMessageDialogComponent. Al termine verrà invocato il metodo add di MessageService che applica il salvataggio. Tutto questo avviene facendo uso dell’oggetto Material MatDialog iniettato all’interno del costruttore.

 

Ultimi sviluppi

Continuiamo con lo sviluppo di ulteriori funzionalità e miglioramenti del nostro esempio di applicazione Angular.

 

Gestione titolo della pagina

Il primo miglioramento che faremo riguarda l’aggiunta di un titolo alle nostre pagine. Lo aggiungiamo all’interno della barra di navigazione nella parte alta della pagina, all’interno del componente Navigation.

Quando si passa da una pagina all’altra, si dovrà notificare l’informazione di cambio titolo a Navigation così da aggiornare l’interfaccia con il titolo della pagina attuale. 

Ma come avviene la comunicazione tra i componenti?

Si utilizza una nuova classe Service contenente un soggetto BehaviorSubject con la quale, un componente, notifica un aggiornamento a tutti gli altri componenti sottoscritti. Creiamo il nuovo servizio:

ng g service services/title
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class TitleService {

  title = new BehaviorSubject<string>('');

  constructor() { }
}

Dobbiamo ora modificare il componente Navigation cosi da ricevere gli aggiornamenti attraverso TitleService e mostrare il titolo nella barra di navigazione:

import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { map } from 'rxjs/operators';
import { TitleService } from '../services/title.service';

@Component({
  selector: 'app-navigation',
  templateUrl: './navigation.component.html',
  styleUrls: ['./navigation.component.scss']
})
export class NavigationComponent implements OnInit {

  title: string = '';

  constructor(
    private readonly titleService: TitleService,
    private readonly ref: ChangeDetectorRef
  ) { }

  ngOnInit(): void {
    this.titleService.title
      .pipe(
        map(title => {
          this.title = title;
          this.ref.detectChanges();
        })
      )
      .subscribe();
  }

}
<mat-toolbar>
    <span>{{ title }}</span>
</mat-toolbar>

<div class="page-wrapper">
    <router-outlet></router-outlet>
</div>

BehaviorSubject fornisce il metodo next per notificare un cambiamento. Implementiamo all’interno delle nostre pagine la chiamata a title. Nel componente MessagesComponent facciamo l’inject del servizio all’interno del costruttore e modifichiamo ngOnInit aggiungendo la chiamata al servizio trasmettendo la stringa “Messaggi”:

constructor(
    ...
    private readonly titleService: TitleService
  ) { }

  ngOnInit(): void {
    ...
    this.titleService.title.next('Messaggi');
  }

mentre nel componente MessageDetailComponent si utilizza il parametro id dato dalla sottoscrizione con ActivatedRoute per comporre il titolo della pagina, aggiungendo all’operatore map l’invio della notifica:

constructor(
    ...
    private readonly titleService: TitleService
  ) { }

  ngOnInit(): void {
    this.route.params
      .pipe(
        switchMap(params => this.messageService.get(+params.id)),
        catchError(err => {
          this.router.navigate(['/']);
          throw err;
        }),
        map((message: Message) => {
          this.message = message;
          this.titleService.title.next(`Messaggio ${message.id}`);
        })
      )
      .subscribe();
  }
 

Notifiche all'utente

Il secondo miglioramento che andiamo ad aggiungere all’esempio consiste nel mostrare un messaggio all’utente a seguito di un’operazione di aggiornamento, ad esempio, il successo o meno dell’inserimento di un nuovo messaggio.

Per fare questo, utilizzeremo il componente MatSnackBar della libreria Angular Material, che aggiungiamo al nostro modulo MaterialModule. 

...
import { MatSnackBarModule, MAT_SNACK_BAR_DEFAULT_OPTIONS } from '@angular/material/snack-bar';

@NgModule({
  imports: [
    ...
    MatSnackBarModule
  ],
  exports: [
    ...
    MatSnackBarModule
  ],
  providers: [
    { provide: MAT_SNACK_BAR_DEFAULT_OPTIONS, useValue: { duration: 3000 } }
  ]
})
export class MaterialModule { }

In aggiunta a import/export del modulo MatSnackBarModule, questa volta, introduciamo una nuova caratteristica aggiungendo ai providers le opzioni  MAT_SNACK_BAR_DEFAULT_OPTIONS. In particolare, definiamo l’opzione duration che imposta la durata (in millisecondi) dello snack bar.

 

Pagina di login

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis, pulvinar dapibus leo.

Aggiungiamo la pagina di Login ed andiamo a modificarne i componenti, rispettivamente login.component.tslogin.component.htmllogin.component.scss:

ng g c pages/login
import { Component, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { AuthenticationService } from 'src/app/services/authentication.service';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {

  username?: string;

  constructor(
    private readonly authenticationService: AuthenticationService,
    private readonly snackBar: MatSnackBar,
    private readonly router: Router
  ) { }

  ngOnInit(): void {
    this.logout();
  }

  login(): void {
    if (this.username) {
      this.authenticationService.login(this.username)
        .subscribe(
          () => {
            this.snackBar.open(`Benvenuto ${this.username}`);
            this.router.navigate(['/']);
          },
          err => this.snackBar.open(`Accesso non riuscito: ${err}`)
        );
    } else {
      this.snackBar.open('Inserisci un nome ...');
    }
  }

  logout(): void {
    this.authenticationService.logout()
      .subscribe(() => this.snackBar.open('Logout effettuato'));
  }

}

All’inizio, viene effettuato un logout preventivo. Nel campo di input della pagina, l’utente dovrà inserire il proprio nome per poter effettuare l’accesso. Cliccando sul pulsante login, i dati inseriti verranno salvati attraverso un servizio, dopo di che, l’utente verrà reindirizzato alla pagina dei messaggi.

<div class="page-wrapper">
    <h2>Login</h2>
    <form #loginForm="ngForm" (ngSubmit)="login()">
        <mat-form-field>
            <mat-label>Username</mat-label>
            <input matInput [(ngModel)]="username" placeholder="Inserisci un nome" name="username" required>
        </mat-form-field>
        <button mat-raised-button color="primary" type="submit" aria-label="Login"
            [disabled]="!loginForm.form.valid">Login</button>
    </form>
</div>
mat-form-field {
    width: 100%;
}

.page-wrapper {
    max-width: 1024px;
    margin: 0 auto;
    padding: 1.5em;
}

Aggiungiamo il percorso /login come rotta principale al file di Routing app-routing.module.ts:

const routes: Routes = [
  {
    path: 'login',
    component: LoginComponent
  },
  {
    path: '',
    component: NavigationComponent,
    children: [
      {
        path: '',
        component: MessagesComponent
      },
      {
        path: 'message/:id',
        component: MessageDetailComponent
      }
    ]
  }
];
 

Servizio di autenticazione

Ora dobbiamo creare un servizio di autenticazione , AuthenticationService, utilizzato dal componente Login.

ng g service services/authentication
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Authentication } from '../model/authentication';

export const ACCESS_TOKEN = 'demo-access-store';

@Injectable({
  providedIn: 'root'
})
export class AuthenticationService {

  private authentication?: Authentication;

  constructor() {
    const stored = localStorage.getItem(ACCESS_TOKEN);
    if (stored) {
      this.authentication = JSON.parse(stored) as Authentication;
    }
  }

  getAuthentication(): Authentication | undefined {
    return this.authentication;
  }

  login(username: string): Observable<void> {
    const loginDate = new Date();
    const expirationDate = new Date(loginDate.getTime() + (60 * 60000));  // 1 ora
    this.authentication = { username, loginDate, expirationDate };
    return of(localStorage.setItem(ACCESS_TOKEN, JSON.stringify(this.authentication)));
  }

  logout(): Observable<void> {
    this.authentication = undefined;
    return of(localStorage.removeItem(ACCESS_TOKEN));
  }
}

All’interno sono definiti i metodi pubblici login, logout e getAuthentication, rispettivamente per effettuare il login/logout ed ottenere informazioni sull’autenticazione.

I dati di autenticazione saranno salvati nel browser attraverso l’oggetto localStorage con la chiave “demo-access-store” definita dalla costante ACCESS_TOKEN.

 

Authentication Guard

Un modo per verificare l’autenticazione di un utente, ed eventualmente bloccarne la navigazione, è attraverso l’utilizzo di una “guardia” che gestisce l’attivazione di uno o più percorsi (rotte). Questa soluzione è particolarmente adatta al nostro esempio, dato che non utilizziamo nessun server per l’autenticazione e le chiamate in generale, bensì una propria logica di gestione delle risorse

Solo per chiudere il cerchio, solitamente è il server ad occuparsi della validazione delle chiamate fornendo lui stesso un codice di errore HTTP, che nel caso di mancata autorizzazione è 401. Di conseguenza, l’applicazione client Angular avrà il solo compito di gestire eventuali errori ricevuti ed agire di conseguenza.

Detto questo, creiamo ora una Guard Angular di tipo CanActivate, così da bloccare l’accesso alle pagine dell’applicazione in caso di mancata autenticazione (login non effettuato o scaduto) e reindirizzando l’utente alla pagina di Login. 

ng g guard services/guards/authentication
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthenticationService } from '../authentication.service';

@Injectable({
  providedIn: 'root'
})
export class AuthenticationGuard implements CanActivate {

  constructor(
    private readonly authenticationService: AuthenticationService,
    private readonly router: Router
  ) { }

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    const authentication = this.authenticationService.getAuthentication();
    if (!authentication || authentication.expirationDate < new Date()) {
      this.router.navigate(['/login']);
    }
    return !!authentication;
  }

}

In questo caso, si recupera l’accesso da AuthenticationService ed eventualmente si esegue un controllo sulla data di scadenza impostata. Il risultato di questa operazione comporta l’attivazione o meno della rotta Navigation sulla quale abbiamo abilitato la guardia. Di seguito il file app-routing.module.ts aggiornato:

const routes: Routes = [
  {
    path: 'login',
    component: LoginComponent
  },
  {
    path: '',
    component: NavigationComponent,
    canActivate: [AuthenticationGuard],
    children: [
      {
        path: '',
        component: MessagesComponent
      },
      {
        path: 'message/:id',
        component: MessageDetailComponent
      }
    ]
  }
];
 

Menu di navigazione

Come ultimo passo nello sviluppo del nostro esempio di applicazione Angular, aggiungiamo un menù alla barra di navigazione per visualizzare i dettagli dell’utente, navigare verso la pagina dei messaggi ed effettuare il logout.

Utilizzeremo il componente MatMenu della libreria Material.

...
import { MatMenuModule } from '@angular/material/menu';

@NgModule({
  imports: [
    ...
    MatMenuModule
  ],
  exports: [
    ...
    MatMenuModule
  ],
  providers: [
    ...
  ]
})
export class MaterialModule { }

Aggiorniamo il template e lo stile del componente NavigationComponent aggiungendo il menù nella parte destra della toolbar.

<mat-toolbar>
    <span>{{ title }}</span>
    <span class="spacer"></span>
    <button mat-icon-button [matMenuTriggerFor]="menu" aria-label="Main menu">
        <mat-icon>menu</mat-icon>
    </button>
    <mat-menu #menu="matMenu">
        <div class="user-info">
            <span>Ciao <b>{{ user }}</b>!</span>
        </div>
        <a mat-menu-item routerLink="/">
            <mat-icon>list</mat-icon>
            <span>Tutti i messaggi</span>
        </a>
        <a mat-menu-item routerLink="/login">
            <mat-icon>logout</mat-icon>
            <span>Logout</span>
        </a>
    </mat-menu>
</mat-toolbar>

<div class="page-wrapper">
    <router-outlet></router-outlet>
</div>
.page-wrapper {
    max-width: 1024px;
    margin: 0 auto;
    padding: 1.5em;
}

.spacer {
    flex: 1 1 auto;
}

.user-info {
    padding: 1em;
}

Recuperiamo il nome dell’utente dal servizio di autenticazione ed assegnamolo alla variabile user dichiarata all’interno della classe NavigationComponent.

...
user?: string;

constructor(
  ...
  private readonly authenticationService: AuthenticationService
) { }

ngOnInit(): void {
  ...
  this.user = this.authenticationService.getAuthentication()?.username;
}
 

Hai appena visto un esempio di applicazione Angular completo e funzionante.

E ora?

Arrivati a questo punto, l’applicazione è completa e pronta per essere testata nel suo insieme. Vai alla pagina http://localhost:4200.

Spero di essere stato sufficientemente chiaro ed esplicativo.

Per ringraziarti, ho reso disponibile tutto il codice dell’esempio su GitHub a questo indirizzo.

 

Recommended Posts

12 Comments

  1. Buongiorno Alessandro
    ho fatto una ricerca (la potevo fare anche prima)
    ed il problema è che con questa versione nel comando di generazione progetto bisognava aggiungere l’opzione

    –no-standalone

    per portare all’uso di componenti standalone, come consigliato dal team Angular.

    • Ciao Piero, esattamente come hai detto tu. Questo esempio non considera componenti standalone mantenendo i moduli, tra cui AppModule e MaterialModule.
      Se vuoi usare solo componenti standalone (dalla versione 16) puoi importare i moduli Material (MatSelectModule, MatInputModule, …) direttamente nel componente.
      Probabilmente in futuro rilascerò una versione che fa uso di standalone.

  2. Buongiorno Alessandro
    ho visto solo adesso il tuo tutorial e volevo provarlo.

    Seguendo le tue istruzioni, quando lancio il comando:

    ng g module shared/material -m app.module –flat=true

    mi ritorna il seguente errore :

    Specified module ‘app.module’ does not exist.
    Looked in the following directories:
    /src/app/shared/material
    /src/app/app.module
    /src/app/shared
    /src/app
    /src

    ed in effetti nella cartella app del progetto creato il file app.module non è presente.

    Il mio ambiente di sviluppo è:
    Visual code 1.88.1
    Node.js 18.18.2
    Angular CLI: 17.3.5
    Node: 20.10.0
    Package Manager: npm 10.2.3
    @angular-devkit/architect 0.1703.5
    @angular-devkit/build-angular 17.3.5
    @angular-devkit/core 17.3.5
    @angular-devkit/schematics 17.3.5
    @schematics/angular 17.3.5
    rxjs 7.8.1
    typescript 5.2.2
    zone.js 0.14.4

    Grazie per l’aiuto che mi potrai dare.

  3. Ciao, volevo farti una domanda. Con Angular bisogna necessariamente lavorare con HTML, e quindi creare “a mano” tutti i file .html dell’applicazione?

    • Ciao, Angular è un framework per realizzare applicazioni client utilizzando HTML e TypeScript, per cui si, serve lavorare con codice HTML.
      Un componente Angular necessita di un template HTML per rappresentare la sua view. Per facilitarti la scrittura del template, puoi utilizzare delle librerie come Angular Material (utilizzato nell’esempio) che fornisce componenti UI pronti, facili da inserire e da personalizzare.
      Spero di aver risposto alla tua domanda.
      Ciao

      • Si, hai risposto perfettamente. Tuttavia, quando dici “fornisce componenti UI pronti, facili da inserire e da personalizzare” intendi che sul sito di Angular Material ci sono proprio i codici HTML per ogni componente che possono essere letteralmente copiati e incollati?
        Grazie

  4. Bravissimo, un tutorial fatto veramente bene. Grazie

  5. Complimenti Alessandro. Gran bell’ articolo, per me molto utilissimo che sono un neofita con Angular!!!

  6. Grazie a te Alessandro, questo mi permetter? di confrontare per intero il codice con quello che ho riscritto io e allinearmi con l’ottimo tutorial che hai pubblicato.

  7. Buongiorno sono andato a vedere nel GitHub segnalato, ma purtroppo mi sembra che sia stata pubblicata solo la generazione standard di Angular-Cli e non il progetto completo. E’ corretto ?

    • Buongiorno, hai perfettamente ragione.
      Ho aggiornato il repository e ora lo dovresti vedere.
      Grazie per il feedback. Ciao


Add a Comment

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *