Przejdź do treści
School IT / Przestrzeń do nauki

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.

Angular 22 form (ngSubmit) type=”submit” type=”button” #f=”ngForm” 45 min
CEL LEKCJI

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: submit albo button.
  • 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" i f.value.

Przygotowanie: lekcje 17–23 — umiesz połączyć z sygnałem każdą kontrolkę. Przewidywany czas: 45 min z zadaniami.

TEORIA

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ć.

ZasadaSzczegół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 poluwewnątrz <form> pole z ngModel bez name daje błąd NG01352 w konsoli
PRZYKŁAD 1

(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.

src/app/nowa-24-1/demo.ts — pełny plik
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());
    }
}
src/app/nowa-24-1/demo.html — pełny plik
<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>
wynik po wpisaniu „Pixel” i naciśnięciu Enter
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.

PRZYKŁAD 2

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ń.

src/app/nowa-24-2/demo.ts — pełny plik
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]);
    }
}
src/app/nowa-24-2/demo.html — pełny plik
<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>
wynik
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ć?

PRZYKŁAD 3

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ć.

src/app/nowa-24-3/demo.ts — pełny plik
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() + ')');
    }
}
src/app/nowa-24-3/demo.html — pełny plik
<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>
wynik
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?

PRZYKŁAD 4

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.

src/app/nowa-24-4/demo.ts — pełny plik
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);
    }
}
src/app/nowa-24-4/demo.html — pełny plik
<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>
ramka f.value przy pustym formularzu
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?

ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenieUwagi
<form (ngSubmit)="metoda()">wysyłka formularzaPrzycisk i Enter; wymaga FormsModule.
type="submit"przycisk wysyłającyDomyślny typ w <form>.
type="button"przycisk pomocniczyZ własnym (click).
#f="ngForm"obiekt formularzaf.value, w lekcji 26 f.invalid.
f.valuewartości pól według nameDo podglądu przez json.
Math.random(), Math.floorlosowa liczba 0–1, część całkowitaLosowy element tablicy.
NG01352pole bez name w <form>Błąd w konsoli przeglądarki.
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
Przycisk bez type w formularzuKaż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 metodzieDziała, dopóki ktoś nie odblokuje przycisku w F12 albo nie wywoła metody inaczej. Warunek sprawdzaj także w metodzie.
(ngSubmit) bez FormsModuleMetoda się nie wykonuje, a strona się przeładowuje — ngSubmit istnieje tylko dzięki FormsModule.
Dwa pola z tym samym name w formularzuFormularz traktuje je jak jedno pole i wartości się mieszają. Każde pole — inna nazwa.
ZADANIA

Zadania

ZAD 1Szukajka★☆☆

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.

ZAD 2Napraw przyciski★☆☆

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".

ZAD 3Księga gości★★☆

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'.

ZAD 4Zgłoszenie z podglądem f.value★★☆

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.

ZAD 5Zapisy z limitem★★★

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).

ZAD 6Rejestracja z potwierdzeniem★★★

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.

PODSUMOWANIE

Co trzeba zapamiętać

  • Wysyłanie obsługujesz przez (ngSubmit) na <form> — działa przycisk i Enter, strona się nie przeładowuje.
  • Przycisk bez type w 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.value to obiekt zbudowany z atrybutów name.

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.

Postęp zapisuje się w tej przeglądarce.