Komunikacja między komponentami rodzeństwa – Serwis

1. Czym jest serwis w Angularze?

Serwis to specjalna klasa w Angularze, która:

  • Przechowuje dane i logikę biznesową
  • Udostępnia funkcjonalności dla komponentów
  • Łączy komponenty między sobą
  • Żyje przez cały czas działania aplikacji

Analogie z życia:

  • Serwis = Poczta 📮 – przekazuje wiadomości między ludźmi
  • Serwis = Bank 🏦 – przechowuje pieniądze, udostępnia operacje
  • Serwis = Biblioteka 📚 – przechowuje książki, wypożycza je różnym osobom
  • Serwis = Recepcja w hotelu 🏨 – komunikuje między gośćmi a różnymi działami

W czym serwis pomaga?

// ❌ BEZ SERWISU - kod powtarzany w każdym komponencie
export class FilmComponent1 {
  films = [...]; // Ta sama lista
  addFilm() { /* ta sama logika */ }
}

export class FilmComponent2 {
  films = [...]; // Ta sama lista - DUPLIKACJA!
  addFilm() { /* ta sama logika - DUPLIKACJA! */ }
}

// ✅ Z SERWISEM - kod w jednym miejscu
export class FilmService {
  films = [...]; // Jedna lista dla wszystkich
  addFilm() { /* logika w jednym miejscu */ }
}

Kiedy tworzymy serwis?

  1. Współdzielenie danych – kilka komponentów potrzebuje tych samych danych
  2. Komunikacja – komponenty muszą się ze sobą „rozmawiać”
  3. Logika biznesowa – obliczenia, walidacja, API calls
  4. Unikanie duplikacji – ten sam kod w wielu miejscach

2. Czym jest komunikacja rodzeństwa?

Komponenty rodzeństwa to komponenty, które są na tym samym poziomie w hierarchii – mają tego samego rodzica, ale nie są swoimi rodzicami ani dziećmi.

Przykład z życia:

Wyobraź sobie dwoje rodzeństwa w domu:

  • Ania (komponent 1) chce przekazać wiadomość Tomkowi (komponent 2)
  • Nie mogą się komunikować bezpośrednio
  • Potrzebują pośrednika – np. kartka na lodówce (serwis)

W aplikacji filmowej:

App Component (rodzic)
├── Film Search (dziecko 1) 
├── Film List (dziecko 2)
└── Film Counter (dziecko 3)

Problem: Jak Film Search może przekazać wyniki wyszukiwania do Film List?

Rozwiązanie: Serwis – „kartka na lodówce” dla komponentów!

2. Jak działa serwis jako pośrednik?

Dlaczego potrzebujemy serwisu?

Problem bez serwisu:

// Komponenty nie mogą się komunikować bezpośrednio!
Component A ❌ Component B  // Nie da się!
Component A ❌ Component C  // Nie da się!

Rozwiązanie z serwisem:

Component A → Service → Component B  ✅
Component A → Service → Component C  ✅
Component B → Service → Component A  ✅

Serwis to „Poczta” między komponentami:

  1. Serwis = Skrzynka pocztowa 📮
  2. Komponent A = Osoba wysyłająca list 📝
  3. Komponent B = Osoba odbierająca list 📬
  4. Subject/Observable = System powiadomień 🔔

Schemat działania:

Komponent A → wysyła → Serwis → powiadamia → Komponent B

Dlaczego to jest lepsze?

  • Centralne miejsce – wszystkie dane w jednym serwisie
  • Automatyczne powiadomienia – gdy dane się zmienią, wszyscy się dowiadują
  • Łatwość dodawania – nowy komponent łatwo się podłącza
  • Brak duplikacji – logika w jednym miejscu

3. Podstawowe pojęcia

Subject vs Observable – prosta analogia:

Subject = Nadajnik radiowy 📻

  • Może wysyłać sygnały (next())
  • Może odbierać sygnały (subscribe())

Observable = Odbiornik radiowy 📟

  • Może tylko odbierać sygnały (subscribe())
// W serwisie
private messageSubject = new Subject<string>();    // Nadajnik
message$ = this.messageSubject.asObservable();     // Odbiornik

// Wysyłanie
this.messageSubject.next("Wiadomość");

// Odbieranie  
this.message$.subscribe(wiadomość => console.log(wiadomość));

4. Przykład krok po kroku – Wyszukiwarka filmów

Krok 1: Tworzymy serwis

// film-search.service.ts
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable({
  providedIn: 'root' // Dostępny w całej aplikacji
})
export class FilmSearchService {
  
  // PRYWATNY nadajnik - tylko serwis może wysyłać
  private searchResultsSubject = new Subject<any[]>();
  
  // PUBLICZNY odbiornik - komponenty mogą nasłuchiwać
  searchResults$ = this.searchResultsSubject.asObservable();
  
  // Metoda do wysyłania wyników wyszukiwania
  updateSearchResults(films: any[]) {
    console.log('🔔 Serwis wysyła nowe wyniki:', films);
    this.searchResultsSubject.next(films);
  }
  
  // Metoda do czyszczenia wyników
  clearResults() {
    console.log('🧹 Serwis czyści wyniki');
    this.searchResultsSubject.next([]);
  }
}

Krok 2: Komponent wysyłający (Film Search)

// film-search.component.ts
import { Component } from '@angular/core';
import { FilmSearchService } from './film-search.service';

@Component({
  selector: 'app-film-search',
  standalone: true,
  template: `
    <div class="search-container">
      <h3>🔍 Wyszukiwarka filmów</h3>
      
      <input 
        type="text" 
        #searchInput
        (input)="searchTerm = searchInput.value"
        placeholder="Wpisz tytuł filmu..."
        class="search-input">
      
      <button (click)="search()" class="search-btn">
        Szukaj
      </button>
      
      <button (click)="clearSearch()" class="clear-btn">
        Wyczyść
      </button>
      
      <p>Szukasz: "{{ searchTerm }}"</p>
    </div>
  `,
  styles: [`
    .search-container {
      background: #f8f9fa;
      padding: 20px;
      border-radius: 8px;
      margin: 10px;
    }
    .search-input {
      padding: 8px;
      margin-right: 10px;
      border: 1px solid #ddd;
      border-radius: 4px;
      width: 200px;
    }
    .search-btn {
      background: #007bff;
      color: white;
      padding: 8px 16px;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      margin-right: 10px;
    }
    .clear-btn {
      background: #6c757d;
      color: white;
      padding: 8px 16px;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
  `]
})
export class FilmSearchComponent {
  searchTerm = '';
  
  // Baza filmów do przeszukiwania
  allFilms = [
    { id: 1, title: 'Avengers: Endgame', year: 2019, genre: 'Akcja' },
    { id: 2, title: 'Spider-Man', year: 2021, genre: 'Akcja' },
    { id: 3, title: 'Batman', year: 2022, genre: 'Akcja' },
    { id: 4, title: 'Titanic', year: 1997, genre: 'Romans' },
    { id: 5, title: 'Joker', year: 2019, genre: 'Dramat' }
  ];
  
  constructor(private filmSearchService: FilmSearchService) {}
  
  search() {
    if (this.searchTerm.trim() === '') {
      this.clearSearch();
      return;
    }
    
    // Filtruj filmy na podstawie wyszukiwanego tekstu
    const results = this.allFilms.filter(film => 
      film.title.toLowerCase().includes(this.searchTerm.toLowerCase())
    );
    
    console.log('📤 Wysyłam wyniki wyszukiwania:', results);
    
    // WYSYŁAM wyniki przez serwis
    this.filmSearchService.updateSearchResults(results);
  }
  
  clearSearch() {
    this.searchTerm = '';
    
    console.log('📤 Wysyłam puste wyniki');
    
    // WYSYŁAM pustą listę przez serwis
    this.filmSearchService.clearResults();
  }
}

Krok 3: Komponent odbierający (Film List)

// film-list.component.ts
import { Component, OnDestroy } from '@angular/core';
import { FilmSearchService } from './film-search.service';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-film-list',
  standalone: true,
  template: `
    <div class="list-container">
      <h3>📋 Lista filmów</h3>
      
      <p>Wyników: {{ films.length }}</p>
      
      @if (films.length === 0) {
        <div class="no-results">
          <p>🔍 Brak wyników wyszukiwania</p>
          <p>Użyj wyszukiwarki powyżej, aby znaleźć filmy</p>
        </div>
      } @else {
        <div class="films-grid">
          @for (film of films; track film.id) {
            <div class="film-card">
              <h4>{{ film.title }}</h4>
              <p>Rok: {{ film.year }}</p>
              <p>Gatunek: {{ film.genre }}</p>
            </div>
          }
        </div>
      }
    </div>
  `,
  styles: [`
    .list-container {
      background: #e9ecef;
      padding: 20px;
      border-radius: 8px;
      margin: 10px;
    }
    .films-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
      gap: 15px;
      margin-top: 15px;
    }
    .film-card {
      background: white;
      padding: 15px;
      border-radius: 8px;
      border-left: 4px solid #007bff;
    }
    .no-results {
      text-align: center;
      color: #6c757d;
      padding: 20px;
    }
  `]
})
export class FilmListComponent implements OnDestroy {
  films: any[] = [];
  private subscription: Subscription;
  
  constructor(private filmSearchService: FilmSearchService) {
    console.log('📥 Film List nasłuchuje wyników wyszukiwania...');
    
    // SUBSKRYBUJĘ się na wyniki wyszukiwania
    this.subscription = this.filmSearchService.searchResults$.subscribe(
      results => {
        console.log('📥 Film List otrzymał nowe wyniki:', results);
        this.films = results;
      }
    );
  }
  
  // WAŻNE: Muszę się wysubskrybować gdy komponent jest niszczony
  ngOnDestroy() {
    console.log('🚫 Film List przestaje nasłuchiwać');
    if (this.subscription) {
      this.subscription.unsubscribe();
    }
  }
}

Krok 4: Drugi odbierający komponent (Film Counter)

// film-counter.component.ts
import { Component, OnDestroy } from '@angular/core';
import { FilmSearchService } from './film-search.service';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-film-counter',
  standalone: true,
  template: `
    <div class="counter-container">
      <h3>📊 Statystyki</h3>
      
      <div class="stats">
        <div class="stat-item">
          <span class="stat-number">{{ filmCount }}</span>
          <span class="stat-label">Znalezione filmy</span>
        </div>
        
        <div class="stat-item">
          <span class="stat-number">{{ genreCount }}</span>
          <span class="stat-label">Różne gatunki</span>
        </div>
        
        <div class="stat-item">
          <span class="stat-number">{{ avgYear }}</span>
          <span class="stat-label">Średni rok</span>
        </div>
      </div>
      
      @if (filmCount > 0) {
        <div class="genres-list">
          <h4>Znalezione gatunki:</h4>
          @for (genre of uniqueGenres; track genre) {
            <span class="genre-badge">{{ genre }}</span>
          }
        </div>
      }
    </div>
  `,
  styles: [`
    .counter-container {
      background: #fff3cd;
      padding: 20px;
      border-radius: 8px;
      margin: 10px;
    }
    .stats {
      display: flex;
      gap: 20px;
      margin: 15px 0;
    }
    .stat-item {
      text-align: center;
    }
    .stat-number {
      display: block;
      font-size: 2rem;
      font-weight: bold;
      color: #856404;
    }
    .stat-label {
      font-size: 0.9rem;
      color: #6c757d;
    }
    .genres-list {
      margin-top: 15px;
    }
    .genre-badge {
      background: #ffc107;
      color: #212529;
      padding: 4px 8px;
      border-radius: 12px;
      margin-right: 8px;
      font-size: 0.8rem;
    }
  `]
})
export class FilmCounterComponent implements OnDestroy {
  filmCount = 0;
  genreCount = 0;
  avgYear = 0;
  uniqueGenres: string[] = [];
  
  private subscription: Subscription;
  
  constructor(private filmSearchService: FilmSearchService) {
    console.log('📥 Film Counter nasłuchuje wyników wyszukiwania...');
    
    // SUBSKRYBUJĘ się na te same wyniki co Film List
    this.subscription = this.filmSearchService.searchResults$.subscribe(
      results => {
        console.log('📥 Film Counter otrzymał nowe wyniki:', results);
        this.updateStats(results);
      }
    );
  }
  
  private updateStats(films: any[]) {
    this.filmCount = films.length;
    
    if (films.length > 0) {
      // Oblicz unikalne gatunki
      this.uniqueGenres = [...new Set(films.map(f => f.genre))];
      this.genreCount = this.uniqueGenres.length;
      
      // Oblicz średni rok
      const totalYears = films.reduce((sum, film) => sum + film.year, 0);
      this.avgYear = Math.round(totalYears / films.length);
    } else {
      this.uniqueGenres = [];
      this.genreCount = 0;
      this.avgYear = 0;
    }
  }
  
  ngOnDestroy() {
    console.log('🚫 Film Counter przestaje nasłuchiwać');
    if (this.subscription) {
      this.subscription.unsubscribe();
    }
  }
}

Krok 5: Główny komponent łączący wszystko

// app.component.ts
import { Component } from '@angular/core';
import { FilmSearchComponent } from './film-search.component';
import { FilmListComponent } from './film-list.component';
import { FilmCounterComponent } from './film-counter.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [FilmSearchComponent, FilmListComponent, FilmCounterComponent],
  template: `
    <div class="app-container">
      <h1>🎬 Komunikacja przez Serwis</h1>
      <p class="description">
        Komponenty rodzeństwa komunikują się przez serwis. 
        Spróbuj wyszukać filmy!
      </p>
      
      <!-- Komponent wysyłający -->
      <app-film-search></app-film-search>
      
      <div class="results-container">
        <!-- Komponenty odbierające -->
        <app-film-list></app-film-list>
        <app-film-counter></app-film-counter>
      </div>
    </div>
  `,
  styles: [`
    .app-container {
      max-width: 1200px;
      margin: 0 auto;
      padding: 20px;
    }
    .description {
      text-align: center;
      color: #6c757d;
      margin-bottom: 30px;
    }
    .results-container {
      display: grid;
      grid-template-columns: 2fr 1fr;
      gap: 20px;
    }
    @media (max-width: 768px) {
      .results-container {
        grid-template-columns: 1fr;
      }
    }
  `]
})
export class AppComponent {
  // Główny komponent nie musi nic robić!
  // Serwis załatwia całą komunikację
}

5.Jak to działa – krok po kroku

1. Użytkownik wpisuje „Spider” w wyszukiwarce

Film Search → wyszukuje filmy → znajduje "Spider-Man"

2. Film Search wysyła wyniki przez serwis

this.filmSearchService.updateSearchResults([{id: 2, title: 'Spider-Man', ...}]);

3. Serwis powiadamia wszystkich subskrybentów

this.searchResultsSubject.next([{id: 2, title: 'Spider-Man', ...}]);

4. Film List otrzymuje wyniki i wyświetla filmy

this.films = results; // Aktualizuje listę

5. Film Counter otrzymuje te same wyniki i aktualizuje statystyki

this.filmCount = results.length; // Aktualizuje licznik

6. Dlaczego to działa?

Kluczowe punkty:

  1. Jeden serwis, wiele subskrybentów
    • Serwis to „stacja radiowa”
    • Komponenty to „odbiorniki radiowe”
    • Jeden sygnał dociera do wszystkich odbiorników
  2. Automatyczne powiadomienia
    • Gdy serwis wysyła nowe dane (next())
    • Wszyscy subskrybenci automatycznie dostają aktualizację
  3. Loose coupling (luźne powiązanie)
    • Komponenty nie wiedzą o sobie nawzajem
    • Znają tylko serwis
    • Można dodawać/usuwać komponenty bez zmian w innych

7. Najważniejsze zasady

✅ Co robić:

1.Zawsze unsubscribe w ngOnDestroy

ngOnDestroy() {
  this.subscription.unsubscribe();
}

2.Używaj opisowych nazw

searchResults$ // $ oznacza Observable
updateSearchResults() // jasna nazwa metody

3.Loguj dla debugowania

console.log('📤 Wysyłam:', data);
console.log('📥 Otrzymuję:', data);

❌ Czego unikać:

  1. Zapominania o unsubscribe – wyciek pamięci!
  2. Bezpośredniego dostępu do Subject – używaj Observable
  3. Modyfikowania danych w serwisie – serwis tylko przekazuje

8. Proste ćwiczenie krok po kroku

Zadanie: Stwórz komunikację „Dodaj do ulubionych”

  1. Serwis – dodaj metodę addToFavorites(film)
  2. Film Card – przycisk „Polub” wysyła film do serwisu
  3. Favorites List – nasłuchuje i wyświetla polubione filmy
  4. Favorites Counter – pokazuje liczbę polubionych
// favorites.service.ts
export class FavoritesService {
  private favoritesSubject = new Subject<Film[]>();
  favorites$ = this.favoritesSubject.asObservable();
  
  private favoritesList: Film[] = [];
  
  addToFavorites(film: Film) {
    // TODO: Dodaj film do listy
    // TODO: Wyślij aktualizację
  }
}

9. Najczęstsze błędy i rozwiązania

Błąd 1: Subscription nie działa

// ❌ Źle
this.service.data$.subscribe();

// ✅ Dobrze  
this.subscription = this.service.data$.subscribe(data => {
  console.log('Otrzymano:', data);
  this.myData = data;
});

Błąd 2: Zapomnienie o unsubscribe

// ❌ Brak unsubscribe = wyciek pamięci

// ✅ Zawsze pamiętaj
ngOnDestroy() {
  if (this.subscription) {
    this.subscription.unsubscribe();
  }
}

Błąd 3: Modyfikowanie danych w subscribe

// ❌ Nie modyfikuj oryginalnych danych
this.service.data$.subscribe(data => {
  data.push(newItem); // Źle!
});

// ✅ Stwórz kopię
this.service.data$.subscribe(data => {
  this.myData = [...data, newItem]; // Dobrze!
});

10. Podsumowanie

Komunikacja przez serwis to:

  • 📮 Poczta między komponentami rodzeństwa
  • 🔔 System powiadomień – jeden wysyła, wszyscy otrzymują
  • 🔗 Luźne powiązanie – komponenty nie znają się nawzajem

Kluczowe elementy:

  • Subject – nadajnik w serwisie
  • Observable – odbiornik dla komponentów
  • subscribe() – słuchanie zmian
  • unsubscribe() – przestawanie słuchania

Pamiętaj:

  • Jeden serwis może obsługiwać wielu subskrybentów
  • Zawsze unsubscribe w ngOnDestroy
  • Używaj console.log do debugowania
  • Serwis tylko przekazuje dane, nie je modyfikuje