Projekt — część 2: zapisy, filtry, nowe wydarzenia
Aplikacja z części 1 tylko pokazuje dane. Teraz zacznie je zmieniać: zapisy i wypisywanie, filtry na liście, formularz nowego wydarzenia i pamięć w przeglądarce. Zobaczysz, jak niewiele zmienia się w komponentach, gdy cała logika siedzi w serwisie.
Czego się dziś nauczysz
- Dodasz do serwisu metody zmieniające dane, które pilnują reguł i zwracają komunikat błędu.
- Zmienisz jedno wydarzenie w tablicy, tworząc nowy obiekt przez
mapi.... - Połączysz trzy filtry — z adresu, z wyszukiwarki i z checkboxa — w jednym
computed. - Zbudujesz formularz nowego wydarzenia z walidacją i przejściem na jego stronę.
- Zapamiętasz stan aplikacji w
localStorageza pomocąeffectw serwisie.
Przygotowanie: lekcja 41 (część 1 projektu), lekcje 24–26 (formularze i walidacja), 31 (map z kopią obiektu), 35 (parametry zapytania), 37 (effect, localStorage). Przewidywany czas: 2 lekcje (90 min).
Zadanie: aplikacja, która zmienia dane
Rozbuduj aplikację z części 1 tak, żeby:
- na stronie szczegółów można było się zapisać (imię co najmniej 3 znaki, bez powtórzeń, nie ponad limit) i wypisać dowolną osobę;
- w menu był licznik wszystkich zapisów, zawsze aktualny;
- lista miała filtr kategorii w adresie (
?kategoria=gry), wyszukiwarkę po tytule i opcję „tylko z wolnymi miejscami”; - pełne wydarzenia były wyróżnione, a kategorie miały własne kolory;
- był formularz nowego wydarzenia z walidacją, który po zapisie przenosi na stronę nowego wydarzenia;
- zmiany przetrwały odświeżenie strony, a jeden przycisk przywracał dane z serwera.
| Krok | Pliki | Co dochodzi |
|---|---|---|
| 1 | serwis, szczegóły, szkielet | metody zapisz, wypisz, licznik zapisów; formularz zapisu |
| 2 | lista, karta | filtr kategorii w adresie, wyszukiwarka, „tylko wolne”; kolory i oznaczenie PEŁNE |
| 3 | nowy komponent, trasy, serwis, szkielet | strona dodaj i metoda dodaj |
| 4 | serwis, szkielet | zapis w localStorage i przywracanie danych |
Laboratorium
Folder projekt-2 to projekt-1 ze zmianami z tej lekcji (plik wydarzenie.ts się nie zmienia). Rejestracja: { path: 'projekt-2', component: Demo, children: trasy }. Pliki w laboratorium pokazują stan po kroku 4; zmiany z wcześniejszych kroków są w lekcji pokazane jako fragmenty do dopisania. Budując projekt samodzielnie, pracuj dalej na swoim folderze z części 1.
Zapis na wydarzenie i licznik w menu
Serwis dostaje computed z liczbą wszystkich zapisów i dwie metody zmieniające dane. Metoda zapisz sprawdza reguły i zwraca komunikat błędu albo pusty tekst, gdy wszystko się udało.
readonly wszystkichZapisow = computed(() => {
let suma = 0;
for (const w of this._lista()) {
suma += w.zapisani.length;
}
return suma;
});
zapisz(id: number, imie: string): string {
const w = this.znajdz(id);
const czyste = imie.trim();
if (!w) {
return 'Nie ma takiego wydarzenia.';
}
if (w.zapisani.length >= w.limit) {
return 'Brak wolnych miejsc.';
}
const powtorka = w.zapisani.find(o => o.toLowerCase() === czyste.toLowerCase());
if (powtorka !== undefined) {
return czyste + ' jest już na liście.';
}
this._lista.update(lista =>
lista.map(x => (x.id === id ? { ...x, zapisani: [...x.zapisani, czyste] } : x))
);
return '';
}
wypisz(id: number, nr: number): void {
this._lista.update(lista =>
lista.map(x => (x.id === id ? { ...x, zapisani: x.zapisani.filter((o, i) => i !== nr) } : x))
);
}
Do importu z @angular/core dopisz computed. Zmiana jednego wydarzenia to wzór z lekcji 31: map tworzy nową tablicę, a w miejscu zmienianego wydarzenia — nowy obiekt { ...x, zapisani: […] } z nową tablicą osób. Dzięki temu każdy computed i każda karta dowiadują się o zmianie.
Strona szczegółów dostaje formularz z walidacją (lekcja 26) i przyciski „wypisz”. Komponent tylko wywołuje metody serwisu i pokazuje komunikat.
import { Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { DatePipe } from '@angular/common';
import { FormsModule, NgForm } from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';
import { WydarzeniaSerwis } from './wydarzenia-serwis';
@Component({
selector: 'app-szczegoly',
imports: [RouterLink, DatePipe, FormsModule],
templateUrl: './szczegoly.html'
})
export class Szczegoly {
private readonly trasa = inject(ActivatedRoute);
private readonly serwis = inject(WydarzeniaSerwis);
readonly parametry = toSignal(this.trasa.paramMap);
readonly id = computed(() => Number(this.parametry()?.get('id')));
readonly wydarzenie = computed(() => this.serwis.znajdz(this.id()));
readonly ladowanie = this.serwis.ladowanie;
readonly wolne = computed(() => {
const w = this.wydarzenie();
return w ? w.limit - w.zapisani.length : 0;
});
readonly imie = signal('');
readonly komunikat = signal('');
readonly sukces = signal(false);
zapisz(f: NgForm): void {
if (f.invalid) {
f.form.markAllAsTouched();
return;
}
const blad = this.serwis.zapisz(this.id(), this.imie());
this.sukces.set(blad === '');
this.komunikat.set(blad === '' ? 'Zapisano: ' + this.imie().trim() : blad);
if (blad === '') {
this.imie.set('');
f.form.markAsUntouched();
}
}
wypisz(nr: number): void {
this.serwis.wypisz(this.id(), nr);
this.komunikat.set('');
}
}
<a class="btn btn-sm btn-link px-0 mb-2" routerLink="../../wydarzenia">← Wszystkie wydarzenia</a>
@if (wydarzenie(); as w) {
<div class="row g-4">
<div class="col-md-7">
<div class="card">
<div class="card-body">
<span class="badge text-bg-secondary">{{ w.kategoria }}</span>
<h3 class="card-title mt-2">{{ w.tytul }}</h3>
<p class="card-text">{{ w.data | date:'EEEE, d MMMM y' }} · sala {{ w.sala }}</p>
<h6>Zapisani ({{ w.zapisani.length }} / {{ w.limit }})</h6>
<ul class="list-group">
@for (osoba of w.zapisani; track $index; let nr = $index) {
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ nr + 1 }}. {{ osoba }}
<button type="button" class="btn btn-sm btn-outline-danger" (click)="wypisz(nr)">wypisz</button>
</li>
} @empty {
<li class="list-group-item text-secondary">Nikt się jeszcze nie zapisał.</li>
}
</ul>
</div>
</div>
</div>
<div class="col-md-5">
<div class="card p-3">
<h5>Zapisz się</h5>
@if (wolne() > 0) {
<form #f="ngForm" (ngSubmit)="zapisz(f)">
<div class="mb-2">
<input name="imie" class="form-control" placeholder="Imię i nazwisko"
required minlength="3" [(ngModel)]="imie" #i="ngModel"
[class.is-invalid]="i.invalid && i.touched">
<div class="invalid-feedback">Podaj co najmniej 3 znaki.</div>
</div>
<button class="btn btn-primary" type="submit">Zapisz (wolne: {{ wolne() }})</button>
</form>
} @else {
<div class="alert alert-secondary mb-0">Brak wolnych miejsc.</div>
}
@if (komunikat()) {
<div class="alert mt-3 mb-0" [class.alert-success]="sukces()" [class.alert-danger]="!sukces()">
{{ komunikat() }}
</div>
}
</div>
</div>
</div>
} @else if (ladowanie()) {
<p>Wczytywanie…</p>
} @else {
<div class="alert alert-warning">Nie ma wydarzenia o numerze {{ parametry()?.get('id') }}.</div>
}
import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { WydarzeniaSerwis } from './wydarzenia-serwis';
@Component({
selector: 'app-demo',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './demo.html'
})
export class Demo {
readonly serwis = inject(WydarzeniaSerwis);
constructor() {
this.serwis.wczytaj();
}
}
<div class="p-3" style="max-width: 60rem">
<nav class="navbar navbar-expand bg-dark rounded px-3 mb-3" data-bs-theme="dark">
<span class="navbar-brand">Klub ZSMI</span>
<div class="navbar-nav me-auto">
<a class="nav-link" routerLink="wydarzenia" routerLinkActive="active">Wydarzenia</a>
</div>
<span class="badge text-bg-warning">Zapisów: {{ serwis.wszystkichZapisow() }}</span>
</nav>
<router-outlet />
</div>
Zapisz się
[Ala ] [Zapisz (wolne: 5)]
Ala jest już na liście. (czerwony)
…
Zapisano: Zenek (zielony)
Menu: Zapisów: 10 → 11
W demo.ts pole serwis nie jest już private, bo szablon czyta z niego licznik. Wróć do listy: karta Turnieju retro pokazuje nową liczbę wolnych miejsc. Kliknij „wypisz” — wszystko cofa się w obu miejscach.
Zatrzymaj się
Reguły zapisu (limit, powtórzenia) są w serwisie, a nie w formularzu. Gdyby doszedł drugi formularz zapisu na innej stronie, ile miejsc musiałbyś zmienić przy zmianie reguł?
Filtry, wyszukiwarka i wyróżnienia
Lista dostaje trzy filtry, które działają razem: kategoria z adresu (lekcja 35), tekst z wyszukiwarki i checkbox. Jeden computed łączy wszystkie trzy warunki operatorem &&. Karta dostaje kolor kategorii i oznaczenie PEŁNE.
import { Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { WydarzeniaSerwis } from './wydarzenia-serwis';
import { Karta } from './karta';
@Component({
selector: 'app-lista',
imports: [Karta, FormsModule, RouterLink],
templateUrl: './lista.html'
})
export class Lista {
readonly serwis = inject(WydarzeniaSerwis);
private readonly trasa = inject(ActivatedRoute);
readonly kategorie = ['gry', 'technika', 'sztuka'];
readonly zapytanie = toSignal(this.trasa.queryParamMap);
readonly kategoria = computed(() => this.zapytanie()?.get('kategoria') ?? '');
readonly szukaj = signal('');
readonly tylkoWolne = signal(false);
readonly widoczne = computed(() => {
const tekst = this.szukaj().trim().toLowerCase();
return this.serwis.lista().filter(w =>
(this.kategoria() === '' || w.kategoria === this.kategoria()) &&
(tekst === '' || w.tytul.toLowerCase().includes(tekst)) &&
(!this.tylkoWolne() || w.zapisani.length < w.limit)
);
});
}
<h4 class="mb-3">Nadchodzące wydarzenia</h4>
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
<div class="btn-group btn-group-sm">
<a class="btn btn-outline-primary" routerLink="." [class.active]="kategoria() === ''">wszystkie</a>
@for (k of kategorie; track k) {
<a class="btn btn-outline-primary" routerLink="." [queryParams]="{ kategoria: k }"
[class.active]="kategoria() === k">{{ k }}</a>
}
</div>
<input name="szukaj" class="form-control form-control-sm" style="width: 14rem"
placeholder="Szukaj w tytułach…" [(ngModel)]="szukaj">
<div class="form-check mb-0">
<input id="wolne" name="wolne" class="form-check-input" type="checkbox" [(ngModel)]="tylkoWolne">
<label class="form-check-label" for="wolne">tylko z wolnymi miejscami</label>
</div>
</div>
@if (serwis.ladowanie()) {
<p><span class="spinner-border spinner-border-sm"></span> Wczytywanie…</p>
} @else if (serwis.blad()) {
<div class="alert alert-danger">{{ serwis.blad() }}</div>
} @else {
<div class="row g-3">
@for (w of widoczne(); track w.id) {
<div class="col-md-4">
<app-karta [wydarzenie]="w" />
</div>
} @empty {
<p class="text-secondary">Żadne wydarzenie nie pasuje do filtrów.</p>
}
</div>
}
import { Component, computed, input } from '@angular/core';
import { DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { Wydarzenie } from './wydarzenie';
@Component({
selector: 'app-karta',
imports: [DatePipe, RouterLink],
templateUrl: './karta.html'
})
export class Karta {
readonly wydarzenie = input.required<Wydarzenie>();
readonly wolne = computed(() => this.wydarzenie().limit - this.wydarzenie().zapisani.length);
readonly procent = computed(() =>
Math.round(this.wydarzenie().zapisani.length / this.wydarzenie().limit * 100)
);
readonly kolor = computed(() => {
const k = this.wydarzenie().kategoria;
if (k === 'gry') {
return 'text-bg-primary';
} else if (k === 'technika') {
return 'text-bg-success';
}
return 'text-bg-info';
});
}
<div class="card h-100" [class.opacity-75]="wolne() === 0">
<div class="card-body">
<span class="badge mb-2" [class]="kolor()">{{ wydarzenie().kategoria }}</span>
@if (wolne() === 0) { <span class="badge text-bg-danger mb-2 ms-1">PEŁNE</span> }
<h5 class="card-title">{{ wydarzenie().tytul }}</h5>
<p class="card-text mb-2">
{{ wydarzenie().data | date:'EEEE, d MMMM' }}<br>
sala {{ wydarzenie().sala }}
</p>
<div class="progress mb-2" style="height: 6px">
<div class="progress-bar" [class.bg-danger]="wolne() === 0" [style.width.%]="procent()"></div>
</div>
<p class="small mb-0">Zapisanych: {{ wydarzenie().zapisani.length }} / {{ wydarzenie().limit }}</p>
</div>
<div class="card-footer bg-transparent">
<a class="btn btn-sm btn-outline-primary" [routerLink]="['../wydarzenie', wydarzenie().id]">
{{ wolne() > 0 ? 'Szczegóły i zapis' : 'Szczegóły' }}
</a>
</div>
</div>
[wszystkie] [gry] [technika] [sztuka] [Szukaj w tytułach…] ☑ tylko z wolnymi miejscami
Warsztat druku 3D Hackathon klasowy
Każdy z trzech warunków ma postać „filtr wyłączony albo element pasuje”, np. (tekst === '' || w.tytul.toLowerCase().includes(tekst)). Kolor kategorii wybiera computed w karcie i zwraca nazwę klasy Bootstrapa; [class.opacity-75] lekko wygasza pełne wydarzenie. Gdy nic nie pasuje, pojawia się komunikat z @empty.
Zatrzymaj się
Kategoria jest w adresie, a tekst wyszukiwarki — w zwykłym sygnale. Dlaczego? Kiedy tekst wyszukiwania też warto byłoby trzymać w adresie?
Dodawanie wydarzenia
Nowa strona z formularzem (lekcje 17–26): tytuł, kategoria z listy, data, sala i limit z zakresem. Serwis nadaje nowemu wydarzeniu kolejny numer, a po zapisie router przenosi użytkownika na stronę nowego wydarzenia (lekcja 34).
import { Component, inject, signal } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { WydarzeniaSerwis } from './wydarzenia-serwis';
@Component({
selector: 'app-nowe-wydarzenie',
imports: [FormsModule],
templateUrl: './nowe-wydarzenie.html'
})
export class NoweWydarzenie {
private readonly serwis = inject(WydarzeniaSerwis);
private readonly router = inject(Router);
private readonly trasa = inject(ActivatedRoute);
readonly kategorie = ['gry', 'technika', 'sztuka'];
readonly tytul = signal('');
readonly kategoria = signal('');
readonly data = signal('');
readonly sala = signal('');
readonly limit = signal<number | null>(10);
zapisz(f: NgForm): void {
if (f.invalid) {
f.form.markAllAsTouched();
return;
}
const id = this.serwis.dodaj({
tytul: this.tytul().trim(),
kategoria: this.kategoria(),
data: this.data(),
sala: this.sala().trim(),
limit: Number(this.limit()),
});
this.router.navigate(['../wydarzenie', id], { relativeTo: this.trasa });
}
}
<h4 class="mb-3">Nowe wydarzenie</h4>
<form #f="ngForm" (ngSubmit)="zapisz(f)" style="max-width: 32rem">
<div class="mb-3">
<label class="form-label" for="tytul">Tytuł *</label>
<input id="tytul" name="tytul" class="form-control" required minlength="3"
[(ngModel)]="tytul" #t="ngModel" [class.is-invalid]="t.invalid && t.touched">
<div class="invalid-feedback">Tytuł musi mieć co najmniej 3 znaki.</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label" for="kategoria">Kategoria *</label>
<select id="kategoria" name="kategoria" class="form-select" required
[(ngModel)]="kategoria" #k="ngModel" [class.is-invalid]="k.invalid && k.touched">
<option value="" disabled>— wybierz —</option>
@for (kat of kategorie; track kat) { <option [value]="kat">{{ kat }}</option> }
</select>
<div class="invalid-feedback">Wybierz kategorię.</div>
</div>
<div class="col-6">
<label class="form-label" for="data">Data *</label>
<input id="data" name="data" type="date" class="form-control" required
[(ngModel)]="data" #d="ngModel" [class.is-invalid]="d.invalid && d.touched">
<div class="invalid-feedback">Wybierz datę.</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label" for="sala">Sala *</label>
<input id="sala" name="sala" class="form-control" required
[(ngModel)]="sala" #s="ngModel" [class.is-invalid]="s.invalid && s.touched">
<div class="invalid-feedback">Podaj salę.</div>
</div>
<div class="col-6">
<label class="form-label" for="limit">Limit miejsc (1–30) *</label>
<input id="limit" name="limit" type="number" class="form-control" required min="1" max="30"
[(ngModel)]="limit" #l="ngModel" [class.is-invalid]="l.invalid && l.touched">
<div class="invalid-feedback">Limit od 1 do 30.</div>
</div>
</div>
<button class="btn btn-success" type="submit">Dodaj wydarzenie</button>
</form>
import { Routes } from '@angular/router';
import { Lista } from './lista';
import { Szczegoly } from './szczegoly';
import { NoweWydarzenie } from './nowe-wydarzenie';
export const trasy: Routes = [
{ path: '', redirectTo: 'wydarzenia', pathMatch: 'full' },
{ path: 'wydarzenia', component: Lista },
{ path: 'wydarzenie/:id', component: Szczegoly },
{ path: 'dodaj', component: NoweWydarzenie },
{ path: '**', redirectTo: 'wydarzenia' },
];
dodaj(dane: Omit<Wydarzenie, 'id' | 'zapisani'>): number {
let najwiekszy = 0;
for (const w of this._lista()) {
if (w.id > najwiekszy) {
najwiekszy = w.id;
}
}
const noweId = najwiekszy + 1;
this._lista.update(lista => [...lista, { ...dane, id: noweId, zapisani: [] }]);
return noweId;
}
<a class="nav-link" routerLink="dodaj" routerLinkActive="active">Dodaj wydarzenie</a>
← Wszystkie wydarzenia
[sztuka]
Kino klubowe
sobota, 12 grudnia 2026 · sala Aula
Zapisani (0 / 10)
Nikt się jeszcze nie zapisał.
Omit<Wydarzenie, 'id' | 'zapisani'> to typ „wydarzenie bez pól id i zapisani” — formularz nie zna numeru i nie ma listy osób, uzupełnia je serwis. Nowy numer to największy dotychczasowy plus jeden. Number(this.limit()) zamienia wartość pola liczbowego na liczbę (pole może zwrócić null, ale po walidacji wiemy, że jest wypełnione).
Zatrzymaj się
Odśwież stronę po dodaniu wydarzenia (bez kroku 4). Gdzie się podziało? Co trzeba zrobić, żeby zostało?
Zapamiętywanie w localStorage
Ostatni punkt zadania. effect w serwisie (lekcja 37) zapisuje całą listę przy każdej zmianie. wczytaj najpierw szuka danych w localStorage i dopiero gdy ich nie ma, pobiera plik z serwera. przywroc kasuje zapis i pobiera dane od nowa. Poniżej pełny serwis po wszystkich krokach.
import { Service, computed, effect, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Wydarzenie } from './wydarzenie';
const KLUCZ = 'klub-zsmi-wydarzenia';
@Service()
export class WydarzeniaSerwis {
private readonly http = inject(HttpClient);
private readonly _lista = signal<Wydarzenie[]>([]);
readonly lista = this._lista.asReadonly();
readonly ladowanie = signal(false);
readonly blad = signal('');
readonly wszystkichZapisow = computed(() => {
let suma = 0;
for (const w of this._lista()) {
suma += w.zapisani.length;
}
return suma;
});
constructor() {
effect(() => {
if (this._lista().length > 0) {
localStorage.setItem(KLUCZ, JSON.stringify(this._lista()));
}
});
}
wczytaj(): void {
if (this._lista().length > 0 || this.ladowanie()) {
return;
}
const zapisane = localStorage.getItem(KLUCZ);
if (zapisane) {
this._lista.set(JSON.parse(zapisane));
return;
}
this.pobierzZSerwera();
}
przywroc(): void {
localStorage.removeItem(KLUCZ);
this._lista.set([]);
this.pobierzZSerwera();
}
znajdz(id: number): Wydarzenie | undefined {
return this._lista().find(w => w.id === id);
}
zapisz(id: number, imie: string): string {
const w = this.znajdz(id);
const czyste = imie.trim();
if (!w) {
return 'Nie ma takiego wydarzenia.';
}
if (w.zapisani.length >= w.limit) {
return 'Brak wolnych miejsc.';
}
const powtorka = w.zapisani.find(o => o.toLowerCase() === czyste.toLowerCase());
if (powtorka !== undefined) {
return czyste + ' jest już na liście.';
}
this._lista.update(lista =>
lista.map(x => (x.id === id ? { ...x, zapisani: [...x.zapisani, czyste] } : x))
);
return '';
}
wypisz(id: number, nr: number): void {
this._lista.update(lista =>
lista.map(x => (x.id === id ? { ...x, zapisani: x.zapisani.filter((o, i) => i !== nr) } : x))
);
}
dodaj(dane: Omit<Wydarzenie, 'id' | 'zapisani'>): number {
let najwiekszy = 0;
for (const w of this._lista()) {
if (w.id > najwiekszy) {
najwiekszy = w.id;
}
}
const noweId = najwiekszy + 1;
this._lista.update(lista => [...lista, { ...dane, id: noweId, zapisani: [] }]);
return noweId;
}
private pobierzZSerwera(): void {
this.ladowanie.set(true);
this.blad.set('');
this.http.get<Wydarzenie[]>('/dane/projekt.json').subscribe({
next: dane => {
this._lista.set(dane);
this.ladowanie.set(false);
},
error: () => {
this.blad.set('Nie udało się wczytać wydarzeń.');
this.ladowanie.set(false);
}
});
}
}
<div class="p-3" style="max-width: 60rem">
<nav class="navbar navbar-expand bg-dark rounded px-3 mb-3" data-bs-theme="dark">
<span class="navbar-brand">Klub ZSMI</span>
<div class="navbar-nav me-auto">
<a class="nav-link" routerLink="wydarzenia" routerLinkActive="active">Wydarzenia</a>
<a class="nav-link" routerLink="dodaj" routerLinkActive="active">Dodaj wydarzenie</a>
</div>
<span class="badge text-bg-warning">Zapisów: {{ serwis.wszystkichZapisow() }}</span>
</nav>
<router-outlet />
<p class="text-end mt-4">
<button class="btn btn-sm btn-link text-secondary" (click)="serwis.przywroc()">Przywróć dane z serwera</button>
</p>
</div>
7 kart, Zapisów: 11 — wszystko na miejscu
F12 → Application → Local Storage → klub-zsmi-wydarzenia: [{"id":1,…}]
„Przywróć dane z serwera” → 6 kart, Zapisów: 10
Pobieranie z serwera przeniesiono do prywatnej metody pobierzZSerwera, bo korzystają z niej dwie metody: wczytaj i przywroc. Żaden komponent nie wie o istnieniu localStorage — gdyby dane miały trafiać do prawdziwego serwera, zmieniłby się tylko serwis. Gotowe: sprawdź wszystkie sześć punktów zadania.
Zatrzymaj się
effect zapisuje dane tylko wtedy, gdy lista nie jest pusta. Co by się stało przy starcie aplikacji bez tego warunku?
Zestawienie elementów
| Element | Znaczenie w projekcie |
|---|---|
lista.map(x => x.id === id ? { ...x, … } : x) | zmiana jednego wydarzenia z nowym obiektem |
[...x.zapisani, imie], filter((o, i) => i !== nr) | dopisanie i usunięcie osoby |
find(o => …), toLowerCase() | szukanie powtórzeń bez względu na wielkość liter |
a && b && c w filter | kilka filtrów działających razem |
Omit<Typ, 'pole'> | typ bez wybranych pól |
f.form.markAllAsTouched(), markAsUntouched() | pokazanie błędów i czyszczenie stanu pól (lekcja 26) |
router.navigate(['../wydarzenie', id], { relativeTo }) | przejście na stronę nowego wydarzenia |
effect + JSON.stringify / JSON.parse | zapamiętywanie listy w przeglądarce |
localStorage.removeItem(klucz) | usunięcie zapisu |
private pobierzZSerwera() | metoda pomocnicza dostępna tylko w serwisie |
Zanim utkniesz
| Zapis | Problem |
|---|---|
Komponent zmienia dane wprost: w.zapisani.push(imie) | Lista na stronie może się nawet zmienić, ale licznik w menu i karty — nie: sygnał nie wie o zmianie, bo tablica jest ta sama (lekcja 10). Zmiany tylko metodami serwisu, zawsze nowe obiekty. |
| Reguły zapisu sprawdzane w formularzu | Każdy nowy formularz musi je powtórzyć, a łatwo o różnice. Reguły w serwisie, formularz tylko pokazuje komunikat. |
Trzy osobne computed dla trzech filtrów | Filtry nie działają razem. Jeden computed łączy wszystkie warunki przez &&. |
Nowe id liczone jako lista.length + 1 | Po usunięciu wydarzenia numery się powtarzają, a track i linki do szczegółów zaczynają się mylić. Bierz największy numer plus jeden. |
effect zapisujący także pustą listę | Przy starcie zapis w localStorage jest nadpisywany pustą tablicą, zanim cokolwiek się wczyta — dane znikają po odświeżeniu. |
JSON.parse(localStorage.getItem(KLUCZ)) | TS2345: Argument of type 'string | null’ is not assignable to parameter of type 'string’. Najpierw sprawdź, czy jest co wczytać. |
Zadania
Obok licznika zapisów w menu dodaj drugi: „Wolnych miejsc: X” — suma wolnych miejsc we wszystkich wydarzeniach.
Kryterium sukcesu: licznik zmienia się po każdym zapisie, wypisaniu i dodaniu wydarzenia.
Podpowiedź
Kolejny computed w serwisie, wzorowany na wszystkichZapisow.
Dodaj w serwisie metodę usun(id) i przycisk „Usuń wydarzenie” na stronie szczegółów. Po usunięciu użytkownik wraca do listy.
Kryterium sukcesu: usunięte wydarzenie znika z listy i z licznika zapisów, a po odświeżeniu nie wraca.
Podpowiedź
filter w serwisie i router.navigate(['../../wydarzenia'], { relativeTo: this.trasa }) w komponencie.
Przed usunięciem wydarzenia (zadanie 2) pokaż okno z lekcji 39: „Czy na pewno usunąć tytuł? Zapisanych osób: X”. Usunięcie dopiero po „Tak”.
Kryterium sukcesu: „Nie”, krzyżyk i kliknięcie w tło zamykają okno bez usuwania; po „Tak” wydarzenie znika.
Podpowiedź
Skopiuj komponent Okno do projektu; sygnał pytanie (true/false) w szczegółach i @if wokół okna.
Rozbuduj zapis: oprócz imienia uczeń wybiera klasę z listy (1TP–5TP). Na liście zapisanych pokazuj „Imię (klasa)”. Zmień model tak, żeby zapisani było tablicą obiektów { imie, klasa }.
Kryterium sukcesu: formularz wymaga obu pól; lista zapisanych pokazuje klasę; kontrola powtórzeń dalej działa.
Podpowiedź
Zmień interfejs i JSON — kompilator wskaże wszystkie miejsca do poprawienia. Po zmianie modelu kliknij „Przywróć dane z serwera”, żeby wyczyścić stary zapis.
Dodaj stronę „Moje zapisy”: pole „Twoje imię” i lista wydarzeń, na które ta osoba jest zapisana, z datami i przyciskiem „wypisz się”. Imię zapamiętaj w localStorage.
Kryterium sukcesu: po wpisaniu imienia widać właściwe wydarzenia; wypisanie zmienia też listę w szczegółach i licznik w menu; imię zostaje po F5.
Podpowiedź
Nowa trasa, nowy komponent, computed z filter(w => w.zapisani.includes(imie)). Numer osoby do wypisania: w.zapisani.indexOf(imie).
Na stronie szczegółów dodaj przycisk „Edytuj”, który prowadzi do edytuj/:id. Strona edycji to ten sam formularz co przy dodawaniu, wypełniony danymi wydarzenia. Zapis zmienia wydarzenie w serwisie (nowa metoda zmien) i wraca do szczegółów. Limitu nie można zmniejszyć poniżej liczby zapisanych osób.
Kryterium sukcesu: po edycji szczegóły, karta i licznik pokazują nowe dane; za mały limit daje komunikat z serwisu i nie zapisuje zmian.
Podpowiedź
Wypełnienie formularza: w konstruktorze strony edycji effect, który po znalezieniu wydarzenia ustawia sygnały pól. Metoda zmien(id, dane) zwraca komunikat błędu jak zapisz.
Co trzeba zapamiętać
- Zmiany danych przechodzą przez metody serwisu, które pilnują reguł i tworzą nowe obiekty.
- Dzięki jednemu źródłu danych licznik w menu, karty i szczegóły zawsze się zgadzają.
- Kilka filtrów działa razem w jednym
computed; filtr, który ma przetrwać w linku, trzymasz w adresie. effectw serwisie zapisuje stan wlocalStorage, a komponenty nic o tym nie wiedzą.- Dobrze podzielona aplikacja rośnie przez dopisywanie metod i stron, a nie przez przepisywanie istniejących.
Sprawdź się: który jeden plik musiałbyś zmienić, gdyby szkoła kupiła prawdziwy serwer z bazą danych, i dlaczego reszta aplikacji mogłaby zostać bez zmian?
Dokumentacja: angular.dev — dokumentacja Angulara.