Paweł Łukasiewicz
2020-02-25
Paweł Łukasiewicz
2020-02-25
Udostępnij Udostępnij Kontakt
Wprowadzenie

Formularze używane są w celu obsługi danych wejściowych użytkownika. W naszej aplikacji możemy używać ich do logowania, aktualizacji konta, wprowadzania różnych informacji, ustawień konfiguracyjnych i wiele, wiele więcej.

Angular wyróżnia dwa podejścia do obsługi danych użytkownika przy użyciu formularzy:

  • Reactive forms;
  • Template-driven forms.

Oba typy formularzy pozwalają na gromadzenie danych wejściowych użytkownika, ich sprawdzenie(walidację) oraz utworzenie modelu formularza oraz modelu danych. Konstrukcja formularzy pozwala na aktualizację oraz śledzenie zmian danych. Jakie są zatem różnice?

Reactive Forms

  • formularze te są bardziej dopracowane/niezawodne/rozwinięte;
  • ich zaletą jest skalowalność, możliwość wielokrotnego użycia oraz łatwość testowania;
  • ich wybór jest również preferowany jeżeli formularze są kluczową częścią naszej aplikacji lub cała aplikacja została zbudowana w oparciu o tzw. reactive principles - w bardzo dużym skrócie: tak zaprojektowane systemy są skalowalne, łatwe w rozbudowie oraz niezależnie od okoliczności(część systemu ulega awarii, nagłe zwiększenie liczby użytkowników) sprawnie przetwarzają dane wprowadzane przez użytkowników.

Template-driven forms

  • formularze te są preferowane jeżeli chcemy dodać proste funkcjonalności, np. formularz rejestracji przy użyciu adresu e-mail;
  • formularze oparte na szablonach są łatwe w użyciu w aplikacji ale nie są tak skalowalne jak poprzedni typ (mówimy tutaj o rozbudowie szablonów i czasie niezbędnym na ukończenie procesu);
  • formularze te są używane głównie, kiedy aplikacja wymaga bardzo podstawowych formularzy oraz logiki biznesowej – może ona zostać zdefiniowana po stronie widoku.

Konfiguracja

W tej części przygotujemy kolejną aplikację do której dodamy dwa komponenty. Każdy będzie odpowiadał za inny rodzaj formularza a my będziemy mieli wzorzec na przyszłość.

Szybka ściagawka:

W moim przypadku utworzyłem aplikację o nazwię Forms w której znajdują się dwa komponenty: Angular: struktura porjektu

Aplikacja, którą utworzyliśmy nie jest gotowa na implementacje formularzy. Musimy jeszcze dodać odpowiednią paczkę, tj. FormsModule. Jak pamiętacie z poprzedniego wpisu pozwala na użycie dyrektywy ngModel oraz innych dyrektyw związanych z obsługą formularzy, np. ngSubmit. Zmian dokonujemy wewnątrz AppModule:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
// Dodajemy paczkę FormsModule
import { FormsModule} from '@angular/forms';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './Dashboard/dashboard.component';
import { TemplateDrivenFormComponent } from './Components/template-driven-form/template-driven-form.component';
import { ReactiveFormComponent } from './Components/reactive-form/reactive-form.component';

@NgModule({
  declarations: [
    AppComponent,
    TemplateDrivenFormComponent,
    ReactiveFormComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    // Użycie paczki będzie możliwe w każdym komponencie
    FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Jako, że jesteśmy już nieco bardziej biegli w naszych implementacjach przy użyciu Angulara przygotujemy nieco ładniejszy formularz – a w dodatku responsywny. W tym celu zainstalujemy bootstrap - jest to biblioteka CSS z którą mieliśmy wielokrotnie styczność tworząc aplikacje ASP.NET.
Jeżeli chcecie dowiedzieć się nieco więcej odsyłam do działu ksiązek: Książka kwietnia - Bootstrap
Paczkę pobierzemy i zainstalujemy korzystając z menadżera pakietów npm przy użyciu poniższego polecenia:

npm install bootstrap --save  
Zrzut ekranu z aplikacji: Angular: instalacja bootstrapa
Zaintalujemy jeszcze drugą paczkę, która jest niezbędna do poprawnego działania biblioteki bootstrap:
npm jquery bootstrap --save  
Po poprawnej instalacji musimy nieznaczenie zmodyfikować plik angular.json. Będą to sekcje odpowiedzialne za style oraz skrypty, tj. styles oraz scripts:
"styles": [
    "./node_modules/bootstrap/dist/css/bootstrap.min.css",
    "src/styles.css"
],
"scripts": [
    "./node_modules/jquery/dist/jquery.min.js",
    "./node_modules/bootstrap/dist/js/bootstrap.min.js"
]
Jak sprawdzić czy wszystko działa poprawnie? Posłużymy się kodem znanym z ASP.NET, tj. użyjemy m.in. klasy container oraz jumbotron:
<div class="container">
    <div class="jumbotron">
        <h1>Witajcie</h1>
        <h2>Test: Angular w połączeniu z Bootstrap'em</h2>
    </div>
    <div class="panel panel-primary">
        <div class="panel-body">
            <h3>{{title}}</h3>
        </div>
    </div>
</div>
Tak powinna wyglądać teraz aplikacja: Angular 8: podstawowa konfiguracja

Podstawowa konfiguracja projektu jest już gotowa – możemy przystąpić do tworzenia kodu naszego formularza.

Template-Driven Form

W pierwszym kroku przygotujemy formularz z wykorzystaniem klas dostępnych w bootstrap:

<div class="row">
    <div class="col-xs-12 col-lg-12">
        <form>
            <div class="row">
                <div class="col-sm-12 col-md-4 form-group">
                    <label for="car-brand">Samochód</label>
                    <input id="car-brand" type="text" class="form-control">
                </div>
                <div class="col-sm-12 col-md-4 form-group">
                    <label for="car-description">Opis zamówienia</label>
                    <input id="car-description" type="text" class="form-control">
                </div>
                <div class="col-sm-12 col-md-4 form-group">
                    <label for="car-amount">Ilość</label>
                    <input id="car-amount" type="number" class="form-control">
                </div>
             </div>
             <hr>
             <div class="row">
                <div class="col-xs-12 col-lg-12">
                    <div class="row">
                        <div class="col-sm-12 col-md-4 form-group">
                        </div>
                        <div class="col-sm-12 col-md-4 form-group">
                            <button class="btn btn-success" type="submit">Dodaj</button>
                            <button class="btn btn-danger" type="button">Usuń</button>
                            <button class="btn btn-primary" type="button">Wyczyść</button>
                        </div>
                        <div class="col-sm-12 col-md-4 form-group">
                        </div>
                    </div>
                </div>
            </div>
        </form>
    </div>
</div>
Dodałem swoje własne style dla przycisków(długość oraz marginesy) oraz niezncznie zmodyfikowałem plik bootstrap.min.css ponieważ domyślna implementacja klasy row ustawia ujemne marginesy co w przypadku bazowego projektu powoduje iż kontroli są ucięte z lewej strony. Tak powinien wyglądać Wasz formularz na dużym: Angular: responsywność oraz na małym ekranie: Angular: responsywność

Podstawowy szablon HTML został utwrzony – dodaliśmy odpowiednie pola oraz przycisk dodawania, usuwania oraz czyszczenia danych formularza.

Użycie selektora form jest rozpoznawane przez Angular ponieważ dodaliśmy paczkę FormModule w pliku app.module.ts. To jednak nie wystarczy do powiązania pól naszego formularza z właściwościami w naszym modelu danych. W tym celu musimy dodać dyrektywę ngModel do naszego formularza. Musimy również dodać dyrektywę pozwalająca na przesyłanie danych, tj. ngSubmit. Nasz szablon ulegnie poniższym zmianą:

<div class="row">
    <div class="col-xs-12 col-lg-12">
        <form (ngSubmit)="onSubmit()">
            <div class="row">
                <div class="col-sm-12 col-md-4 form-group">
                    <label for="car-brand">Samochód</label>
                    <input id="car-brand" type="text" class="form-control" name="carBrand" ngModel>
                </div>
                <div class="col-sm-12 col-md-4 form-group">
                    <label for="car-description">Opis zamówienia</label>
                    <input id="car-description" type="text" class="form-control" name="carDescription" ngModel>
                </div>
                <div class="col-sm-12 col-md-4 form-group">
                    <label for="car-amount">Ilość</label>
                    <input id="car-amount" type="number" class="form-control" carAmount ngModel>
                </div>
             </div>
             <hr>
            <div class="row">
                <div class="col-xs-12 col-lg-12">
                    <div class="row">
                        <div class="col-sm-12 col-md-4 form-group">
                        </div>
                        <div class="col-sm-12 col-md-4 form-group">
                            <button class="btn btn-success" type="submit">Dodaj</button>
                            <button class="btn btn-danger" type="button">Usuń</button>
                            <button class="btn btn-primary" type="button">Wyczyść</button>
                        </div>
                        <div class="col-sm-12 col-md-4 form-group">
                        </div>
                    </div>
                </div>
            </div>
        </form>
    </div>
</div>
W tym momencie możemy również przypisać domyślne wartości do pól naszego formularza. Wystarczy dodać do dyrektywy ngModel oczekiwną wartość:
<input id="car-brand" type="text" class="form-control" name="carBrand" [ngModel]="'Audi RS6'">
Po zapisaniu zmian możemy zobaczyć daną wartość na naszym formularzu: Angular - domyślna wartość formularza

Zanim przejdziemy do przesłania danych formularza dokonamy jeszcze podstawowego sprawdzenia poprawności danych, w naszym przypadku będziemy wymagać wprowadzenia nazwy samochodu. Dodatkowo, aby uzyskać dostęp do danych formularza w naszym kodzie, dodamy lokalne odwołanie do formularza:

<div class="row">
    <div class="col-xs-12 col-lg-12">
        <!-- Zmienna lokalna naszego formularz to #f - możesz śmiało zdefiniować inną -->
        <form (ngSubmit)="onSubmit(f)" #f="ngForm">
            <div class="row">
                <div class="col-sm-12 col-md-4 form-group">
                    <label for="car-brand">Samochód</label>
                    <input id="car-brand" type="text" class="form-control" name="carBrand" [ngModel]="'Audi RS6'" required>
                </div>
                <div class="col-sm-12 col-md-4 form-group">
                    <label for="car-description">Opis zamówienia</label>
                    <input id="car-description" type="text" class="form-control" name="carDescription" ngModel required>
                </div>
                <div class="col-sm-12 col-md-4 form-group">
                    <label for="car-amount">Ilość</label>
                    <input id="car-amount" type="number" class="form-control" carAmount ngModel required>
                </div>
            </div>
            <hr>
            <div class="row">
                <div class="col-xs-12 col-lg-12">
                    <div class="row">
                        <div class="col-sm-12 col-md-4 form-group">
                        </div>
                        <div class="col-sm-12 col-md-4 form-group">
                            <button class="btn btn-success" type="submit">Dodaj</button>
                            <button class="btn btn-danger" type="button">Usuń</button>
                            <!-- Event Binding -->
                            <button class="btn btn-primary" type="button" (click)="onClear()">Wyczyść</button>
                        </div>
                        <div class="col-sm-12 col-md-4 form-group">
                        </div>
                    </div>
                </div>
            </div>
        </form>
    </div>
</div>
Strona forumlarza jest już gotowa. Dodajmy zatem implementacje metody onSubmit() oraz czyszczenie formularza. Funkcjonalność tę możemy osignąć przez odwołanie się do ViewChild po stronie kodu TypeScript. Spójrzcie poniżej na kod naszego komponentu:
import { Component, OnInit, ViewChild } from '@angular/core';
import { NgForm } from '@angular/forms';

@Component({
  selector: 'app-template-driven-form',
  templateUrl: './template-driven-form.component.html',
  styleUrls: ['./template-driven-form.component.css']
})
export class TemplateDrivenFormComponent implements OnInit {

  title: string;

  constructor() {
    this.title = "Wygląda na to, że wszystko działa jak należy!"
  }

  // Flaga static jest wymaga od wersji Angular 8
  // Zmiana ta związana jest cyklem życia strony oraz dostępnością danych
  // Więcej tutaj: https://medium.com/angular-in-depth/embrace-yourself-angular-8-is-coming-1bf187c8f0bf
  @ViewChild('f', { static: false }) carForm: NgForm;

  onSubmit(form: NgForm) {
    // Przekaliśmy naszą zmienną lokalną 'f' z formularza dzięki czemu mamy dostęp do poszczególnych pól
    console.log("Marka samochodu: " + form.value.carBrand);
    console.log("Opis samochodu: " + form.value.carDescription);
    console.log("Wielkość zamówienia: " + form.value.carAmount);
  }
  
  onClear() {
    this.carForm.reset();
  }
  ngOnInit() {
  }
}
Zanim przejdziemy do prezentacji dodajmy kod wyświetlający informacje, że wymagane pole nie zostało wprowadzone. Warto pamiętać, że ten komunikat nie powinien pojawić się od razu po załadowaniu strony a jedynie przy próbie przesłania danych przez formularz. Spójrzcie na poniższy kod oraz niezwykle istotne komentarze:
<div class="col-sm-12 col-md-4 form-group">
    <label for="car-brand">Samochód</label>
    <input id="car-brand" type="text" class="form-control" name="carBrand" [ngModel]="'Audi RS6'" required
        #carBrand="ngModel">
    </div>
<div class="row">
    <!-- Korzystamy tutaj z właściwości błędów dostępnych w Angularze
    'dirty' oraz 'touched' zapobiegają wyświetleniu błędów bez interakcji użytkownika, tj. podczas ładowania formularza
    Więcej możecie przeczytać w oficjalnej dokumentacji dostępnej pod adresem:
    https://angular.io/guide/form-validation#why-check-dirty-and-touched -->
    <div style="color:red" *ngIf="carBrand.errors">
        <p *ngIf="carBrand.errors.required && (carBrand.dirty || carBrand.touched)">
            Nazwa samochodu jest wymagana!
        </p>
    </div>
</div>
Kod wyświetlania błędów umieściłem poniżej całego formularza – zobaczycie to w poniższej prezentacji. Przejdźmy jeszcze do wyjaśnienia modyfikacji w kodzie.
Dodaliśmy element div pozwalający na wyświetlenie błędów. Dyrektywa *ngIf powoduje, że ta sekcja jest widoczna tylko, kiedy wystapią błędy poprawności w polu carBrand - w naszym wypadku sprawdzamy czy to pole jest puste ponieważ daliśmy właściwość required. Warto zwrócić uwagę na modyfikację naszej kontrolki celem wskazania pola carBrand na instację ngModel - daje nam to dostęp do wszelkich właściwości, które pozwalają na walidację wprowadzanych danych.

Spójrzmy teraz jak zachowuje się nasz formularz po wprowadzeniu tych wszystkich zmian:

Bardziej dociekliwe osoby w trakcie testów mogą zauważyć jedną niepokojącą rzecz. Mimo, iż walidacja po stronie formularza zwróciła nam informację o braku wymaganego pola cały formularz został wysłany wraz z uruchomieniem metody onSubmit(f).
Wszystko dlatego, że Angular automatycznie dodaje do formularza atrybut novalidate, kiedy korzystamy z podejścia opartego na szablonach. Tutaj niestety odbiegamy nieco od tematu dlatego dla osób zaintersowanych pozostawiam atrybut ngNativeValidate, którego celem jest włączenie walidacji po stronie przeglądarki.

A my przechodzimy do dalszej części wpisu, tzn. drugiego typu formularza.

Reactive Forms

Spojrzymy teraz na odmienne podejście, którym są formularze reaktywne. Znane są również pod nazwą formularzy sterowanych przez model. W tym podejściu zaczynamy od strony komponentu a następnie przygotowaną logikę wiążemy z szablonem HTML. W tym przypadku musimy zaimportować paczkę ReactiveFormsModule, która również zostana dodana do pliku app.module.ts. Obracam się w ramach tego samego projektu ale implementacja będzie w drugim komponencie przygotowanym we wprowadzeniu. Nasz plik app.module.ts przedstawia się teraz w poniższy sposób:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule, IterableDiffers } from '@angular/core';
// Dodajemy paczkę FormsModule oraz ReactiveFormsModule
import { FormsModule, ReactiveFormsModule } from '@angular/forms';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './Dashboard/dashboard.component';
import { TemplateDrivenFormComponent } from './Components/template-driven-form/template-driven-form.component';
// Dodajemy paczkę ReactiveFormComponent
import { ReactiveFormComponent } from './Components/reactive-form/reactive-form.component';

// Pamiętacje, że komponenty są deklarowane a moduły importowane
// Komponent: w Angularze jest częścią aplikacji z powiązanym szablonem. 
//            Ma zdefiniowany selektor pozwalający na renderowanie szablonu.
// Moduł: jest kolekcją komponentów, dyrektyw, itd. Zwykle służy do zbierania 
//       wielu małych części, które należą do jednej, logicznej całości.
@NgModule({
  declarations: [
    AppComponent,
    TemplateDrivenFormComponent,
    ReactiveFormComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    // Użycie paczki będzie możliwe w każdym komponencie
    FormsModule,
    // Paczka dla formularzy reaktywnych
    ReactiveFormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
Przerobimy przykład z poprzedniego podejścia. Tym razem zaczniemy od strony komponentu, gdzie stworzymy nową klasę FormGroup wewnątrz której zdefiniujemy wszystkie nasze kontrolki za pomocą klasy FormControl. Spójrzcie na plik reactive-form.component.ts:
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';

@Component({
  selector: 'app-reactive-form',
  templateUrl: './reactive-form.component.html',
  styleUrls: ['./reactive-form.component.css']
})
export class ReactiveFormComponent implements OnInit {
  carForm: FormGroup;

  constructor() { }

  ngOnInit() {
    this.initializeForm();
  }

  private initializeForm() {
    this.carForm = new FormGroup({
      'carBrand': new FormControl("Audi R8", Validators.required),
      'carDescription': new FormControl(null),
      'carAmount': new FormControl(null)
    });
  }

}
W pierwszym kroku utworzyliśmy instancję dla FormGroup, która została przypisana do naszego formularza, tj. carForm. Wewnątrz metody initializeForm() zdefiniowaliśmy kontrolki przy użyciu FormControl(…). Podobnie jak w pierwszym przykładzie zdefiniowaliśmy domyślną wartość kontrolki (nieco inną), w pozostałych przypadkach wartość została określona jako null.

Dodatkowo, zwróćcie uwagę na drugi argument tworzonej kontrolki, tj. Validators.required - podobie jak w pierwszym przypadku wymagamy, aby pole to było obowiązkowe.

Dochodzimy do punktu w których kod po stronie komponentu jest (praktycznie) ukończony. Angular jednak nie wie, które z naszych kontrolek odnoszą się do danych wejściowych szablonu HTML. W tym miejcu zrobimy użytek z dyrektywy formControlName, która po stronie szablonu połączy poszczególne kontrolki z kontrolkami po stronie komponentu zdefiniowanymi w instancji FormGroup:

<div class="row">
    <div class="col-xs-12 col-lg-12">
        <form [formGroup]="carForm" (ngSubmit)="onSubmit()">
            <div class="row">
                <div class="col-sm-12 col-md-4 form-group">
                    <label for="car-brand">Samochód</label>
                    <input id="car-brand" type="text" class="form-control" formControlName="carBrand">
                </div>
                <div class="col-sm-12 col-md-4 form-group">
                    <label for="car-description">Opis zamówienia</label>
                    <input id="car-description" type="text" class="form-control" formControlName="carDescription">
                </div>
                    <div class="col-sm-12 col-md-4 form-group">
                    <label for="car-amount">Ilość</label>
                    <input id="car-amount" type="number" class="form-control" formControlName="carAmount">
                </div>
            </div>
            <hr>
            <div class="row">
                <div class="col-xs-12 col-lg-12">
                    <div class="row">
                        <div class="col-sm-12 col-md-4 form-group">
                        </div>
                        <div class="col-sm-12 col-md-4 form-group">
                            <button class="btn btn-success" type="submit">Dodaj</button>
                            <button class="btn btn-danger" type="button">Usuń</button>
                            <!-- Event Binding -->
                            <button class="btn btn-primary" type="button" (click)="onClear()">Wyczyść</button>
                        </div>
                        <div class="col-sm-12 col-md-4 form-group">
                        </div>
                    </div>
                </div>
            </div>
        </form>
    </div>
</div>
Oczywiście musimy pamietać, żeby używać tej samej nazwy dla formControlName jak w przypadku kodu komponentu. Dyrektywa fromGroup wskazuje Angularowi, aby użył naszej definicji FormGroup zamiast tworzyć ją automatycznie. Dzięki tej dyrektywie dochodzi do automatycznej synchronizacji formularza z kodem napisanym w TypeScript.

Dodamy jeszcze komunikat o błędzie tak, żeby mieć dokładne porównanie z pierwszym przykładem:

<div *ngIf="!carForm.get('carBrand').valid && carForm.get('carBrand').touched" style="color:red">
    Nazwa samochodu jest wymagana!
</div>
W ramach walidacji oznaczmy jeszcze pole carDescription jako wymagane o minimalnej długości opisu. Na koniec wyślemy obiekt formularza oraz wyświetlimy informacje w oknie konsoli przeglądarki:
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';

@Component({
  selector: 'app-reactive-form',
  templateUrl: './reactive-form.component.html',
  styleUrls: ['./reactive-form.component.css']
})
export class ReactiveFormComponent implements OnInit {
  carForm: FormGroup;

  constructor() { }

  ngOnInit() {
    this.initializeForm();
  }

  onSubmit() {
    console.log(this.carForm);
  }

  private initializeForm() {
    this.carForm = new FormGroup({
      'carBrand': new FormControl("Audi R8", Validators.required),
      'carDescription': new FormControl(null, [Validators.required, Validators.minLength(10)]),
      'carAmount': new FormControl(null)
    });
  }
}
Dodamy również odpowiednie komunikaty po stronie szablonu HTML:
<div *ngIf="!carForm.get('carDescription').valid && carForm.get('carDescription').touched" style="color:red">
    Opis o długości większej niż 10 znaków jest wymagany!
</div>
Aby zabezpieczyć formularz przed wysłaniem (o ile dane są niepoprawne) wykorzystamy poniższy zapis:
<button class="btn btn-success" type="submit" [disabled]="!carForm.valid">Dodaj</button>
Zablokowaliśmy możliwość wysłania formularza jeżeli wprowadzone dane nie są poprawne. Sprawdźmy jak prezentuje się całość:

Podsumowanie

Był to niewątpliwie najdłuższy z wpisów dotyczących Angulara. Z jednej strony: tego wymagało porównanie, z drugiej: chciałem wykorzystać jak najwięcej z poprzednich lekcji. Jak widzicie w przypadku formularzy reaktywnych cała logika walidacji znajduje się po stronie kodu komponentu. Pozwala to na zachowanie czystszego kodu szablonu oraz wpływa na łatwość pisania testów jednostkowych – cała logika jest napisana w TypeScript.

Kolejny wpis będzie równie obszerny, będzie dotyczył niezwykle ważnego zagadanienia jakim jest routing.