Przejdź do treści
School IT / Przestrzeń do nauki

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.

Angular 22 :id [routerLink] z tablicą ActivatedRoute toSignal paramMap queryParams 50 min
CEL LEKCJI

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 w computed.

Przygotowanie: lekcja 34 (trasy, routerLink, <router-outlet />), lekcja 11 (computed), lekcja 21 (@if (…; as x)). Przewidywany czas: 50 min z zadaniami.

TEORIA

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.

KrokKodUwagi
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 trasaprivate 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. Numercomputed(() => 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).

PRZYKŁAD 1

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.

src/app/nowa-35-1/dane.ts — pełny plik
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.' },
];
src/app/nowa-35-1/szczegoly.ts — pełny plik
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()));
}
src/app/nowa-35-1/szczegoly.html — pełny plik
@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>
}
src/app/nowa-35-1/trasy.ts — pełny plik
import { Routes } from '@angular/router';
import { Szczegoly } from './szczegoly';

export const trasy: Routes = [
    { path: 'wydarzenie/:id', component: Szczegoly },
];
src/app/nowa-35-1/demo.ts — pełny plik
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;
}
src/app/nowa-35-1/demo.html — pełny plik
<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>
po kliknięciu „Noc robotów” — adres …/nowa-35-1/wydarzenie/2
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?

PRZYKŁAD 2

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.

src/app/nowa-35-2/wybierz.ts — pełny plik
import { Component } from '@angular/core';

@Component({
    selector: 'app-wybierz',
    imports: [],
    template: `<div class="alert alert-secondary">← Wybierz wydarzenie z listy.</div>`
})
export class Wybierz {}
src/app/nowa-35-2/szczegoly.ts — pełny plik
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()));
}
src/app/nowa-35-2/szczegoly.html — pełny plik
@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>
}
src/app/nowa-35-2/trasy.ts — pełny plik
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 },
];
src/app/nowa-35-2/demo.html — pełny plik
<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>
adres …/wydarzenie/abc
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?

PRZYKŁAD 3

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.

src/app/nowa-35-3/szczegoly.ts — pełny plik
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);
}
src/app/nowa-35-3/szczegoly.html — pełny plik
@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>
}
src/app/nowa-35-3/trasy.ts — pełny plik
import { Routes } from '@angular/router';
import { Szczegoly } from './szczegoly';

export const trasy: Routes = [
    { path: '', redirectTo: 'wydarzenie/1', pathMatch: 'full' },
    { path: 'wydarzenie/:id', component: Szczegoly },
];
src/app/nowa-35-3/demo.html — pełny plik
<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>
po trzech kliknięciach „następne →” — adres …/wydarzenie/4
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 ..?

PRZYKŁAD 4

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.

src/app/nowa-35-4/demo.ts — pełny plik
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())
    );
}
src/app/nowa-35-4/demo.html — pełny plik
<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>
po kliknięciu „Gry” — adres /nowa-35-4?kategoria=gry
[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.

ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenie
{ 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-interopzamienia 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?.bodczyt b tylko wtedy, gdy a istnieje
[class.disabled] na linku-przyciskuwygaszenie linku (Bootstrap)
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
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 NumberNigdy 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 dwukropkaTrasa 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 importsNG8002: Can’t bind to 'routerLink’ since it isn’t a known property of 'a’.
ZADANIA

Zadania

ZAD 1Bez magicznej czwórki★☆☆

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;.

ZAD 2Profil ucznia★☆☆

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.

ZAD 3Profil z obsługą błędów★★☆

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.

ZAD 4Sortowanie w adresie★★☆

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 ?:.

ZAD 5Galeria zdjęć z numerem★★★

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.

ZAD 6Sklep z kategoriami i szczegółami★★★

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".

PODSUMOWANIE

Co trzeba zapamiętać

  • Trasa z parametrem: { path: 'wydarzenie/:id', … }; link: [routerLink]="['wydarzenie', w.id]".
  • Parametr odczytujesz jako sygnał: toSignal(inject(ActivatedRoute).paramMap), potem Number(…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=wartosc ustawiasz przez [queryParams], odczytujesz z queryParamMap.

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.

Postęp zapisuje się w tej przeglądarce.