Kurs Angular · Moduł 8: Formularze

Formularze w Angular - Sztuka Kaligrafii

2 min czytania ·
W tej lekcji2

Formularze w Angular to jak sztuka kaligrafii - wymagają precyzji, elegancji i oferują dwa główne podejścia. Każde pociągnięcie pędzla (pole formularza) musi być dokładne!

Dwa Podejścia do Formularzy

Template-driven Forms

  • Deklaratywne, w szablonie HTML
  • Prostsze, mniej kodu TypeScript
  • Dobre dla prostych formularzy

Reactive Forms (Zalecane)

  • Programistyczne, w kodzie TypeScript
  • Pełna kontrola i testowalność
  • Lepsze dla złożonych formularzy

Reactive Forms - Podstawy

import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-samurai-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="samuraiForm" (ngSubmit)="onSubmit()">
      <div class="field">
        <label for="name">Imię Samuraja</label>
        <input id="name" formControlName="name" placeholder="Musashi">
        @if (samuraiForm.get('name')?.errors?.['required'] &&
             samuraiForm.get('name')?.touched) {
          <span class="error">Imię jest wymagane</span>
        }
        @if (samuraiForm.get('name')?.errors?.['minlength']) {
          <span class="error">Minimum 2 znaki</span>
        }
      </div>

      <div class="field">
        <label for="level">Poziom</label>
        <input id="level" formControlName="level" type="number">
        @if (samuraiForm.get('level')?.errors?.['min']) {
          <span class="error">Minimum poziom 1</span>
        }
      </div>

      <div class="field">
        <label for="clan">Klan</label>
        <select id="clan" formControlName="clan">
          <option value="">Wybierz klan</option>
          <option value="tokugawa">Tokugawa</option>
          <option value="oda">Oda</option>
          <option value="takeda">Takeda</option>
        </select>
      </div>

      <button type="submit" [disabled]="samuraiForm.invalid">
        Zapisz Samuraja
      </button>

      <p>Status: {{ samuraiForm.valid ? 'Poprawny' : 'Niepoprawny' }}</p>
    </form>
  `
})
export class SamuraiFormComponent {
  private fb = inject(FormBuilder);

  samuraiForm = this.fb.group({
    name: ['', [Validators.required, Validators.minLength(2)]],
    level: [1, [Validators.required, Validators.min(1), Validators.max(100)]],
    clan: ['']
  });

  onSubmit(): void {
    if (this.samuraiForm.valid) {
      console.log('Dane samuraja:', this.samuraiForm.value);
      // { name: 'Musashi', level: 10, clan: 'tokugawa' }
    }
  }
}

Zamiast samuraiForm.get('name')?.errors?.['required'] możesz zapytać kontrolkę wprost metodą hasError(): wywołanie samuraiForm.get('name')?.hasError('required') zwraca true, gdy pole ma błąd o tej nazwie. Najczęściej łączy się je z flagą touched, żeby komunikat pojawił się dopiero wtedy, gdy użytkownik opuści pole:

@if (samuraiForm.get('name')?.hasError('required') && samuraiForm.get('name')?.touched) {
  <span class="error">Imię jest wymagane</span>
}

Gdy wpiszesz imię, hasError('required') zwraca false i komunikat znika.

Kod do tej lekcji: src/app/app.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators, AbstractControl, ValidationErrors } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  template: `
    <div class="calligraphy-school">
      <h1>Szkoła Kaligrafii - Formularze Angular</h1>

      <form [formGroup]="samuraiForm" (ngSubmit)="onSubmit()">
        <section class="basic-info">
          <h2>Dane Samuraja</h2>

          <div class="form-group">
            <label for="name">Imię wojownika *</label>
            <input id="name" formControlName="name" placeholder="Wpisz imię...">
            @if (samuraiForm.get('name')?.touched && samuraiForm.get('name')?.errors) {
              <div class="error">
                @if (samuraiForm.get('name')?.errors?.['required']) {
                  Imię jest wymagane
                }
                @if (samuraiForm.get('name')?.errors?.['minlength']) {
                  Minimum 3 znaki
                }
              </div>
            }
          </div>

          <div class="form-group">
            <label for="clan">Klan</label>
            <select id="clan" formControlName="clan">
              <option value="">Wybierz klan...</option>
              <option value="tokugawa">Tokugawa</option>
              <option value="takeda">Takeda</option>
              <option value="oda">Oda</option>
              <option value="shimazu">Shimazu</option>
            </select>
          </div>

          <div class="form-group">
            <label for="age">Wiek (18-80)</label>
            <input id="age" type="number" formControlName="age">
            @if (samuraiForm.get('age')?.touched && samuraiForm.get('age')?.errors) {
              <div class="error">
                @if (samuraiForm.get('age')?.errors?.['min']) {
                  Minimum 18 lat
                }
                @if (samuraiForm.get('age')?.errors?.['max']) {
                  Maksimum 80 lat
                }
              </div>
            }
          </div>
        </section>

        <section class="skills-section" formGroupName="skills">
          <h2>Umiejętności</h2>

          <div class="skill-sliders">
            <div class="skill">
              <label>Miecz: {{ samuraiForm.get('skills.sword')?.value }}</label>
              <input type="range" min="0" max="100" formControlName="sword">
            </div>

            <div class="skill">
              <label>Łuk: {{ samuraiForm.get('skills.bow')?.value }}</label>
              <input type="range" min="0" max="100" formControlName="bow">
            </div>

            <div class="skill">
              <label>Strategia: {{ samuraiForm.get('skills.strategy')?.value }}</label>
              <input type="range" min="0" max="100" formControlName="strategy">
            </div>
          </div>

          <div class="total-skills">
            Suma punktów: {{ totalSkillPoints() }} / 200
            @if (samuraiForm.errors?.['skillsLimit']) {
              <span class="error">Przekroczono limit!</span>
            }
          </div>
        </section>

        <section class="weapons-section">
          <h2>Uzbrojenie (FormArray)</h2>

          <div formArrayName="weapons">
            @for (weapon of weapons.controls; track $index; let i = $index) {
              <div class="weapon-row" [formGroupName]="i">
                <input formControlName="name" placeholder="Nazwa broni">
                <select formControlName="type">
                  <option value="melee">Biała</option>
                  <option value="ranged">Dystansowa</option>
                </select>
                <input type="number" formControlName="damage" placeholder="Obrażenia">
                <button type="button" class="remove-btn" (click)="removeWeapon(i)">×</button>
              </div>
            }
          </div>

          <button type="button" class="add-btn" (click)="addWeapon()">
            + Dodaj broń
          </button>
        </section>

        <section class="form-actions">
          <div class="status">
            <span [class.valid]="samuraiForm.valid" [class.invalid]="samuraiForm.invalid">
              Formularz: {{ samuraiForm.valid ? 'Poprawny' : 'Niepoprawny' }}
            </span>
          </div>

          <button type="submit" [disabled]="samuraiForm.invalid">
            Zapisz Samuraja
          </button>

          <button type="button" (click)="resetForm()">
            Wyczyść
          </button>
        </section>
      </form>

      @if (submittedData()) {
        <section class="submitted">
          <h2>Zapisane dane:</h2>
          <pre>{{ submittedData() | json }}</pre>
        </section>
      }
    </div>
  `,
  styles: [`
    .calligraphy-school {
      font-family: 'Segoe UI', sans-serif;
      padding: 20px;
      background: linear-gradient(135deg, #f5f0e8 0%, #e8dcc8 100%);
      min-height: 100vh;
    }

    h1 {
      text-align: center;
      color: #8b4513;
      text-shadow: 1px 1px 2px rgba(0,0,0,0.2);
    }

    h2 {
      color: #8b4513;
      border-bottom: 2px solid #8b4513;
      padding-bottom: 5px;
    }

    form {
      max-width: 600px;
      margin: 0 auto;
    }

    section {
      background: white;
      padding: 20px;
      border-radius: 10px;
      margin: 15px 0;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    }

    .form-group {
      margin-bottom: 15px;
    }

    .form-group label {
      display: block;
      margin-bottom: 5px;
      font-weight: bold;
      color: #5a3d2b;
    }

    .form-group input,
    .form-group select {
      width: 100%;
      padding: 10px;
      border: 2px solid #ddd;
      border-radius: 5px;
      font-size: 16px;
      box-sizing: border-box;
    }

    .form-group input:focus,
    .form-group select:focus {
      border-color: #8b4513;
      outline: none;
    }

    .form-group input.ng-invalid.ng-touched {
      border-color: #dc3545;
    }

    .error {
      color: #dc3545;
      font-size: 0.85em;
      margin-top: 5px;
    }

    .skill-sliders {
      display: flex;
      flex-direction: column;
      gap: 15px;
    }

    .skill {
      display: flex;
      align-items: center;
      gap: 10px;
    }

    .skill label {
      min-width: 120px;
    }

    .skill input[type="range"] {
      flex: 1;
    }

    .total-skills {
      margin-top: 15px;
      padding: 10px;
      background: #f8f4f0;
      border-radius: 5px;
      text-align: center;
    }

    .weapon-row {
      display: flex;
      gap: 10px;
      margin-bottom: 10px;
    }

    .weapon-row input,
    .weapon-row select {
      padding: 8px;
      border: 1px solid #ddd;
      border-radius: 4px;
    }

    .weapon-row input[type="text"] {
      flex: 2;
    }

    .weapon-row input[type="number"] {
      width: 80px;
    }

    .remove-btn {
      background: #dc3545;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      padding: 8px 12px;
    }

    .add-btn {
      background: #28a745;
      color: white;
      border: none;
      padding: 10px 20px;
      border-radius: 5px;
      cursor: pointer;
      margin-top: 10px;
    }

    .form-actions {
      display: flex;
      gap: 15px;
      align-items: center;
      flex-wrap: wrap;
    }

    .status {
      flex: 1;
    }

    .status .valid {
      color: #28a745;
    }

    .status .invalid {
      color: #dc3545;
    }

    .form-actions button[type="submit"] {
      background: #8b4513;
      color: white;
      border: none;
      padding: 12px 24px;
      border-radius: 5px;
      cursor: pointer;
      font-size: 16px;
    }

    .form-actions button[type="submit"]:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }

    .form-actions button[type="button"] {
      background: #6c757d;
      color: white;
      border: none;
      padding: 12px 24px;
      border-radius: 5px;
      cursor: pointer;
    }

    .submitted {
      background: #d4edda;
      border: 1px solid #c3e6cb;
    }

    .submitted pre {
      background: white;
      padding: 15px;
      border-radius: 5px;
      overflow: auto;
    }
  `]
})
export class AppComponent {
  samuraiForm: FormGroup;
  submittedData = signal<any>(null);

  constructor(private fb: FormBuilder) {
    this.samuraiForm = this.fb.group({
      name: ['', [Validators.required, Validators.minLength(3)]],
      clan: [''],
      age: [25, [Validators.min(18), Validators.max(80)]],
      skills: this.fb.group({
        sword: [50],
        bow: [30],
        strategy: [40]
      }, { validators: this.skillsLimitValidator }),
      weapons: this.fb.array([])
    });

    // Dodaj początkową broń
    this.addWeapon();
  }

  get weapons(): FormArray {
    return this.samuraiForm.get('weapons') as FormArray;
  }

  totalSkillPoints(): number {
    const skills = this.samuraiForm.get('skills')?.value;
    return (skills?.sword || 0) + (skills?.bow || 0) + (skills?.strategy || 0);
  }

  skillsLimitValidator(control: AbstractControl): ValidationErrors | null {
    const skills = control.value;
    const total = (skills?.sword || 0) + (skills?.bow || 0) + (skills?.strategy || 0);
    return total > 200 ? { skillsLimit: true } : null;
  }

  addWeapon(): void {
    const weaponGroup = this.fb.group({
      name: ['', Validators.required],
      type: ['melee'],
      damage: [10, [Validators.min(1), Validators.max(100)]]
    });
    this.weapons.push(weaponGroup);
  }

  removeWeapon(index: number): void {
    this.weapons.removeAt(index);
  }

  onSubmit(): void {
    if (this.samuraiForm.valid) {
      this.submittedData.set(this.samuraiForm.value);
    }
  }

  resetForm(): void {
    this.samuraiForm.reset({
      name: '',
      clan: '',
      age: 25,
      skills: { sword: 50, bow: 30, strategy: 40 }
    });
    this.weapons.clear();
    this.addWeapon();
    this.submittedData.set(null);
  }
}

Hasła z dokumentacji w tej lekcji

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Co reprezentuje FormControl w Reactive Forms?

  2. 2. Które podejście do formularzy w Angular jest zalecane dla złożonych formularzy?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Uzupełnij kod tworzący FormGroup dla rejestracji samuraja.

  • Układanie w poziomie

    Ułóż tokeny instrukcji importu ReactiveFormsModule (jedna linia, od lewej do prawej).

  • Układanie w poziomie

    Ułóż tokeny wiązania pola input z kontrolką formularza (jedna linia).

  • Klikanie w kolejności

    Zbuduj wyrażenie odczytujące błąd 'required' pola name: form.get('name')?.errors?.['required']. Klikaj elementy po kolei.

Przydatne artykuły