Kurs Angular · Moduł 8: Formularze
Formularze w Angular - Sztuka Kaligrafii
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. Co reprezentuje FormControl w Reactive Forms?
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.