Écoutez les modifications avec les output et model

Dans le chapitre précédent, vous avez appris à passer les informations d'un component à ses enfants. Oui mais, pour qu'une application soit totalement réactive, les informations doivent pouvoir circuler dans les deux sens — les components enfants doivent être capables de faire remonter des informations à leurs parents.

Émettez aux parents

Tout comme lesinputressemblent aux@Input(), lesoutputressemblent fortement aux@Output()! Profitons-en tout de suite pour implémenter la complétion, l'annulation, et la suppression des tâches.

Ajoutez unoutputà TaskCardHeaderComponent qui émettra desTaskAction, créez la méthode pour le faire émettre :

export class TaskCardHeaderComponent {
  task = input.required<Task>();
  taskAction = output<TaskAction>();

  onTaskAction(action: TaskAction) {
    this.taskAction.emit(action);
  }
}

Puis appelez cette méthode depuis le template :

<div class="task-actions">
	@if (task().completed) {
		<app-task-card-action-button action="cancel" (click)="onTaskAction('cancel')"/>
	} @else {
		<app-task-card-action-button action="complete" (click)="onTaskAction('complete')"/>
	}
	<app-task-card-action-button action="delete" (click)="onTaskAction('delete')"/>
</div>

Avec ceci, dans TaskCardComponent, vous pouvez capter ces émissions et y réagir. Dans notre architecture actuelle, le component smart est TaskGridComponent, donc TaskCardComponent se contentera de bubble up l'événement, c'est-à-dire de le ré-émettre via son propreoutput:

export class TaskCardComponent {
  task = input.required<Task>();
  completed = computed(() => this.task().completed);
  taskAction = output<TaskAction>();

  onTaskAction(action: TaskAction) {
    this.taskAction.emit(action);
  }
}

Captez l'événement de TaskCardHeaderComponent et appelez  onTaskAction()  :

<div class="task-card" [class.completed]="completed()">
	<app-task-card-header 
	    [task]="task()" 
        (taskAction)="onTaskAction($event)"/>
    <app-subtasks-list [subtasks]="task().subtasks"/>
</div>

Il ne reste plus qu'à capturer l'émission dans le parent, c'est-à-dire dans TaskGridComponent. Voici la méthode que je vous propose :

onTaskAction(id: string, action: TaskAction) {
    switch (action) {
      case 'complete':
        this.taskService.completeTask(id);
        break;
      case 'cancel':
        this.taskService.uncompleteTask(id);
        break;
      case 'delete':
        this.taskService.deleteTask(id);
        break;
    }
}

Ainsi, dans le template, vous l'appelez avec :

@for (task of tasks(); track task.id) {
    <app-task-card [task]="task" (taskAction)="onTaskAction(task.id, $event)"/>
}

Comme ça, vous pouvez compléter, "décompléter", et supprimer des tâches !

Il reste une dernière fonctionnalité à implémenter : si l'utilisateur complète toutes les sous-tâches, on veut compléter la tâche, et vice versa, si une tâche est marquée comme complétée et que l'utilisateur décoche une sous-tâche, on veut "décompléter" la tâche.

La bonne nouvelle, c'est que toute la logique métier est déjà implémentée : il ne reste plus qu'à faire remonter les actions de l'utilisateur sur les sous-tâches.

Gérez les sous-tâches

Je vous propose de commencer "en bas", avec SubtasksListItemComponent.

Il faut réagir aux événementschangedu checkbox et en émettre l'état au parent :

<input type="checkbox" 
       [id]="title" 
       class="subtask-checkbox" 
       [checked]="completed"
       (change)="onSubtaskToggle($event)"/>

Créez la méthode correspondante côté TypeScript :

export class SubtasksListItemComponent {
  subtask = input.required<Subtask>();
  subtaskToggle = output<boolean>();

  onSubtaskToggle(checkboxEvent: Event) {
    const checked = (checkboxEvent.target as HTMLInputElement).checked;
    this.subtaskToggle.emit(checked);
  }
}

Ensuite, dans le parent, SubtasksListComponent, réagissez à votre événementsubtaskToggle, sans oublier de faire remonter le titre de la sous-tâche :

<div class="subtasks-list">
  @for (subtask of subtasks(); track subtask.title) {
    <app-subtasks-list-item 
      [subtask]="subtask" 
      (subtaskToggle)="onSubtaskToggle(subtask.title, $event)"/>
  }
</div>

Et côté TypeScript :

export class SubtasksListComponent {
  subtasks = input<Subtask[]>([]);
  subtaskToggle = output<{ title: string, checked: boolean }>();

  onSubtaskToggle(title: string, checked: boolean) {
    this.subtaskToggle.emit({ title, checked });
  }
}

Ensuite il faut bubble up par TaskCardComponent :

<!-- ... -->
<app-subtasks-list 
    [subtasks]="task().subtask"
	(subtaskToggle)="onSubtaskToggle($event)"/>
<!-- ... -->

Qui appelle dans le component :

export class TaskCardComponent {
  //...
  subtaskToggle = output<{ title: string; checked: boolean }>();
  //...
  onSubtaskToggle(toggleEvent: { title: string; checked: boolean }) {
    this.subtaskToggle.emit(toggleEvent);
  }
}

Pour enfin capter et traiter dans TaskGridComponent :

@for (task of tasks(); track task.id) {
    <app-task-card 
        [task]="task" 
        (taskAction)="onTaskAction(task.id, $event)" 
        (subtaskToggle)="onSubtaskToggle(task.id, $event)" />
}

En appelant la méthode finale :

onSubtaskToggle(id: string, 
                subtaskToggle: { title: string; checked: boolean }) {
    subtaskToggle.checked ?
    this.taskService.completeSubtask(id, subtaskToggle.title) :
    this.taskService.uncompleteSubtask(id, subtaskToggle.title);
}

Ça y est ! Ça fait pas mal d'étapes, mais maintenant les sous-tâches sont liées au statut de leur tâche !

Communiquez dans les deux sens

Il existe un autre outil de communication inter-components : les model input.

Ces model, un peu comme leurs cousins les  NgModel, permettent une communication dans les deux sens. Concrètement, il s'agit d'un Signal partagé entre un component parent et son enfant. Cela permet à l'enfant d'en modifier la valeur, partageant ainsi les données avec son parent.

Dans Signalize, je vous propose d'utiliser un model pour le formulaire de création d'une nouvelle Task. Regardons les premières lignes de NewTaskModalFormComponent dans le dossier  tasks/components/new-task-modal/new-task-modal-form:

export class NewTaskModalFormComponent {
  //...
  newTask = signal<CreateTaskDto>(nullCreateTaskDto());
  newTaskUpdates$ = this.newTaskForm.valueChanges.pipe(
    takeUntilDestroyed(),
    tap(value => this.newTask.set(value as CreateTaskDto))
  );
  //...
}

Dans ce component, nous avons un SignalnewTaskqui est mis à jour à chaque modification venant du formulaire. Sous sa forme actuelle, il n'a aucun moyen de communiquer sa valeur au component parent.

D'ailleurs, dans le parent, NewTaskModalComponent, il y a également un SignalnewTask:

export class NewTaskModalComponent {
  newTask = signal(nullCreateTaskDto());
  //...
}

On va pouvoir utiliser un model pour lier ces deux Signals, permettant une communication dans les deux sens entre parent et enfant !

Dans l'enfant, NewTaskModalFormComponent, remplaçons  signal  par  model  pour créer un model input :

export class NewTaskModalFormComponent {
  //...
  newTask = model<CreateTaskDto>(nullCreateTaskDto());
  //...
}

Maintenant, vous pouvez injecter le Signal depuis le template du parent (NewTaskModalComponent) :

<app-new-task-modal-form [(newTask)]="newTask"/>

Il y a deux choses importantes à noter ici :

  • on utilise le two-way binding[()]— c'est ce qui permet la communication dans les deux sens

  • on ne met pas les parenthèses aprèsnewTask, car ce qui nous intéresse ici, c'est de passer l'instance du Signal et non sa valeur !

Maintenant que le parent injecte son Signal, essayez de créer une nouvelle Task et regardez la console :

La console montre les propriétés de la Task entrée par l'utilisateur
La nouvelle Task

Dans la méthode onCreateTask de NewTaskModalComponent, retirez le console.log et dé-commentez la ligne commentée :

onCreateTask() {
  this.taskService.addTask(this.newTask());
  this.closeModal();
}

Du coup, lorsque vous ajoutez une Task :

La Task ajoutée par l'utilisateur s'affiche dans l'application
La nouvelle Task dans l'application

Grâce au model, vos utilisateurs peuvent créer une nouvelle Task !

À vous de jouer

Contexte

Le bouton Complete All Tasks, pour l'instant, ne fait rien !

Consigne

Voici les fonctionnalités à implémenter sur le bouton Complete All Tasks :

  • il doit êtredisabledet comporter la classedisabledsi toutes les tâches sont déjà complétées

  • lorsqu'il n'est pas désactivé, un clic sur ce bouton doit compléter toutes les tâches via la méthode  onCompleteAllTasks()de son parent, ProgressComponent

Pour ces deux fonctionnalités, je vous propose d'utiliser seulement lesinputetoutput: il n'y a pas de cas d'usage pour unmodelici.

En résumé

  • input()génère un Signal à partir d'une propriété passée à un component par son parent

  • input.required()génère un input requis

  • output()émet des événements aux parents d'un component

  • input()etoutput()prennent un paramètre de type, permettant un typage strict

Maintenant que vous maîtrisez la communication entre components, dans le prochain et dernier chapitre, vous découvrirez les effects — un outil puissant, mais potentiellement dangereux !

Ever considered an OpenClassrooms diploma?
  • Up to 100% of your training program funded
  • Flexible start date
  • Career-focused projects
  • Individual mentoring
Find the training program and funding option that suits you best