Walidacja w szablonie
Formularz, który przyjmie pusty e-mail i wiek 250 lat, to formularz do poprawki. Angular ma gotowe walidatory, które włącza się zwykłymi atrybutami HTML: required, minlength, pattern, min, max, email. Twoje zadanie to pokazać błędy w odpowiednim momencie i nie zapisać złych danych.
Czego się dziś nauczysz
- Włączysz walidatory atrybutami
required,minlength,maxlength,pattern,min,maxiemail. - Odczytasz stan pola przez
#x="ngModel":invalid,touched,dirty,errors. - Pokażesz błąd dopiero po opuszczeniu pola (
invalid && touched). - Dobierzesz komunikat do rodzaju błędu, odczytując klucz z
errors. - Użyjesz klas Bootstrapa
is-invalid,is-validiinvalid-feedback. - Zablokujesz zapis niepoprawnego formularza (
f.invalid) i pokażesz wszystkie błędy naraz.
Przygotowanie: lekcje 17–25 — znasz kontrolki, <form>, (ngSubmit) i #f="ngForm". Przewidywany czas: 2 × 45 min z zadaniami.
Dobre dane i dobre komunikaty
W poprzednich lekcjach poprawność sprawdzaliśmy ręcznie w computed: długość, zakres, małpa w adresie. To działa, ale przy dziesięciu polach i kilku warunkach na każde robi się z tego gąszcz. Angular robi to za Ciebie: każde pole z ngModel wie, czy jest poprawne, jakie ma błędy i czy użytkownik już go dotknął.
required nie blokuje wysłania
Gdy w komponencie jest FormsModule, Angular wyłącza wbudowane dymki walidacji przeglądarki. Formularz z pustym polem required i tak się wyśle, a (ngSubmit) się wywoła. To Ty decydujesz, co wtedy zrobić — sprawdzając f.invalid.
| Atrybut | Warunek | Klucz w errors |
|---|---|---|
required | pole nie może być puste (checkbox — zaznaczony) | required |
minlength="3" | co najmniej 3 znaki | minlength z requiredLength, actualLength |
maxlength="20" | najwyżej 20 znaków | zwykle nie wystąpi — przeglądarka nie pozwoli wpisać więcej |
pattern="[0-9]{9}" | tekst pasuje do wzoru | pattern |
min="13", max="19" | zakres liczby (pole number) | min / max |
email | poprawny adres e-mail | email |
Stan pola i moment pokazania błędu
Zapis #poleImie="ngModel" to zmienna szablonowa, która wskazuje nie na element HTML, ale na kontrolkę Angulara — tę samą dyrektywę NgModel, którą poznałeś w lekcji 17.
| Właściwość | Znaczenie |
|---|---|
invalid / valid | pole ma błędy / jest poprawne |
touched | użytkownik wszedł do pola i z niego wyszedł |
dirty | użytkownik coś zmienił |
errors | obiekt z błędami albo null, gdy ich nie ma |
Warunek invalid && touched pokazuje błąd dopiero, gdy użytkownik wyjdzie z pola. Czerwony komunikat przy pustym formularzu, zanim ktokolwiek zaczął pisać, tylko denerwuje. Bootstrap ma gotowe klasy: is-invalid na polu (czerwona ramka) i <div class="invalid-feedback"> zaraz pod polem — komunikat pojawia się sam, gdy pole ma is-invalid.
Bezpieczny dostęp ?.
l.errors?.['required']: gdy errors jest null (brak błędów), całe wyrażenie daje undefined zamiast błędu. Klucz w nawiasach kwadratowych i apostrofach, bo tak się sięga do pola obiektu po nazwie.
Pole obowiązkowe: required
Jedno pole z walidatorem required. Pod polem pokazujemy na żywo stan kontrolki, żebyś widział, kiedy zmieniają się invalid i touched.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly imie = signal('');
}
<div class="p-3" style="width: 24rem">
<label class="form-label" for="imie">Imię *</label>
<input id="imie" name="imie" class="form-control" required
[(ngModel)]="imie" #poleImie="ngModel"
[class.is-invalid]="poleImie.invalid && poleImie.touched"
[class.is-valid]="poleImie.valid && poleImie.touched">
<div class="invalid-feedback">Podaj imię.</div>
<p class="small text-secondary mt-3">
invalid: {{ poleImie.invalid }} · touched: {{ poleImie.touched }} · dirty: {{ poleImie.dirty }}
</p>
</div>
na starcie: invalid: true · touched: false · dirty: false (bez czerwieni)
kliknięcie w pole i Tab: czerwona ramka, „Podaj imię.”
po wpisaniu „Ola”: zielona ramka
Zatrzymaj się
Zmienna szablonowa nazywa się poleImie, a sygnał imie. Dlaczego nie mogą mieć tej samej nazwy?
Kilka walidatorów i właściwy komunikat
Login: obowiązkowy, 4–12 znaków, tylko małe litery i cyfry. Każdy walidator dodaje do errors własny klucz, więc możemy pokazać dokładnie ten komunikat, który pasuje.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { JsonPipe } from '@angular/common';
@Component({
selector: 'app-demo',
imports: [FormsModule, JsonPipe],
templateUrl: './demo.html'
})
export class Demo {
readonly login = signal('');
}
<div class="p-3" style="width: 26rem">
<label class="form-label" for="login">Login *</label>
<input id="login" name="login" class="form-control"
required minlength="4" maxlength="12" pattern="[a-z0-9]+"
[(ngModel)]="login" #l="ngModel"
[class.is-invalid]="l.invalid && l.touched">
@if (l.invalid && l.touched) {
<div class="invalid-feedback">
@if (l.errors?.['required']) { Login jest obowiązkowy. }
@if (l.errors?.['minlength']) {
Za krótki: {{ l.errors?.['minlength'].actualLength }} z {{ l.errors?.['minlength'].requiredLength }} znaków.
}
@if (l.errors?.['pattern']) { Dozwolone są tylko małe litery i cyfry. }
</div>
}
<pre class="bg-light p-2 mt-3 small">errors = {{ l.errors | json }}</pre>
</div>
(puste) → Login jest obowiązkowy.
ab → Za krótki: 2 z 4 znaków.
Abcd → Dozwolone są tylko małe litery i cyfry.
abcd → brak błędu, errors = null
Ramka na dole pokazuje dokładnie, co Angular wie o błędach. Do podglądu potrzebny jest JsonPipe w imports.
Zatrzymaj się
Przy pustym polu widać tylko błąd required, a nie minlength, choć pusty tekst jest krótszy niż 4 znaki. Jak myślisz, dlaczego Angular tak to zaprojektował?
Zakres liczby i adres e-mail
Wiek uczestnika od 13 do 19 lat (min, max na polu liczbowym) i e-mail z walidatorem email. Komunikat przy wieku podaje dozwolony zakres odczytany z błędu.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly wiek = signal<number | null>(null);
readonly email = signal('');
}
<div class="p-3" style="width: 26rem">
<div class="mb-3">
<label class="form-label" for="wiek">Wiek *</label>
<input id="wiek" name="wiek" type="number" class="form-control"
required min="13" max="19" [(ngModel)]="wiek" #w="ngModel"
[class.is-invalid]="w.invalid && w.touched">
<div class="invalid-feedback">
@if (w.errors?.['required']) { Podaj wiek. }
@if (w.errors?.['min']) { Minimalny wiek to {{ w.errors?.['min'].min }} lat. }
@if (w.errors?.['max']) { Maksymalny wiek to {{ w.errors?.['max'].max }} lat. }
</div>
</div>
<div class="mb-3">
<label class="form-label" for="email">E-mail *</label>
<input id="email" name="email" type="email" class="form-control"
required email [(ngModel)]="email" #e="ngModel"
[class.is-invalid]="e.invalid && e.touched">
<div class="invalid-feedback">
@if (e.errors?.['required']) { Podaj adres e-mail. }
@if (e.errors?.['email']) { To nie jest poprawny adres e-mail. }
</div>
</div>
</div>
wiek 12 → Minimalny wiek to 13 lat.
wiek 25 → Maksymalny wiek to 19 lat.
e-mail „ala” → To nie jest poprawny adres e-mail.
Komunikaty są w invalid-feedback bez @if dookoła — Bootstrap sam je chowa, gdy pole nie ma is-invalid. Dlatego każde pole razem ze swoim komunikatem siedzi we własnym <div class="mb-3">: Bootstrap pokazuje każdy invalid-feedback stojący za błędnym polem w tym samym elemencie. Bez osobnych div-ów błąd wieku pokazałby też komunikat pod e-mailem.
email przepuści ala@szkola
Walidator email uzna za poprawny adres bez kropki w domenie — jest zgodny ze standardem, choć w praktyce nie istnieje. Jeśli wymagasz domeny z kropką, dołóż pattern.
Zatrzymaj się
Atrybuty min i max pojawiły się już w lekcji 19 i wtedy nie chroniły przed wpisaniem złej liczby. Co zmieniło się teraz?
Cały formularz: blokada zapisu
Trzy pola z walidacją w jednym formularzu. Po kliknięciu „Zapisz” sprawdzamy f.invalid. Jeśli są błędy — oznaczamy wszystkie pola jako dotknięte (markAllAsTouched), żeby od razu było widać, co poprawić. Jeśli nie — zapisujemy. Typ NgForm importujesz z @angular/forms.
import { Component, signal } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly imie = signal('');
readonly telefon = signal('');
readonly regulamin = signal(false);
readonly wynik = signal('');
zapisz(f: NgForm): void {
if (f.invalid) {
f.form.markAllAsTouched();
this.wynik.set('Popraw zaznaczone pola.');
return;
}
this.wynik.set('Zapisano: ' + this.imie() + ', tel. ' + this.telefon());
}
}
<div class="p-3" style="width: 26rem">
<form #f="ngForm" (ngSubmit)="zapisz(f)">
<div class="mb-3">
<label class="form-label" for="imie">Imię i nazwisko *</label>
<input id="imie" name="imie" class="form-control" required minlength="5"
[(ngModel)]="imie" #i="ngModel" [class.is-invalid]="i.invalid && i.touched">
<div class="invalid-feedback">Podaj imię i nazwisko (co najmniej 5 znaków).</div>
</div>
<div class="mb-3">
<label class="form-label" for="tel">Telefon rodzica *</label>
<input id="tel" name="telefon" class="form-control" required pattern="[0-9]{9}"
placeholder="9 cyfr bez spacji"
[(ngModel)]="telefon" #t="ngModel" [class.is-invalid]="t.invalid && t.touched">
<div class="invalid-feedback">Numer telefonu to 9 cyfr.</div>
</div>
<div class="form-check mb-3">
<input id="reg" name="regulamin" class="form-check-input" type="checkbox" required
[(ngModel)]="regulamin" #r="ngModel" [class.is-invalid]="r.invalid && r.touched">
<label class="form-check-label" for="reg">Akceptuję regulamin *</label>
<div class="invalid-feedback">Akceptacja regulaminu jest wymagana.</div>
</div>
<button class="btn btn-primary" type="submit">Zapisz</button>
<p class="mt-3">{{ wynik() }}</p>
<p class="small text-secondary">Formularz poprawny: {{ f.valid }}</p>
</form>
</div>
„Zapisz” przy pustym formularzu: trzy czerwone pola, „Popraw zaznaczone pola.”
po poprawnym wypełnieniu: Zapisano: Jan Nowak, tel. 600100200
Na checkboxie required oznacza „musi być zaznaczony”. Ostatni wiersz pokazuje f.valid — zmienia się na true dopiero przy komplecie.
Zatrzymaj się
Przycisk „Zapisz” nie jest tu blokowany przez [disabled]="f.invalid". Jaka jest przewaga tego rozwiązania dla użytkownika, który nie wie, czego brakuje?
Zestawienie elementów
| Element | Znaczenie | Uwagi |
|---|---|---|
required, minlength, pattern, min, max, email | walidatory | Atrybuty HTML. |
#x="ngModel" | uchwyt do kontrolki | Inna nazwa niż sygnał. |
x.invalid, x.touched, x.dirty | stan pola | Błąd: invalid && touched. |
x.errors?.['klucz'] | konkretny błąd | Bezpieczny dostęp ?.. |
is-invalid, is-valid | czerwona / zielona ramka | Bootstrap. |
invalid-feedback | komunikat pod polem | Pole i komunikat w jednym div. |
f.invalid, f.valid | stan całego formularza | Z #f="ngForm". |
f.form.markAllAsTouched() | pokaż wszystkie błędy | Po próbie zapisu. |
NgForm | typ formularza | Import z @angular/forms. |
Zanim utkniesz
| Zapis | Problem |
|---|---|
#imie="ngModel" i sygnał imie | Konflikt nazw: w szablonie imie oznacza zmienną szablonową, a nie sygnał. Nadaj zmiennej inną nazwę. |
l.errors['required'] bez ?. | Błąd kompilacji — errors może być null. Pisz l.errors?.['required']. |
invalid-feedback nie pojawia się mimo błędu | Komunikat musi stać za polem z is-invalid i w tym samym elemencie nadrzędnym. |
Kilka pól z komunikatami w jednym div | Błąd w pierwszym polu pokazuje komunikaty wszystkich pól poniżej. Każde pole — własny div. |
| Komunikaty widoczne od samego początku | Brak warunku touched. |
Liczenie na to, że required zablokuje wysłanie | (ngSubmit) się wywoła — sprawdź f.invalid. |
pattern="[0-9]{9}", a użytkownik wpisuje „600 100 200” | Błąd wzorca przez spacje. Napisz w placeholder, w jakiej postaci podać dane, albo usuń spacje w (ngModelChange) (lekcja 25). |
Zadania
Pole „Nick” z walidatorami required i minlength="3". Dwa różne komunikaty i zielona ramka, gdy pole jest poprawne.
Kryterium sukcesu: komunikaty pojawiają się dopiero po opuszczeniu pola i zawsze pasują do błędu.
Podpowiedź
Przykład 2 z mniejszą liczbą walidatorów.
Pole „Numer legitymacji” — dokładnie 6 cyfr. Komunikat ma mówić, ile cyfr wpisano, gdy jest ich za mało, i że dozwolone są tylko cyfry, gdy pojawi się litera.
Kryterium sukcesu: dla „123” widać „wpisano 3 z 6 cyfr”, dla „12a456” — komunikat o cyfrach, dla „123456” — zielona ramka.
Podpowiedź
required minlength="6" maxlength="6" pattern="[0-9]*" i odczyt errors?.['minlength'].actualLength.
Formularz: login (4–16 znaków, małe litery i cyfry), e-mail, hasło (co najmniej 8 znaków) i powtórzenie hasła. Przycisk „Załóż konto” działa jak w przykładzie 4.
Kryterium sukcesu: każde pole ma własne komunikaty; przy różnych hasłach pod drugim polem pojawia się „Hasła się różnią”, a zapis się nie udaje.
Podpowiedź
Zgodności haseł nie sprawdzi żaden atrybut. Policz ją w computed z dwóch sygnałów i dołóż do warunku w metodzie: if (f.invalid || !haslaZgodne()).
Rozbuduj przykład 4: nad przyciskiem pokaż „Do poprawienia: X pól”, liczone na podstawie stanu kontrolek, a przycisk niech ma napis „Zapisz (3 błędy)” albo „Zapisz”, gdy wszystko jest dobrze.
Kryterium sukcesu: liczba zmienia się przy poprawianiu kolejnych pól i spada do zera przy poprawnym formularzu.
Podpowiedź
W szablonie: (i.invalid ? 1 : 0) + (t.invalid ? 1 : 0) + (r.invalid ? 1 : 0) — albo metoda w klasie, która dostaje f i liczy Object.keys(f.controls) z błędami.
Formularz rezerwacji pracowni: nazwisko nauczyciela (obowiązkowe), sala (select z opcją „— wybierz —”, obowiązkowy), data (obowiązkowa), liczba uczniów (1–16) i cel zajęć (textarea, 10–200 znaków). Udane rezerwacje trafiają na listę pod formularzem.
Kryterium sukcesu: niepoprawnego formularza nie da się zapisać, wszystkie błędy widać po kliknięciu przycisku, a poprawna rezerwacja pojawia się na liście i czyści formularz.
Podpowiedź
required na <select> działa z opcją [ngValue]="null". Po zapisie ustaw sygnały na wartości początkowe i wywołaj f.form.markAsUntouched(), żeby czerwone ramki nie pojawiły się od razu.
Formularz: imię i nazwisko (min. 5 znaków), PESEL (11 cyfr), telefon rodzica (9 cyfr, spacje usuwane w locie), wiek (13–19), rozmiar koszulki (radio, obowiązkowy) i zgoda rodzica (checkbox, wymagany). Po wysłaniu poprawnego formularza pokaż podsumowanie w karcie i przycisk „Wypełnij ponownie”; przy błędach — ramkę alert-danger z liczbą błędnych pól.
Kryterium sukcesu: wszystkie walidatory działają, spacje w telefonie znikają podczas pisania, a podsumowanie pojawia się tylko dla poprawnych danych.
Podpowiedź
Telefon w wersji rozpisanej z lekcji 25 plus pattern="[0-9]{9}". Radio z required: atrybut wystarczy na jednym przycisku grupy.
Co trzeba zapamiętać
- Walidatory włączasz atrybutami:
required,minlength,maxlength,pattern,min,max,email. #x="ngModel"daje dostęp do stanu pola:invalid,touched,dirty,errors.- Błąd pokazujesz przy
invalid && touched, klasamiis-invalidiinvalid-feedback. - Komunikat dobierasz po kluczu w
errors, z bezpiecznym dostępem?.. (ngSubmit)wywołuje się także dla błędnego formularza: sprawdźf.invalidi w razie potrzebymarkAllAsTouched().
Sprawdź się: użytkownik kliknął „Zapisz”, nic się nie stało i nie wie dlaczego. Jakie dwie rzeczy dodasz, żeby wiedział, co poprawić?
Dokumentacja: angular.dev — walidacja formularzy.