Chapitre 4 : Synchronisation Offline
SmartCommon fournit un module complet pour la synchronisation offline-first des applications PWA Dolibarr.
Vue d'ensemble
Le module sync permet de :
- Travailler hors connexion avec les données locales
- Synchroniser automatiquement quand la connexion revient
- Gérer les conflits de données entre client et serveur
useSyncClient
Hook principal pour la synchronisation offline-first.
Import
- snippet.javascript
import { useSyncClient } from '@cap-rel/smartcommon';
Configuration
- snippet.javascript
function MyApp() { const { isOnline, isSyncing, pendingCount, sync, create, update, remove, getConflicts, resolveConflict } = useSyncClient({ apiUrl: '/api/smartauth', getAccessToken: () => localStorage.getItem('access_token'), scope: ['thirdparty', 'contact', 'product'] }); return ( <div> <p>Status : {isOnline ? 'En ligne' : 'Hors ligne'}</p> <p>Modifications en attente : {pendingCount}</p> </div> ); }
Paramètres
| Paramètre | Type | Description |
|---|---|---|
| apiUrl | string | URL de base de l'API de synchronisation |
| getAccessToken | function | Fonction retournant le token JWT |
| scope | string[] | Liste des entités à synchroniser |
Valeurs retournées
| Propriété | Type | Description |
|---|---|---|
| isOnline | boolean | Statut de connexion |
| isSyncing | boolean | Synchronisation en cours |
| pendingCount | number | Nombre de modifications en attente |
| sync | function | Déclencher la synchronisation |
| create | function | Créer une entité (offline-capable) |
| update | function | Modifier une entité |
| remove | function | Supprimer une entité |
| upsert | function | Créer ou mettre à jour localement (cache) |
| getConflicts | function | Récupérer les conflits |
| resolveConflict | function | Résoudre un conflit |
Créer une entité
- snippet.javascript
function CreateThirdpartyForm() { const { create, pendingCount } = useSyncClient({ apiUrl: '/api/smartauth', getAccessToken: () => localStorage.getItem('access_token'), scope: ['thirdparty'] }); const handleCreate = async (data) => { // Crée localement avec un ID temporaire // Sera synchronisé quand la connexion revient const tempId = await create('thirdparty', { name: data.name, email: data.email, phone: data.phone }); console.log('Créé avec ID temporaire:', tempId); }; return ( <form onSubmit={handleSubmit}> {/* ... */} <p>En attente de sync : {pendingCount}</p> </form> ); }
Modifier et supprimer
- snippet.javascript
function ThirdpartyActions({ thirdparty }) { const { update, remove } = useSyncClient({ apiUrl: '/api/smartauth', getAccessToken: () => localStorage.getItem('access_token'), scope: ['thirdparty'] }); const handleUpdate = async () => { await update('thirdparty', thirdparty.id, { name: 'Nouveau nom' }); }; const handleDelete = async () => { await remove('thirdparty', thirdparty.id); }; return ( <div> <button onClick={handleUpdate}>Modifier</button> <button onClick={handleDelete}>Supprimer</button> </div> ); }
Upsert (cache local)
La méthode upsert permet de stocker des données localement sans déclencher de synchronisation vers le serveur. Elle crée l'entité si elle n'existe pas, ou la met à jour si elle existe déjà.
- snippet.javascript
function ThirdpartyDetail({ id }) { const { upsert, getEntity } = useSyncClient({ apiUrl: '/api/smartauth', getAccessToken: () => localStorage.getItem('access_token'), scope: ['thirdparty'] }); const cacheServerData = async () => { // Récupérer les données du serveur const data = await api.private.get(`thirdparties/${id}`).json(); // Stocker localement sans déclencher de sync await upsert('thirdparty', id, data); }; // Avec queueChange = true, la modification sera synchronisée const upsertAndSync = async (data) => { await upsert('thirdparty', id, data, true); }; // ... }
Paramètres
| Paramètre | Type | Défaut | Description |
|---|---|---|---|
| table | string | - | Nom de la table |
| id | number/string | - | ID de l'entité |
| data | object | - | Données de l'entité |
| queueChange | boolean | false | Si true, ajoute la modification à la queue de sync |
Synchronisation manuelle
- snippet.javascript
function SyncButton() { const { sync, isSyncing, pendingCount, isOnline } = useSyncClient({ apiUrl: '/api/smartauth', getAccessToken: () => localStorage.getItem('access_token'), scope: ['thirdparty', 'contact'] }); const handleSync = async () => { const result = await sync(); console.log('Synchronisé:', result); }; return ( <button onClick={handleSync} disabled={isSyncing || !isOnline || pendingCount === 0} > {isSyncing ? 'Synchronisation...' : `Synchroniser (${pendingCount})`} </button> ); }
ConflictResolver
Composant UI pour résoudre les conflits de synchronisation.
Import
- snippet.javascript
import { ConflictResolver } from '@cap-rel/smartcommon';
Utilisation
- snippet.javascript
function SyncManager() { const { getConflicts, resolveConflict } = useSyncClient({ apiUrl: '/api/smartauth', getAccessToken: () => localStorage.getItem('access_token'), scope: ['thirdparty'] }); const [conflicts, setConflicts] = useState([]); useEffect(() => { loadConflicts(); }, []); const loadConflicts = async () => { const list = await getConflicts(); setConflicts(list); }; const handleResolve = async (conflictId, resolution) => { await resolveConflict(conflictId, resolution); await loadConflicts(); }; if (conflicts.length === 0) { return <p>Aucun conflit</p>; } return ( <ConflictResolver conflicts={conflicts} onResolve={handleResolve} /> ); }
Props
| Prop | Type | Description |
|---|---|---|
| conflicts | array | Liste des conflits à afficher |
| onResolve | function | Callback appelé lors de la résolution |
Structure d'un conflit
- snippet.javascript
{ id: 'conflict_123', entity: 'thirdparty', entityId: 456, localData: { name: 'Version locale', ... }, serverData: { name: 'Version serveur', ... }, localTimestamp: 1707900000000, serverTimestamp: 1707899000000 }
Résolutions possibles
- 'local' : Garder la version locale
- 'server' : Garder la version serveur
- 'merge' : Fusionner (si supporté)
useOnlineStatus
Hook pour détecter le statut online/offline avec health check serveur optionnel.
Import
- snippet.javascript
import { useOnlineStatus } from '@cap-rel/smartcommon';
Utilisation simple
- snippet.javascript
function NetworkStatus() { const { isOnline, isOffline } = useOnlineStatus(); return ( <div className={isOffline ? 'bg-red-500' : 'bg-green-500'}> {isOnline ? 'En ligne' : 'Hors ligne'} </div> ); }
Avec health check serveur
- snippet.javascript
function ServerStatus() { const { isOnline, isServerReachable, lastCheck, checkNow } = useOnlineStatus({ healthCheckUrl: '/api/health', healthCheckInterval: 60000, // Vérifier toutes les 60s stabilityDelay: 2000, // Attendre 2s avant de déclarer "en ligne" timeout: 5000 // Timeout de 5s }); return ( <div> <p>Navigateur : {isOnline ? 'En ligne' : 'Hors ligne'}</p> <p>Serveur : {isServerReachable ? 'Accessible' : 'Inaccessible'}</p> <p>Dernière vérification : {new Date(lastCheck).toLocaleTimeString()}</p> <button onClick={checkNow}>Vérifier maintenant</button> </div> ); }
Paramètres
| Paramètre | Type | Défaut | Description |
|---|---|---|---|
| healthCheckUrl | string | null | URL pour vérifier le serveur (null = désactivé) |
| healthCheckInterval | number | 30000 | Intervalle entre les vérifications (ms) |
| stabilityDelay | number | 2000 | Délai avant de déclarer “en ligne” (ms) |
| timeout | number | 5000 | Timeout du health check (ms) |
Valeurs retournées
| Propriété | Type | Description |
|---|---|---|
| isOnline | boolean | Navigateur en ligne |
| isOffline | boolean | Navigateur hors ligne |
| isServerReachable | boolean/null | Serveur accessible (null si non testé) |
| lastOnline | number | Timestamp du dernier état “en ligne” |
| lastCheck | number | Timestamp de la dernière vérification |
| checkNow | function | Forcer une vérification immédiate |
useCachedQuery
Hook pour le cache de requêtes avec stratégies multiples.
Import
- snippet.javascript
import { useCachedQuery, CACHE_STRATEGIES } from '@cap-rel/smartcommon';
Stratégies disponibles
| Stratégie | Description |
|---|---|
| NETWORKFIRST | Réseau d'abord, cache en fallback | | CACHEFIRST | Cache d'abord si valide, sinon réseau |
| STALEWHILEREVALIDATE | Afficher le cache, rafraîchir en arrière-plan |
Exemple : Cache-first pour dictionnaires
- snippet.javascript
function CountrySelect() { const { data: countries, isLoading, isFromCache } = useCachedQuery({ db: db.instance, store: 'queryCache', key: 'countries', fetchFn: () => api.get('dictionaries/countries').json(), strategy: CACHE_STRATEGIES.CACHE_FIRST, ttl: 86400000 // 24h }); if (isLoading) return <Spinner />; return ( <select> {countries.map(c => ( <option key={c.code} value={c.code}>{c.label}</option> ))} </select> ); }
Exemple : Stale-while-revalidate pour config
- snippet.javascript
function AppConfig() { const { data: config, isStale, refetch, invalidate } = useCachedQuery({ db: db.instance, store: 'queryCache', key: 'app-config', fetchFn: () => api.get('config').json(), strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE, staleTime: 300000 // 5 min }); return ( <div> {isStale && <p>Mise à jour en cours...</p>} <button onClick={invalidate}>Forcer le rafraîchissement</button> </div> ); }
Paramètres
| Paramètre | Type | Défaut | Description |
|---|---|---|---|
| db | object | - | Instance Dexie (db.instance) |
| store | string | - | Nom du store IndexedDB |
| key | string | - | Clé de cache |
| fetchFn | function | - | Fonction de récupération des données |
| strategy | string | NETWORK_FIRST | Stratégie de cache |
| ttl | number | 3600000 | Durée de vie du cache (1h) |
| staleTime | number | 60000 | Temps avant données “stale” (1min) |
| enabled | boolean | true | Activer/désactiver le fetch |
Valeurs retournées
| Propriété | Type | Description |
|---|---|---|
| data | any | Données récupérées/cachées |
| isLoading | boolean | Chargement en cours |
| isFromCache | boolean | Données provenant du cache |
| isStale | boolean | Données périmées |
| error | Error | Erreur éventuelle |
| lastFetch | number | Timestamp du dernier fetch |
| refetch | function | Relancer le fetch |
| invalidate | function | Vider le cache et refetch |
useAuthenticatedImage
Hook pour charger des images authentifiées avec cache IndexedDB.
Import
- snippet.javascript
import { useAuthenticatedImage } from '@cap-rel/smartcommon';
Utilisation
- snippet.javascript
function UserAvatar({ userId }) { const { src, isLoading, isFromCache, error } = useAuthenticatedImage({ db: db.instance, store: 'imageCache', url: `/api/users/${userId}/photo`, token: accessToken, placeholder: '/images/default-avatar.png', ttl: 86400000, // 24h staleTime: 3600000 // 1h }); if (isLoading) return <Spinner />; return <img src={src} alt="Avatar" />; }
Paramètres
| Paramètre | Type | Défaut | Description |
|---|---|---|---|
| db | object | - | Instance Dexie |
| store | string | 'imageCache' | Nom du store |
| url | string | - | URL de l'image |
| token | string | - | Token JWT |
| ttl | number | 86400000 | Durée de vie (24h) |
| staleTime | number | 3600000 | Temps avant stale (1h) |
| placeholder | string | null | Image par défaut |
Valeurs retournées
| Propriété | Type | Description |
|---|---|---|
| src | string | URL de l'image (blob ou placeholder) |
| isLoading | boolean | Chargement en cours |
| isFromCache | boolean | Image provenant du cache |
| error | Error | Erreur éventuelle |
Configuration IndexedDB
Pour utiliser useCachedQuery et useAuthenticatedImage, configurer les stores Dexie :
- snippet.javascript
const db = useDb({ name: 'myApp', version: 2, stores: { // Store pour les requêtes cachées queryCache: 'key', // Store pour les images imageCache: 'key', // Autres stores... items: 'id++, name' } });
Exemple complet : Application offline-first
- snippet.javascript
import { useEffect } from 'react'; import { useSyncClient, useOnlineStatus, useCachedQuery, useDb, Page, Block, List, ListItem, Button, ConflictResolver } from '@cap-rel/smartcommon'; function ThirdpartyList() { const db = useDb({ name: 'myApp', version: 1, stores: { queryCache: 'key', pendingChanges: 'id++, entity, action' } }); const { isOnline } = useOnlineStatus({ healthCheckUrl: '/api/health' }); const { sync, isSyncing, pendingCount, getConflicts } = useSyncClient({ apiUrl: '/api/smartauth', getAccessToken: () => localStorage.getItem('access_token'), scope: ['thirdparty'] }); const { data: thirdparties, isLoading, isFromCache, refetch } = useCachedQuery({ db: db.instance, store: 'queryCache', key: 'thirdparties', fetchFn: () => api.get('thirdparties').json(), strategy: 'swr' }); // Synchroniser automatiquement quand on revient en ligne useEffect(() => { if (isOnline && pendingCount > 0) { sync(); } }, [isOnline]); return ( <Page title="Tiers"> <Block> <div className="flex justify-between items-center"> <span> {isOnline ? 'En ligne' : 'Hors ligne'} {isFromCache && ' (cache)'} </span> {pendingCount > 0 && ( <Button onClick={sync} disabled={!isOnline || isSyncing} > Sync ({pendingCount}) </Button> )} </div> </Block> <Block> <List> {thirdparties?.map(t => ( <ListItem key={t.id}> {t.name} </ListItem> ))} </List> </Block> </Page> ); }
Points clés à retenir
- useSyncClient pour les opérations CRUD offline-capable
- useOnlineStatus pour détecter la connectivité
- useCachedQuery pour le cache intelligent avec stratégies
- useAuthenticatedImage pour les images protégées
- ConflictResolver pour la résolution de conflits UI
- Configurer les stores IndexedDB pour le cache