Kurs Angular · Moduł 8: Formularze

Typed Forms (Angular 14+)

1 min czytania ·
W tej lekcji2

Typed Forms zapewniają pełne bezpieczeństwo typów - jak precyzyjne pociągnięcia mistrza kaligrafii!

Definiowanie Typów

import { FormControl, FormGroup, NonNullableFormBuilder } from '@angular/forms';

// Interfejs dla formularza
interface SamuraiForm {
  name: FormControl<string>;
  level: FormControl<number>;
  clan: FormControl<string>;
  skills: FormControl<string[]>;
}

@Component({...})
export class TypedFormComponent {
  // NonNullableFormBuilder - kontrolki nie mogą być null
  private fb = inject(NonNullableFormBuilder);

  samuraiForm: FormGroup<SamuraiForm> = this.fb.group({
    name: ['', Validators.required],
    level: [1, Validators.min(1)],
    clan: ['Ronin'],
    skills: [['Kendo']]
  });

  onSubmit(): void {
    // value jest w pełni typowane!
    const formValue = this.samuraiForm.getRawValue();
    // Typ: { name: string, level: number, clan: string, skills: string[] }

    console.log(formValue.name);  // string, nie string | null
  }
}

FormControl vs FormGroup vs FormArray

import { FormControl, FormGroup, FormArray } from '@angular/forms';

// FormControl - pojedyncza wartość
const nameControl = new FormControl<string>('');

// FormGroup - obiekt z polami
const samuraiGroup = new FormGroup({
  name: new FormControl(''),
  level: new FormControl(1)
});

// FormArray - tablica kontrolek
const skillsArray = new FormArray([
  new FormControl('Kendo'),
  new FormControl('Iaido')
]);

// Dostęp do wartości
console.log(nameControl.value);       // string
console.log(samuraiGroup.value);      // { name: string, level: number }
console.log(skillsArray.value);       // string[]
Kod do tej lekcji: src/app/app.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators, AbstractControl, ValidationErrors, AsyncValidatorFn } from '@angular/forms';
import { Observable, of, delay, map } from 'rxjs';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  template: `
    <div class="registration-dojo">
      <h1>Rejestracja w Akademii Samurajów</h1>

      <form [formGroup]="registrationForm" (ngSubmit)="onSubmit()">

        <div class="form-field">
          <label>Nazwa wojownika *</label>
          <input formControlName="username" placeholder="Unikalna nazwa...">

          @if (registrationForm.get('username')?.pending) {
            <span class="checking">Sprawdzanie dostępności...</span>
          }

          @if (registrationForm.get('username')?.touched && registrationForm.get('username')?.errors) {
            <div class="error-box">
              @if (registrationForm.get('username')?.errors?.['required']) {
                <span>Nazwa jest wymagana</span>
              }
              @if (registrationForm.get('username')?.errors?.['minlength']) {
                <span>Minimum 4 znaki</span>
              }
              @if (registrationForm.get('username')?.errors?.['usernameTaken']) {
                <span>Ta nazwa jest już zajęta!</span>
              }
            </div>
          }

          @if (registrationForm.get('username')?.valid) {
            <span class="valid-mark">Nazwa dostępna</span>
          }
        </div>

        <div class="form-field">
          <label>Email *</label>
          <input type="email" formControlName="email" placeholder="twoj@email.com">

          @if (registrationForm.get('email')?.touched && registrationForm.get('email')?.errors) {
            <div class="error-box">
              @if (registrationForm.get('email')?.errors?.['required']) {
                <span>Email jest wymagany</span>
              }
              @if (registrationForm.get('email')?.errors?.['email']) {
                <span>Nieprawidłowy format email</span>
              }
            </div>
          }
        </div>

        <div class="form-field" formGroupName="passwords">
          <label>Hasło *</label>
          <input type="password" formControlName="password" placeholder="Silne hasło...">

          @if (registrationForm.get('passwords.password')?.touched) {
            <div class="password-strength">
              <div class="strength-bar" [style.width.%]="passwordStrength()">
              </div>
              <span>Siła: {{ passwordStrengthLabel() }}</span>
            </div>
          }

          <label>Powtórz hasło *</label>
          <input type="password" formControlName="confirmPassword" placeholder="Powtórz hasło...">

          @if (registrationForm.get('passwords')?.errors?.['passwordMismatch'] &&
               registrationForm.get('passwords.confirmPassword')?.touched) {
            <div class="error-box">
              <span>Hasła nie są identyczne!</span>
            </div>
          }
        </div>

        <div class="form-field">
          <label class="checkbox-label">
            <input type="checkbox" formControlName="acceptTerms">
            Akceptuję kodeks Bushido (regulamin) *
          </label>

          @if (registrationForm.get('acceptTerms')?.touched &&
               registrationForm.get('acceptTerms')?.errors?.['requiredTrue']) {
            <div class="error-box">
              <span>Musisz zaakceptować regulamin</span>
            </div>
          }
        </div>

        <div class="form-summary">
          <h3>Status formularza:</h3>
          <ul>
            <li [class.valid]="registrationForm.get('username')?.valid">
              Nazwa: {{ registrationForm.get('username')?.valid ? 'OK' : 'X' }}
            </li>
            <li [class.valid]="registrationForm.get('email')?.valid">
              Email: {{ registrationForm.get('email')?.valid ? 'OK' : 'X' }}
            </li>
            <li [class.valid]="registrationForm.get('passwords')?.valid">
              Hasła: {{ registrationForm.get('passwords')?.valid ? 'OK' : 'X' }}
            </li>
            <li [class.valid]="registrationForm.get('acceptTerms')?.valid">
              Regulamin: {{ registrationForm.get('acceptTerms')?.valid ? 'OK' : 'X' }}
            </li>
          </ul>
        </div>

        <button type="submit" [disabled]="registrationForm.invalid || registrationForm.pending">
          {{ registrationForm.pending ? '⏳ Sprawdzanie...' : 'Dołącz do Akademii' }}
        </button>
      </form>

      @if (registered()) {
        <div class="success-message">
          <h2>Witaj w Akademii, {{ registrationForm.get('username')?.value }}!</h2>
          <p>Twoja samurajska przygoda właśnie się zaczyna!</p>
        </div>
      }
    </div>
  `,
  styles: [`
    .registration-dojo {
      font-family: 'Segoe UI', sans-serif;
      padding: 20px;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      min-height: 100vh;
      color: #eee;
    }

    h1 {
      text-align: center;
      color: #e94560;
    }

    form {
      max-width: 500px;
      margin: 0 auto;
      background: rgba(255,255,255,0.1);
      padding: 30px;
      border-radius: 15px;
    }

    .form-field {
      margin-bottom: 20px;
    }

    .form-field label {
      display: block;
      margin-bottom: 8px;
      color: #e94560;
      font-weight: bold;
    }

    .form-field input[type="text"],
    .form-field input[type="email"],
    .form-field input[type="password"] {
      width: 100%;
      padding: 12px;
      border: 2px solid #0f3460;
      border-radius: 8px;
      background: #0f3460;
      color: white;
      font-size: 16px;
      box-sizing: border-box;
      margin-bottom: 5px;
    }

    .form-field input:focus {
      border-color: #e94560;
      outline: none;
    }

    .form-field input.ng-valid.ng-touched {
      border-color: #28a745;
    }

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

    .checking {
      color: #ffc107;
      font-size: 0.9em;
    }

    .valid-mark {
      color: #28a745;
      font-size: 0.9em;
    }

    .error-box {
      background: rgba(220, 53, 69, 0.2);
      border: 1px solid #dc3545;
      border-radius: 5px;
      padding: 8px;
      margin-top: 5px;
    }

    .error-box span {
      display: block;
      color: #fca5a5;
      font-size: 0.85em;
    }

    .password-strength {
      margin: 10px 0;
    }

    .strength-bar {
      height: 8px;
      background: linear-gradient(90deg, #dc3545 0%, #ffc107 50%, #28a745 100%);
      border-radius: 4px;
      transition: width 0.3s;
    }

    .checkbox-label {
      display: flex;
      align-items: center;
      gap: 10px;
      cursor: pointer;
    }

    .checkbox-label input {
      width: 20px;
      height: 20px;
    }

    .form-summary {
      background: rgba(0,0,0,0.3);
      padding: 15px;
      border-radius: 8px;
      margin: 20px 0;
    }

    .form-summary h3 {
      margin-top: 0;
      color: #e94560;
    }

    .form-summary ul {
      list-style: none;
      padding: 0;
    }

    .form-summary li {
      padding: 5px 0;
      color: #888;
    }

    .form-summary li.valid {
      color: #28a745;
    }

    button[type="submit"] {
      width: 100%;
      padding: 15px;
      background: #e94560;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 18px;
      cursor: pointer;
      transition: background 0.3s;
    }

    button[type="submit"]:hover:not(:disabled) {
      background: #ff6b6b;
    }

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

    .success-message {
      text-align: center;
      background: rgba(40, 167, 69, 0.2);
      border: 2px solid #28a745;
      border-radius: 15px;
      padding: 30px;
      margin-top: 20px;
      max-width: 500px;
      margin-left: auto;
      margin-right: auto;
    }

    .success-message h2 {
      color: #28a745;
    }
  `]
})
export class AppComponent {
  registrationForm: FormGroup;
  registered = signal(false);

  // Symulacja zajętych nazw
  private takenUsernames = ['musashi', 'kojiro', 'yagyu', 'admin', 'samurai'];

  constructor(private fb: FormBuilder) {
    this.registrationForm = this.fb.group({
      username: ['',
        [Validators.required, Validators.minLength(4)],
        [this.usernameAvailableValidator()]
      ],
      email: ['', [Validators.required, Validators.email]],
      passwords: this.fb.group({
        password: ['', [Validators.required, Validators.minLength(8)]],
        confirmPassword: ['', Validators.required]
      }, { validators: this.passwordMatchValidator }),
      acceptTerms: [false, Validators.requiredTrue]
    });
  }

  // Async validator - sprawdza, czy nazwa jest dostępna
  usernameAvailableValidator(): AsyncValidatorFn {
    return (control: AbstractControl): Observable<ValidationErrors | null> => {
      if (!control.value) {
        return of(null);
      }

      // Symulacja zapytania do API
      return of(control.value).pipe(
        delay(1000),
        map(username => {
          const isTaken = this.takenUsernames.includes(username.toLowerCase());
          return isTaken ? { usernameTaken: true } : null;
        })
      );
    };
  }

  // Cross-field validator
  passwordMatchValidator(control: AbstractControl): ValidationErrors | null {
    const password = control.get('password')?.value;
    const confirmPassword = control.get('confirmPassword')?.value;

    if (password && confirmPassword && password !== confirmPassword) {
      return { passwordMismatch: true };
    }
    return null;
  }

  passwordStrength(): number {
    const password = this.registrationForm.get('passwords.password')?.value || '';
    let strength = 0;

    if (password.length >= 8) strength += 25;
    if (password.length >= 12) strength += 15;
    if (/[A-Z]/.test(password)) strength += 20;
    if (/[a-z]/.test(password)) strength += 10;
    if (/[0-9]/.test(password)) strength += 15;
    if (/[^A-Za-z0-9]/.test(password)) strength += 15;

    return Math.min(strength, 100);
  }

  passwordStrengthLabel(): string {
    const strength = this.passwordStrength();
    if (strength < 30) return 'Słabe';
    if (strength < 60) return 'Średnie';
    if (strength < 80) return 'Dobre';
    return 'Bardzo silne';
  }

  onSubmit(): void {
    if (this.registrationForm.valid) {
      this.registered.set(true);
    }
  }
}

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 zapewniają Typed Forms (Angular 14+)?

  2. 2. Co gwarantuje użycie NonNullableFormBuilder?

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

Zadania praktyczne w grze

  • Układanie w pionie

    Uporządkuj bloki budujące formularz od najmniejszego do największego zakresu (od pojedynczej wartości do tablicy grup).

  • Edytor kodu

    Uzupełnij szablon wyświetlający błędy walidacji tylko gdy pole było dotknięte.

  • Klikanie w kolejności

    Zbuduj definicję kontrolki z walidatorami: ['', [Validators.required, Validators.email]]. Klikaj elementy po kolei.

Przydatne artykuły