first comit

This commit is contained in:
Johan
2025-12-17 09:53:44 +01:00
commit f5bcdd11e7
30 changed files with 10738 additions and 0 deletions

17
.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

137
README.md Normal file
View File

@@ -0,0 +1,137 @@
# Démonstration 2 : Service, Observable, réactivité et formulaires
Nous allons maintenant faire évoluer notre application pour adopter une architecture plus robuste et professionnelle.
L'objectif est de séparer la gestion des données de l'affichage en introduisant un **service**. Ce service agira comme une fausse API (une "API en mémoire"), gérant l'état de nos tâches.
Nous allons également créer un formulaire réactif pour ajouter de nouvelles tâches, ce qui nous permettra de voir comment la liste se met à jour en temps réel grâce à la puissance des signaux.
### Architecture cible : service et réactivité
Ce diagramme montre comment les composants interagissent avec le service pour lire les données (flux de réactivité) et pour envoyer des actions (flux d'événement).
```mermaid
graph TD
subgraph "Flux de Données (Réactivité)"
direction TB
S(TodoApiService) -- "gère" --> SP["signal privé todos"]
SP -- "expose" --> SR["signal public todos$.asReadonly()"]
TLC(TodoListComponent) -- "lit (s'abonne)" --> SR
TLC -- "affiche les données" --> HTML[Template HTML]
end
subgraph "Flux d'Action (Événement)"
direction LR
User[Utilisateur] -- "clique" --> HTML
HTML -- "appelle (click)='toggleTodo(id)'" --> TLC
TLC -- "délègue l'action" --> S
S -- "met à jour l'état" --> SP
end
```
-----
### Objectifs pédagogiques
À la fin de cette démonstration, vous saurez :
* **Créer un service** pour centraliser la logique et l'état des données.
* **Utiliser l'injection de dépendances** avec `inject()` pour fournir le service aux composants.
* **Exposer un état réactif** depuis un service en utilisant un `signal` public en lecture seule (`.asReadonly()`).
* **Créer un formulaire réactif** complet avec `FormBuilder` et des validateurs.
* **Gérer la soumission d'un formulaire**, appeler une méthode de service asynchrone (`Observable`) et gérer la navigation.
* **Construire une application entièrement réactive** où les changements de données dans le service sont instantanément reflétés dans l'interface utilisateur.
---
### Installation
1. Ouvrir le projet sous WebStorm.
2. Ouvrir un terminal dans WebStorm.
3. Lancer la commande `npm install` pour installer les dépendances.
4. Lancer la commande `ng serve` pour démarrer le serveur de développement.
5. Ouvrir le navigateur à l'adresse `http://localhost:4200/`.
-----
### Flux de soumission du formulaire
L'étape 3 vous demandera de gérer la création d'une tâche. Voici le flux logique de la soumission du formulaire que vous allez implémenter.
```mermaid
graph TD
A["Utilisateur clique Enregistrer"] --> B(TodoFormComponent.saveTodo)
B -- "Vérifie" --> C{Formulaire valide ?}
C -- "Non" --> D[Fin : Affiche erreurs de validation]
C -- "Oui" --> E[Prépare le payload des données]
E --> F(Appelle TodoApiService.createTodo)
F -- "Retourne" --> G["Observable simule délai réseau"]
B -- "Souscrit à l'Observable" --> G
G -- "Émet la tâche créée (next)" --> H(Callback .subscribe)
H -- "Appelle" --> I(Router.navigate vers la liste)
I --> J[Fin : Redirection effectuée]
```
### Instructions
Le projet est déjà configuré. Votre travail est de compléter le code dans les fichiers indiqués en suivant les `// TODO`.
#### Étape 1 : le cerveau de l'application - le service
Ouvrez le fichier `app/core/services/todo.service.ts`. C'est ici que nous allons centraliser toute la gestion de nos tâches.
1. **`// TODO 1.1` : Créer la source de vérité.**
* Déclarez un `signal` **privé** nommé `todos` pour stocker le tableau de tâches.
2. **`// TODO 1.2` : Exposer les données de manière sécurisée.**
* Déclarez une propriété **publique** `todos$` qui expose le signal `todos` en lecture seule grâce à la méthode `.asReadonly()`. C'est ce que les composants utiliseront pour lire les données.
3. **`// TODO 1.3` : Implémenter la création d'une tâche.**
* Dans la méthode `createTodo`, créez un nouvel objet `Todo`.
* Utilisez `this.todos.update()` pour ajouter la nouvelle tâche à la liste.
* Retournez la nouvelle tâche dans un `Observable` qui simule un délai réseau avec `of(...).pipe(delay(...))`.
4. **`// TODO 1.4` : Implémenter les interactions.**
* Complétez les méthodes `toggleTodo` et `deleteTodo` pour qu'elles mettent à jour le signal privé `todos` en utilisant `.map()` (pour basculer) et `.filter()` (pour supprimer).
#### Étape 2 : connecter la liste au Service
Ouvrez le fichier `app/features/todo/todo-list/todo-list.component.ts`. Ce composant ne gérera plus les données lui-même, il va simplement les afficher et déléguer les actions au service.
1. **`// TODO 2.1` : Injecter le service.**
* Utilisez `inject(TodoApiService)` pour obtenir une instance de votre service.
2. **`// TODO 2.2` : Se connecter au flux de données.**
* Connectez le signal `todos` du composant directement au signal public `todos$` de votre service.
3. **`// TODO 2.3` : Déléguer les actions.**
* Dans les méthodes `toggleTodo` et `deleteTodo` du composant, appelez simplement les méthodes correspondantes de votre service.
#### Étape 3 : créer le formulaire d'ajout
Ouvrez le fichier `app/features/todo/todo-form/todo-form.component.ts`. Nous allons construire ici le formulaire de création.
1. **`// TODO 3.1` : Injecter les outils nécessaires.**
* Injectez le `FormBuilder`, le `Router` et votre `TodoApiService`.
2. **`// TODO 3.2` : Construire le formulaire.**
* Utilisez `this.fb.nonNullable.group({...})` pour définir la structure de votre formulaire avec les champs `title` et `completed` et leurs validateurs.
3. **`// TODO 3.3` : Gérer la soumission.**
* Dans la méthode `saveTodo`, vérifiez si le formulaire est valide.
* Préparez le `payload` (les données à envoyer) à partir des valeurs du formulaire.
* Appelez la méthode `createTodo` de votre service. Comme elle retourne un `Observable`, vous devez y **souscrire** avec `.subscribe({...})`.
* Dans le `next` de la souscription, redirigez l'utilisateur vers la liste des tâches avec `this.router.navigate(...)`.
#### Étape 4 : activer les interactions dans les templates
Il ne reste plus qu'à connecter les actions dans les fichiers HTML.
1. **`// TODO 4.1` : Dans `todo-list.component.html`**
* Assurez-vous que les événements `(click)` sur le `<span>` et le bouton de suppression appellent bien les méthodes `toggleTodo(todo.id)` et `deleteTodo(todo.id)`.
2. **`// TODO 4.2` : Dans `todo-form.component.html`**
* Liez le `formGroup` à la balise `<form>`.
* Liez l'événement `(ngSubmit)` à votre méthode `saveTodo()`.
* Connectez chaque `input` à son `formControlName`.
Une fois toutes les étapes terminées, votre application sera entièrement fonctionnelle... et réactive !

98
angular.json Normal file
View File

@@ -0,0 +1,98 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"observables": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "observables:build:production"
},
"development": {
"buildTarget": "observables:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
}
}
}
}
}
}

9649
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "observables",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^20.3.6",
"@angular/cli": "^20.3.6",
"@angular/compiler-cli": "^20.3.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.9.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.2"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

24
src/app/app.component.ts Normal file
View File

@@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HeaderComponent } from './core/layout/header/header.component';
@Component({
selector: 'app-root',
template: `
<app-header />
<main class="container">
<router-outlet></router-outlet>
</main>
`,
styles: `
.container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, HeaderComponent],
})
export class AppComponent {
}

13
src/app/app.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding())
]
};

19
src/app/app.routes.ts Normal file
View File

@@ -0,0 +1,19 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path:'todos',
// Lazy loading des routes de la fonctionnalité "todos"
loadChildren: () =>
import('./features/todo/todo.routes').then((m) => m.TODO_ROUTES),
},
{
path:'',
redirectTo:'todos',
pathMatch: 'full',
}
];

View File

@@ -0,0 +1,42 @@
:host {
display: block;
}
.header {
background-color: var(--surface-color);
padding: 0 2rem;
box-shadow: var(--box-shadow);
border-bottom: 1px solid var(--border-color);
}
.header-nav {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1200px;
margin: 0 auto;
height: 64px;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
color: var(--primary-color);
}
.nav-link {
font-size: 1rem;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
transition: background-color 0.2s ease, color 0.2s ease;
&.active {
background-color: var(--primary-color);
color: var(--text-color-light);
}
&:not(.active):hover {
background-color: var(--background-color);
}
}

View File

@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {RouterLink, RouterLinkActive} from '@angular/router';
@Component({
selector: 'app-header',
template: `
<header class="header">
<nav class="header-nav">
<a class="logo" routerLink="/">ToDo List</a>
<a class="nav-link" routerLink="/todos" routerLinkActive="active">
Liste des tâches
</a>
<a class="nav-link" routerLink="/todos/new" routerLinkActive="active">
Créer une tâche
</a>
</nav>
</header>
`,
styleUrls: ['./header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, RouterLinkActive],
})
export class HeaderComponent {}

View File

@@ -0,0 +1,9 @@
export interface Todo {
id: number;
title: string;
completed: boolean;
}
// Type utilisé pour la création d'une tâche, où l'ID n'est pas encore défini.
export type TodoCreate = Omit<Todo, 'id'>;

View File

@@ -0,0 +1,53 @@
import { Injectable, signal, Signal } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import { Todo, TodoCreate } from '../models/todo.model';
@Injectable({ providedIn: 'root' })
export class TodoApiService {
// TODO 1.1: Créer un signal privé pour stocker la liste de tâches.
// Initialisez-le avec le tableau de données fourni dans les commentaires.
/*
[
{ id: 1, title: 'Apprendre les signaux Angular', completed: true },
{ id: 2, title: 'Créer un service en mémoire', completed: false },
{ id: 3, title: 'Préparer la transition vers HttpClient', completed: false },
]
*/
private readonly todos = signal<Todo[]>([]);
private nextId = 4;
// TODO 1.2: Exposer le signal `todos` en tant que propriété publique `todos$`
// en lecture seule en utilisant `.asReadonly()`.
public readonly todos$: Signal<Todo[]> = this.todos.asReadonly();
// Cette méthode sert uniquement à SIMULER un appel réseau pour le chargement initial.
fetchInitialTodos(): Observable<void> {
console.log('SERVICE: Simulation du chargement initial des tâches...');
// On ne retourne pas les données ici, on retourne `void` après un délai.
// Le composant saura que le chargement est terminé.
return of(undefined).pipe(delay(800));
}
createTodo(todoData: TodoCreate): Observable<Todo> {
// TODO 1.3: Implémenter la logique de création.
// 1. Créez un objet `newTodo` avec un nouvel `id`, et les données de `todoData`.
// 2. Mettez à jour le signal `todos` avec `.update()`.
// 3. Retournez le `newTodo` dans un `Observable` avec un `delay` de 300ms.
const newTodo: Todo = { id: 0, title: '', completed: false }; // A MODIFIER
return of(newTodo); // A MODIFIER
}
toggleTodo(id: number): void {
// TODO 1.4: Implémenter la logique de bascule (toggle).
// Utilisez `this.todos.update()` et la méthode `.map()` pour inverser
// l'état `completed` de la tâche correspondante.
}
deleteTodo(id: number): void {
// TODO 1.4 (suite): Implémenter la logique de suppression.
// Utilisez `this.todos.update()` et la méthode `.filter()` pour
// supprimer la tâche correspondante.
}
}

View File

@@ -0,0 +1,33 @@
<div class="form-container">
<div class="form-header">
<h1>Ajouter un nouveau todo</h1>
<a routerLink="/todos" class="btn btn-secondary">Annuler</a>
</div>
<form> <!-- formGroup et ngSubmit à ajouter -->
<div class="form-group">
<label for="title">Titre</label>
<input id="title" type="text" class="form-control"> <!-- formControlName à ajouter -->
@if (todoForm.get('title')?.invalid && todoForm.get('title')?.touched) {
<small class="form-error">Le titre est requis (2 caractères min).</small>
}
</div>
<div class="form-group">
<div class="form-check-group">
<input id="completed" type="checkbox" class="form-check-input"> <!-- formControlName à ajouter -->
<label for="completed" class="form-check-label">Terminé ?</label>
</div>
</div>
<div class="form-actions">
@if (isSaving()) {
<span>Enregistrement...</span>
} @else {
<button type="submit" class="btn btn-primary" [disabled]="todoForm.invalid">
Enregistrer le todo
</button>
}
</div>
</form>
</div>

View File

@@ -0,0 +1,104 @@
.form-container {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
background-color: var(--surface-color);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
h1 {
margin: 0;
color: var(--primary-color);
}
}
.form-group {
margin-bottom: 1.5rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
}
.form-control {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(103, 58, 183, 0.2);
}
}
.form-check-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-check-input {
width: 1.25rem;
height: 1.25rem;
}
.form-error {
color: var(--error-color);
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 2rem;
}
// Styles pour les boutons (générique)
.btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: bold;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.btn-primary {
color: var(--text-color-light);
background-color: var(--primary-color);
&:hover:not(:disabled) {
background-color: var(--primary-color-dark);
}
}
.btn-secondary {
background-color: #f0f0f0;
color: var(--text-color);
border: 1px solid var(--border-color);
&:hover:not(:disabled) {
background-color: #e0e0e0;
}
}

View File

@@ -0,0 +1,39 @@
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { TodoApiService } from '../../../core/services/todo.service';
import { TodoCreate } from '../../../core/models/todo.model';
@Component({
selector: 'app-todo-form',
templateUrl: './todo-form.component.html',
styleUrls: ['./todo-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, RouterLink],
})
export class TodoFormComponent {
// TODO 3.1: Injecter le FormBuilder, le Router et le TodoApiService.
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
private readonly todoApiService = inject(TodoApiService);
readonly isSaving = signal(false);
// TODO 3.2: Créer le formulaire `todoForm` avec le FormBuilder.
// Il doit contenir deux contrôles :
// - 'title': chaîne vide, validateurs 'required' et 'minLength(2)'
// - 'completed': booléen `false`, validateur 'required'
readonly todoForm = this.fb.nonNullable.group({
// À COMPLÉTER
});
saveTodo(): void {
// TODO 3.3: Implémenter la logique de sauvegarde.
// 1. Vérifier si `this.todoForm` est invalide. Si oui, marquer tous les champs comme "touchés" et arrêter.
// 2. Passer le signal `isSaving` à `true`.
// 3. Créer un `todoPayload` à partir des valeurs du formulaire.
// 4. Appeler `this.todoApiService.createTodo()` et souscrire à l'Observable.
// 5. Dans `next`, passer `isSaving` à `false` et naviguer vers '/todos'.
// 6. Dans `error`, passer `isSaving` à `false` et afficher une erreur en console.
}
}

View File

@@ -0,0 +1,18 @@
<div class="todo-container">
@if (false) { <!-- remplacer par isLoading() lorsque le loading sera implémenté -->
<app-spinner />
} @else {
@if (todos().length > 0) {
<ul class="todo-list">
@for (todo of todos(); track todo.id) {
<li [class.completed]="todo.completed">
<span class="todo-title" (click)="toggleTodo(todo.id)">{{ todo.title }}</span>
<button class="btn-remove" (click)="deleteTodo(todo.id)">X</button>
</li>
}
</ul>
} @else {
<p class="empty-state">Bravo, aucune tâche pour le moment !</p>
}
}
</div>

View File

@@ -0,0 +1,158 @@
.todo-container {
max-width: 600px;
margin: 2rem auto;
background: var(--surface-color);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
padding: 2rem;
h1 {
text-align: center;
color: var(--primary-color);
margin-bottom: 1.5rem;
}
}
.todo-input-group {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
input[type='text'] {
flex-grow: 1;
padding: 0.75rem;
font-size: 1rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
transition: border-color 0.2s, box-shadow 0.2s;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(103, 58, 183, 0.2);
}
}
button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: bold;
color: var(--text-color-light);
background-color: var(--primary-color);
border: none;
border-radius: var(--border-radius);
cursor: pointer;
transition: background-color 0.2s;
&:hover:not(:disabled) {
background-color: var(--primary-color-dark);
}
&:disabled {
background-color: #ccc;
cursor: not-allowed;
}
}
}
.todo-list {
list-style: none;
padding: 0;
margin: 0;
}
li {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s;
&:last-child {
border-bottom: none;
}
&.completed .todo-title {
text-decoration: line-through;
color: #999;
}
}
.todo-title {
flex-grow: 1;
cursor: pointer;
}
.btn-remove {
background-color: transparent;
border: none;
color: var(--error-color);
font-weight: bold;
font-size: 1.2rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 50%;
line-height: 1;
width: 30px;
height: 30px;
transition: background-color 0.2s, color 0.2s;
&:hover {
background-color: rgba(244, 67, 54, 0.1);
}
}
footer {
margin-top: 1.5rem;
text-align: center;
color: #777;
font-size: 0.9rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #888;
font-style: italic;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
h1 {
margin: 0;
}
}
.btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: bold;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
text-decoration: none;
display: inline-block;
text-align: center;
transition: background-color 0.2s, opacity 0.2s;
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.btn-primary {
color: var(--text-color-light);
background-color: var(--primary-color);
&:hover:not(:disabled) {
background-color: var(--primary-color-dark);
}
}

View File

@@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component, inject, Signal } from '@angular/core';
import { TodoApiService } from '../../../core/services/todo.service';
import { Todo } from '../../../core/models/todo.model';
import { SpinnerComponent } from '../../../shared/components/spinner/spinner.component';
@Component({
selector: 'app-todo-list',
templateUrl: './todo-list.component.html',
styleUrls: ['./todo-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SpinnerComponent],
})
export class TodoListComponent {
// TODO 2.1: Injecter le TodoApiService.
private readonly todoApiService = inject(TodoApiService); // À COMPLÉTER
// TODO 2.2: Connecter le signal `todos` du composant au signal public `todos$` du service.
readonly todos: Signal<Todo[]> = this.todoApiService.todos$; // À COMPLÉTER
toggleTodo(id: number): void {
// TODO 2.3: Appeler la méthode `toggleTodo` du service.
}
deleteTodo(id: number): void {
// TODO 2.3 (suite): Appeler la méthode `deleteTodo` du service.
}
}

View File

@@ -0,0 +1,16 @@
import { Routes } from '@angular/router';
import {TodoListComponent} from './todo-list/todo-list.component';
import {TodoFormComponent} from "./todo-form/todo-form.component";
export const TODO_ROUTES: Routes = [
{
path: '',
component: TodoListComponent,
title: 'Todo List',
},
{
path: 'new',
component: TodoFormComponent,
title: 'Add Todo',
}
];

View File

@@ -0,0 +1,5 @@
export interface Todo {
id: number;
title: string;
completed: boolean;
}

View File

@@ -0,0 +1,19 @@
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
width: 36px;
height: 36px;
border-radius: 50%;
border-left-color: var(--primary-color);
margin: 2rem auto;
animation: spin 1s ease infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-spinner',
template: `<div class="spinner"></div>`,
styleUrls: ['./spinner.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SpinnerComponent {}

13
src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Signals</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

6
src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import {AppComponent} from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

53
src/styles.scss Normal file
View File

@@ -0,0 +1,53 @@
/* --- Variables de couleur et de style --- */
:root {
--primary-color: #673ab7;
--primary-color-dark: #512da8;
--accent-color: #ff4081;
--text-color: #333;
--text-color-light: #f5f5f5;
--background-color: #f0f2f5;
--surface-color: #ffffff;
--border-color: #e0e0e0;
--success-color: #4caf50;
--error-color: #f44336;
--border-radius: 8px;
--box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* --- Réinitialisation et styles de base --- */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica,
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
background-color: var(--background-color);
color: var(--text-color);
line-height: 1.6;
}
a {
color: var(--primary-color);
text-decoration: none;
transition: color 0.2s ease-in-out;
}
a:hover {
color: var(--primary-color-dark);
}
main.container {
padding: 2rem;
max-width: 900px;
margin: 0 auto;
}

15
tsconfig.app.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

14
tsconfig.spec.json Normal file
View File

@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.ts"
]
}