Créer un article hors connexion et le publier lorsque Internet revient

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-actualites
cd backend-actualites

Nous allons installer les dépendances :

npm install @nestjs/typeorm typeorm mysql2 @nestjs/config

Afin de valider les données reçus :

npm install class-validator class-transformer

Nous allons générer les éléments Nestjs :

nest g module articles
nest g controller articles
nest g service articles

Nous allons créer le fichier .env :

DB_HOST=xxxxxxxxx
DB_PORT=xxxxxxx
DB_USER=root
DB_PASSWORD=
DB_NAME=actualites_db

N.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:dev

Notre Front-end : Angular

ng new frontend-actualites --standalone --routing=false
cd frontend-actualites

Nous 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 Brouillon

keyof 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 serve

Notre PWA

Transformer une application Angular en PWA :

ng add @angular/pwa

Cette 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.