Przyciski i wysyłka formularza
Do tej pory pola stały luzem, a przycisk miał (click). Prawdziwy formularz to znacznik <form>: wysyła się Enterem, ma przycisk „Wyślij” i czasem „Wyczyść”. Zobaczysz, jak Angular przejmuje wysyłanie i dlaczego przycisk bez atrybutu type potrafi napsuć krwi.
Czego się dziś nauczysz
- Wyjaśnisz, co robi zwykły formularz HTML po wysłaniu i dlaczego w Angularze strona się nie przeładowuje.
- Obsłużysz wysłanie formularza zdarzeniem
(ngSubmit)— przyciskiem i klawiszem Enter. - Dobierzesz typ przycisku:
submitalbobutton. - Sprawdzisz kompletność danych w metodzie i pokażesz komunikat.
- Zapiszesz wysłane dane na liście i wyczyścisz formularz.
- Podejrzysz obiekt formularza przez
#f="ngForm"if.value.
Przygotowanie: lekcje 17–23 — umiesz połączyć z sygnałem każdą kontrolkę. Przewidywany czas: 45 min z zadaniami.
Formularz to coś więcej niż pola
Zwykły formularz HTML (<form>) po wysłaniu przeładowuje stronę i dopisuje wartości pól do adresu — tak działały strony, zanim pojawił się JavaScript. W aplikacji Angulara przeładowanie oznaczałoby utratę wszystkich danych w sygnałach. Do tego każdy przycisk w <form> domyślnie wysyła formularz — także ten, który miał tylko wyczyścić pola.
Gdy w komponencie jest FormsModule, Angular podpina się pod każdy znacznik <form>: blokuje przeładowanie i daje zdarzenie (ngSubmit). Ty decydujesz tylko, co zrobić z danymi — i które przyciski mają wysyłać.
| Zasada | Szczegóły |
|---|---|
(ngSubmit) na <form> | nie (click) na przycisku — wtedy zadziała i przycisk, i Enter w polu tekstowym |
type="submit" | przycisk, który wysyła; jeden na formularz, zwykle na końcu; typ domyślny, gdy nie napiszesz żadnego |
type="button" | przycisk, który nie wysyła: „Wyczyść”, „Pokaż hasło”, „+1” — z własnym (click) |
name przy każdym polu | wewnątrz <form> pole z ngModel bez name daje błąd NG01352 w konsoli |
(ngSubmit) — przycisk i Enter
Formularz z jednym polem. Wysłać go można przyciskiem albo Enterem w polu. Licznik pokazuje, ile razy formularz został wysłany — i że strona się nie przeładowuje.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly nick = signal('');
readonly wyslano = signal(0);
readonly ostatni = signal('');
zapisz(): void {
this.wyslano.update(n => n + 1);
this.ostatni.set(this.nick());
}
}
<div class="p-3" style="width: 24rem">
<form (ngSubmit)="zapisz()">
<label class="form-label" for="nick">Nick</label>
<input id="nick" name="nick" class="form-control mb-2" [(ngModel)]="nick">
<button class="btn btn-primary" type="submit">Zapisz</button>
</form>
<p class="mt-3">Wysłano {{ wyslano() }} razy. Ostatnio: „{{ ostatni() }}”</p>
</div>
Wysłano 1 razy. Ostatnio: „Pixel”
Przycisk robi to samo. Strona się nie przeładowuje, choć nigdzie w kodzie nie ma nic, co by to blokowało — załatwia to FormsModule.
Zatrzymaj się
Usuń na chwilę FormsModule z imports i [(ngModel)] z pola. Wyślij formularz Enterem. Co się stało ze stroną i z licznikiem? Przywróć oba.
Przycisk, który wysyła za dużo
Ten sam formularz z dwoma dodatkowymi przyciskami: „Losuj nick” bez atrybutu type i „Wyczyść” z type="button". Obserwuj licznik wysłań.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly nick = signal('');
readonly wyslano = signal(0);
readonly nicki = ['Pixel', 'Bajt', 'Kursor', 'Ping'];
zapisz(): void {
this.wyslano.update(n => n + 1);
}
losuj(): void {
const nr = Math.floor(Math.random() * this.nicki.length);
this.nick.set(this.nicki[nr]);
}
}
<div class="p-3" style="width: 28rem">
<form (ngSubmit)="zapisz()">
<label class="form-label" for="nick">Nick</label>
<input id="nick" name="nick" class="form-control mb-2" [(ngModel)]="nick">
<button class="btn btn-outline-warning me-2" (click)="losuj()">Losuj nick (bez type)</button>
<button class="btn btn-outline-secondary me-2" type="button" (click)="nick.set('')">Wyczyść</button>
<button class="btn btn-primary" type="submit">Zapisz</button>
</form>
<p class="mt-3">Wysłano formularz: {{ wyslano() }} razy</p>
</div>
po „Wyczyść”: Wysłano formularz: 0 razy
po „Losuj nick”: Wysłano formularz: 1 razy ← a nikt nie klikał Zapisz
Przycisk bez type zrobił swoje i przy okazji wysłał formularz. W prawdziwej aplikacji wysłałby niekompletne zgłoszenie do serwera. Zasada na egzamin i na zawsze: każdy przycisk w formularzu ma jawny type.
Zatrzymaj się
Dopisz type="button" do przycisku „Losuj”. Ile przycisków typu submit zostało w formularzu i czy tak powinno być?
Niepełny formularz — komunikat zamiast zapisu
Metoda wywołana przez (ngSubmit) najpierw sprawdza, czy dane są kompletne. Jeśli nie — zamiast zapisu pokazuje komunikat, co trzeba uzupełnić.
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly imie = signal('');
readonly klasa = signal<string | null>(null);
readonly komunikat = signal('');
readonly kompletne = computed(() => this.imie().trim() !== '' && this.klasa() !== null);
zapisz(): void {
if (!this.kompletne()) {
this.komunikat.set('Uzupełnij wszystkie pola.');
return;
}
this.komunikat.set('Zapisano: ' + this.imie().trim() + ' (' + this.klasa() + ')');
}
}
<div class="p-3" style="width: 24rem">
<form (ngSubmit)="zapisz()">
<label class="form-label" for="imie">Imię</label>
<input id="imie" name="imie" class="form-control mb-2" [(ngModel)]="imie">
<label class="form-label" for="klasa">Klasa</label>
<select id="klasa" name="klasa" class="form-select mb-3" [(ngModel)]="klasa">
<option [ngValue]="null" disabled>— wybierz —</option>
<option [ngValue]="'1TP'">1TP</option>
<option [ngValue]="'2TP'">2TP</option>
<option [ngValue]="'3TP'">3TP</option>
</select>
<button class="btn btn-primary" type="submit">Zapisz</button>
</form>
<p class="mt-3">{{ komunikat() }}</p>
</div>
imię „Ola”, bez klasy, Enter: Uzupełnij wszystkie pola.
imię „Ola”, klasa 2TP, Enter: Zapisano: Ola (2TP)
A gdyby zablokować przycisk?
Druga droga to [disabled]="!kompletne()" na przycisku submit. Przeglądarka nie wyśle wtedy formularza ani przyciskiem, ani Enterem — ale użytkownik nie wie, czego brakuje. Komunikat jest przyjaźniejszy. Sprawdzenie w metodzie zostawiaj zawsze: przycisk da się odblokować w narzędziach przeglądarki, a formularz może kiedyś wysłać inny kod.
Zatrzymaj się
[ngValue]="'1TP'" ma apostrofy wewnątrz cudzysłowów. Co by się stało bez apostrofów?
Wysłane dane na liście i czyszczenie formularza
Pełny cykl: wypełnij, wyślij, zobacz wpis na liście, formularz jest znowu pusty. Dodatkowo podglądamy obiekt f.value, który Angular buduje z pól formularza na podstawie ich atrybutów name.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { JsonPipe } from '@angular/common';
interface Zgloszenie {
nick: string;
gra: string;
zgodaRodzica: boolean;
}
@Component({
selector: 'app-demo',
imports: [FormsModule, JsonPipe],
templateUrl: './demo.html'
})
export class Demo {
readonly gry = ['Tetris', 'Pac-Man', 'Bomberman'];
readonly nick = signal('');
readonly gra = signal('Tetris');
readonly zgoda = signal(false);
readonly lista = signal<Zgloszenie[]>([]);
zapisz(): void {
if (this.nick().trim() === '') {
return;
}
const nowe: Zgloszenie = { nick: this.nick().trim(), gra: this.gra(), zgodaRodzica: this.zgoda() };
this.lista.update(l => [...l, nowe]);
this.wyczysc();
}
wyczysc(): void {
this.nick.set('');
this.gra.set('Tetris');
this.zgoda.set(false);
}
}
<div class="p-3">
<div class="row g-4" style="max-width: 56rem">
<div class="col-md-6">
<form #f="ngForm" (ngSubmit)="zapisz()">
<input name="nick" class="form-control mb-2" placeholder="Nick" [(ngModel)]="nick">
<select name="gra" class="form-select mb-2" [(ngModel)]="gra">
@for (g of gry; track g) { <option [ngValue]="g">{{ g }}</option> }
</select>
<div class="form-check mb-3">
<input id="zgoda" name="zgodaRodzica" class="form-check-input" type="checkbox" [(ngModel)]="zgoda">
<label class="form-check-label" for="zgoda">Mam zgodę rodzica</label>
</div>
<button class="btn btn-outline-secondary me-2" type="button" (click)="wyczysc()">Wyczyść</button>
<button class="btn btn-primary" type="submit">Zgłoś</button>
</form>
<pre class="bg-light p-2 mt-3 small">f.value = {{ f.value | json }}</pre>
</div>
<div class="col-md-6">
<h6>Zgłoszenia ({{ lista().length }})</h6>
<ul class="list-group">
@for (z of lista(); track $index) {
<li class="list-group-item">
{{ z.nick }} — {{ z.gra }}
@if (!z.zgodaRodzica) { <span class="badge text-bg-warning">brak zgody</span> }
</li>
} @empty {
<li class="list-group-item text-secondary">Brak zgłoszeń.</li>
}
</ul>
</div>
</div>
</div>
f.value = {
"nick": "",
"gra": "Tetris",
"zgodaRodzica": false
}
#f="ngForm" to zmienna szablonowa, ale zamiast elementu HTML wskazuje obiekt formularza Angulara. Klucze w f.value to wartości atrybutów name, stąd zgodaRodzica, choć sygnał nazywa się zgoda. W lekcji 26 ten sam obiekt powie, czy wszystkie pola są poprawne (f.invalid).
Zatrzymaj się
W track listy jest $index, a nie z.nick. Dlaczego? Co by się stało, gdyby dwie osoby podały ten sam nick?
Zestawienie elementów
| Element | Znaczenie | Uwagi |
|---|---|---|
<form (ngSubmit)="metoda()"> | wysyłka formularza | Przycisk i Enter; wymaga FormsModule. |
type="submit" | przycisk wysyłający | Domyślny typ w <form>. |
type="button" | przycisk pomocniczy | Z własnym (click). |
#f="ngForm" | obiekt formularza | f.value, w lekcji 26 f.invalid. |
f.value | wartości pól według name | Do podglądu przez json. |
Math.random(), Math.floor | losowa liczba 0–1, część całkowita | Losowy element tablicy. |
| NG01352 | pole bez name w <form> | Błąd w konsoli przeglądarki. |
Zanim utkniesz
| Zapis | Problem |
|---|---|
Przycisk bez type w formularzu | Każde kliknięcie wysyła formularz. Przyciski pomocnicze — type="button". |
(click)="zapisz()" na przycisku zamiast (ngSubmit) | Enter w polu tekstowym nie wywoła metody — albo wywoła ją dwa razy, jeśli dopiszesz oba. |
Pole z ngModel bez name w <form> | NG01352: If ngModel is used within a form tag, either the name attribute must be set… |
Tylko [disabled] na przycisku, bez sprawdzenia w metodzie | Działa, dopóki ktoś nie odblokuje przycisku w F12 albo nie wywoła metody inaczej. Warunek sprawdzaj także w metodzie. |
(ngSubmit) bez FormsModule | Metoda się nie wykonuje, a strona się przeładowuje — ngSubmit istnieje tylko dzięki FormsModule. |
Dwa pola z tym samym name w formularzu | Formularz traktuje je jak jedno pole i wartości się mieszają. Każde pole — inna nazwa. |
Zadania
Formularz z polem „Szukaj” i przyciskiem. Po wysłaniu (także Enterem) pod spodem pojawia się „Szukano: …”, a pole się czyści.
Kryterium sukcesu: Enter i przycisk działają tak samo; strona się nie przeładowuje.
Podpowiedź
Przykład 1 plus set('') po zapisaniu wyniku.
Formularz logowania ma trzy przyciski bez atrybutu type: „Pokaż hasło”, „Wyczyść” i „Zaloguj”. Odtwórz go z licznikiem wysłań i sprawdź, które przyciski wysyłają formularz. Potem popraw atrybuty tak, żeby wysyłał tylko „Zaloguj”.
Kryterium sukcesu: po poprawce licznik rośnie tylko po „Zaloguj” i po Enterze w polu.
Podpowiedź
Dwa przyciski dostają type="button", jeden — type="submit".
Formularz: imię i wpis (textarea), przyciski „Wyczyść” i „Dodaj wpis”. Wpisy wyświetlaj od najnowszego, z godziną dodania.
Kryterium sukcesu: „Wyczyść” nie dodaje wpisu; pusty wpis nie trafia na listę i daje komunikat; najnowszy jest na górze.
Podpowiedź
Nowy element na początku tablicy: [nowy, ...lista]. Godzinę zapisz jako new Date() i pokaż pipe’em date:'HH:mm'.
Formularz zgłoszenia na turniej: nick (text), wiek (number), gra (select) i zgoda (checkbox). Pod formularzem ramka z f.value | json. Zmień atrybuty name tak, żeby klucze w podglądzie były po angielsku (nickname, age, game, consent), a sygnały zostały po polsku.
Kryterium sukcesu: podgląd pokazuje angielskie klucze i aktualne wartości; zmiana sygnałów nie była potrzebna.
Podpowiedź
Klucze w f.value pochodzą wyłącznie z name.
Rozbuduj przykład 4: najwyżej 5 zgłoszeń. Po piątym formularz znika i zamiast niego pojawia się komunikat „Lista pełna”. Każdy wpis na liście ma przycisk „Usuń”, który zwalnia miejsce i przywraca formularz.
Kryterium sukcesu: szóste zgłoszenie jest niemożliwe; usunięcie wpisu przywraca formularz.
Podpowiedź
computed sprawdzający długość listy i @if wokół całego <form>. Usuwanie po numerze: filter((z, i) => i !== nr).
Formularz rejestracji: login, e-mail, hasło (z przyciskiem „Pokaż” — pamiętaj o typie!), powtórzone hasło i zgoda na regulamin. Po kliknięciu „Zarejestruj” metoda sprawdza wszystkie warunki i pokazuje listę błędów w ramce alert-danger albo — gdy wszystko jest dobrze — formularz znika i pojawia się podsumowanie z przyciskiem „Zarejestruj kolejną osobę”.
Kryterium sukcesu: „Pokaż” nie wysyła formularza; każdy brak daje osobny wiersz na liście błędów; poprawne dane dają podsumowanie.
Podpowiedź
Błędy zbieraj w metodzie do tablicy i zapisz w sygnale bledy; pokaż je pętlą. Sygnał wyslano steruje @if wokół formularza.
Co trzeba zapamiętać
- Wysyłanie obsługujesz przez
(ngSubmit)na<form>— działa przycisk i Enter, strona się nie przeładowuje. - Przycisk bez
typew formularzu wysyła go; przyciski pomocnicze majątype="button". - Kompletność danych sprawdzaj w metodzie;
[disabled]to tylko dodatek. - Po zapisie czyść formularz, ustawiając sygnały na wartości początkowe.
#f="ngForm"daje dostęp do formularza;f.valueto obiekt zbudowany z atrybutówname.
Sprawdź się: użytkownik skarży się, że po kliknięciu „Pokaż hasło” dostaje e-mail z potwierdzeniem rejestracji. Co poprawisz w kodzie?
Dokumentacja: angular.dev — formularze oparte na szablonie.