Nous allons travailler sur une fonctionnalité particulièrement utile pour une application d’actualités : la sauvegarde automatique des brouillons, même en l’absence de connexion Internet.
Cet exercice, de niveau intermédiaire, permettra de mettre en pratique les technologies suivantes : Angular, NestJS et PWA (Progressive Web App).
L’objectif est de permettre aux utilisateurs de rédiger leurs articles, de sauvegarder automatiquement leurs modifications en local lorsqu’ils sont hors ligne, puis de synchroniser leurs brouillons avec le serveur dès que la connexion Internet est rétablie.
N.B. : La publication après reconnexion sera manuelle. La synchronisation automatique fera l’objet d’un exercice plus avancé.
Notre Back-end : Nestjs
nest new backend-actualitescd backend-actualitesNous allons installer les dépendances :
npm install @nestjs/typeorm typeorm mysql2 @nestjs/configAfin de valider les données reçus :
npm install class-validator class-transformerNous allons générer les éléments Nestjs :
nest g module articles
nest g controller articles
nest g service articlesNous allons créer le fichier .env :
DB_HOST=xxxxxxxxx
DB_PORT=xxxxxxx
DB_USER=root
DB_PASSWORD=
DB_NAME=actualites_dbN.B. : Adapte les identifiants de connexion en fonction de ta configuration MySQL locale. Pour des raisons de sécurité, veille à ne jamais ajouter ce fichier à ton dépôt Git, car il peut contenir des informations sensibles, notamment tes identifiants et mots de passe.
Nous allons créer l’entité Article
Fichier : src/articles/article.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn
} from 'typeorm';
@Entity('articles')
export class Article {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'text' })
titre: string;
@Column({ type: 'text' })
contenu: string;
@CreateDateColumn()
dateCreation: Date;
}N.B. : @CreateDateColumn() enregistre automatiquement la date de création de l’article.
Nous allons créer le DTO
Crée le fichier src/articles/dto/create-article.dto.ts.
import {
IsString,
MinLength,
MaxLength
} from 'class-validator';
export class CreateArticleDto {
@IsString()
@MinLength(5)
@MaxLength(150)
titre: string;
@IsString()
@MinLength(20)
contenu: string;
}Le DTO vérifie que le titre contient au moins 5 caractères et que le contenu en contient au moins 20.
Nous allons créer le service
Fichier : src/articles/articles.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Article } from './article.entity';
import { CreateArticleDto } from './dto/create-article.dto';
@Injectable()
export class ArticlesService {
constructor(
@InjectRepository(Article)
private readonly repository: Repository<Article>
) {}
// Enregistrer un nouvel article
async create(dto: CreateArticleDto) {
const article = this.repository.create(dto);
return this.repository.save(article);
}
// Récupérer tous les articles
async findAll() {
return this.repository.find({
order: {
dateCreation: 'DESC'
}
});
}
}Nous allons créer le contrôleur
Fichier : src/articles/articles.controller.ts
import {
Controller,
Post,
Get,
Body
} from '@nestjs/common';
import { ArticlesService } from './articles.service';
import { CreateArticleDto } from './dto/create-article.dto';
@Controller('articles')
export class ArticlesController {
constructor(
private readonly service: ArticlesService
) {}
@Post()
create(@Body() dto: CreateArticleDto) {
return this.service.create(dto);
}
@Get()
findAll() {
return this.service.findAll();
}
}Nous allons configurer les modules
Dans src/articles/articles.module.ts :
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Article } from './article.entity';
import { ArticlesController } from './articles.controller';
import { ArticlesService } from './articles.service';
@Module({
imports: [
TypeOrmModule.forFeature([Article])
],
controllers: [ArticlesController],
providers: [ArticlesService]
})
export class ArticlesModule {}Dans src/app.module.ts :
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ArticlesModule } from './articles/articles.module';
import { Article } from './articles/article.entity';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true
}),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'mysql',
host: config.get<string>('DB_HOST'),
port: Number(config.get('DB_PORT', 3306)),
username: config.get<string>('DB_USER'),
password: config.get<string>('DB_PASSWORD'),
database: config.get<string>('DB_NAME'),
entities: [Article],
// Développement uniquement
synchronize: true
})
}),
ArticlesModule
]
})
export class AppModule {}Dans src/main.ts, active CORS et la validation :
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Autoriser Angular
app.enableCors({
origin: 'http://localhost:4200'
});
// Valider les données des DTO
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true
})
);
await app.listen(3000);
}
bootstrap();Lance le serveur :
npm run start:devNotre Front-end : Angular
ng new frontend-actualites --standalone --routing=falsecd frontend-actualitesNous allons créer notre composant
Nous allons utiliser trois Signals :
brouillon = signal<Brouillon>({
titre: '',
contenu: ''
});
enLigne = signal(navigator.onLine);
message = signal('');Le premier contient les données de l’article, le deuxième représente l’état de la connexion et le troisième affiche les messages à l’utilisateur.
Fichier : src/app/app.ts
import {
Component,
signal,
inject,
HostListener
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
// Type TypeScript de notre brouillon
interface Brouillon {
titre: string;
contenu: string;
}
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.html'
})
export class App {
private http = inject(HttpClient);
private api = 'http://localhost:3000/articles';
// État de la connexion
enLigne = signal(navigator.onLine);
// Message utilisateur
message = signal('');
// Indique si une publication est en cours
publicationEnCours = signal(false);
// Charger le brouillon sauvegardé
brouillon = signal<Brouillon>(
this.chargerBrouillon()
);
// Lire localStorage au démarrage
chargerBrouillon(): Brouillon {
const valeur = localStorage.getItem('article-brouillon');
if (valeur) {
try {
return JSON.parse(valeur) as Brouillon;
} catch {
// Ignorer les données locales invalides
}
}
return {
titre: '',
contenu: ''
};
}
// Modifier un champ et sauvegarder
modifierChamp(
champ: keyof Brouillon,
valeur: string
) {
this.brouillon.update(ancien => ({
...ancien,
[champ]: valeur
}));
// Sauvegarde automatique
localStorage.setItem(
'article-brouillon',
JSON.stringify(this.brouillon())
);
}
// Détecter le retour de la connexion
@HostListener('window:online')
connexionRetablie() {
this.enLigne.set(true);
this.message.set(
'Connexion rétablie. Vous pouvez publier.'
);
}
// Détecter la perte de connexion
@HostListener('window:offline')
connexionPerdue() {
this.enLigne.set(false);
this.message.set(
'Mode hors connexion : votre brouillon est conservé.'
);
}
// Publier l'article
publier() {
if (!this.enLigne() || this.publicationEnCours()) {
return;
}
const article = this.brouillon();
// Validation côté Angular
if (
article.titre.trim().length < 5 ||
article.contenu.trim().length < 20
) {
this.message.set(
'Titre : 5 caractères minimum. Contenu : 20 minimum.'
);
return;
}
this.publicationEnCours.set(true);
this.http.post(this.api, article).subscribe({
next: () => {
// Effacer le brouillon après succès
localStorage.removeItem('article-brouillon');
this.brouillon.set({
titre: '',
contenu: ''
});
this.message.set(
'Article publié avec succès !'
);
this.publicationEnCours.set(false);
},
error: () => {
// Ne pas supprimer le brouillon
this.message.set(
'Publication impossible. Brouillon conservé.'
);
this.publicationEnCours.set(false);
}
});
}
}N.B. : Observer cette instruction :
champ: keyof Brouillonkeyof permet d’obtenir les noms des propriétés d’un type.
Dans notre cas :
type Champ = 'titre' | 'contenu';Cela évite de passer un nom de champ qui n’existe pas.
Nous allons créer le formulaire HTML
Fichier : src/app/app.html
<h1>📰 Rédaction d'un article</h1>
<!-- État de la connexion -->
@if (enLigne()) {
<p style="color: green">
🟢 Connexion disponible
</p>
} @else {
<p style="color: red">
🔴 Mode hors connexion
</p>
}
<!-- Titre -->
<div>
<label for="titre">Titre de l'article</label>
<input
id="titre"
type="text"
[value]="brouillon().titre"
(input)="modifierChamp('titre', $any($event.target).value)"
/>
</div>
<br />
<!-- Contenu -->
<div>
<label for="contenu">Contenu</label>
<textarea
id="contenu"
rows="8"
[value]="brouillon().contenu"
(input)="modifierChamp('contenu', $any($event.target).value)"
></textarea>
</div>
<br />
<!-- Publication -->
<button
[disabled]="!enLigne() || publicationEnCours()"
(click)="publier()"
>
{{ publicationEnCours() ? 'Publication...' : 'Publier l’article' }}
</button>
<!-- Message -->
@if (message()) {
<p>
{{ message() }}
</p>
}
<p>
💾 Votre brouillon est sauvegardé automatiquement.
</p>Démarre Angular :
ng serveNotre PWA
Transformer une application Angular en PWA :
ng add @angular/pwaCette commande ajoute notamment la configuration du service worker et le manifeste de l’application.
N.B. : Le serveur ng serve n’est pas adapté au test du cache de production du service worker. Pour vérifier le chargement hors connexion, construis l’application en production et sers les fichiers générés sur localhost ou en HTTPS.
Le localStorage conserve les données, mais ne suffit pas à rendre une application entièrement accessible hors connexion. C’est le service worker qui peut permettre de charger les ressources déjà mises en cache.
Bonus : Ajoute un bouton « Supprimer le brouillon »
Ce bouton devra vider les deux champs du formulaire, supprimer la clé article-brouillon de localStorage et afficher un message de confirmation.