Parametr trasy i parametry zapytania
Adres /wydarzenie/3 ma część stałą i zmienną. Jeden komponent szczegółów obsłuży wszystkie wydarzenia: odczyta numer z adresu i znajdzie odpowiednie dane. A filtr w adresie ?kategoria=gry sprawi, że widok z filtrem można wysłać komuś linkiem.
Czego się dziś nauczysz
- Zdefiniujesz trasę z parametrem
wydarzenie/:id. - Zbudujesz link z wartością z pętli:
[routerLink]="['wydarzenie', w.id]". - Odczytasz parametr w komponencie jako sygnał i znajdziesz na jego podstawie dane.
- Obsłużysz nieistniejący numer i pusty adres.
- Dodasz filtr w parametrach zapytania
?kategoria=…i odczytasz go wcomputed.
Przygotowanie: lekcja 34 (trasy, routerLink, <router-outlet />), lekcja 11 (computed), lekcja 21 (@if (…; as x)). Przewidywany czas: 50 min z zadaniami.
Jeden komponent, wiele adresów
Trasa wydarzenie/:id pasuje do każdego adresu tego kształtu: wydarzenie/1, wydarzenie/2, wydarzenie/99. Dwukropek mówi routerowi: „to, co tu stoi, zapamiętaj pod nazwą id”. Numer w adresie można wysłać komuś linkiem albo dodać do zakładek.
| Krok | Kod | Uwagi |
|---|---|---|
| 1. Trasa | { path: 'wydarzenie/:id', component: Szczegoly } | dwukropek = parametr |
| 2. Link | [routerLink]="['wydarzenie', w.id]" | tablica części adresu; router połączy je ukośnikiem |
| 3. Bieżąca trasa | private readonly trasa = inject(ActivatedRoute); | jak Router w lekcji 34 |
| 4. Parametry jako sygnał | readonly parametry = toSignal(this.trasa.paramMap); | toSignal z @angular/core/rxjs-interop |
| 5. Numer | computed(() => Number(this.parametry()?.get('id'))) | parametr zawsze jest tekstem — stąd Number |
Dlaczego sygnał, a nie jednorazowy odczyt?
Gdy przechodzisz z /wydarzenie/1 na /wydarzenie/2, router nie tworzy komponentu od nowa — zmienia tylko parametr. paramMap to strumień, który podaje nowe parametry przy każdej zmianie; toSignal zamienia go w sygnał, a computed przelicza wydarzenie. Jednorazowy odczyt w konstruktorze zostałby przy starym numerze. Znak ?. w parametry()?.get('id') chroni przed chwilą, w której sygnał nie ma jeszcze wartości (undefined).
Lista i szczegóły na jednym ekranie
Po lewej lista wydarzeń, po prawej <router-outlet /> ze szczegółami wybranego. Dane leżą w pliku dane.ts — razem z interfejsem — i korzystają z nich oba komponenty. WYDARZENIA to stała tablica pisana wielkimi literami, bo się nie zmienia.
export interface Wydarzenie {
id: number;
tytul: string;
kategoria: string;
sala: string;
opis: string;
}
export const WYDARZENIA: Wydarzenie[] = [
{ id: 1, tytul: 'Turniej retro', kategoria: 'gry', sala: '12', opis: 'Tetris, Pac-Man i Bomberman na szkolnych komputerach.' },
{ id: 2, tytul: 'Noc robotów', kategoria: 'technika', sala: '7', opis: 'Budujemy i programujemy roboty do wyścigu po linii.' },
{ id: 3, tytul: 'Warsztat druku 3D', kategoria: 'technika', sala: '14', opis: 'Od modelu w programie do gotowego breloka.' },
{ id: 4, tytul: 'Liga planszówek', kategoria: 'gry', sala: 'Biblioteka', opis: 'Turniej gier planszowych dla drużyn dwuosobowych.' },
];
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { WYDARZENIA } from './dane';
@Component({
selector: 'app-szczegoly',
imports: [],
templateUrl: './szczegoly.html'
})
export class Szczegoly {
private readonly trasa = inject(ActivatedRoute);
readonly parametry = toSignal(this.trasa.paramMap);
readonly id = computed(() => Number(this.parametry()?.get('id')));
readonly wydarzenie = computed(() => WYDARZENIA.find(w => w.id === this.id()));
}
@if (wydarzenie(); as w) {
<div class="card">
<div class="card-header">Wydarzenie nr {{ id() }}</div>
<div class="card-body">
<h4 class="card-title">{{ w.tytul }}</h4>
<p class="card-text">{{ w.opis }}</p>
<span class="badge text-bg-secondary">sala {{ w.sala }}</span>
</div>
</div>
}
import { Routes } from '@angular/router';
import { Szczegoly } from './szczegoly';
export const trasy: Routes = [
{ path: 'wydarzenie/:id', component: Szczegoly },
];
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { WYDARZENIA } from './dane';
@Component({
selector: 'app-demo',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './demo.html'
})
export class Demo {
readonly wydarzenia = WYDARZENIA;
}
<div class="p-3">
<div class="row g-4" style="max-width: 56rem">
<div class="col-md-5">
<div class="list-group">
@for (w of wydarzenia; track w.id) {
<a class="list-group-item list-group-item-action"
[routerLink]="['wydarzenie', w.id]" routerLinkActive="active">{{ w.tytul }}</a>
}
</div>
</div>
<div class="col-md-7">
<router-outlet />
</div>
</div>
</div>
Turniej retro ┌ Wydarzenie nr 2 ─────────────────┐
[Noc robotów] │ Noc robotów │
Warsztat druku 3D │ Budujemy i programujemy roboty… │
Liga planszówek │ [sala 7] │
└──────────────────────────────────┘
Klikaj kolejne pozycje — karta zmienia treść bez migania, a pozycja na liście jest podświetlona przez routerLinkActive. Wpisz w adresie ręcznie …/wydarzenie/4 — otworzy się „Liga planszówek”. W zwykłym projekcie link może mieć ukośnik: [routerLink]="['/wydarzenie', w.id]".
Zatrzymaj się
Karta jest w @if (wydarzenie(); as w). Kiedy find zwróci undefined i co wtedy zobaczy użytkownik?
Nieistniejący numer i ekran „wybierz z listy”
Dwa brakujące przypadki z przykładu 1: pusty panel na starcie i numer, którego nie ma w danych. Dla pustego adresu dodajemy trasę z komponentem-podpowiedzią, a w szczegółach — gałąź @else.
import { Component } from '@angular/core';
@Component({
selector: 'app-wybierz',
imports: [],
template: `<div class="alert alert-secondary">← Wybierz wydarzenie z listy.</div>`
})
export class Wybierz {}
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { WYDARZENIA } from './dane';
@Component({
selector: 'app-szczegoly',
imports: [],
templateUrl: './szczegoly.html'
})
export class Szczegoly {
private readonly trasa = inject(ActivatedRoute);
readonly parametry = toSignal(this.trasa.paramMap);
readonly id = computed(() => Number(this.parametry()?.get('id')));
readonly wydarzenie = computed(() => WYDARZENIA.find(w => w.id === this.id()));
}
@if (wydarzenie(); as w) {
<div class="card">
<div class="card-body">
<h4 class="card-title">{{ w.tytul }}</h4>
<p class="card-text">{{ w.opis }}</p>
</div>
</div>
} @else {
<div class="alert alert-danger">
Nie ma wydarzenia o numerze „{{ parametry()?.get('id') }}”.
</div>
}
import { Routes } from '@angular/router';
import { Szczegoly } from './szczegoly';
import { Wybierz } from './wybierz';
export const trasy: Routes = [
{ path: '', component: Wybierz },
{ path: 'wydarzenie/:id', component: Szczegoly },
];
<div class="p-3">
<h5 class="mb-3">Wydarzenia klubu</h5>
<div class="row g-4" style="max-width: 56rem">
<div class="col-md-5">
<div class="list-group">
@for (w of wydarzenia; track w.id) {
<a class="list-group-item list-group-item-action"
[routerLink]="['wydarzenie', w.id]" routerLinkActive="active">{{ w.tytul }}</a>
}
</div>
</div>
<div class="col-md-7">
<router-outlet />
</div>
</div>
</div>
Nie ma wydarzenia o numerze „abc”. (czerwony alert)
demo.ts i dane.ts są takie jak w przykładzie 1. Number('abc') daje NaN (nie-liczba), a takiego id nie ma — więc działa ta sama gałąź co dla 99. W komunikacie pokazujemy surowy tekst z adresu, a nie id().
Zatrzymaj się
Co pokazałby {{ id() }} dla adresu …/wydarzenie/abc? Który zapis jest czytelniejszy dla użytkownika?
Przyciski „poprzednie” i „następne”
Na karcie szczegółów dwa przyciski prowadzą do sąsiednich numerów. Link ['..', id() + 1] oznacza: piętro wyżej (z wydarzenie/2 do wydarzenie) i dalej nowy numer. Przy pierwszym i ostatnim wydarzeniu odpowiedni przycisk jest wyłączony.
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { WYDARZENIA } from './dane';
@Component({
selector: 'app-szczegoly',
imports: [RouterLink],
templateUrl: './szczegoly.html'
})
export class Szczegoly {
private readonly trasa = inject(ActivatedRoute);
readonly parametry = toSignal(this.trasa.paramMap);
readonly id = computed(() => Number(this.parametry()?.get('id')));
readonly wydarzenie = computed(() => WYDARZENIA.find(w => w.id === this.id()));
readonly pierwsze = computed(() => this.id() <= 1);
readonly ostatnie = computed(() => this.id() >= WYDARZENIA.length);
}
@if (wydarzenie(); as w) {
<div class="card">
<div class="card-body">
<p class="text-secondary small mb-1">{{ id() }} z 4</p>
<h4 class="card-title">{{ w.tytul }}</h4>
<p class="card-text">{{ w.opis }}</p>
</div>
<div class="card-footer d-flex justify-content-between">
<a class="btn btn-sm btn-outline-primary" [class.disabled]="pierwsze()"
[routerLink]="['..', id() - 1]">← poprzednie</a>
<a class="btn btn-sm btn-outline-primary" [class.disabled]="ostatnie()"
[routerLink]="['..', id() + 1]">następne →</a>
</div>
</div>
}
import { Routes } from '@angular/router';
import { Szczegoly } from './szczegoly';
export const trasy: Routes = [
{ path: '', redirectTo: 'wydarzenie/1', pathMatch: 'full' },
{ path: 'wydarzenie/:id', component: Szczegoly },
];
<div class="p-3">
<p class="text-secondary">Przeglądaj wydarzenia po kolei albo wybierz z listy.</p>
<div class="row g-4" style="max-width: 56rem">
<div class="col-md-5">
<div class="list-group">
@for (w of wydarzenia; track w.id) {
<a class="list-group-item list-group-item-action"
[routerLink]="['wydarzenie', w.id]" routerLinkActive="active">{{ w.tytul }}</a>
}
</div>
</div>
<div class="col-md-7">
<router-outlet />
</div>
</div>
</div>
4 z 4
Liga planszówek
Turniej gier planszowych dla drużyn dwuosobowych.
[← poprzednie] [następne →] (wyszarzony)
Przykład startuje od razu na pierwszym wydarzeniu dzięki przekierowaniu. Link <a> nie ma atrybutu disabled — klasa Bootstrapa disabled wygasza przycisk i blokuje kliknięcia. Liczba 4 w szablonie jest wpisana na sztywno — poprawisz to w zadaniu 1.
Zatrzymaj się
Dlaczego w linku jest '..', a nie 'wydarzenie'? Jaki adres powstałby po kliknięciu w wersji bez ..?
Filtr w adresie: ?kategoria=gry
Parametry po znaku zapytania (query params) nie wybierają strony, tylko ją modyfikują: filtr, sortowanie, numer strony wyników. Przyciski ustawiają ?kategoria=…, a lista odczytuje go z queryParamMap i filtruje dane w computed.
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { WYDARZENIA } from './dane';
@Component({
selector: 'app-demo',
imports: [RouterLink],
templateUrl: './demo.html'
})
export class Demo {
private readonly trasa = inject(ActivatedRoute);
readonly zapytanie = toSignal(this.trasa.queryParamMap);
readonly kategoria = computed(() => this.zapytanie()?.get('kategoria') ?? 'wszystkie');
readonly widoczne = computed(() =>
this.kategoria() === 'wszystkie'
? WYDARZENIA
: WYDARZENIA.filter(w => w.kategoria === this.kategoria())
);
}
<div class="p-3">
<div style="max-width: 34rem">
<div class="btn-group mb-3">
<a class="btn btn-outline-primary" routerLink="." [class.active]="kategoria() === 'wszystkie'">Wszystkie</a>
<a class="btn btn-outline-primary" routerLink="." [queryParams]="{ kategoria: 'gry' }"
[class.active]="kategoria() === 'gry'">Gry</a>
<a class="btn btn-outline-primary" routerLink="." [queryParams]="{ kategoria: 'technika' }"
[class.active]="kategoria() === 'technika'">Technika</a>
</div>
<ul class="list-group">
@for (w of widoczne(); track w.id) {
<li class="list-group-item d-flex justify-content-between">
{{ w.tytul }} <span class="badge text-bg-secondary">{{ w.kategoria }}</span>
</li>
}
</ul>
<p class="small text-secondary mt-2">Filtr z adresu: {{ kategoria() }}</p>
</div>
</div>
[Wszystkie] [Gry] [Technika]
Turniej retro [gry]
Liga planszówek [gry]
Filtr z adresu: gry
routerLink="." oznacza „ta sama strona”, a [queryParams] dokleja parametry po znaku zapytania. Link „Wszystkie” nie ma queryParams, więc je usuwa. ?? 'wszystkie' (lekcja 19) podstawia wartość, gdy w adresie nie ma kategorii. Ten przykład nie ma tras-dzieci, więc nie ma pliku trasy.ts — w laboratorium rejestrujesz go zwyczajnie.
Zatrzymaj się
Filtr można by trzymać w zwykłym sygnale, jak dotąd. Co zyskujemy, trzymając go w adresie? Skopiuj adres z filtrem i otwórz go w nowej karcie.
Zestawienie elementów
| Element | Znaczenie |
|---|---|
{ path: 'wydarzenie/:id', … } | trasa z parametrem id |
[routerLink]="['wydarzenie', w.id]" | link złożony z części; '..' — piętro wyżej |
toSignal(…) z @angular/core/rxjs-interop | zamienia strumień (Observable) w sygnał |
trasa.paramMap, .get('id') | parametry trasy; wartość zawsze tekstem albo null |
trasa.queryParamMap, .get('kategoria') | parametry po znaku zapytania |
routerLink="." [queryParams]="{ kategoria: 'gry' }" | ta sama strona z parametrem zapytania |
Number(tekst) | tekst na liczbę; z nieliczby wychodzi NaN |
a?.b | odczyt b tylko wtedy, gdy a istnieje |
[class.disabled] na linku-przycisku | wygaszenie linku (Bootstrap) |
Zanim utkniesz
| Zapis | Problem |
|---|---|
routerLink="wydarzenie/w.id" | Link prowadzi dosłownie do wydarzenie/w.id. Wartość z pętli — przez tablicę w [routerLink]. |
w.id === this.parametry()?.get('id') bez Number | Nigdy nie znajduje wydarzenia: liczba 3 i tekst '3' nie są równe przez ===. |
Jednorazowy odczyt this.trasa.snapshot.paramMap.get('id') | Pierwsza karta jest dobra, ale po kliknięciu innego wydarzenia nic się nie zmienia — komponent nie powstaje od nowa. |
{ path: 'wydarzenie/id' } bez dwukropka | Trasa pasuje tylko do dosłownego adresu wydarzenie/id. |
import { toSignal } from '@angular/core' | TS2724: '”@angular/core”’ has no exported member named 'toSignal’. Did you mean 'Signal’?. Właściwy import: @angular/core/rxjs-interop. |
[routerLink] w komponencie bez RouterLink w imports | NG8002: Can’t bind to 'routerLink’ since it isn’t a known property of 'a’. |
Zadania
W przykładzie 3 zastąp wpisaną na sztywno liczbę 4 prawdziwą liczbą wydarzeń z danych.
Kryterium sukcesu: dopisanie piątego wydarzenia do dane.ts zmienia napis na „X z 5” i pozwala do niego przejść.
Podpowiedź
Pole w klasie: readonly razem = WYDARZENIA.length;.
Lista pięciu uczniów (id, imie, klasa, ulubiony przedmiot) w pliku z danymi. Kliknięcie ucznia otwiera jego profil pod adresem uczen/:id.
Kryterium sukcesu: każdy uczeń ma własny adres; pozycja na liście jest podświetlona.
Podpowiedź
Przykład 1 z innymi danymi.
Rozbuduj zadanie 2 jak w przykładzie 2: podpowiedź „Wybierz ucznia” na starcie i komunikat dla złego numeru. Dodaj na profilu link „Wróć do listy”, który prowadzi do pustego adresu.
Kryterium sukcesu: ręcznie wpisany zły numer pokazuje komunikat zamiast pustej strony; „Wróć do listy” pokazuje podpowiedź.
Podpowiedź
Link powrotu: routerLink="../.." — dwa piętra wyżej z uczen/3.
Tabela wyników turnieju (nick, punkty, czas). Przyciski „po punktach” i „po czasie” ustawiają ?sort=punkty albo ?sort=czas, a tabela sortuje się w computed.
Kryterium sukcesu: adres z ?sort=czas otwarty w nowej karcie od razu pokazuje tabelę posortowaną po czasie; bez parametru — po punktach.
Podpowiedź
Kopia przed sortowaniem: [...WYNIKI].sort(…); wybór porównania przez ?:.
Galeria sześciu obrazków z folderu public/img. Pod adresem zdjecie/:nr widać duże zdjęcie, podpis i przyciski poprzednie/następne, które zawijają się (po ostatnim jest pierwsze). Pod zdjęciem — rząd miniatur, aktywna podświetlona.
Kryterium sukcesu: przejście „następne” z ostatniego zdjęcia prowadzi do pierwszego; miniatury zmieniają adres i podświetlenie.
Podpowiedź
Numer następnego zdjęcia: nr() === razem ? 1 : nr() + 1 w computed.
Połącz przykłady 3 i 4: lista produktów z filtrem kategorii w ?kategoria=… po lewej i szczegóły produktu pod produkt/:id po prawej.
Kryterium sukcesu: filtr i szczegóły działają razem; adres z filtrem i numerem produktu otwarty w nowej karcie pokazuje dokładnie ten sam widok.
Podpowiedź
Żeby filtr nie znikał po kliknięciu produktu, dodaj do linku queryParamsHandling="preserve".
Co trzeba zapamiętać
- Trasa z parametrem:
{ path: 'wydarzenie/:id', … }; link:[routerLink]="['wydarzenie', w.id]". - Parametr odczytujesz jako sygnał:
toSignal(inject(ActivatedRoute).paramMap), potemNumber(…get('id')). - Przy zmianie parametru komponent zostaje ten sam — dlatego sygnał, a nie jednorazowy odczyt.
- Brak danych dla numeru obsługujesz gałęzią
@else; pusty adres — osobną trasą albo przekierowaniem. - Parametry zapytania
?klucz=wartoscustawiasz przez[queryParams], odczytujesz zqueryParamMap.
Sprawdź się: dlaczego numer wydarzenia lepiej trzymać w adresie niż w zwykłym sygnale? Podaj dwie korzyści dla użytkownika.
Dokumentacja: angular.dev — Read route state.