Dataczwartek, 13 sierpnia 2026 Czas17:47:27
← Aplikacje webowe

Lekcja 25. Projekt 6: Serwis ogłoszeniowy — dodawanie, wyszukiwanie i filtrowanie

Trudny / egzaminacyjny

Po co się tego uczymy?

Trzecia z czterech funkcjonalności nazwanych wprost w podstawie programowej (INF.04.7.3) — serwis ogłoszeniowy (jak OLX czy Otomoto). Ten projekt ćwiczy zaawansowane filtrowanie WIELOMA kryteriami naraz (kategoria, cena od-do, lokalizacja) oraz sortowanie wyników — umiejętności przydatne w niemal każdej aplikacji z większym katalogiem danych.

Teoria

Wymagania projektu:

  • Dodawanie ogłoszenia — WYMAGA zalogowania; tytuł, opis, cena, kategoria, zdjęcie (upload, lekcja 18);
  • Wyszukiwanie i filtrowanie WIELOMA kryteriami naraz: kategoria, zakres cenowy (od-do), fraza w tytule/opisie;
  • Sortowanie wyników: od najnowszych, od najtańszych, od najdroższych;
  • Szczegóły ogłoszenia z danymi kontaktowymi do wystawiającego (widoczne np. dopiero po zalogowaniu — ochrona przed botami zbierającymi dane kontaktowe);
  • "Moje ogłoszenia" — lista WŁASNYCH ogłoszeń zalogowanego użytkownika, z możliwością edycji/usunięcia/oznaczenia jako "sprzedane".

Filtrowanie wieloma kryteriami naraz — budowanie zapytania KROK PO KROKU. Rozszerzenie techniki z projektu sklepu (lekcja 22, filtrowanie po kategorii i frazie) o WIĘCEJ warunków, dokładanych WARUNKOWO (tylko jeśli dany filtr został ustawiony przez użytkownika):

var zapytanie = context.Ogloszenia.AsQueryable();
if (kategoria != null) zapytanie = zapytanie.Where(o => o.Kategoria == kategoria);
if (cenaOd.HasValue) zapytanie = zapytanie.Where(o => o.Cena >= cenaOd.Value);
if (cenaDo.HasValue) zapytanie = zapytanie.Where(o => o.Cena 
    o.Tytul.Contains(fraza) || o.Opis.Contains(fraza));

Każdy if DOKŁADA kolejny warunek do TEGO SAMEGO obiektu zapytania — dopiero ostatnie wywołanie (.OrderBy(...).ToListAsync()) faktycznie generuje i wykonuje JEDNO zapytanie SQL łączące WSZYSTKIE ustawione filtry przez AND.

Sortowanie dynamiczne — wybór kolumny w czasie działania. Parametr sortuj (np. "cena_rosnaco", "cena_malejaco", "data") decyduje, jakiej metody OrderBy/OrderByDescending użyć — realizowane przez proste wyrażenie switch zwracające odpowiednio posortowane zapytanie.

Formularz filtrów w Angularze — grupa kontrolek Reactive Forms. Zamiast osobnych zmiennych na każdy filtr, wygodnie jest zebrać WSZYSTKIE kryteria w jeden FormGroup (poznany w lekcji 11) — zmiana KTÓREGOKOLWIEK pola filtra (przez subskrypcję formularz.valueChanges) automatycznie wywołuje nowe zapytanie do backendu z aktualnym zestawem filtrów.

Schemat

FormGroup filtrow = { kategoria, cenaOd, cenaDo, fraza, sortuj }
        │
        │  formularzFiltrow.valueChanges.subscribe(wartosci => pobierzOgloszenia(wartosci))
        ▼
GET /api/ogloszenia?kategoria=X&cenaOd=100&cenaDo=500&fraza=rower&sortuj=cena_rosnaco
        │
        ▼
ASP.NET Core: budowanie zapytania KROK PO KROKU
  zapytanie = context.Ogloszenia.AsQueryable()
  if kategoria    → .Where(kategoria)
  if cenaOd       → .Where(cena >= cenaOd)
  if cenaDo       → .Where(cena <= cenaDo)
  if fraza        → .Where(tytul/opis zawiera fraza)
  switch sortuj   → .OrderBy(...) / .OrderByDescending(...)
        │
        ▼
  JEDNO zapytanie SQL z wszystkimi warunkami (AND) → wynik do Angular

Przykład z życia

Serwisy takie jak OLX czy Otomoto pozwalają zawęzić tysiące ogłoszeń do garstki pasujących wyników przez kombinację filtrów (kategoria, cena, lokalizacja, słowo kluczowe) — to jeden z najbardziej praktycznych, codziennych przykładów zaawansowanego wyszukiwania w aplikacjach webowych, a ten projekt buduje uproszczoną, ale strukturalnie prawdziwą wersję tego mechanizmu.

Controllers/OgloszeniaController.cs

// Controllers/OgloszeniaController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace OgloszeniaApp.Server.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OgloszeniaController : ControllerBase
{
    private readonly OgloszeniaContext _context;
    public OgloszeniaController(OgloszeniaContext context) => _context = context;

    [HttpGet]
    public async Task<ActionResult<List<Ogloszenie>>> Szukaj(
        [FromQuery] string? kategoria,
        [FromQuery] double? cenaOd,
        [FromQuery] double? cenaDo,
        [FromQuery] string? fraza,
        [FromQuery] string sortuj = "data")
    {
        var zapytanie = _context.Ogloszenia.Where(o => !o.CzySprzedane).AsQueryable();

        if (!string.IsNullOrWhiteSpace(kategoria))
            zapytanie = zapytanie.Where(o => o.Kategoria == kategoria);

        if (cenaOd.HasValue)
            zapytanie = zapytanie.Where(o => o.Cena >= cenaOd.Value);

        if (cenaDo.HasValue)
            zapytanie = zapytanie.Where(o => o.Cena <= cenaDo.Value);

        if (!string.IsNullOrWhiteSpace(fraza))
            zapytanie = zapytanie.Where(o =>
                o.Tytul.Contains(fraza) || o.Opis.Contains(fraza));

        zapytanie = sortuj switch
        {
            "cena_rosnaco" => zapytanie.OrderBy(o => o.Cena),
            "cena_malejaco" => zapytanie.OrderByDescending(o => o.Cena),
            _ => zapytanie.OrderByDescending(o => o.DataDodania)
        };

        return Ok(await zapytanie.ToListAsync());
    }
}

filtr.component.ts

// filtr.component.ts - formularz filtrów reagujący na KAŻDĄ zmianę
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
import { FormGroup, FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime } from 'rxjs/operators';

export interface KryteriaWyszukiwania {
  kategoria: string | null;
  cenaOd: number | null;
  cenaDo: number | null;
  fraza: string | null;
  sortuj: string;
}

@Component({
  selector: 'app-filtr',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './filtr.component.html'
})
export class FiltrComponent implements OnInit {
  @Output() zmianaFiltrow = new EventEmitter<KryteriaWyszukiwania>();

  formularz = new FormGroup({
    kategoria: new FormControl<string | null>(null),
    cenaOd: new FormControl<number | null>(null),
    cenaDo: new FormControl<number | null>(null),
    fraza: new FormControl<string | null>(null),
    sortuj: new FormControl('data')
  });

  ngOnInit(): void {
    this.formularz.valueChanges
      .pipe(debounceTime(300))
      .subscribe(wartosci => {
        this.zmianaFiltrow.emit(wartosci as KryteriaWyszukiwania);
      });
  }
}

Komentarz i wyjaśnienie kodu

Where(o => !o.CzySprzedane) na SAMYM POCZĄTKU budowania zapytania to WAŻNY, "twardy" filtr obowiązujący ZAWSZE, niezależnie od tego, jakie kryteria wpisze użytkownik — sprzedane ogłoszenia nigdy nie powinny pojawić się w wynikach wyszukiwania.

zapytanie = sortuj switch { ... } — wyrażenie switch PRZYPISUJE do zmiennej zapytanie WYNIK jednej z gałęzi (każda gałąź to inny sposób sortowania TEGO SAMEGO, już przefiltrowanego zapytania) — to eleganckie połączenie wcześniej poznanego wzorca "dokładania warunków" z wyborem WARIANTU zamiast kolejnego warunku.

this.formularz.valueChanges.pipe(debounceTime(300)).subscribe(...) w Angularze subskrybuje zmiany CAŁEGO formularza filtrów naraz — niezależnie, KTÓRE pole użytkownik zmieni (kategorię, cenę, frazę), po 300ms ciszy wszystkie aktualne wartości formularza są wysyłane do rodzica przez @Output(), a stamtąd — nowym żądaniem do backendu.

Ćwiczenie samodzielne

Zbuduj formularz filtrów (kategoria, zakres cen, fraza) i kontroler API z filtrowaniem krok po kroku jak w przykładzie. Sprawdź, że kombinacja WIELU filtrów naraz (np. kategoria + zakres cen) poprawnie zawęża wyniki, spełniając WSZYSTKIE warunki jednocześnie.

Zadania do pracy własnej

  1. Dodaj do formularza filtrów pole wyboru sortowania (<select> z opcjami: najnowsze/najtańsze/najdroższe) połączone z parametrem sortuj w zapytaniu.

  2. Zbuduj stronę "Moje ogłoszenia" (chronioną, wymagającą zalogowania) z listą WŁASNYCH ogłoszeń zalogowanego użytkownika i przyciskiem "Oznacz jako sprzedane" (aktualizującym pole CzySprzedane przez [HttpPut]).

  3. Dodaj paginację (stronicowanie) wyników wyszukiwania — zamiast zwracać WSZYSTKIE pasujące ogłoszenia naraz, backend przyjmuje dodatkowe parametry strona i rozmiarStrony, stosuje .Skip((strona-1) * rozmiarStrony).Take(rozmiarStrony) PO wszystkich filtrach i sortowaniu, a Angular pokazuje przyciski "Poprzednia"/"Następna strona" wraz z informacją o łącznej liczbie wyników (dodatkowe zapytanie .CountAsync() na przefiltrowanym, ale NIE stronicowanym zapytaniu).

Typowe błędy

Sprawdzanie WARUNKÓW filtrów w niewłaściwej kolejności względem sortowania — sortowanie (OrderBy) powinno następować PO wszystkich Where, nie przed; w przeciwnym razie EF Core może próbować sortować dane, które dopiero potem zostaną odfiltrowane, co bywa mniej wydajne.

Brak twardego filtra na sprzedane/nieaktywne ogłoszenia — bez Where(o => !o.CzySprzedane) na starcie budowania zapytania, wyniki wyszukiwania pokazywałyby też oferty, które nie powinny już być widoczne.

Wysyłanie osobnego żądania HTTP dla KAŻDEGO pojedynczego pola filtra zamiast zebrania wszystkich zmian w jedno żądanie przez debounceTime na CAŁYM FormGroup — prowadzi do wielu zbędnych, częściowo nieaktualnych zapytań do backendu.

Nawiązanie do egzaminu zawodowego

To realizacja "funkcjonalności serwisu ogłoszeniowego" nazwanej wprost w INF.04.7.3, rozwijająca wzorzec filtrowania z projektu sklepu (lekcja 22) o wielokryterialne wyszukiwanie i sortowanie dynamiczne. Ostatni z dodatkowych projektów (lekcja 26) to system głosowania/ankiet — zupełnie inna domena, skupiona na agregacji i prezentacji wyników.