Angular

Lab 4: Component Communication

arrow_back Alle labs
timer 20 minutter
sell module-4-start → module-5-start
Mål: Brug @Output() og EventEmitter til at sende data fra child components op til parent components. Byg en komplet dataflow fra HerdCard helt op til ResultPanel, så appen beregner total CO₂ udledning.

Udgangspunkt

Checkout tag module-4-start. HerdCard modtager allerede et herd objekt via @Input({ required: true }) og viser data med interpolation. Input-feltet har en setActive metode der kun styrer lokal styling. Ændringer i input-feltet sender altså ikke data nogen steder hen.

git checkout module-4-start

Lige nu ser herd-card.ts sådan ud:

// herd-card.ts (udgangspunkt)
import { Component, Input } from '@angular/core';
import { Card } from '../shared/herd-data';

export class HerdCard {
  @Input({ required: true }) herd!: Card;
  isActive = false;

  setActive(count: number) {
    this.isActive = count > 0;
  }
}

Og i herd-card.html kalder input-feltet setActive:

<input type="number" min="0" placeholder="0" #input
       (input)="setActive(input.valueAsNumber)" />

Problemet: værdien fra input-feltet bliver aldrig sendt til parent. Vi skal bruge @Output() til at kommunikere opad.

Hvad er @Output og EventEmitter?

I Angular flyder data i én retning. @Input() sender data ned fra parent til child. @Output() sender data op fra child til parent.

EventEmitter er den klasse du bruger til at "fyre" en event. Tænk på det som en knap der sender en besked til den component der lytter. Du opretter en EventEmitter med @Output() dekoratoren, og parent kan så lytte med den velkendte (eventNavn) syntaks fra event binding.

Trin for trin

looks_one Tilføj @Output() i herd-card.ts

Importér Output og EventEmitter fra @angular/core. Tilføj en countChanged output og omdøb setActive til onCountChanged:

// herd-card.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Card } from '../shared/herd-data';

export class HerdCard {
  @Input({ required: true }) herd!: Card;
  @Output() countChanged = new EventEmitter<number>();
  isActive = false;

  onCountChanged(count: number) {
    this.isActive = count > 0;
    this.countChanged.emit(count);
  }
}

EventEmitter<number> angiver at eventen sender et tal med. .emit(count) fyrer eventen og sender værdien op til den parent der lytter.

looks_two Opdatér herd-card.html

Skift metodekaldet i template fra setActive til onCountChanged:

<!-- herd-card.html -->
<input type="number" min="0" placeholder="0" #input
       (input)="onCountChanged(input.valueAsNumber)" />

#input er en template reference variable der giver direkte adgang til DOM elementet, så vi kan kalde input.valueAsNumber uden at gå via $event.

looks_3 Lyt på eventen i herd-input.html

I herd-input.html tilføjer du output binding med runde parenteser, præcis ligesom du lytter på DOM events:

<!-- herd-input.html -->
@for(herd of herdCards; track herd.key) {
  <app-herd-card
    [herd]="herd"
    (countChanged)="onCountChanged(herd.key, $event)"
  />
}

$event indeholder den værdi der blev emitted, altså tallet fra input-feltet. Vi sender også herd.key med, så parent ved hvilket dyr der blev ændret.

looks_4 Håndtér ændringer i herd-input.ts

Nu skal HerdInput holde styr på antal af hvert dyr, beregne total udledning og sende resultatet videre opad med sin egen @Output():

// herd-input.ts
import { Component, Output, EventEmitter } from '@angular/core';
import { Card, HerdCounts, herdCards } from '../shared/herd-data';

export class HerdInput {
  readonly herdCards: Card[] = herdCards;

  @Output() totalEmissionChange = new EventEmitter<{ totalEmission: number }>();

  herdCounts: HerdCounts = {
    dairyCows: 0,
    beefCows: 0,
    calves: 0,
    pigs: 0,
    sows: 0,
    chickens: 0,
  };

  onCountChanged(key: keyof HerdCounts, count: number) {
    this.herdCounts[key] = count;
    this.calculateTotal();
  }

  calculateTotal() {
    const totalEmission = this.herdCards.reduce(
      (sum, card) => sum + this.herdCounts[card.key] * card.factor,
      0
    ) / 1000;
    this.totalEmissionChange.emit({ totalEmission });
  }
}

reduce summerer antal × faktor for hvert dyr og dividerer med 1000 for at konvertere fra kg til ton.

looks_5 Modtag i calculator.ts

Calculator er parent til både HerdInput og ResultPanel. Den lytter på totalEmissionChange fra HerdInput og sender resultatet ned til ResultPanel:

// calculator.ts
export class Calculator {
  totalEmission = 0;

  onTotalEmissionChange(event: { totalEmission: number }) {
    this.totalEmission = event.totalEmission;
  }
}

I calculator template, bind det sammen:

<!-- calculator.html -->
<app-herd-input
  (totalEmissionChange)="onTotalEmissionChange($event)"
/>
<app-result-panel
  [totalEmission]="totalEmission"
/>

looks_6 Vis resultatet i result-panel.ts

Tilføj en @Input() til ResultPanel og vis værdien formateret:

// result-panel.ts
import { Component, Input } from '@angular/core';

export class ResultPanel {
  @Input() totalEmission = 0;
}

I result-panel template:

{{ totalEmission | number:'1.1-1' }} ton/år

number:'1.1-1' er en pipe der formaterer til mindst 1 decimal og højst 1 decimal (f.eks. 12.3).

lightbulb
Den komplette dataflow:
herd-card(countChanged)herd-input(totalEmissionChange)calculator[totalEmission]result-panel

Data flyder opad via @Output() events og nedad via @Input() property binding. Denne ensrettede datastrøm gør det let at følge hvor data kommer fra og hvor den ender.
Hint: Komplet herd-card.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import { Card } from '../shared/herd-data';

@Component({
  selector: 'app-herd-card',
  imports: [DecimalPipe],
  templateUrl: './herd-card.html',
  styleUrl: './herd-card.css',
})
export class HerdCard {
  @Input({ required: true }) herd!: Card;
  @Output() countChanged = new EventEmitter<number>();
  isActive = false;

  onCountChanged(count: number) {
    this.isActive = count > 0;
    this.countChanged.emit(count);
  }
}
Hint: Komplet herd-input.ts
import { Component, Output, EventEmitter } from '@angular/core';
import { Card, HerdCounts, herdCards } from '../shared/herd-data';
import { HerdCard } from '../herd-card/herd-card';

@Component({
  selector: 'app-herd-input',
  imports: [HerdCard],
  templateUrl: './herd-input.html',
  styleUrl: './herd-input.css',
})
export class HerdInput {
  readonly herdCards: Card[] = herdCards;

  @Output() totalEmissionChange = new EventEmitter<{ totalEmission: number }>();

  herdCounts: HerdCounts = {
    dairyCows: 0,
    beefCows: 0,
    calves: 0,
    pigs: 0,
    sows: 0,
    chickens: 0,
  };

  onCountChanged(key: keyof HerdCounts, count: number) {
    this.herdCounts[key] = count;
    this.calculateTotal();
  }

  calculateTotal() {
    const totalEmission = this.herdCards.reduce(
      (sum, card) => sum + this.herdCounts[card.key] * card.factor,
      0
    ) / 1000;
    this.totalEmissionChange.emit({ totalEmission });
  }
}
Hint: Komplet calculator.ts
import { Component } from '@angular/core';
import { HerdInput } from '../herd-input/herd-input';
import { ResultPanel } from '../result-panel/result-panel';

@Component({
  selector: 'app-calculator',
  imports: [HerdInput, ResultPanel],
  templateUrl: './calculator.html',
  styleUrl: './calculator.css',
})
export class Calculator {
  totalEmission = 0;

  onTotalEmissionChange(event: { totalEmission: number }) {
    this.totalEmission = event.totalEmission;
  }
}

Dokumentation