` inutile, utilisez un Fragment :
```javascript
// INCORRECT - plusieurs éléments racine
function UserInfo({ user }) {
return (
{user.name}
{user.email}
// Erreur !
);
}
// CORRECT - avec Fragment
function UserInfo({ user }) {
return (
<>
{user.name}
{user.email}
>
);
}
// Ou la syntaxe longue
import { Fragment } from 'react';
function UserInfo({ user }) {
return (
{user.name}
{user.email}
);
}
```
## Exercices
### Exercice 1 : Convertir en JSX
Convertir ce HTML en JSX :
```html
```
**Solution :**
```javascript
```
### Exercice 2 : Affichage conditionnel
Créer un composant qui affiche :
- "Chargement..." si `loading` est true
- "Aucun résultat" si `items` est vide
- La liste des items sinon
**Solution :**
```javascript
function ItemList({ loading, items }) {
if (loading) {
return
Chargement...
;
}
if (items.length === 0) {
return
Aucun résultat
;
}
return (
{items.map(item => (
{item.name}
))}
);
}
```
## Points clés à retenir
1. **JSX n'est pas HTML** : c'est du JavaScript transformé
2. **className** au lieu de class, **htmlFor** au lieu de for
3. **Accolades `{}`** pour insérer du JavaScript
4. **Ternaire `? :`** ou **&&** pour les conditions
5. **map()** pour les boucles, avec une **key** unique
6. **Fragments `<>>`** pour retourner plusieurs éléments
[← Chapitre précédent](/training/module2-introduction-react/philosophie) | [Retour au module](/training/module2-introduction-react) | [Chapitre suivant : Composants ->](/training/module2-introduction-react/composants)
- [Chapitre 3 : Composants](https://smartmaker.org/training/module2-introduction-react/composants)
# Chapitre 3 : Composants
## Qu'est-ce qu'un composant ?
Un composant est une fonction JavaScript qui retourne du JSX :
```javascript
function Welcome() {
return
Bonjour ! ;
}
```
C'est tout. Une fonction qui retourne de l'UI.
## Conventions de nommage
- **PascalCase** pour les noms de composants : `UserCard`, `LoginForm`, `NavigationMenu`
- Les composants commençant par une minuscule sont traités comme des balises HTML
```javascript
// CORRECT
function UserCard() { ... }
// INCORRECT - traité comme une balise HTML
function userCard() { ... }
// Ne fonctionne pas !
```
## Props : passer des données
Les props (properties) permettent de passer des données à un composant :
```javascript
// Définition du composant avec props
function Welcome({ name }) {
return Bonjour, {name} ! ;
}
// Utilisation
```
### Plusieurs props
```javascript
function UserCard({ name, email, role }) {
return (
);
}
```
### Props avec valeur par défaut
```javascript
function Button({ label, type = "button", disabled = false }) {
return (
{label}
);
}
```
### Props de type différent
```javascript
function Product({ name, price, inStock, tags, onClick }) {
return (
{name}
{price} €
{inStock &&
En stock }
);
}
console.log('Cliqué')}
/>
```
| Type | Exemple |
| --- | --- |
| String | `name="Jean"` |
| Number | `age={30}` |
| Boolean | `active={true}` ou juste `active` |
| Array | `items={[1, 2, 3]}` |
| Object | `user={{ name: 'Jean' }}` |
| Function | `onClick={() => {}}` |
## Props children
La prop spéciale `children` contient le contenu entre les balises ouvrante et fermante :
```javascript
function Card({ title, children }) {
return (
);
}
// Utilisation
Ceci est le contenu de la carte.
Action
```
C'est très utile pour créer des composants "wrapper" ou "layout".
## Composition de composants
Les composants peuvent utiliser d'autres composants :
```javascript
function Avatar({ src, alt }) {
return ;
}
function UserInfo({ name, email }) {
return (
{name}
{email}
);
}
function UserCard({ user }) {
return (
);
}
function UserList({ users }) {
return (
{users.map(user => (
))}
);
}
```
Hiérarchie : `UserList` -> `UserCard` -> `Avatar` + `UserInfo`
## Props sont en lecture seule
**Règle fondamentale** : un composant ne doit jamais modifier ses props.
```javascript
// INCORRECT - ne jamais faire ça !
function BadComponent({ user }) {
user.name = "Modifié"; // INTERDIT !
return {user.name}
;
}
// CORRECT - les props sont immuables
function GoodComponent({ user }) {
return {user.name}
;
}
```
Si vous devez modifier des données, utilisez l'état (useState - prochain module).
## Déstructuration des props
Plusieurs façons de récupérer les props :
```javascript
// 1. Déstructuration dans les paramètres (recommandé)
function UserCard({ name, email }) {
return {name} - {email}
;
}
// 2. Déstructuration dans le corps
function UserCard(props) {
const { name, email } = props;
return {name} - {email}
;
}
// 3. Sans déstructuration
function UserCard(props) {
return {props.name} - {props.email}
;
}
```
## Spread de props
Pour transférer toutes les props à un élément enfant :
```javascript
function Button({ children, ...rest }) {
return (
{children}
);
}
// Utilisation - onClick et disabled sont passés au button
Envoyer
```
## Organisation des fichiers
Convention SmartMaker : un composant par dossier avec `index.jsx` :
```
src/components/
├── UserCard/
│ └── index.jsx
├── UserList/
│ └── index.jsx
└── common/
├── Button/
│ └── index.jsx
└── Card/
└── index.jsx
```
```javascript
// src/components/UserCard/index.jsx
export default function UserCard({ user }) {
return (
);
}
// Import
import UserCard from 'src/components/UserCard';
```
## Exercices
### Exercice 1 : Créer un composant Button
Créer un composant `Button` qui accepte :
- `label` : texte du bouton
- `variant` : "primary" ou "secondary" (défaut: "primary")
- `onClick` : fonction à appeler au clic
**Solution :**
```javascript
function Button({ label, variant = "primary", onClick }) {
return (
{label}
);
}
// Utilisation
console.log('Envoyé')} />
```
### Exercice 2 : Composition
Créer une structure de composants pour afficher une liste de produits :
- `ProductList` : reçoit un tableau de produits
- `ProductCard` : affiche un produit (nom, prix)
- `Price` : affiche un prix formaté avec le symbole €
**Solution :**
```javascript
function Price({ value }) {
return {value.toFixed(2)} € ;
}
function ProductCard({ product }) {
return (
);
}
function ProductList({ products }) {
return (
{products.map(product => (
))}
);
}
// Utilisation
const products = [
{ id: 1, name: 'Laptop', price: 999.99 },
{ id: 2, name: 'Phone', price: 699.50 }
];
```
## Points clés à retenir
1. **Composant** = fonction qui retourne du JSX
2. **PascalCase** pour les noms de composants
3. **Props** : données passées au composant (lecture seule)
4. **children** : contenu entre les balises
5. **Composition** : composants qui utilisent d'autres composants
6. **Déstructuration** : `function Component({ prop1, prop2 })`
## Récapitulatif du Module 2
Vous connaissez maintenant les bases de React :
| Concept | Description |
| --- | --- |
| JSX | Syntaxe pour écrire de l'UI en JavaScript |
| Composant | Fonction qui retourne du JSX |
| Props | Données passées à un composant |
| children | Contenu entre les balises |
| key | Identifiant unique pour les listes |
| Fragment | `<>>` pour retourner plusieurs éléments |
[← Chapitre précédent](/training/module2-introduction-react/jsx) | [Retour au module](/training/module2-introduction-react) | [Module suivant : Hooks Fondamentaux ->](/training/module3-hooks-fondamentaux)
- [Chapitre 1 : useState](https://smartmaker.org/training/module3-hooks-fondamentaux/usestate)
# Chapitre 1 : useState
## Le problème
En JavaScript classique, vous pouvez modifier une variable :
```javascript
let count = 0;
count = count + 1;
```
Mais en React, cela ne fonctionne pas :
```javascript
function Counter() {
let count = 0;
const increment = () => {
count = count + 1; // La variable change...
console.log(count); // Affiche bien 1, 2, 3...
};
// Mais l'UI n'est JAMAIS mise à jour !
return (
{count}
{/* Affiche toujours 0 */}
+1
);
}
```
**Pourquoi ?** React ne sait pas que la variable a changé. Il faut lui dire explicitement.
## La solution : useState
```javascript
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1); // Dit à React de re-rendre
};
return (
);
}
```
`useState` retourne un tableau avec :
1. **La valeur actuelle** (`count`)
2. **Une fonction pour la modifier** (`setCount`)
## Syntaxe
```javascript
const [state, setState] = useState(initialValue);
```
- `state` : la valeur actuelle
- `setState` : fonction pour modifier la valeur
- `initialValue` : valeur initiale (une seule fois au premier rendu)
## Différents types d'état
### Nombres
```javascript
const [count, setCount] = useState(0);
setCount(count + 1);
setCount(count - 1);
```
### Chaînes
```javascript
const [name, setName] = useState('');
setName('Jean');
setName(e.target.value); // Pour un input
```
### Booléens
```javascript
const [isOpen, setIsOpen] = useState(false);
setIsOpen(true);
setIsOpen(!isOpen); // Toggle
```
### Objets
```javascript
const [user, setUser] = useState({ name: '', email: '' });
// INCORRECT - mutation directe
user.name = 'Jean'; // Ne fonctionne pas !
// CORRECT - nouvel objet avec spread
setUser({ ...user, name: 'Jean' });
```
### Tableaux
```javascript
const [items, setItems] = useState([]);
// Ajouter un élément
setItems([...items, newItem]);
// Supprimer un élément
setItems(items.filter(item => item.id !== idToRemove));
// Modifier un élément
setItems(items.map(item =>
item.id === idToUpdate ? { ...item, name: 'Nouveau nom' } : item
));
```
## Mise à jour basée sur l'état précédent
Quand la nouvelle valeur dépend de l'ancienne, utilisez la forme fonctionnelle :
```javascript
// PROBLÈME POTENTIEL avec plusieurs appels rapides
setCount(count + 1);
setCount(count + 1); // count est toujours l'ancienne valeur !
// Résultat : +1 au lieu de +2
// CORRECT - forme fonctionnelle
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
// Résultat : +2
```
La forme fonctionnelle garantit que vous travaillez avec la dernière valeur.
## Plusieurs états dans un composant
```javascript
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// ...
}
```
Ou regroupés dans un objet :
```javascript
function LoginForm() {
const [form, setForm] = useState({
email: '',
password: '',
isLoading: false,
error: null
});
const updateField = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }));
};
// updateField('email', 'jean@example.com');
}
```
## Initialisation paresseuse
Si la valeur initiale nécessite un calcul coûteux, passez une fonction :
```javascript
// PROBLÈME - calcul exécuté à chaque rendu
const [data, setData] = useState(expensiveCalculation());
// CORRECT - calcul exécuté une seule fois
const [data, setData] = useState(() => expensiveCalculation());
```
## Comparaison avec PHP
| Concept | PHP (session) | React (useState) |
| --- | --- | --- |
| Stockage | `$_SESSION['count'] = 0` | `useState(0)` |
| Lecture | `$_SESSION['count']` | `count` |
| Écriture | `$_SESSION['count']++` | `setCount(c => c + 1)` |
| Persistance | Côté serveur | Mémoire navigateur |
## Exercices
### Exercice 1 : Compteur avec min/max
Créer un compteur qui :
- Ne peut pas descendre en dessous de 0
- Ne peut pas dépasser 10
**Solution :**
```javascript
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(prev => Math.min(prev + 1, 10));
};
const decrement = () => {
setCount(prev => Math.max(prev - 1, 0));
};
return (
-
{count}
+
);
}
```
### Exercice 2 : Liste de tâches
Créer un composant qui :
- Affiche une liste de tâches
- Permet d'ajouter une tâche
- Permet de supprimer une tâche
**Solution :**
```javascript
function TodoList() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const addTodo = () => {
if (input.trim()) {
setTodos([...todos, { id: Date.now(), text: input }]);
setInput('');
}
};
const removeTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return (
);
}
```
## Points clés à retenir
1. **useState** retourne `[valeur, setValeur]`
2. **Ne jamais modifier** l'état directement, toujours utiliser le setter
3. **Spread operator** pour les objets et tableaux : `{...obj}`, `[...arr]`
4. **Forme fonctionnelle** : `setState(prev => ...)` quand le nouvel état dépend de l'ancien
5. L'état est **local** au composant
[← Retour au module](/training/module3-hooks-fondamentaux) | [Chapitre suivant : useEffect ->](/training/module3-hooks-fondamentaux/useeffect)
- [Chapitre 2 : useEffect](https://smartmaker.org/training/module3-hooks-fondamentaux/useeffect)
# Chapitre 2 : useEffect
> Ce chapitre est **crucial**. useEffect est le hook le plus complexe à maîtriser, mais aussi le plus utilisé après useState.
## Qu'est-ce qu'un effet de bord ?
Un effet de bord (side effect) est toute opération qui affecte quelque chose en dehors du composant :
- Appeler une API
- Modifier le titre de la page
- S'abonner à un événement (WebSocket, resize, scroll)
- Manipuler le DOM directement
- Utiliser un timer (setTimeout, setInterval)
Ces opérations ne peuvent pas être faites directement dans le corps du composant, car celui-ci est exécuté à chaque rendu.
## Syntaxe de base
```javascript
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// Code exécuté après le rendu
console.log('Composant rendu !');
});
return Mon composant
;
}
```
## Le tableau de dépendances
Le deuxième argument de useEffect contrôle **quand** l'effet s'exécute :
### Sans tableau : à chaque rendu
```javascript
useEffect(() => {
console.log('Exécuté après CHAQUE rendu');
});
```
**Rarement utile** - peut causer des problèmes de performance.
### Tableau vide : une seule fois
```javascript
useEffect(() => {
console.log('Exécuté UNE SEULE FOIS après le premier rendu');
}, []);
```
**Équivalent à componentDidMount** en classes.
### Avec dépendances : quand elles changent
```javascript
useEffect(() => {
console.log('Exécuté quand userId change');
fetchUser(userId);
}, [userId]);
```
L'effet s'exécute :
1. Après le premier rendu
2. Après chaque rendu où `userId` a changé
## Exemple concret : appel API
```javascript
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Fonction async dans useEffect
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]); // Re-fetch quand userId change
if (loading) return Chargement...
;
if (error) return Erreur : {error}
;
if (!user) return null;
return {user.name}
;
}
```
## Fonction de nettoyage (cleanup)
useEffect peut retourner une fonction qui sera appelée :
- Avant la prochaine exécution de l'effet
- Quand le composant est démonté
```javascript
useEffect(() => {
// Setup
const subscription = api.subscribe(data => {
setData(data);
});
// Cleanup
return () => {
subscription.unsubscribe();
};
}, []);
```
### Exemple : événement de redimensionnement
```javascript
function WindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
// Cleanup : retirer l'écouteur
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // [] car on veut s'abonner une seule fois
return {size.width} x {size.height}
;
}
```
### Exemple : timer
```javascript
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
// Cleanup : arrêter le timer
return () => {
clearInterval(interval);
};
}, []);
return {seconds} secondes
;
}
```
## Pièges courants
### 1. Boucle infinie
```javascript
// DANGER ! Boucle infinie
useEffect(() => {
setCount(count + 1); // Modifie l'état
}); // Pas de tableau = exécuté à chaque rendu
// Modifie l'état -> re-rendu -> useEffect -> modifie l'état -> ...
```
**Solution** : toujours mettre un tableau de dépendances.
### 2. Dépendances manquantes
```javascript
// PROBLÈME : userId n'est pas dans les dépendances
useEffect(() => {
fetchUser(userId);
}, []); // Fetch uniquement au montage, ignore les changements de userId
```
**Solution** : ajouter toutes les valeurs utilisées dans le tableau.
```javascript
useEffect(() => {
fetchUser(userId);
}, [userId]); // Correct
```
### 3. Objet ou fonction dans les dépendances
```javascript
// PROBLÈME : l'objet est recréé à chaque rendu
function MyComponent({ user }) {
const options = { limit: 10 }; // Nouvel objet à chaque rendu
useEffect(() => {
fetchData(options);
}, [options]); // options change à chaque rendu !
}
```
**Solution** : déplacer hors du composant ou utiliser useMemo (module suivant).
### 4. Async dans useEffect
```javascript
// INCORRECT - useEffect ne peut pas être async
useEffect(async () => { // Erreur !
const data = await fetchData();
}, []);
// CORRECT - définir une fonction async à l'intérieur
useEffect(() => {
const loadData = async () => {
const data = await fetchData();
setData(data);
};
loadData();
}, []);
```
## Ordre d'exécution
```javascript
function MyComponent() {
console.log('1. Rendu');
useEffect(() => {
console.log('3. Effet');
return () => console.log('2. Cleanup (si re-rendu)');
});
return Test
;
}
// Premier rendu :
// 1. Rendu
// 3. Effet
// Re-rendu :
// 1. Rendu
// 2. Cleanup
// 3. Effet
```
## Comparaison avec le cycle de vie PHP
| Événement | PHP | React useEffect |
| --- | --- | --- |
| Initialisation | constructeur | `useEffect(() => {}, [])` |
| À chaque requête | code du contrôleur | `useEffect(() => {})` |
| Nettoyage | destructeur | fonction retournée par useEffect |
## Quand utiliser useEffect ?
**OUI** :
- Appels API
- Abonnements (WebSocket, events)
- Timers
- Modifier le DOM directement
- Synchroniser avec un système externe
**NON** :
- Calculer une valeur dérivée de l'état -> useMemo
- Transformer des données pour le rendu -> faire dans le rendu
- Réagir à une action utilisateur -> gestionnaire d'événement
## Exercices
### Exercice 1 : Titre de page dynamique
Créer un composant qui met à jour le titre de la page avec le compteur.
**Solution :**
```javascript
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Compteur : ${count}`;
}, [count]);
return (
{count}
setCount(c => c + 1)}>+1
);
}
```
### Exercice 2 : Fetch avec gestion d'erreur
Créer un composant qui charge une liste de posts depuis une API.
**Solution :**
```javascript
function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchPosts = async () => {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!response.ok) throw new Error('Erreur réseau');
const data = await response.json();
setPosts(data.slice(0, 10));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchPosts();
}, []);
if (loading) return Chargement...
;
if (error) return Erreur : {error}
;
return (
{posts.map(post => (
{post.title}
))}
);
}
```
## Points clés à retenir
1. **useEffect** exécute du code **après** le rendu
2. **Tableau vide `[]`** = une seule fois au montage
3. **Avec dépendances** = quand ces valeurs changent
4. **Fonction de retour** = nettoyage (cleanup)
5. **Toujours lister les dépendances** utilisées dans l'effet
6. **Async** : définir une fonction async à l'intérieur, pas sur useEffect
[← Chapitre précédent](/training/module3-hooks-fondamentaux/usestate) | [Retour au module](/training/module3-hooks-fondamentaux) | [Chapitre suivant : useRef ->](/training/module3-hooks-fondamentaux/useref)
- [Chapitre 3 : useRef](https://smartmaker.org/training/module3-hooks-fondamentaux/useref)
# Chapitre 3 : useRef
## Deux usages de useRef
useRef a deux usages principaux :
1. **Accéder à un élément DOM** (input, div, etc.)
2. **Stocker une valeur qui persiste** entre les rendus sans déclencher de re-rendu
## Accéder au DOM
### Exemple : Focus sur un input
```javascript
import { useRef } from 'react';
function SearchForm() {
const inputRef = useRef(null);
const handleClick = () => {
inputRef.current.focus();
};
return (
Mettre le focus
);
}
```
### Comment ça marche
1. `useRef(null)` crée un objet `{ current: null }`
2. `ref={inputRef}` dit à React de mettre l'élément DOM dans `inputRef.current`
3. Après le rendu, `inputRef.current` contient l'élément ` `
### Exemple : Scroll vers un élément
```javascript
function ScrollToSection() {
const sectionRef = useRef(null);
const scrollToSection = () => {
sectionRef.current.scrollIntoView({ behavior: 'smooth' });
};
return (
Aller à la section
{/* ... beaucoup de contenu ... */}
);
}
```
### Exemple : Mesurer un élément
```javascript
function MeasuredBox() {
const boxRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
if (boxRef.current) {
const { width, height } = boxRef.current.getBoundingClientRect();
setDimensions({ width, height });
}
}, []);
return (
Contenu de la boîte
Dimensions : {dimensions.width} x {dimensions.height}
);
}
```
## Stocker une valeur persistante
### Différence avec useState
| Critère | useState | useRef |
| --- | --- | --- |
| Persiste entre rendus | Oui | Oui |
| Déclenche un re-rendu | Oui | Non |
| Accès à la valeur | `state` | `ref.current` |
### Exemple : Compter les rendus
```javascript
function RenderCounter() {
const [count, setCount] = useState(0);
const renderCount = useRef(0);
// Incrémenté à chaque rendu, sans causer de re-rendu
renderCount.current += 1;
return (
Count : {count}
Rendus : {renderCount.current}
setCount(c => c + 1)}>+1
);
}
```
### Exemple : Valeur précédente
```javascript
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
function Counter() {
const [count, setCount] = useState(0);
const previousCount = usePrevious(count);
return (
Actuel : {count}
Précédent : {previousCount}
setCount(c => c + 1)}>+1
);
}
```
### Exemple : Éviter les re-exécutions de useEffect
```javascript
function Timer({ onTick }) {
const onTickRef = useRef(onTick);
// Met à jour la ref sans déclencher de re-rendu
useEffect(() => {
onTickRef.current = onTick;
}, [onTick]);
useEffect(() => {
const interval = setInterval(() => {
onTickRef.current(); // Utilise toujours la dernière version
}, 1000);
return () => clearInterval(interval);
}, []); // Pas besoin de onTick dans les dépendances
}
```
## Cas d'usage avec formulaires non contrôlés
Parfois, il est plus simple de lire la valeur du DOM directement :
```javascript
function UncontrolledForm() {
const nameRef = useRef(null);
const emailRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
console.log({
name: nameRef.current.value,
email: emailRef.current.value
});
};
return (
);
}
```
**Note** : les formulaires contrôlés (avec useState) sont généralement préférés car ils permettent la validation en temps réel.
## Attention : ne pas lire/écrire pendant le rendu
```javascript
// INCORRECT
function BadComponent() {
const ref = useRef(0);
ref.current += 1; // Modification pendant le rendu
return {ref.current}
;
}
// CORRECT - modifier dans useEffect ou gestionnaire d'événement
function GoodComponent() {
const ref = useRef(0);
useEffect(() => {
ref.current += 1; // OK dans useEffect
});
const handleClick = () => {
ref.current += 1; // OK dans gestionnaire
};
return ...
;
}
```
## Exercices
### Exercice 1 : Auto-focus au montage
Créer un formulaire de login qui met automatiquement le focus sur le champ email au chargement.
**Solution :**
```javascript
function LoginForm() {
const emailRef = useRef(null);
useEffect(() => {
emailRef.current.focus();
}, []);
return (
);
}
```
### Exercice 2 : Chronomètre avec pause
Créer un chronomètre avec boutons Start/Pause/Reset.
**Solution :**
```javascript
function Stopwatch() {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(null);
useEffect(() => {
if (isRunning) {
intervalRef.current = setInterval(() => {
setTime(t => t + 1);
}, 1000);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isRunning]);
const start = () => setIsRunning(true);
const pause = () => setIsRunning(false);
const reset = () => {
setIsRunning(false);
setTime(0);
};
return (
{time} secondes
Start
Pause
Reset
);
}
```
## Points clés à retenir
1. **useRef pour le DOM** : `ref={myRef}` puis `myRef.current`
2. **useRef pour persister** : stocke une valeur sans re-rendu
3. **Ne pas modifier pendant le rendu** : seulement dans useEffect ou événements
4. **Valeur dans `.current`** : `ref.current` et non `ref`
[← Chapitre précédent](/training/module3-hooks-fondamentaux/useeffect) | [Retour au module](/training/module3-hooks-fondamentaux) | [Chapitre suivant : useContext ->](/training/module3-hooks-fondamentaux/usecontext)
- [Chapitre 4 : useContext](https://smartmaker.org/training/module3-hooks-fondamentaux/usecontext)
# Chapitre 4 : useContext
## Le problème : Prop Drilling
Quand des données doivent passer à travers plusieurs niveaux de composants :
```javascript
function App() {
const [user, setUser] = useState({ name: 'Jean' });
return ;
}
function Layout({ user }) {
return (
);
}
function Header({ user }) {
return ;
}
function Navbar({ user }) {
return ; // Enfin utilisé ici !
}
function UserMenu({ user }) {
return {user.name} ;
}
```
`Layout` et `Header` ne font que transmettre `user` sans l'utiliser. C'est le **prop drilling**.
## La solution : Context
Context permet de "téléporter" des données à travers l'arbre de composants.
### Étape 1 : Créer le Context
```javascript
import { createContext } from 'react';
const UserContext = createContext(null);
```
### Étape 2 : Fournir la valeur (Provider)
```javascript
function App() {
const [user, setUser] = useState({ name: 'Jean' });
return (
);
}
```
### Étape 3 : Consommer la valeur (useContext)
```javascript
import { useContext } from 'react';
function UserMenu() {
const user = useContext(UserContext);
return {user.name} ;
}
```
Les composants intermédiaires n'ont plus besoin de passer `user` :
```javascript
function Layout() {
return (
);
}
function Header() {
return ;
}
function Navbar() {
return ;
}
```
## Exemple complet : Thème
```javascript
// ThemeContext.js
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
{children}
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
```
```javascript
// App.js
import { ThemeProvider } from './ThemeContext';
function App() {
return (
);
}
```
```javascript
// ThemeToggle.js
import { useTheme } from './ThemeContext';
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
Mode actuel : {theme}
);
}
```
```javascript
// Card.js
import { useTheme } from './ThemeContext';
function Card({ children }) {
const { theme } = useTheme();
return (
{children}
);
}
```
## Passer des fonctions via Context
Vous pouvez passer des fonctions pour permettre aux enfants de modifier l'état :
```javascript
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = async (credentials) => {
const response = await api.login(credentials);
setUser(response.user);
};
const logout = () => {
setUser(null);
};
return (
{children}
);
}
function useAuth() {
return useContext(AuthContext);
}
// Utilisation
function LoginButton() {
const { user, login, logout } = useAuth();
if (user) {
return Déconnexion ;
}
return login({ email, password })}>Connexion ;
}
```
## Quand utiliser Context ?
**Bons cas d'usage** :
- Thème (dark/light mode)
- Utilisateur connecté
- Langue / internationalisation
- Configuration globale
**Mauvais cas d'usage** :
- État local d'un formulaire
- Données qui ne concernent que quelques composants proches
- État qui change très fréquemment (performance)
## Context vs Props
| Critère | Props | Context |
| --- | --- | --- |
| Données concernent peu de composants | ✅ | ❌ |
| Données traversent beaucoup de niveaux | ❌ | ✅ |
| Flux de données explicite | ✅ | ❌ |
| Configuration globale | ❌ | ✅ |
## Plusieurs Contexts
Vous pouvez imbriquer plusieurs Providers :
```javascript
function App() {
return (
);
}
```
## Exercices
### Exercice 1 : Context de langue
Créer un context pour gérer la langue (fr/en) avec un bouton pour changer.
**Solution :**
```javascript
// LanguageContext.js
import { createContext, useContext, useState } from 'react';
const LanguageContext = createContext(null);
const translations = {
fr: { greeting: 'Bonjour', button: 'Anglais' },
en: { greeting: 'Hello', button: 'French' }
};
export function LanguageProvider({ children }) {
const [lang, setLang] = useState('fr');
const toggleLang = () => {
setLang(prev => prev === 'fr' ? 'en' : 'fr');
};
const t = (key) => translations[lang][key];
return (
{children}
);
}
export function useLanguage() {
return useContext(LanguageContext);
}
```
```javascript
// Greeting.js
function Greeting() {
const { t, toggleLang } = useLanguage();
return (
{t('greeting')}
{t('button')}
);
}
```
## Points clés à retenir
1. **Context** évite le prop drilling
2. **createContext** -> **Provider** -> **useContext**
3. Créer un **custom hook** (useTheme, useAuth) pour simplifier l'usage
4. Utiliser pour des données **vraiment globales**
5. Ne pas abuser : les props restent souvent plus claires
## Récapitulatif du Module 3
| Hook | Usage | Déclenche un re-rendu |
| --- | --- | --- |
| useState | État local | Oui |
| useEffect | Effets de bord (API, events) | Non (mais peut modifier l'état) |
| useRef | DOM et valeurs persistantes | Non |
| useContext | Données globales | Oui (si la valeur change) |
[← Chapitre précédent](/training/module3-hooks-fondamentaux/useref) | [Retour au module](/training/module3-hooks-fondamentaux) | [Module suivant : React Avancé ->](/training/module4-react-avance)
- [Chapitre 1 : useCallback et useMemo](https://smartmaker.org/training/module4-react-avance/usecallback-usememo)
# Chapitre 1 : useCallback et useMemo
## Le problème : re-rendus inutiles
À chaque rendu d'un composant, tout son code est ré-exécuté :
```javascript
function ProductList({ products }) {
// Recalculé à CHAQUE rendu, même si products n'a pas changé
const sortedProducts = products.sort((a, b) => a.price - b.price);
// Nouvelle fonction créée à CHAQUE rendu
const handleClick = (id) => {
console.log('Clicked:', id);
};
return (
{sortedProducts.map(p => (
))}
);
}
```
Si `products` contient 1000 éléments, le tri est fait à chaque rendu, même si rien n'a changé.
## useMemo : mémoriser une valeur
`useMemo` mémorise le résultat d'un calcul et ne le recalcule que si les dépendances changent.
### Syntaxe
```javascript
const memoizedValue = useMemo(() => computeValue(a, b), [a, b]);
```
### Exemple : calcul coûteux
```javascript
import { useMemo } from 'react';
function ProductList({ products, sortBy }) {
// Recalculé SEULEMENT si products ou sortBy change
const sortedProducts = useMemo(() => {
console.log('Tri en cours...');
return [...products].sort((a, b) => {
if (sortBy === 'price') return a.price - b.price;
if (sortBy === 'name') return a.name.localeCompare(b.name);
return 0;
});
}, [products, sortBy]);
return (
{sortedProducts.map(p => (
{p.name} - {p.price}€
))}
);
}
```
### Exemple : filtrage
```javascript
function UserList({ users, searchTerm }) {
const filteredUsers = useMemo(() => {
return users.filter(user =>
user.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [users, searchTerm]);
return (
{filteredUsers.map(user => (
{user.name}
))}
);
}
```
## useCallback : mémoriser une fonction
`useCallback` mémorise une fonction et ne la recrée que si les dépendances changent.
### Syntaxe
```javascript
const memoizedFn = useCallback(() => {
doSomething(a, b);
}, [a, b]);
```
### Pourquoi c'est utile ?
En JavaScript, chaque fois qu'une fonction est définie, c'est une nouvelle référence :
```javascript
const fn1 = () => console.log('hello');
const fn2 = () => console.log('hello');
fn1 === fn2; // false ! Ce sont deux fonctions différentes
```
Cela pose problème avec React.memo et les dépendances de useEffect.
### Exemple avec React.memo
```javascript
// Composant enfant mémoïsé
const ProductItem = React.memo(function ProductItem({ product, onSelect }) {
console.log('ProductItem rendu:', product.name);
return (
onSelect(product.id)}>
{product.name}
);
});
// Composant parent
function ProductList({ products }) {
const [selected, setSelected] = useState(null);
// SANS useCallback : nouvelle fonction à chaque rendu
// -> ProductItem est re-rendu même si product n'a pas changé
const handleSelect = (id) => {
setSelected(id);
};
// AVEC useCallback : même fonction tant que les dépendances ne changent pas
const handleSelectMemo = useCallback((id) => {
setSelected(id);
}, []); // [] car setSelected est stable
return (
);
}
```
### Exemple avec dépendances
```javascript
function SearchForm({ onSearch, category }) {
const [query, setQuery] = useState('');
// La fonction dépend de category
const handleSubmit = useCallback(() => {
onSearch(query, category);
}, [onSearch, query, category]);
return (
);
}
```
## React.memo : mémoriser un composant
`React.memo` empêche un composant de se re-rendre si ses props n'ont pas changé.
```javascript
const MyComponent = React.memo(function MyComponent({ name, onClick }) {
console.log('Rendu de MyComponent');
return {name} ;
});
```
**Important** : React.memo compare les props par référence. Si une fonction est recréée à chaque rendu, le composant sera quand même re-rendu.
## Quand utiliser useMemo ?
**OUI** :
- Calculs coûteux (tri, filtrage de grandes listes)
- Création d'objets passés en props à des composants mémoïsés
- Calculs dont le résultat est utilisé dans plusieurs endroits
**NON** :
- Calculs simples (addition, concaténation)
- Composants qui se re-rendent de toute façon
- Par défaut "au cas où"
## Quand utiliser useCallback ?
**OUI** :
- Fonctions passées à des composants enfants mémoïsés (React.memo)
- Fonctions dans les dépendances de useEffect
- Gestionnaires d'événements coûteux
**NON** :
- Fonctions utilisées uniquement localement
- Composants enfants non mémoïsés
- Par défaut "pour optimiser"
## Comparaison
| Hook | Mémorise | Recalcule si |
| --- | --- | --- |
| useMemo | Une valeur | Les dépendances changent |
| useCallback | Une fonction | Les dépendances changent |
| React.memo | Un composant | Les props changent |
## Piège : dépendances incorrectes
```javascript
// PROBLÈME : items manque dans les dépendances
const getTotal = useCallback(() => {
return items.reduce((sum, item) => sum + item.price, 0);
}, []); // items devrait être dans le tableau !
// CORRECT
const getTotal = useCallback(() => {
return items.reduce((sum, item) => sum + item.price, 0);
}, [items]);
```
ESLint avec le plugin `eslint-plugin-react-hooks` vous avertira des dépendances manquantes.
## Exercices
### Exercice 1 : Liste filtrée optimisée
Créer une liste de produits avec un champ de recherche. Optimiser pour ne pas refiltrer si la recherche n'a pas changé.
**Solution :**
```javascript
function OptimizedProductList({ products }) {
const [search, setSearch] = useState('');
const [sortBy, setSortBy] = useState('name');
const filteredAndSorted = useMemo(() => {
console.log('Filtrage et tri...');
return products
.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
if (sortBy === 'price') return a.price - b.price;
return 0;
});
}, [products, search, sortBy]);
return (
);
}
```
### Exercice 2 : Callback stable
Créer un composant parent avec plusieurs boutons enfants. Optimiser pour que les boutons ne se re-rendent pas inutilement.
**Solution :**
```javascript
const Button = React.memo(function Button({ id, onClick, children }) {
console.log('Button rendu:', id);
return onClick(id)}>{children} ;
});
function ButtonGroup() {
const [clicked, setClicked] = useState(null);
const handleClick = useCallback((id) => {
setClicked(id);
console.log('Bouton cliqué:', id);
}, []);
return (
Dernier clic : {clicked}
Bouton A
Bouton B
Bouton C
);
}
```
## Points clés à retenir
1. **useMemo** mémorise une **valeur** calculée
2. **useCallback** mémorise une **fonction**
3. **React.memo** mémorise un **composant**
4. **Ne pas optimiser prématurément** - mesurer d'abord
5. **Dépendances complètes** - toujours lister toutes les valeurs utilisées
[← Retour au module](/training/module4-react-avance) | [Chapitre suivant : Custom Hooks ->](/training/module4-react-avance/custom-hooks)
- [Chapitre 2 : Custom Hooks](https://smartmaker.org/training/module4-react-avance/custom-hooks)
# Chapitre 2 : Custom Hooks
## Qu'est-ce qu'un Custom Hook ?
Un custom hook est une fonction JavaScript qui :
- Commence par `use` (convention obligatoire)
- Peut appeler d'autres hooks
- Encapsule de la logique réutilisable
```javascript
// Custom hook
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// Utilisation
function Counter() {
const { count, increment, decrement, reset } = useCounter(10);
return (
);
}
```
## Pourquoi créer des Custom Hooks ?
1. **Réutilisation** : même logique dans plusieurs composants
2. **Séparation** : isoler la logique de l'affichage
3. **Tests** : plus facile à tester que des composants
4. **Lisibilité** : composants plus simples
## Exemple : useLocalStorage
Hook qui synchronise l'état avec localStorage :
```javascript
function useLocalStorage(key, initialValue) {
// Initialisation depuis localStorage
const [value, setValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
// Mise à jour localStorage quand la valeur change
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(error);
}
}, [key, value]);
return [value, setValue];
}
// Utilisation
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [language, setLanguage] = useLocalStorage('language', 'fr');
return (
setTheme(e.target.value)}>
Clair
Sombre
setLanguage(e.target.value)}>
Français
English
);
}
```
## Exemple : useFetch
Hook pour les appels API :
```javascript
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
if (!cancelled) {
setData(json);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
fetchData();
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
// Utilisation
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return Chargement...
;
if (error) return Erreur : {error}
;
return {user.name}
;
}
```
## Exemple : useDebounce
Hook pour retarder une valeur (utile pour la recherche) :
```javascript
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
// Utilisation
function SearchInput({ onSearch }) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
onSearch(debouncedQuery);
}
}, [debouncedQuery, onSearch]);
return (
setQuery(e.target.value)}
placeholder="Rechercher..."
/>
);
}
```
## Exemple : useToggle
Hook simple pour les valeurs booléennes :
```javascript
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue(v => !v);
}, []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
// Utilisation
function Modal() {
const { value: isOpen, toggle, setFalse: close } = useToggle();
return (
Ouvrir/Fermer
{isOpen && (
)}
);
}
```
## Exemple : useForm
Hook pour gérer les formulaires :
```javascript
function useForm(initialValues) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const handleChange = useCallback((e) => {
const { name, value, type, checked } = e.target;
setValues(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
}, []);
const setValue = useCallback((name, value) => {
setValues(prev => ({ ...prev, [name]: value }));
}, []);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
}, [initialValues]);
const validate = useCallback((validationRules) => {
const newErrors = {};
for (const [field, rules] of Object.entries(validationRules)) {
for (const rule of rules) {
const error = rule(values[field], values);
if (error) {
newErrors[field] = error;
break;
}
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [values]);
return {
values,
errors,
handleChange,
setValue,
reset,
validate
};
}
// Utilisation
function LoginForm({ onSubmit }) {
const { values, errors, handleChange, validate } = useForm({
email: '',
password: ''
});
const validationRules = {
email: [
v => !v && 'Email requis',
v => !v.includes('@') && 'Email invalide'
],
password: [
v => !v && 'Mot de passe requis',
v => v.length < 6 && 'Minimum 6 caractères'
]
};
const handleSubmit = (e) => {
e.preventDefault();
if (validate(validationRules)) {
onSubmit(values);
}
};
return (
);
}
```
## Exemple : useOnClickOutside
Hook pour détecter les clics en dehors d'un élément :
```javascript
function useOnClickOutside(ref, handler) {
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) {
return;
}
handler(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
// Utilisation
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
useOnClickOutside(dropdownRef, () => setIsOpen(false));
return (
setIsOpen(!isOpen)}>Menu
{isOpen && (
Option 1
Option 2
Option 3
)}
);
}
```
## Règles pour les Custom Hooks
1. **Nom commence par `use`** : obligatoire pour que React applique les règles des hooks
2. **Peut appeler d'autres hooks** : useState, useEffect, useCallback, autres custom hooks
3. **Retourne ce dont le composant a besoin** : valeurs, fonctions, objets
4. **Chaque appel est indépendant** : deux composants utilisant le même hook ont des états séparés
## Organisation des fichiers
```
src/
hooks/
useLocalStorage.js
useFetch.js
useDebounce.js
useToggle.js
useForm.js
index.js // export tous les hooks
```
```javascript
// hooks/index.js
export { useLocalStorage } from './useLocalStorage';
export { useFetch } from './useFetch';
export { useDebounce } from './useDebounce';
export { useToggle } from './useToggle';
export { useForm } from './useForm';
```
## Exercices
### Exercice 1 : useWindowSize
Créer un hook qui retourne les dimensions de la fenêtre et se met à jour au redimensionnement.
**Solution :**
```javascript
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
// Utilisation
function ResponsiveComponent() {
const { width, height } = useWindowSize();
return (
Fenêtre : {width} x {height}
{width < 768 &&
Mode mobile
}
);
}
```
### Exercice 2 : usePrevious
Créer un hook qui retourne la valeur précédente d'une variable.
**Solution :**
```javascript
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// Utilisation
function Counter() {
const [count, setCount] = useState(0);
const previousCount = usePrevious(count);
return (
Actuel : {count}
Précédent : {previousCount}
setCount(c => c + 1)}>+1
);
}
```
## Points clés à retenir
1. **Custom hook = fonction commençant par `use`**
2. **Encapsule la logique réutilisable** entre composants
3. **Chaque appel a son propre état** indépendant
4. **Peut appeler d'autres hooks** (useState, useEffect, etc.)
5. **Simplifie les composants** en extrayant la logique
[← Chapitre précédent](/training/module4-react-avance/usecallback-usememo) | [Retour au module](/training/module4-react-avance) | [Chapitre suivant : Redux ->](/training/module4-react-avance/redux)
- [Chapitre 3 : Redux et Redux Toolkit](https://smartmaker.org/training/module4-react-avance/redux)
# Chapitre 3 : Redux et Redux Toolkit
## Pourquoi Redux ?
Context est bien pour des données simples (thème, utilisateur). Mais pour des applications complexes :
- État partagé entre beaucoup de composants
- Logique de mise à jour complexe
- Besoin d'un historique des actions (debug)
- Performance avec de nombreuses mises à jour
Redux offre une solution structurée et prévisible.
## Les concepts fondamentaux
### 1. Store
Le **store** est l'unique source de vérité. Tout l'état de l'application est dans un seul objet.
```javascript
{
user: { id: 1, name: 'Jean' },
products: [...],
cart: { items: [], total: 0 },
ui: { isLoading: false, error: null }
}
```
### 2. Actions
Les **actions** décrivent ce qui s'est passé. Ce sont des objets avec un `type` :
```javascript
{ type: 'cart/addItem', payload: { id: 1, name: 'Produit', price: 29.99 } }
{ type: 'user/login', payload: { id: 1, name: 'Jean' } }
{ type: 'ui/setLoading', payload: true }
```
### 3. Reducers
Les **reducers** spécifient comment l'état change en réponse à une action :
```javascript
function cartReducer(state, action) {
switch (action.type) {
case 'cart/addItem':
return {
...state,
items: [...state.items, action.payload]
};
case 'cart/removeItem':
return {
...state,
items: state.items.filter(item => item.id !== action.payload)
};
default:
return state;
}
}
```
### Le flux Redux
```
Action -> Reducer -> Nouveau State -> UI mise à jour
```
1. Un événement déclenche une action
2. Le reducer calcule le nouvel état
3. Le store notifie les composants
4. Les composants se re-rendent
## Redux Toolkit : Redux simplifié
Redux classique nécessite beaucoup de code. **Redux Toolkit** (RTK) simplifie tout :
```bash
npm install @reduxjs/toolkit react-redux
```
## Créer un Slice
Un **slice** regroupe l'état, les reducers et les actions pour une fonctionnalité :
```javascript
// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0
},
reducers: {
increment: (state) => {
// RTK utilise Immer : on peut "muter" directement
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
}
}
});
// Export des actions
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// Export du reducer
export default counterSlice.reducer;
```
## Configurer le Store
```javascript
// app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer
}
});
```
## Connecter React au Store
```javascript
// index.js ou App.js
import { Provider } from 'react-redux';
import { store } from './app/store';
function App() {
return (
);
}
```
## Utiliser Redux dans un composant
```javascript
// features/counter/Counter.js
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount } from './counterSlice';
function Counter() {
// Lire l'état
const count = useSelector(state => state.counter.value);
// Obtenir la fonction dispatch
const dispatch = useDispatch();
return (
{count}
dispatch(increment())}>+1
dispatch(decrement())}>-1
dispatch(incrementByAmount(5))}>+5
);
}
```
## Exemple complet : Panier d'achat
### Le Slice
```javascript
// features/cart/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
total: 0
},
reducers: {
addItem: (state, action) => {
const existingItem = state.items.find(
item => item.id === action.payload.id
);
if (existingItem) {
existingItem.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
state.total = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
removeItem: (state, action) => {
state.items = state.items.filter(item => item.id !== action.payload);
state.total = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
updateQuantity: (state, action) => {
const { id, quantity } = action.payload;
const item = state.items.find(item => item.id === id);
if (item) {
item.quantity = quantity;
if (item.quantity <= 0) {
state.items = state.items.filter(i => i.id !== id);
}
}
state.total = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
clearCart: (state) => {
state.items = [];
state.total = 0;
}
}
});
export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
```
### Les composants
```javascript
// components/ProductCard.js
import { useDispatch } from 'react-redux';
import { addItem } from '../features/cart/cartSlice';
function ProductCard({ product }) {
const dispatch = useDispatch();
const handleAddToCart = () => {
dispatch(addItem(product));
};
return (
{product.name}
{product.price} €
Ajouter au panier
);
}
```
```javascript
// components/Cart.js
import { useSelector, useDispatch } from 'react-redux';
import { removeItem, updateQuantity, clearCart } from '../features/cart/cartSlice';
function Cart() {
const { items, total } = useSelector(state => state.cart);
const dispatch = useDispatch();
if (items.length === 0) {
return Panier vide
;
}
return (
Panier
{items.map(item => (
{item.name}
dispatch(updateQuantity({
id: item.id,
quantity: parseInt(e.target.value)
}))}
min="0"
/>
{item.price * item.quantity} €
dispatch(removeItem(item.id))}>
Supprimer
))}
Total : {total} €
dispatch(clearCart())}>Vider le panier
);
}
```
## Actions asynchrones avec createAsyncThunk
Pour les appels API :
```javascript
// features/users/usersSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// Action asynchrone
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async () => {
const response = await fetch('/api/users');
return response.json();
}
);
const usersSlice = createSlice({
name: 'users',
initialState: {
items: [],
loading: false,
error: null
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
}
});
export default usersSlice.reducer;
```
```javascript
// Utilisation
function UserList() {
const { items, loading, error } = useSelector(state => state.users);
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
if (loading) return Chargement...
;
if (error) return Erreur : {error}
;
return (
{items.map(user => (
{user.name}
))}
);
}
```
## Selectors
Les **selectors** sont des fonctions pour extraire des données du store :
```javascript
// features/cart/cartSelectors.js
// Selector simple
export const selectCartItems = state => state.cart.items;
export const selectCartTotal = state => state.cart.total;
// Selector calculé
export const selectCartItemCount = state =>
state.cart.items.reduce((count, item) => count + item.quantity, 0);
// Utilisation
function CartIcon() {
const itemCount = useSelector(selectCartItemCount);
return 🛒 {itemCount} ;
}
```
## Quand utiliser Redux vs Context ?
| Critère | Context | Redux |
| --- | --- | --- |
| Données globales simples (thème, user) | ✅ | ❌ Overkill |
| État partagé entre composants éloignés | ✅ | ✅ |
| Logique de mise à jour complexe | ❌ | ✅ |
| Actions asynchrones | Manuel | ✅ createAsyncThunk |
| DevTools (time-travel debug) | ❌ | ✅ |
| Large application | ❌ Performance | ✅ |
## Structure recommandée
```
src/
app/
store.js
features/
cart/
cartSlice.js
cartSelectors.js
Cart.js
CartIcon.js
users/
usersSlice.js
UserList.js
products/
productsSlice.js
ProductList.js
```
## Points clés à retenir
1. **Store unique** : une seule source de vérité
2. **Actions** : décrivent ce qui s'est passé
3. **Reducers** : calculent le nouvel état
4. **Redux Toolkit** : simplifie Redux avec createSlice
5. **useSelector** : lire l'état
6. **useDispatch** : envoyer des actions
7. **createAsyncThunk** : actions asynchrones
[← Chapitre précédent](/training/module4-react-avance/custom-hooks) | [Retour au module](/training/module4-react-avance) | [Module suivant : Architecture SmartMaker ->](/training/module5-architecture-smartmaker)
- [Chapitre 1 : Structure du projet](https://smartmaker.org/training/module5-architecture-smartmaker/structure-projet)
# Chapitre 1 : Structure du projet
## Vue d'ensemble
Un projet SmartMaker se compose de deux parties :
1. **mobile/** : Code source React (développement)
2. **pwa/** : Application compilée + API PHP (production)
## Arborescence complète
```
monmodule/ # Module Dolibarr
├── mobile/ # CODE SOURCE REACT
│ ├── src/
│ │ ├── App.jsx # Composant racine
│ │ ├── main.jsx # Point d'entrée
│ │ ├── appConfig.js # Configuration
│ │ ├── components/
│ │ │ ├── app/
│ │ │ │ └── Router/ # Configuration routes
│ │ │ │ ├── index.jsx
│ │ │ │ └── Guards/ # Protection routes
│ │ │ └── pages/
│ │ │ ├── public/ # Pages sans auth
│ │ │ │ ├── LoginPage/
│ │ │ │ └── WelcomePage/
│ │ │ ├── private/ # Pages avec auth
│ │ │ │ ├── HomePage/
│ │ │ │ └── ItemPage/
│ │ │ └── errors/
│ │ │ └── Error404Page/
│ │ ├── redux/ # Store Redux (optionnel)
│ │ │ └── reducers/
│ │ ├── i18n/ # Configuration i18next
│ │ ├── utils/ # Helpers, constantes
│ │ └── locales/ # Fichiers de traductions
│ ├── public/
│ │ ├── images/ # Icônes PWA
│ │ └── locales/ # Traductions
│ ├── index.html # Template HTML
│ ├── package.json # Dépendances npm
│ ├── vite.config.js # Configuration Vite
│ └── .env # Variables d'environnement
│
├── pwa/ # APPLICATION COMPILÉE + API
│ ├── api.php # Point d'entrée API
│ └── dist/ # Fichiers React compilés
│
├── smartmaker-api/ # BACKEND PHP
│ ├── Controllers/ # Logique métier
│ └── Mappers/ # Conversion Dolibarr -> DTO
│
└── smartmaker-api-prepend.php # Bootstrap PHP
```
## Détail des fichiers clés
### main.jsx - Point d'entrée
```javascript
// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')).render(
);
```
C'est le fichier appelé par `index.html`. Il monte l'application React dans le DOM.
### App.jsx - Composant racine
```javascript
// src/App.jsx
import { Provider } from '@cap-rel/smartcommon';
import { Router } from './components/app/Router';
import { config } from './appConfig';
export const App = () => (
);
```
Le `Provider` de SmartCommon englobe toute l'application et fournit :
- Configuration globale
- État Redux
- Contexte API
- i18next
### appConfig.js - Configuration
```javascript
// src/appConfig.js
export const config = {
debug: import.meta.env.DEV,
api: {
prefixUrl: import.meta.env.VITE_API_URL,
timeout: 30000,
paths: {
login: "login",
logout: "logout",
refresh: "refresh"
}
},
storage: {
local: ["session", "settings"]
},
globalState: {
reducers: {
session: null,
settings: { lng: "fr" },
items: []
}
},
pages: {
"/": { "/settings": "slideLeft", "*": "fade" },
"*": "fade"
}
};
```
Détaillé dans le chapitre suivant.
### index.html - Template
```html
Mon Application
```
Vite injecte automatiquement les scripts compilés en production.
## Organisation des composants
### Structure recommandée pour une page
```
components/pages/private/ItemPage/
├── index.jsx # Composant principal
├── ItemPage.module.css # Styles (optionnel)
└── components/ # Sous-composants locaux (optionnel)
├── ItemHeader.jsx
└── ItemActions.jsx
```
### Exemple de page
```javascript
// components/pages/private/ItemPage/index.jsx
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Page, Block, Spinner } from '@cap-rel/smartcommon';
import { useApi, useStates } from '@cap-rel/smartcommon';
export const ItemPage = () => {
const { id } = useParams();
const api = useApi();
const st = useStates({
initialStates: {
item: null,
loading: true,
error: null
}
});
useEffect(() => {
const fetchItem = async () => {
try {
const data = await api.private.get(`items/${id}`).json();
st.set('item', data);
} catch (err) {
st.set('error', err.message);
} finally {
st.set('loading', false);
}
};
fetchItem();
}, [id]);
if (st.get('loading')) {
return ;
}
if (st.get('error')) {
return Erreur : {st.get('error')} ;
}
const item = st.get('item');
return (
{item.description}
);
};
```
## Le Router
### Configuration des routes
```javascript
// components/app/Router/index.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { PublicRoutes, PrivateRoutes } from './Guards';
// Pages
import { LoginPage } from '../../pages/public/LoginPage';
import { HomePage } from '../../pages/private/HomePage';
import { ItemPage } from '../../pages/private/ItemPage';
import { Error404Page } from '../../pages/errors/Error404Page';
export const Router = () => {
return (
{/* Routes publiques */}
}>
} />
{/* Routes protégées */}
}>
} />
} />
{/* Fallback */}
} />
);
};
```
### Guards d'authentification
```javascript
// components/app/Router/Guards/index.jsx
import { Outlet, Navigate } from 'react-router-dom';
import { useGlobalStates } from '@cap-rel/smartcommon';
export const PrivateRoutes = () => {
const gst = useGlobalStates();
return gst.get('session') ? : ;
};
export const PublicRoutes = () => {
const gst = useGlobalStates();
return gst.get('session') ? : ;
};
```
## Variables d'environnement
```bash
# .env
VITE_API_URL=https://mondomaine.com/modules/monmodule/pwa/api.php
```
Accès dans le code :
```javascript
const apiUrl = import.meta.env.VITE_API_URL;
const isDev = import.meta.env.DEV;
const isProd = import.meta.env.PROD;
```
**Important** : Les variables doivent commencer par `VITE_` pour être exposées au client.
## Dossier pwa/ - Production
Après compilation (`npm run build`), les fichiers sont générés dans `pwa/dist/` :
```
pwa/
├── api.php # Point d'entrée API (à créer)
└── dist/
├── index.html # HTML avec assets injectés
├── assets/
│ ├── index-abc123.js # Bundle JS
│ └── index-def456.css # Bundle CSS
└── images/
```
## Points clés à retenir
1. **mobile/** = code source pour développement
2. **pwa/** = code compilé pour production
3. **App.jsx** englobe tout avec le Provider SmartCommon
4. **appConfig.js** centralise la configuration
5. **Router** gère les routes avec guards d'authentification
6. Variables d'environnement préfixées par **VITE_**
[← Retour au module](/training/module5-architecture-smartmaker) | [Chapitre suivant : Configuration ->](/training/module5-architecture-smartmaker/configuration)
- [Chapitre 2 : Configuration](https://smartmaker.org/training/module5-architecture-smartmaker/configuration)
# Chapitre 2 : Configuration
## Le fichier appConfig.js
Le fichier `appConfig.js` centralise toute la configuration de l'application. Il est passé au `Provider` de SmartCommon.
```javascript
// src/appConfig.js
export const config = {
// Mode debug
debug: import.meta.env.DEV,
// Configuration API
api: {
prefixUrl: import.meta.env.VITE_API_URL,
timeout: 30000,
debug: import.meta.env.DEV,
paths: {
login: "login",
logout: "logout",
refresh: "refresh"
}
},
// Persistance localStorage
storage: {
local: ["session", "settings"]
},
// État global initial
globalState: {
reducers: {
session: null,
settings: { lng: "fr" },
items: []
}
},
// Animations de transition
pages: {
"/": { "/settings": "slideLeft", "*": "fade" },
"*": "fade"
}
};
```
## Détail des options
### debug
```javascript
debug: import.meta.env.DEV
```
Active les logs de debug dans la console. Utilise la variable d'environnement Vite `DEV` qui est `true` en développement.
### api
Configuration du client HTTP (ky) avec authentification JWT.
```javascript
api: {
// URL de base pour toutes les requêtes
prefixUrl: import.meta.env.VITE_API_URL,
// Timeout en millisecondes
timeout: 30000,
// Logs des requêtes en console
debug: import.meta.env.DEV,
// Endpoints SmartAuth
paths: {
login: "login", // POST pour authentification
logout: "logout", // POST pour déconnexion
refresh: "refresh" // GET pour renouveler le token
}
}
```
### storage
Définit quelles clés de l'état global sont persistées en localStorage.
```javascript
storage: {
local: ["session", "settings"]
}
```
Avec cette configuration :
- `session` sera sauvegardé dans `localStorage.session`
- `settings` sera sauvegardé dans `localStorage.settings`
- Au rechargement de la page, ces valeurs seront restaurées
**Cas d'usage** :
- `session` : tokens JWT et informations utilisateur
- `settings` : préférences (langue, thème)
### globalState
Initialise l'état global Redux via `useGlobalStates`.
```javascript
globalState: {
reducers: {
// Utilisateur connecté (null = non connecté)
session: null,
// Préférences utilisateur
settings: { lng: "fr" },
// Données métier
items: [],
currentItem: null
}
}
```
Chaque clé devient accessible via `useGlobalStates` :
```javascript
const gst = useGlobalStates();
const session = gst.get('session');
const settings = gst.get('settings');
const items = gst.get('items');
// Écriture
gst.set('items', [...items, newItem]);
// Écriture avec persistance localStorage
gst.local.set('session', userData);
gst.local.set('settings', { lng: 'en' });
```
### pages
Configuration des animations de transition entre pages (Framer Motion).
```javascript
pages: {
// Depuis la page "/"
"/": {
"/settings": "slideLeft", // Vers settings : glisse à gauche
"*": "fade" // Vers autres : fondu
},
// Depuis toute autre page
"*": "fade"
}
```
Animations disponibles :
- `fade` : fondu enchaîné
- `slideLeft` : glisse vers la gauche
- `slideRight` : glisse vers la droite
- `slideUp` : glisse vers le haut
- `slideDown` : glisse vers le bas
## Le Provider SmartCommon
Le `Provider` initialise tous les contextes nécessaires :
```javascript
// src/App.jsx
import { Provider } from '@cap-rel/smartcommon';
import { Router } from './components/app/Router';
import { config } from './appConfig';
export const App = () => (
);
```
### Props du Provider
| Prop | Type | Description | |
| --- | --- | --- | --- |
| config | object | Configuration de l'application (appConfig) | |
| onError | function | Callback en cas d'erreur | |
| errorFallback | ReactNode | Contenu de remplacement en cas d'erreur | |
| ErrorFallbackComponent | Component | Composant de remplacement en cas d'erreur | |
| pwaUpdate | object | Props transmises à UpdatePrompt (voir [chapitre Gestion d'état](/training/module7-smartcommon-hooks/etat)) |
### Exemple avec mise à jour PWA
```javascript
export const App = () => (
);
```
En interne, le `Provider` encapsule :
```javascript
// Équivalent interne (simplifié)
{children}
{pwaUpdate && }
```
## Accéder à la configuration
Dans n'importe quel composant :
```javascript
import { useLibConfig } from '@cap-rel/smartcommon';
function MyComponent() {
const config = useLibConfig();
console.log(config.api.prefixUrl);
console.log(config.debug);
return ...
;
}
```
## Configuration avancée
### Internationalisation (i18n)
```javascript
export const config = {
// ...
i18n: {
defaultLanguage: 'fr',
supportedLanguages: ['fr', 'en'],
debug: import.meta.env.DEV
}
};
```
### Base de données locale (Dexie)
```javascript
export const config = {
// ...
db: {
name: 'monapp',
version: 1,
stores: {
items: 'id++, name, category',
logs: 'id++, action, timestamp'
}
}
};
```
## Bonnes pratiques
### 1. Utiliser les variables d'environnement
```javascript
// .env.development
VITE_API_URL=http://localhost/dolibarr/modules/monmodule/pwa/api.php
// .env.production
VITE_API_URL=https://production.com/modules/monmodule/pwa/api.php
```
### 2. Séparer les environnements
```javascript
const isDev = import.meta.env.DEV;
export const config = {
debug: isDev,
api: {
prefixUrl: import.meta.env.VITE_API_URL,
timeout: isDev ? 60000 : 30000, // Plus long en dev
debug: isDev
}
};
```
### 3. Ne pas stocker de données sensibles
```javascript
storage: {
// OK : données non sensibles
local: ["session", "settings", "cart"],
// PAS dans le code : mots de passe, clés API côté serveur
}
```
## Exemple complet
```javascript
// src/appConfig.js
const isDev = import.meta.env.DEV;
export const config = {
debug: isDev,
api: {
prefixUrl: import.meta.env.VITE_API_URL,
timeout: isDev ? 60000 : 30000,
debug: isDev,
paths: {
login: "login",
logout: "logout",
refresh: "refresh"
}
},
storage: {
local: ["session", "settings"]
},
globalState: {
reducers: {
// Auth
session: null,
// Préférences
settings: {
lng: "fr",
theme: "light",
notifications: true
},
// Données métier
products: [],
cart: { items: [], total: 0 },
currentProduct: null
}
},
pages: {
"/": {
"/cart": "slideLeft",
"/product/*": "slideLeft",
"*": "fade"
},
"/cart": {
"/": "slideRight",
"*": "fade"
},
"*": "fade"
},
i18n: {
defaultLanguage: 'fr',
supportedLanguages: ['fr', 'en']
}
};
```
## Points clés à retenir
1. **appConfig.js** centralise toute la configuration
2. **api** configure le client HTTP avec JWT
3. **storage.local** définit ce qui est persisté
4. **globalState.reducers** initialise l'état global
5. **pages** configure les animations de transition
6. Utiliser **import.meta.env** pour les variables d'environnement
[← Chapitre précédent](/training/module5-architecture-smartmaker/structure-projet) | [Retour au module](/training/module5-architecture-smartmaker) | [Chapitre suivant : Flux de données ->](/training/module5-architecture-smartmaker/flux-donnees)
- [Chapitre 3 : Flux de données](https://smartmaker.org/training/module5-architecture-smartmaker/flux-donnees)
# Chapitre 3 : Flux de données
## Vue d'ensemble
Comprendre comment les données circulent dans SmartMaker est essentiel. Ce chapitre trace le chemin complet d'une donnée, du clic utilisateur jusqu'au rendu final.
## Les 3 types de données
| Type | Stockage | Persistance | Hook |
| --- | --- | --- | --- |
| État local | Mémoire composant | Non | useState, useStates |
| État global | Redux store | Optionnel (localStorage) | useGlobalStates |
| Données serveur | API -> Dolibarr | Base de données | useApi |
## Flux d'une requête API
```
┌──────────────────────────────────────────────────────────────┐
│ FRONTEND │
├──────────────────────────────────────────────────────────────┤
│ 1. Utilisateur clique "Charger" │
│ ↓ │
│ 2. Composant appelle useApi() │
│ ↓ │
│ 3. api.private.get('items') │
│ ↓ │
│ 4. ky ajoute automatiquement : │
│ - Authorization: Bearer │
│ - X-DEVICEID: │
└──────────────────────────────────────────────────────────────┘
↓ HTTPS
┌──────────────────────────────────────────────────────────────┐
│ BACKEND │
├──────────────────────────────────────────────────────────────┤
│ 5. pwa/api.php reçoit la requête │
│ ↓ │
│ 6. SmartAuth valide le JWT │
│ ↓ │
│ 7. Router appelle ItemController::index() │
│ ↓ │
│ 8. Controller charge les objets Dolibarr │
│ ↓ │
│ 9. Mapper convertit en DTO │
│ ↓ │
│ 10. Retourne JSON │
└──────────────────────────────────────────────────────────────┘
↓ HTTPS
┌──────────────────────────────────────────────────────────────┐
│ FRONTEND │
├──────────────────────────────────────────────────────────────┤
│ 11. ky parse la réponse JSON │
│ ↓ │
│ 12. Composant met à jour l'état │
│ ↓ │
│ 13. React re-rend avec les nouvelles données │
└──────────────────────────────────────────────────────────────┘
```
## Exemple concret : Liste de produits
### 1. Le composant React
```javascript
// components/pages/private/ProductsPage/index.jsx
import { useEffect } from 'react';
import { Page, Block, List, ListItem, Spinner } from '@cap-rel/smartcommon';
import { useApi, useStates } from '@cap-rel/smartcommon';
export const ProductsPage = () => {
const api = useApi();
const st = useStates({
initialStates: {
products: [],
loading: true,
error: null
}
});
// Chargement au montage
useEffect(() => {
loadProducts();
}, []);
const loadProducts = async () => {
st.set('loading', true);
st.set('error', null);
try {
// Requête API avec JWT automatique
const data = await api.private.get('products').json();
st.set('products', data.products);
} catch (err) {
st.set('error', err.message);
} finally {
st.set('loading', false);
}
};
// Affichage conditionnel
if (st.get('loading')) {
return ;
}
if (st.get('error')) {
return (
Erreur : {st.get('error')}
);
}
return (
{st.get('products').map(product => (
))}
);
};
```
### 2. Le routeur API (PHP)
```php
query($sql);
$products = [];
if ($result) {
$mapper = new dmProduct();
while ($obj = $db->fetch_object($result)) {
$product = new \Product($db);
$product->fetch($obj->rowid);
// Mapper vers DTO React
$products[] = $mapper->exportMappedData($product);
}
}
return [['products' => $products], 200];
}
public function show($payload = null)
{
global $db;
$id = $payload['id'];
require_once DOL_DOCUMENT_ROOT . '/product/class/product.class.php';
$product = new \Product($db);
if ($product->fetch($id) <= 0) {
return [['error' => 'Produit non trouvé'], 404];
}
$mapper = new dmProduct();
return [$mapper->exportMappedData($product), 200];
}
}
```
### 4. Le Mapper (PHP)
```php
(int) $product->id,
'ref' => $product->ref,
'label' => $product->label,
'description' => $product->description,
'price' => (float) $product->price,
'price_ttc' => (float) $product->price_ttc,
'stock' => (float) $product->stock_reel,
'status' => (int) $product->status,
'created_at' => $product->datec,
'updated_at' => $product->tms
];
}
public function importMappedData($data)
{
// Conversion DTO -> Dolibarr pour création/modification
return [
'ref' => $data['ref'] ?? '',
'label' => $data['label'] ?? '',
'description' => $data['description'] ?? '',
'price' => $data['price'] ?? 0
];
}
}
```
## Authentification JWT
### Flux de login
```
1. Utilisateur saisit email/password
↓
2. api.login({ login, password })
↓
3. SmartAuth vérifie les credentials
↓
4. Retourne { accessToken, refreshToken, user }
↓
5. SmartCommon stocke dans session (localStorage)
↓
6. Toutes les requêtes futures ont le token
```
### Exemple login
```javascript
// components/pages/public/LoginPage/index.jsx
import { Page, Block, Form, Input, Button } from '@cap-rel/smartcommon';
import { useApi, useNavigation } from '@cap-rel/smartcommon';
export const LoginPage = () => {
const api = useApi();
const nav = useNavigation();
const handleSubmit = async (values) => {
try {
// api.login gère tout automatiquement
await api.login({
login: values.email,
password: values.password
});
// Redirige vers l'accueil
nav.navigate('/');
} catch (err) {
alert('Identifiants incorrects');
}
};
return (
);
};
```
### Refresh automatique
Le token d'accès expire après 15 minutes. SmartCommon gère automatiquement :
1. Détecte l'erreur 401 (token expiré)
2. Appelle `/refresh` avec le refreshToken
3. Obtient un nouveau accessToken
4. Relance la requête originale
Tout est transparent pour le développeur.
## État global vs État local
### Quand utiliser useStates (local)
- Données propres à un composant
- État de formulaire
- État de chargement
- Données temporaires
```javascript
const st = useStates({
initialStates: {
loading: false,
formData: { name: '', email: '' }
}
});
```
### Quand utiliser useGlobalStates
- Données partagées entre composants
- Session utilisateur
- Préférences
- Données métier principales
```javascript
const gst = useGlobalStates();
const session = gst.get('session');
const cart = gst.get('cart');
// Écriture
gst.local.set('session', userData);
gst.set('cart', { items: [], total: 0 });
```
## Création de données
### Flux de création
```
1. Formulaire rempli
↓
2. api.private.post('products', { json: data })
↓
3. Controller::create() valide et crée
↓
4. Retourne le produit créé
↓
5. Mise à jour de l'état local/global
```
### Exemple
```javascript
const handleCreate = async (formData) => {
try {
const newProduct = await api.private
.post('products', { json: formData })
.json();
// Ajouter à la liste locale
st.set('products', [...st.get('products'), newProduct]);
// Ou mettre à jour l'état global
// const gst = useGlobalStates();
// gst.set('products', [...gst.get('products'), newProduct]);
nav.navigate('/products');
} catch (err) {
st.set('error', err.message);
}
};
```
## Résumé du flux complet
| Étape | Couche | Action |
| --- | --- | --- |
| 1 | React | Utilisateur interagit |
| 2 | Hook | useApi() prépare la requête |
| 3 | ky | Ajoute JWT + envoie |
| 4 | PHP | api.php route la requête |
| 5 | SmartAuth | Valide le token |
| 6 | Controller | Exécute la logique métier |
| 7 | Dolibarr | Accès base de données |
| 8 | Mapper | Convertit en DTO |
| 9 | PHP | Retourne JSON |
| 10 | ky | Parse la réponse |
| 11 | Hook | Met à jour l'état |
| 12 | React | Re-rend l'interface |
## Points clés à retenir
1. **useApi** gère automatiquement l'authentification JWT
2. **Controller** charge les objets Dolibarr
3. **Mapper** convertit Dolibarr -> DTO React
4. **useStates** pour l'état local
5. **useGlobalStates** pour l'état partagé
6. Le refresh token est géré automatiquement
[← Chapitre précédent](/training/module5-architecture-smartmaker/configuration) | [Retour au module](/training/module5-architecture-smartmaker) | [Module suivant : SmartCommon Composants ->](/training/module6-smartcommon-composants)
- [Chapitre 1 : Mise en page](https://smartmaker.org/training/module6-smartcommon-composants/mise-en-page)
# Chapitre 1 : Mise en page
## Page
Le composant `Page` est le conteneur principal de chaque écran. Il gère :
- Le titre de la page
- Les animations de transition
- Le scroll
### Syntaxe de base
```javascript
import { Page } from '@cap-rel/smartcommon';
function HomePage() {
return (
Contenu de la page
);
}
```
### Props principales
| Prop | Type | Défaut | Description |
| --- | --- | --- | --- |
| title | string | - | Titre affiché dans la navbar |
| subtitle | string | - | Sous-titre optionnel |
| className | string | - | Classes CSS additionnelles |
| onRefresh | function | - | Callback pour pull-to-refresh |
### Page avec refresh
```javascript
function ItemsPage() {
const [items, setItems] = useState([]);
const handleRefresh = async () => {
const data = await api.private.get('items').json();
setItems(data);
};
return (
{items.map(item => (
{item.label}
))}
);
}
```
## Block
Le composant `Block` structure le contenu en sections visuelles distinctes.
### Syntaxe de base
```javascript
import { Page, Block } from '@cap-rel/smartcommon';
function ProfilePage() {
return (
Informations personnelles
Nom : Jean Dupont
Préférences
Langue : Français
);
}
```
### Props
| Prop | Type | Défaut | Description |
| --- | --- | --- | --- |
| title | string | - | Titre du block |
| className | string | - | Classes CSS additionnelles |
| padding | boolean | true | Ajouter du padding interne |
### Block avec titre
```javascript
Contenu du block
```
## Panel
Le composant `Panel` affiche un panneau coulissant depuis le côté de l'écran.
### Syntaxe
```javascript
import { useState } from 'react';
import { Page, Block, Button, Panel } from '@cap-rel/smartcommon';
function MyPage() {
const [isPanelOpen, setIsPanelOpen] = useState(false);
return (
setIsPanelOpen(true)}>
Ouvrir le panneau
setIsPanelOpen(false)}
position="right"
>
Contenu du panneau
Informations supplémentaires
);
}
```
### Props
| Prop | Type | Défaut | Description |
| --- | --- | --- | --- |
| isOpen | boolean | false | Contrôle l'affichage |
| onClose | function | - | Callback à la fermeture |
| position | string | 'right' | 'left', 'right', 'top', 'bottom' |
| title | string | - | Titre du panneau |
## Popup
Le composant `Popup` affiche une fenêtre modale centrée.
### Syntaxe
```javascript
import { useState } from 'react';
import { Page, Block, Button, Popup } from '@cap-rel/smartcommon';
function MyPage() {
const [isPopupOpen, setIsPopupOpen] = useState(false);
return (
setIsPopupOpen(true)}>
Supprimer
setIsPopupOpen(false)}
title="Confirmation"
>
Voulez-vous vraiment supprimer cet élément ?
setIsPopupOpen(false)}>
Annuler
Supprimer
);
}
```
### Props
| Prop | Type | Défaut | Description |
| --- | --- | --- | --- |
| isOpen | boolean | false | Contrôle l'affichage |
| onClose | function | - | Callback à la fermeture |
| title | string | - | Titre du popup |
| closeOnOverlay | boolean | true | Fermer au clic sur l'overlay |
## Exemple complet
```javascript
import { useState } from 'react';
import {
Page,
Block,
Panel,
Popup,
Button,
List,
ListItem
} from '@cap-rel/smartcommon';
export const ProductsPage = () => {
const [selectedProduct, setSelectedProduct] = useState(null);
const [showDeletePopup, setShowDeletePopup] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const products = [
{ id: 1, label: 'Produit A', price: 29.99 },
{ id: 2, label: 'Produit B', price: 49.99 },
{ id: 3, label: 'Produit C', price: 19.99 }
];
const handleDelete = async () => {
// Supprimer le produit
console.log('Suppression de', selectedProduct);
setShowDeletePopup(false);
setSelectedProduct(null);
};
return (
{/* Actions */}
setShowFilters(true)}>
Filtres
{/* Liste */}
{products.map(product => (
setSelectedProduct(product)}
actions={
{
e.stopPropagation();
setSelectedProduct(product);
setShowDeletePopup(true);
}}
>
Supprimer
}
/>
))}
{/* Panneau filtres */}
setShowFilters(false)}
title="Filtres"
position="right"
>
Options de filtrage ici
{/* Popup confirmation */}
setShowDeletePopup(false)}
title="Confirmer la suppression"
>
Supprimer "{selectedProduct?.label}" ?
setShowDeletePopup(false)}>
Annuler
Supprimer
);
};
```
## Bonnes pratiques
1. **Une seule Page par composant de route**
2. **Utiliser Block pour regrouper** le contenu logiquement
3. **Panel pour les actions secondaires** (filtres, détails)
4. **Popup pour les confirmations** et actions critiques
## Points clés à retenir
1. **Page** = conteneur principal avec titre et animations
2. **Block** = section de contenu avec style
3. **Panel** = panneau coulissant latéral
4. **Popup** = modal centré pour confirmations
[← Retour au module](/training/module6-smartcommon-composants) | [Chapitre suivant : Navigation ->](/training/module6-smartcommon-composants/navigation)
- [Chapitre 2 : Navigation](https://smartmaker.org/training/module6-smartcommon-composants/navigation)
# Chapitre 2 : Navigation
## Navbar
La barre de navigation supérieure. Affiche le titre et des actions.
### Syntaxe de base
```javascript
import { Navbar, UpperNavbarItem } from '@cap-rel/smartcommon';
import { FiMenu, FiUser } from 'react-icons/fi';
function MyPage() {
return (
<>
{/* Contenu */}
>
);
}
```
### Props Navbar
| Prop | Type | Description |
| --- | --- | --- |
| title | string | Titre principal |
| subtitle | string | Sous-titre optionnel |
| showBack | boolean | Afficher le bouton retour |
| onBack | function | Callback du bouton retour |
### Props UpperNavbarItem
| Prop | Type | Description |
| --- | --- | --- |
| icon | Component | Icône react-icons |
| onClick | function | Action au clic |
| badge | number | Compteur de notification |
## Sidebar
Menu latéral avec navigation.
### Syntaxe
```javascript
import { useState } from 'react';
import { Sidebar, Button } from '@cap-rel/smartcommon';
import { useNavigation, useGlobalStates, useApi } from '@cap-rel/smartcommon';
import { FiHome, FiList, FiSettings, FiLogOut } from 'react-icons/fi';
function MainLayout({ children }) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const nav = useNavigation();
const api = useApi();
const gst = useGlobalStates();
const menuItems = [
{ icon: FiHome, label: 'Accueil', path: '/' },
{ icon: FiList, label: 'Produits', path: '/products' },
{ icon: FiSettings, label: 'Paramètres', path: '/settings' }
];
const handleLogout = async () => {
await api.logout();
nav.navigate('/login');
};
return (
<>
setIsSidebarOpen(false)}
>
{/* En-tête utilisateur */}
{gst.get('session')?.user?.name}
{gst.get('session')?.user?.email}
{/* Menu */}
{menuItems.map(item => (
{
nav.navigate(item.path);
setIsSidebarOpen(false);
}}
className="flex items-center gap-3 w-full p-3 rounded hover:bg-gray-100"
>
{item.label}
))}
{/* Déconnexion */}
Déconnexion
{children}
>
);
}
```
### Props
| Prop | Type | Description |
| --- | --- | --- |
| isOpen | boolean | Contrôle l'affichage |
| onClose | function | Callback à la fermeture |
| position | string | 'left' ou 'right' |
## Tabbar
Barre d'onglets en bas de l'écran (navigation mobile).
### Syntaxe
```javascript
import { Tabbar, TabbarItem } from '@cap-rel/smartcommon';
import { useNavigation } from '@cap-rel/smartcommon';
import { useLocation } from 'react-router-dom';
import { FiHome, FiSearch, FiShoppingCart, FiUser } from 'react-icons/fi';
function AppTabbar() {
const nav = useNavigation();
const location = useLocation();
const tabs = [
{ icon: FiHome, label: 'Accueil', path: '/' },
{ icon: FiSearch, label: 'Recherche', path: '/search' },
{ icon: FiShoppingCart, label: 'Panier', path: '/cart', badge: 3 },
{ icon: FiUser, label: 'Profil', path: '/profile' }
];
return (
{tabs.map(tab => (
nav.navigate(tab.path)}
/>
))}
);
}
```
### Props TabbarItem
| Prop | Type | Description |
| --- | --- | --- |
| icon | Component | Icône react-icons |
| label | string | Texte sous l'icône |
| active | boolean | État sélectionné |
| badge | number | Compteur notification |
| onClick | function | Action au clic |
## useNavigation
Hook pour la navigation programmatique.
### Syntaxe
```javascript
import { useNavigation } from '@cap-rel/smartcommon';
function MyComponent() {
const nav = useNavigation();
return (
{/* Navigation vers une route */}
nav.navigate('/products')}>
Voir les produits
{/* Navigation avec paramètre */}
nav.navigate(`/products/${productId}`)}>
Détails
{/* Retour arrière */}
nav.navigate(-1)}>
Retour
{/* Remplacement (pas d'historique) */}
nav.replace('/login').to()}>
Se connecter
);
}
```
## Exemple complet : Layout avec navigation
```javascript
// components/layouts/MainLayout/index.jsx
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import {
Navbar,
Sidebar,
Tabbar,
TabbarItem,
UpperNavbarItem
} from '@cap-rel/smartcommon';
import { useNavigation, useGlobalStates, useApi } from '@cap-rel/smartcommon';
import {
FiMenu,
FiHome,
FiList,
FiPlus,
FiSettings,
FiLogOut
} from 'react-icons/fi';
export const MainLayout = ({ children, title }) => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const nav = useNavigation();
const location = useLocation();
const api = useApi();
const gst = useGlobalStates();
const handleLogout = async () => {
await api.logout();
nav.navigate('/login');
};
return (
{/* Navbar */}
setSidebarOpen(true)}
/>
{/* Sidebar */}
setSidebarOpen(false)}
>
{gst.get('session')?.user?.name}
{
nav.navigate('/');
setSidebarOpen(false);
}}
/>
{
nav.navigate('/products');
setSidebarOpen(false);
}}
/>
{
nav.navigate('/settings');
setSidebarOpen(false);
}}
/>
Déconnexion
{/* Contenu principal */}
{children}
{/* Tabbar */}
nav.navigate('/')}
/>
nav.navigate('/products')}
/>
nav.navigate('/products/new')}
/>
nav.navigate('/settings')}
/>
);
};
// Composant helper
const SidebarLink = ({ icon: Icon, label, onClick }) => (
{label}
);
```
### Utilisation du layout
```javascript
// components/pages/private/ProductsPage/index.jsx
import { MainLayout } from '../../../layouts/MainLayout';
import { Block, List, ListItem } from '@cap-rel/smartcommon';
export const ProductsPage = () => {
const products = [...];
return (
{products.map(p => (
))}
);
};
```
## Points clés à retenir
1. **Navbar** = barre supérieure avec titre et actions
2. **Sidebar** = menu latéral coulissant
3. **Tabbar** = navigation par onglets en bas
4. **useNavigation** = navigation programmatique
5. Combiner ces composants dans un **Layout réutilisable**
[← Chapitre précédent](/training/module6-smartcommon-composants/mise-en-page) | [Retour au module](/training/module6-smartcommon-composants) | [Chapitre suivant : Formulaires ->](/training/module6-smartcommon-composants/formulaires)
- [Chapitre 3 : Formulaires](https://smartmaker.org/training/module6-smartcommon-composants/formulaires)
# Chapitre 3 : Formulaires
## Form
Le composant `Form` gère la soumission et la validation des formulaires.
### Syntaxe de base
```javascript
import { Form, Input, Button } from '@cap-rel/smartcommon';
function ContactForm() {
const handleSubmit = (values) => {
console.log(values);
// { name: 'Jean', email: 'jean@example.com' }
};
return (
);
}
```
### Avec valeurs initiales
```javascript
function EditProductForm({ product }) {
const handleSubmit = async (values) => {
await api.private.put(`products/${product.id}`, { json: values });
};
return (
);
}
```
## Input
Champ de saisie texte polyvalent.
### Props principales
| Prop | Type | Description |
| --- | --- | --- |
| name | string | Nom du champ (obligatoire) |
| label | string | Libellé affiché |
| type | string | 'text', 'email', 'password', 'number', 'tel' |
| placeholder | string | Texte indicatif |
| required | boolean | Champ obligatoire |
| disabled | boolean | Champ désactivé |
| error | string | Message d'erreur |
### Exemples
```javascript
// Texte simple
// Email avec validation
// Mot de passe
// Nombre
// Téléphone
// Avec placeholder
// Désactivé
```
## Textarea
Zone de texte multiligne.
```javascript
import { Textarea } from '@cap-rel/smartcommon';
```
## Select
Liste déroulante.
```javascript
import { Select } from '@cap-rel/smartcommon';
// Options simples
// Avec valeur vide
// Options depuis une API
function ProductForm() {
const [categories, setCategories] = useState([]);
useEffect(() => {
api.private.get('categories').json().then(data => {
setCategories(data.map(c => ({
value: c.id,
label: c.name
})));
});
}, []);
return (
);
}
```
## Boolean
Interrupteur on/off.
```javascript
import { Boolean } from '@cap-rel/smartcommon';
```
## Checker
Case à cocher.
```javascript
import { Checker } from '@cap-rel/smartcommon';
// Groupe de cases
```
## Calendar
Sélecteur de date.
```javascript
import { Calendar } from '@cap-rel/smartcommon';
// Date simple
// Avec contraintes
// Date et heure
```
## Timer
Sélecteur d'heure.
```javascript
import { Timer } from '@cap-rel/smartcommon';
```
## Range
Slider de valeur.
```javascript
import { Range } from '@cap-rel/smartcommon';
```
## ColorPicker
Sélecteur de couleur.
```javascript
import { ColorPicker } from '@cap-rel/smartcommon';
```
## FilesUploader
Upload de fichiers.
```javascript
import { FilesUploader } from '@cap-rel/smartcommon';
```
## PhotosUploader
Upload de photos avec prévisualisation.
```javascript
import { PhotosUploader } from '@cap-rel/smartcommon';
```
## SignaturePad
Zone de signature manuscrite.
```javascript
import { SignaturePad } from '@cap-rel/smartcommon';
```
## Exemple complet : Formulaire produit
```javascript
import { useEffect } from 'react';
import {
Page,
Block,
Form,
Input,
Textarea,
Select,
Boolean,
Calendar,
PhotosUploader,
Button
} from '@cap-rel/smartcommon';
import { useApi, useNavigation, useStates } from '@cap-rel/smartcommon';
export const ProductFormPage = ({ productId }) => {
const api = useApi();
const nav = useNavigation();
const isEdit = !!productId;
const st = useStates({
initialStates: {
product: null,
categories: [],
loading: isEdit,
submitting: false
}
});
// Charger les catégories
useEffect(() => {
api.private.get('categories').json().then(data => {
st.set('categories', data.map(c => ({
value: c.id,
label: c.name
})));
});
}, []);
// Charger le produit si édition
useEffect(() => {
if (isEdit) {
api.private.get(`products/${productId}`).json().then(data => {
st.set('product', data);
st.set('loading', false);
});
}
}, [productId]);
const handleSubmit = async (values) => {
st.set('submitting', true);
try {
if (isEdit) {
await api.private.put(`products/${productId}`, { json: values });
} else {
await api.private.post('products', { json: values });
}
nav.navigate('/products');
} catch (err) {
alert('Erreur : ' + err.message);
} finally {
st.set('submitting', false);
}
};
if (st.get('loading')) {
return ;
}
return (
);
};
```
## Validation manuelle avec useForm
```javascript
import { Form, Input, Button } from '@cap-rel/smartcommon';
import { useForm } from '@cap-rel/smartcommon';
function RegisterForm() {
const form = useForm({
defaultValues: { email: '', password: '', confirmPassword: '' }
});
const handleSubmit = async (data) => {
console.log(data);
};
// Validation manuelle via setField
const validateEmail = (value) => {
form.setField({
name: 'email',
value,
errors: {
required: { condition: !value },
format: { condition: value && !value.includes('@') }
}
});
};
return (
S'inscrire
);
}
```
## Points clés à retenir
1. **Form** gère la soumission et collecte les valeurs
2. **name** est obligatoire sur chaque champ
3. **defaultValues** pour pré-remplir (édition)
4. **required** pour les champs obligatoires
5. **Zod** pour la validation avancée
[← Chapitre précédent](/training/module6-smartcommon-composants/navigation) | [Retour au module](/training/module6-smartcommon-composants) | [Chapitre suivant : Affichage ->](/training/module6-smartcommon-composants/affichage)
- [Chapitre 4 : Affichage](https://smartmaker.org/training/module6-smartcommon-composants/affichage)
# Chapitre 4 : Affichage
## Composants de formatage
SmartCommon fournit des composants pour afficher les données de manière formatée et cohérente.
## String
Affichage de texte court.
```javascript
import { String } from '@cap-rel/smartcommon';
// Avec troncature
```
## Text
Affichage de texte long avec préservation des sauts de ligne.
```javascript
import { Text } from '@cap-rel/smartcommon';
// Avec limite de lignes
```
## Number
Affichage de nombre formaté selon la locale.
```javascript
import { Number } from '@cap-rel/smartcommon';
// Nombre simple
// Affiche : 1 234,56
// Avec décimales fixées
// Affiche : 99,90
// Prix
// Affiche : 29,99 €
// Pourcentage
// Affiche : 15,6 %
```
## Datetime
Affichage de date et heure formatées.
```javascript
import { Datetime } from '@cap-rel/smartcommon';
// Date complète
// Affiche : 15 mars 2024 à 14:30
// Date seule
// Affiche : 15 mars 2024
// Heure seule
// Affiche : 14:30
// Format court
// Affiche : 15/03/2024
// Format relatif
// Affiche : il y a 2 jours
```
## Duration
Affichage de durée.
```javascript
import { Duration } from '@cap-rel/smartcommon';
// Durée en secondes
// Affiche : 1h 1min 1s
// Durée en minutes
// Affiche : 1h 30min
// Format compact
// Affiche : 1:00:00
```
## Email
Affichage d'email avec lien cliquable.
```javascript
import { Email } from '@cap-rel/smartcommon';
// Crée un lien mailto:
```
## Url
Affichage d'URL avec lien cliquable.
```javascript
import { Url } from '@cap-rel/smartcommon';
```
## PhoneNumber
Affichage de numéro de téléphone cliquable.
```javascript
import { PhoneNumber } from '@cap-rel/smartcommon';
// Affiche : +33 6 12 34 56 78 (formaté)
// Crée un lien tel:
```
## Address
Affichage d'adresse formatée.
```javascript
import { Address } from '@cap-rel/smartcommon';
// Avec lien vers Google Maps
```
## Coordinates
Affichage de coordonnées GPS.
```javascript
import { Coordinates } from '@cap-rel/smartcommon';
// Avec lien vers carte
```
## Color
Affichage d'une couleur.
```javascript
import { Color } from '@cap-rel/smartcommon';
// Affiche un carré de couleur + le code
// Affiche uniquement le carré
```
## Icon
Affichage d'icône.
```javascript
import { Icon } from '@cap-rel/smartcommon';
// Avec react-icons
import { FiCheck, FiX } from 'react-icons/fi';
```
## Tags
Affichage d'étiquettes.
```javascript
import { Tags } from '@cap-rel/smartcommon';
```
## Tag
Étiquette unique.
```javascript
import { Tag } from '@cap-rel/smartcommon';
Nouveau
Actif
Urgent
// Avec icône
Validé
```
## Files
Affichage de fichiers avec téléchargement.
```javascript
import { Files } from '@cap-rel/smartcommon';
```
## Signature
Affichage d'une signature.
```javascript
import { Signature } from '@cap-rel/smartcommon';
```
## List et ListItem
Affichage de listes.
```javascript
import { List, ListItem } from '@cap-rel/smartcommon';
import { useNavigation } from '@cap-rel/smartcommon';
function ProductsList({ products }) {
const nav = useNavigation();
return (
{products.map(product => (
nav.navigate(`/products/${product.id}`)}
chevron
/>
))}
);
}
```
### Props ListItem
| Prop | Type | Description |
| --- | --- | --- |
| title | string | Titre principal |
| subtitle | string | Sous-titre |
| image | string | URL de l'image |
| icon | Component | Icône à gauche |
| onClick | function | Action au clic |
| chevron | boolean | Afficher flèche droite |
| actions | ReactNode | Actions à droite |
## Button
Bouton d'action.
```javascript
import { Button } from '@cap-rel/smartcommon';
// Variantes
Défaut
Primaire
Secondaire
Contour
Danger
Ghost
// Tailles
Petit
Normal
Grand
// États
Chargement
Désactivé
// Avec icône
Ajouter
```
## Spinner
Indicateur de chargement.
```javascript
import { Spinner } from '@cap-rel/smartcommon';
// Page de chargement
function LoadingPage() {
return (
);
}
```
## Exemple complet : Fiche produit
```javascript
import {
Page,
Block,
String,
Text,
Number,
Datetime,
Tags,
Tag,
Files,
Button,
Carousel,
CarouselItem
} from '@cap-rel/smartcommon';
import { useNavigation } from '@cap-rel/smartcommon';
export const ProductDetailPage = ({ product }) => {
const nav = useNavigation();
return (
{/* Photos */}
{product.photos?.length > 0 && (
{product.photos.map((photo, index) => (
))}
)}
{/* Informations principales */}
{product.isActive ? 'Actif' : 'Inactif'}
{/* Description */}
{/* Détails */}
Référence
Stock
Créé le
Modifié le
{/* Documents */}
{product.documents?.length > 0 && (
)}
{/* Actions */}
nav.navigate(`/products/${product.id}/edit`)}
>
Modifier
Commander
);
};
```
## Points clés à retenir
1. **Composants de format** pour affichage cohérent
2. **Number** avec locale française automatique
3. **Datetime** avec formats relatifs pratiques
4. **List/ListItem** pour les listes navigables
5. **Button** avec variantes et états
[← Chapitre précédent](/training/module6-smartcommon-composants/formulaires) | [Retour au module](/training/module6-smartcommon-composants) | [Module suivant : SmartCommon Hooks ->](/training/module7-smartcommon-hooks)
- [Chapitre 1 : useApi](https://smartmaker.org/training/module7-smartcommon-hooks/useapi)
# Chapitre 1 : useApi
Le hook `useApi` simplifie les appels API avec gestion automatique de l'authentification JWT, du rafraîchissement de token et de la gestion des erreurs.
## Configuration
L'API est configurée dans `appConfig.js` :
```javascript
// src/appConfig.js
export const config = {
api: {
prefixUrl: import.meta.env.VITE_API_URL,
timeout: 30000,
debug: import.meta.env.DEV,
paths: {
login: "login",
logout: "logout",
refresh: "refresh"
}
}
};
```
## Structure retournée
```javascript
import { useApi } from '@cap-rel/smartcommon';
const api = useApi();
```
| Méthode | Description |
| --- | --- |
| api.user | Objet utilisateur connecté |
| api.login(body) | Connexion utilisateur |
| api.logout() | Déconnexion |
| api.entities() | Récupérer les entités disponibles |
| api.device(body) | Enregistrer un appareil |
| api.public | Instance ky pour requêtes publiques |
| api.private | Instance ky pour requêtes authentifiées |
| api.get(url, options) | Raccourci GET authentifié |
| api.post(url, options) | Raccourci POST authentifié |
| api.put(url, options) | Raccourci PUT authentifié |
| api.patch(url, options) | Raccourci PATCH authentifié |
| api.del(url, options) | Raccourci DELETE authentifié |
## Authentification
### Login
```javascript
import { useApi, useNavigation } from '@cap-rel/smartcommon';
function LoginPage() {
const api = useApi();
const nav = useNavigation();
const handleLogin = async (email, password) => {
try {
const user = await api.login({
login: email,
password: password,
rememberMe: true
});
console.log('Connecté :', user);
nav.navigate('/');
} catch (error) {
console.error('Erreur de connexion:', error);
alert('Identifiants incorrects');
}
};
return (
{
e.preventDefault();
const fd = new FormData(e.target);
handleLogin(fd.get('email'), fd.get('password'));
}}>
Connexion
);
}
```
### Logout
```javascript
function LogoutButton() {
const api = useApi();
const nav = useNavigation();
const handleLogout = async () => {
await api.logout();
nav.navigate('/login');
};
return (
Déconnexion
);
}
```
### Accès à l'utilisateur connecté
useApi expose directement l'objet utilisateur :
```javascript
const api = useApi();
if (api.user) {
console.log('Connecté :', api.user.login);
console.log('Token expire à :', api.user.tokenExpiry);
}
```
## Requêtes publiques
Pour les endpoints sans authentification :
```javascript
const api = useApi();
// GET
const info = await api.public.get('public/info').json();
// POST
const result = await api.public.post('public/contact', {
json: { name: 'Jean', message: 'Bonjour' }
}).json();
```
## Requêtes authentifiées
Pour les endpoints protégés par JWT :
```javascript
const api = useApi();
// GET - Liste
const items = await api.private.get('items').json();
// GET - Détail
const item = await api.private.get(`items/${id}`).json();
// POST - Création
const newItem = await api.private.post('items', {
json: { label: 'Nouveau produit', price: 29.99 }
}).json();
// PUT - Modification
const updated = await api.private.put(`items/${id}`, {
json: { label: 'Produit modifié' }
}).json();
// DELETE - Suppression
await api.del(`items/${id}`);
```
## Méthodes raccourcies
En plus des méthodes `api.private.get(...).json()`, useApi expose des raccourcis qui gèrent automatiquement le parsing JSON et les erreurs :
```javascript
const api = useApi();
// GET - retourne directement les données parsées
const items = await api.get('items');
// POST
const created = await api.post('items', { json: { label: 'Nouveau' } });
// PUT
const updated = await api.put(`items/${id}`, { json: data });
// PATCH
const patched = await api.patch(`items/${id}`, { json: { status: 1 } });
// DELETE
await api.del(`items/${id}`);
```
### Option raw
Pour récupérer des données binaires (images, fichiers Excel, PDF...) :
```javascript
const response = await api.get(`temp-file/${token}/binary`, { raw: true });
const blob = await response.blob();
```
### Option silent
Supprime la notification automatique en cas d'erreur :
```javascript
const data = await api.get('optional-endpoint', { silent: true });
```
## Exemple complet : CRUD
```javascript
import { useEffect } from 'react';
import { Page, Block, List, ListItem, Button, Spinner } from '@cap-rel/smartcommon';
import { useApi, useStates, useNavigation } from '@cap-rel/smartcommon';
export const ProductsPage = () => {
const api = useApi();
const nav = useNavigation();
const st = useStates({
initialStates: {
products: [],
loading: true,
error: null
}
});
// Charger les produits
useEffect(() => {
loadProducts();
}, []);
const loadProducts = async () => {
st.set('loading', true);
st.set('error', null);
try {
const data = await api.private.get('products').json();
st.set('products', data.products);
} catch (err) {
st.set('error', err.message);
} finally {
st.set('loading', false);
}
};
// Supprimer un produit
const deleteProduct = async (id) => {
if (!confirm('Supprimer ce produit ?')) return;
try {
await api.del(`products/${id}`);
// Retirer de la liste locale
st.set('products', st.get('products').filter(p => p.id !== id));
} catch (err) {
alert('Erreur : ' + err.message);
}
};
if (st.get('loading')) {
return ;
}
if (st.get('error')) {
return (
Erreur : {st.get('error')}
Réessayer
);
}
return (
nav.navigate('/products/new')}>
Nouveau produit
{st.get('products').map(product => (
nav.navigate(`/products/${product.id}`)}
actions={
{
e.stopPropagation();
deleteProduct(product.id);
}}
>
Supprimer
}
/>
))}
);
};
```
## Gestion des erreurs
```javascript
const api = useApi();
const createProduct = async (data) => {
try {
const result = await api.private.post('products', { json: data }).json();
return { success: true, data: result };
} catch (error) {
// Erreur HTTP
if (error.response) {
const status = error.response.status;
const body = await error.response.json().catch(() => ({}));
if (status === 400) {
return { success: false, error: 'Données invalides', details: body };
}
if (status === 401) {
return { success: false, error: 'Session expirée' };
}
if (status === 403) {
return { success: false, error: 'Accès interdit' };
}
if (status === 404) {
return { success: false, error: 'Ressource non trouvée' };
}
if (status >= 500) {
return { success: false, error: 'Erreur serveur' };
}
}
// Erreur réseau
return { success: false, error: 'Erreur de connexion' };
}
};
```
## Gestion globale des erreurs (onApiError)
Configurez un callback global dans appConfig pour centraliser le traitement des erreurs API :
```javascript
// src/appConfig.js
import toast from 'react-hot-toast';
export const config = {
api: {
prefixUrl: import.meta.env.VITE_API_URL,
onApiError: (message) => {
toast.error(message);
},
},
};
```
Quand une requête échoue via les méthodes raccourcies (get, post, put, patch, del), useApi :
- Extrait le message d'erreur du body JSON (champ `error` ou `message`)
- Appelle `onApiError` avec ce message (sauf si `silent: true`)
- Détecte aussi les erreurs applicatives (HTTP 200 avec champ `error` dans le body)
## Fonctionnalités automatiques
### Gestion des tokens JWT
- Le token d'accès est ajouté automatiquement aux requêtes `api.private`
- Le token est rafraîchi automatiquement avant expiration
- En cas d'erreur 401, un refresh est tenté automatiquement
### Headers automatiques
Chaque requête inclut :
- `Authorization: Bearer ` (requêtes private)
- `X-DEVICEID: ` (identification de l'appareil)
- `Content-Type: application/json`
### Circuit breaker
Protection contre les requêtes en cascade :
- Blocage temporaire après plusieurs échecs
- Détection automatique de la connexion internet
## Paramètres de requête
### Query string
```javascript
// GET /products?category=electronics&limit=10
const products = await api.private.get('products', {
searchParams: {
category: 'electronics',
limit: 10
}
}).json();
```
### Headers personnalisés
```javascript
const data = await api.private.get('items', {
headers: {
'X-Custom-Header': 'value'
}
}).json();
```
### Timeout personnalisé
```javascript
const data = await api.private.get('slow-endpoint', {
timeout: 60000 // 60 secondes
}).json();
```
## Points clés à retenir
1. **api.login()** gère automatiquement le stockage des tokens
2. **api.private** ajoute automatiquement l'authentification
3. **api.public** pour les endpoints sans auth
4. Terminer par **.json()** pour parser la réponse
5. Le **refresh token** est géré automatiquement
6. **api.get/post/put/patch/del** : raccourcis avec gestion d'erreur automatique
7. **api.user** : accès direct à l'utilisateur connecté
8. **onApiError** : centralise la notification des erreurs
9. **{ raw: true }** pour les fichiers binaires
10. **{ silent: true }** pour supprimer les notifications d'erreur
[← Retour au module](/training/module7-smartcommon-hooks) | [Chapitre suivant : Gestion d'état ->](/training/module7-smartcommon-hooks/etat)
- [Chapitre 2 : Gestion d'état](https://smartmaker.org/training/module7-smartcommon-hooks/etat)
# Chapitre 2 : Gestion d'état
SmartCommon propose plusieurs hooks pour gérer l'état selon les besoins :
- **useGlobalStates** : état partagé entre composants avec persistance
- **useStates** : état local d'un composant
- **useForm** : état spécialisé pour les formulaires
## useGlobalStates
État global accessible partout, avec persistance automatique.
### Syntaxe de base
```javascript
import { useGlobalStates } from '@cap-rel/smartcommon';
function MyComponent() {
const gst = useGlobalStates();
return (
Utilisateur : {gst.get('session')?.user?.name}
Langue : {gst.get('settings')?.lng}
);
}
```
### Configuration de la persistance
Dans `appConfig.js` :
```javascript
export const config = {
storage: {
local: ["session", "settings"] // Persisté en localStorage
},
globalState: {
reducers: {
session: null,
settings: { lng: "fr", theme: "light" },
cart: { items: [], total: 0 }
}
}
};
```
### Lecture et écriture
```javascript
function SettingsPage() {
const gst = useGlobalStates();
const settings = gst.get('settings');
const changeLanguage = (lng) => {
gst.local.set('settings', { ...settings, lng });
};
const changeTheme = (theme) => {
gst.local.set('settings', { ...settings, theme });
};
return (
changeLanguage(e.target.value)}
>
Français
English
changeTheme('light')}>Clair
changeTheme('dark')}>Sombre
);
}
```
### Exemple : Panier d'achat
```javascript
function useCart() {
const gst = useGlobalStates();
const cart = gst.get('cart');
const addItem = (product) => {
const existing = cart.items.find(i => i.id === product.id);
let newItems;
if (existing) {
newItems = cart.items.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
} else {
newItems = [...cart.items, { ...product, quantity: 1 }];
}
const total = newItems.reduce(
(sum, i) => sum + i.price * i.quantity,
0
);
gst.set('cart', { items: newItems, total });
};
const removeItem = (productId) => {
const newItems = cart.items.filter(i => i.id !== productId);
const total = newItems.reduce(
(sum, i) => sum + i.price * i.quantity,
0
);
gst.set('cart', { items: newItems, total });
};
const clearCart = () => {
gst.set('cart', { items: [], total: 0 });
};
return { cart, addItem, removeItem, clearCart };
}
// Utilisation
function ProductCard({ product }) {
const { addItem } = useCart();
return (
{product.label}
{product.price} €
addItem(product)}>
Ajouter au panier
);
}
function CartIcon() {
const { cart } = useCart();
const itemCount = cart.items.reduce((sum, i) => sum + i.quantity, 0);
return 🛒 {itemCount} ;
}
```
## useStates
État local avec notation par chemin (path notation).
### Syntaxe de base
```javascript
import { useStates } from '@cap-rel/smartcommon';
function MyComponent() {
const st = useStates({
initialStates: {
count: 0,
user: { name: '', email: '' },
items: [],
loading: false
},
debug: true // Affiche les changements en console
});
return (
Count: {st.get('count')}
st.set('count', st.get('count') + 1)}>
+1
);
}
```
### Méthodes disponibles
| Méthode | Description |
| --- | --- |
| st.get(path) | Lire une valeur |
| st.set(path, value) | Écrire une valeur |
| st.unset(path) | Supprimer une valeur |
| st.values | Objet contenant toutes les valeurs |
### Path notation
```javascript
const st = useStates({
initialStates: {
user: { name: '', address: { city: '' } },
items: []
}
});
// Lecture
st.get('user'); // { name: '', address: { city: '' } }
st.get('user.name'); // ''
st.get('user.address.city'); // ''
st.get('items'); // []
st.get('items[0]'); // undefined
// Écriture
st.set('user.name', 'Jean');
st.set('user.address.city', 'Paris');
// Écriture avec fonction
st.set('count', prev => prev + 1);
// Manipulation de tableaux
st.set('items[]', { id: 1 }); // Push
st.set('items[0].name', 'test'); // Modifier index
st.unset('items[0]'); // Supprimer index
```
### Exemple : Page de détail
```javascript
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Page, Block, Spinner } from '@cap-rel/smartcommon';
import { useApi, useStates } from '@cap-rel/smartcommon';
export const ProductDetailPage = () => {
const { id } = useParams();
const api = useApi();
const st = useStates({
initialStates: {
product: null,
loading: true,
error: null,
isEditing: false
}
});
useEffect(() => {
loadProduct();
}, [id]);
const loadProduct = async () => {
st.set('loading', true);
st.set('error', null);
try {
const data = await api.private.get(`products/${id}`).json();
st.set('product', data);
} catch (err) {
st.set('error', err.message);
} finally {
st.set('loading', false);
}
};
if (st.get('loading')) {
return ;
}
if (st.get('error')) {
return Erreur : {st.get('error')} ;
}
const product = st.get('product');
return (
Prix : {product.price} €
Stock : {product.stock}
);
};
```
## useForm
Hook spécialisé pour les formulaires avec validation.
### Syntaxe de base
```javascript
import { useForm } from '@cap-rel/smartcommon';
import { Form, Input, Button } from '@cap-rel/smartcommon';
function LoginForm() {
const form = useForm({ defaultValues: { email: '', password: '' } });
const handleSubmit = async (data) => {
console.log('Données validées:', data);
};
return (
Connexion
);
}
```
### Avec valeurs initiales
```javascript
function EditProductForm({ product }) {
const form = useForm({
defaultValues: {
label: product.label,
price: product.price,
description: product.description
}
});
const handleSubmit = async (data) => {
await api.private.put(`products/${product.id}`, { json: data });
};
return (
Enregistrer
);
}
```
### Validation manuelle avec setField
```javascript
// useForm ne fait pas de validation automatique.
// Utilisez setField pour gérer les erreurs manuellement :
form.setField({
name: 'email',
value: inputValue,
errors: {
required: { condition: !inputValue },
format: { condition: inputValue && !isValidEmail(inputValue) }
}
});
// Vérifier les erreurs
const hasError = form.get('errors.email.required'); // true | false
```
## Comparaison des hooks d'état
| Hook | Portée | Persistance | Cas d'usage |
| --- | --- | --- | --- |
| useGlobalStates | Application | localStorage | Session, préférences, panier |
| useStates | Composant | Non | État de page, chargement |
| useForm | Composant | Non | Formulaires avec validation |
| useState (React) | Composant | Non | État simple |
## Bonnes pratiques
### 1. Choisir le bon hook
```javascript
// Session utilisateur -> useGlobalStates
const gst = useGlobalStates();
const session = gst.get('session');
// État de chargement d'une page -> useStates
const st = useStates({ initialStates: { loading: true, data: null } });
// Formulaire -> useForm
const form = useForm({ defaultValues: { name: '', email: '' } });
// Toggle simple -> useState
const [isOpen, setIsOpen] = useState(false);
```
### 2. Organiser l'état global
```javascript
// appConfig.js
globalState: {
reducers: {
// Auth
session: null,
// Préférences utilisateur
settings: { lng: 'fr', theme: 'light' },
// Données métier globales
cart: { items: [], total: 0 },
// Cache
categories: []
}
}
```
## useConfirm
Hook pour afficher des dialogues de confirmation et d'alerte modaux.
### Import
```javascript
import { useConfirm } from '@cap-rel/smartcommon';
```
### Fonctions retournées
Le hook retourne un objet avec deux fonctions :
```javascript
const { confirm, alert } = useConfirm();
```
- **confirm** : affiche un dialogue avec boutons Confirmer / Annuler, retourne `true` ou `false`
- **alert** : affiche un dialogue avec un seul bouton OK, retourne toujours `true`
### Utilisation de confirm
```javascript
function DeleteButton({ item, onDelete }) {
const { confirm } = useConfirm();
const handleDelete = async () => {
const confirmed = await confirm({
type: 'delete',
title: 'Supprimer cet élément ?',
message: `Voulez-vous vraiment supprimer "${item.name}" ?`,
detail: item.ref,
confirmText: 'Supprimer',
cancelText: 'Annuler'
});
if (confirmed) {
onDelete(item.id);
}
};
return Supprimer ;
}
```
### Utilisation de alert
```javascript
function SaveButton({ onSave }) {
const { alert } = useConfirm();
const handleSave = async () => {
try {
await onSave();
await alert({
type: 'info',
title: 'Succès',
message: 'Les données ont été enregistrées.'
});
} catch (err) {
await alert({
type: 'warning',
title: 'Erreur',
message: err.message
});
}
};
return Enregistrer ;
}
```
### Options
| Option | Type | Description |
| --- | --- | --- |
| type | string | Type de dialogue : `'danger'`, `'delete'`, `'warning'`, `'info'` |
| title | string | Titre du dialogue |
| message | string | Message de confirmation |
| detail | string | Texte complémentaire affiché dans un encadré gris |
| confirmText | string | Texte du bouton de confirmation |
| cancelText | string | Texte du bouton d'annulation |
### Types et icônes
| Type | Icône | Couleur du bouton |
| --- | --- | --- |
| danger | Corbeille (rouge) | Rouge |
| delete | Corbeille (rouge) | Rouge |
| warning | Triangle exclamation (orange) | Orange |
| info | Info (bleu) | Bleu |
| (autre) | Point d'interrogation (gris) | Bleu (défaut) |
### Prérequis
Le composant `ConfirmProvider` doit envelopper l'application :
```javascript
import { ConfirmProvider } from '@cap-rel/smartcommon';
function App() {
return (
);
}
```
La prop `labels` permet de définir les textes par défaut des boutons. Si non fournie, les valeurs sont `"Cancel"` et `"OK"`.
## usePWAUpdate
Hook pour détecter et gérer les mises à jour de l'application PWA.
### Import
```javascript
import { usePWAUpdate } from '@cap-rel/smartcommon';
```
### Utilisation
```javascript
function UpdateBanner() {
const {
updateAvailable,
updateActivated,
checkForUpdates,
applyUpdate
} = usePWAUpdate({
checkInterval: 300000 // Vérifier toutes les 5 min
});
if (!updateAvailable) return null;
return (
Une mise à jour est disponible
Mettre à jour maintenant
);
}
```
### Avec rechargement automatique
```javascript
function App() {
usePWAUpdate({
autoReload: true,
onUpdateAvailable: () => {
console.log('Mise à jour disponible');
},
onUpdateActivated: () => {
console.log('Mise à jour activée');
}
});
return ;
}
```
### Options
| Option | Type | Défaut | Description |
| --- | --- | --- | --- |
| autoReload | boolean | false | Recharger automatiquement après mise à jour |
| checkInterval | number | 0 | Intervalle de vérification en ms (0 = désactivé) |
| onUpdateAvailable | function | - | Callback quand une mise à jour est disponible |
| onUpdateActivated | function | - | Callback quand la mise à jour est activée |
### Valeurs retournées
| Propriété | Type | Description |
| --- | --- | --- |
| updateAvailable | boolean | Mise à jour en attente |
| updateActivated | boolean | Mise à jour activée |
| registration | object | ServiceWorkerRegistration |
| checkForUpdates | function | Vérifier manuellement |
| applyUpdate | function | Appliquer la mise à jour |
| reloadPage | function | Recharger la page |
## UpdatePrompt
Composant UI prêt à l'emploi qui encapsule `usePWAUpdate` et affiche une notification quand une mise à jour est disponible.
### Import
```javascript
import { UpdatePrompt } from '@cap-rel/smartcommon';
```
### Variantes d'affichage
Trois variantes sont disponibles :
- **toast** (défaut) : notification discrète via react-hot-toast
- **banner** : bandeau fixe en haut ou en bas de l'écran
- **modal** : dialogue modal centré
### Utilisation directe
```javascript
function App() {
return (
);
}
```
### Via le Provider
Le `Provider` de SmartCommon accepte une prop `pwaUpdate` qui intègre automatiquement `UpdatePrompt` :
```javascript
import { Provider } from '@cap-rel/smartcommon';
import { config } from './appConfig';
export const App = () => (
);
```
### Props
| Prop | Type | Défaut | Description |
| --- | --- | --- | --- |
| variant | string | `"toast"` | `"toast"`, `"banner"` ou `"modal"` |
| position | string | `"bottom"` | Position du banner : `"top"` ou `"bottom"` |
| autoReload | boolean | `false` | Recharger automatiquement après activation |
| checkInterval | number | `0` | Intervalle de vérification en ms (0 = désactivé) |
| labels | object | - | Textes personnalisés (voir ci-dessous) |
| onUpdateAvailable | function | - | Callback quand une mise à jour est détectée |
| onUpdateActivated | function | - | Callback quand la mise à jour est activée |
### Labels par défaut
| Clé | Valeur par défaut |
| --- | --- |
| title | "Mise à jour disponible" |
| message | "Une nouvelle version est disponible." |
| reloadButton | "Rafraîchir" |
| dismissButton | "Plus tard" |
## Points clés à retenir
1. **useGlobalStates** pour les données partagées et persistées
2. **useStates** pour l'état local avec path notation
3. **useForm** pour les formulaires avec validation manuelle
4. **useConfirm** pour les dialogues de confirmation et d'alerte
5. **usePWAUpdate** pour détecter les mises à jour PWA
6. **UpdatePrompt** pour afficher une UI de mise à jour prête à l'emploi
7. **Path notation** : `user.address.city`, `items[0]`, `items[]`
8. Configurer **storage.local** pour la persistance
[← Chapitre précédent](/training/module7-smartcommon-hooks/useapi) | [Retour au module](/training/module7-smartcommon-hooks) | [Chapitre suivant : Utilitaires ->](/training/module7-smartcommon-hooks/utilitaires)
- [Chapitre 3 : Hooks utilitaires](https://smartmaker.org/training/module7-smartcommon-hooks/utilitaires)
# Chapitre 3 : Hooks utilitaires
SmartCommon fournit des hooks utilitaires pour les tâches courantes.
## useNavigation
Navigation programmatique avec React Router.
```javascript
import { useNavigation } from '@cap-rel/smartcommon';
function MyComponent() {
const nav = useNavigation();
return (
{/* Navigation vers une route */}
nav.navigate('/products')}>
Produits
{/* Navigation avec paramètre */}
nav.navigate(`/products/${id}`)}>
Détails
{/* Retour arrière */}
nav.navigate(-1)}>
Retour
{/* Remplacement (sans historique) */}
nav.replace('/login').to()}>
Login
);
}
```
## useLibConfig
Accès à la configuration de l'application.
```javascript
import { useLibConfig } from '@cap-rel/smartcommon';
function DebugInfo() {
const config = useLibConfig();
if (!config.debug) return null;
return (
API URL: {config.api.prefixUrl}
Mode: {config.debug ? 'Debug' : 'Production'}
);
}
```
## useIntl
Formatage de dates et nombres avec l'API Intl.
### Formatage de dates
```javascript
import { useIntl } from '@cap-rel/smartcommon';
function EventCard({ event }) {
const intl = useIntl();
// Format par défaut
const dateFormatted = intl.DateTimeFormat(event.date);
// "15/03/2024, 14:30:00"
// Format personnalisé
const dateOnly = intl.DateTimeFormat(event.date, 'fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
// "15 mars 2024"
// Heure seule
const timeOnly = intl.DateTimeFormat(event.date, 'fr-FR', {
hour: '2-digit',
minute: '2-digit'
});
// "14:30"
return (
{event.title}
Le {dateOnly} à {timeOnly}
);
}
```
### Formatage de nombres
```javascript
function PriceDisplay({ price, currency = 'EUR' }) {
const intl = useIntl();
const formatted = intl.NumberFormat(price, 'fr-FR', {
style: 'currency',
currency: currency
});
// "29,99 €"
return {formatted} ;
}
function PercentDisplay({ value }) {
const intl = useIntl();
const formatted = intl.NumberFormat(value, 'fr-FR', {
style: 'percent',
minimumFractionDigits: 1
});
// "15,5 %"
return {formatted} ;
}
```
## useWindow
Informations sur la fenêtre du navigateur.
```javascript
import { useWindow } from '@cap-rel/smartcommon';
function ResponsiveLayout({ children }) {
const { orientation, windowDimension, darkMode } = useWindow();
// orientation: 'portrait' ou 'landscape'
// windowDimension: { w, h }
// darkMode: boolean
if (windowDimension.w < 768) {
return {children} ;
}
if (windowDimension.w < 1024) {
return {children} ;
}
return {children} ;
}
function WindowInfo() {
const { windowDimension } = useWindow();
return Fenêtre : {windowDimension.w} x {windowDimension.h}
;
}
```
## useFile
Utilitaires pour la manipulation de fichiers.
### Redimensionner une image
```javascript
import { useFile } from '@cap-rel/smartcommon';
function ImageUploader({ onUpload }) {
const { resizeImage } = useFile();
const handleFileChange = async (e) => {
const file = e.target.files[0];
if (!file) return;
// Redimensionner avant upload
const base64 = await resizeImage(file, {
maxWidth: 1920,
maxHeight: 1080,
quality: 85
});
// base64 = "data:image/jpeg;base64,..."
onUpload(base64);
};
return (
);
}
```
## useDb
Base de données IndexedDB via Dexie pour le stockage local de grandes quantités de données.
### Configuration
```javascript
import { useDb } from '@cap-rel/smartcommon';
const db = useDb({
name: 'myApp',
version: 1,
stores: {
items: 'id++, name, category, createdAt',
logs: 'id++, action, timestamp'
},
debug: true
});
```
### Opérations CRUD
```javascript
function ItemsManager() {
const db = useDb({
name: 'myApp',
version: 1,
stores: {
items: 'id++, name, category'
}
});
// Créer
const addItem = async (item) => {
const id = await db.items.add(item);
console.log('Item créé avec id:', id);
return id;
};
// Lire tous
const getAllItems = async () => {
return db.items.toArray();
};
// Lire un
const getItem = async (id) => {
return db.items.get(id);
};
// Modifier
const updateItem = async (id, changes) => {
await db.items.update(id, changes);
};
// Supprimer
const deleteItem = async (id) => {
await db.items.delete(id);
};
// ...
}
```
### Requêtes avancées
```javascript
// Filtrer par valeur
const electronics = await db.items
.where('category')
.equals('electronics')
.toArray();
// Filtrer par plage
const expensiveItems = await db.items
.where('price')
.above(100)
.toArray();
// Trier
const sorted = await db.items
.orderBy('createdAt')
.reverse()
.toArray();
// Limiter
const first10 = await db.items
.limit(10)
.toArray();
// Compter
const count = await db.items.count();
// Recherche texte
const matching = await db.items
.filter(item => item.name.includes('test'))
.toArray();
```
### Exemple : Mode offline
```javascript
import { useEffect } from 'react';
import { useApi, useDb, useStates } from '@cap-rel/smartcommon';
function OfflineCapableList() {
const api = useApi();
const db = useDb({
name: 'myApp',
version: 1,
stores: { items: 'id, name, synced' }
});
const st = useStates({
initialStates: { items: [], loading: true }
});
useEffect(() => {
loadItems();
}, []);
const loadItems = async () => {
try {
// Essayer de charger depuis l'API
const data = await api.private.get('items').json();
// Sauvegarder en local
await db.items.clear();
await db.items.bulkAdd(data.map(i => ({ ...i, synced: true })));
st.set('items', data);
} catch (err) {
// En cas d'erreur réseau, charger depuis IndexedDB
console.log('Mode offline - chargement local');
const localItems = await db.items.toArray();
st.set('items', localItems);
} finally {
st.set('loading', false);
}
};
const addItem = async (item) => {
// Sauvegarder localement d'abord
const id = await db.items.add({ ...item, synced: false });
// Mettre à jour l'UI
st.set('items', [...st.get('items'), { ...item, id }]);
// Tenter de synchroniser
try {
const result = await api.private.post('items', { json: item }).json();
await db.items.update(id, { ...result, synced: true });
} catch (err) {
console.log('Sync échouée, sera retentée plus tard');
}
};
// ...
}
```
## useAnimation
Gestion d'animations avec Framer Motion.
```javascript
import { useAnimation } from '@cap-rel/smartcommon';
import { motion } from 'framer-motion';
function AnimatedList({ items }) {
const { start } = useAnimation();
return (
{items.map((item, index) => (
{item.name}
))}
);
}
```
## useListDnD
Drag and drop pour réordonner des listes.
```javascript
import { useListDnD } from '@cap-rel/smartcommon';
function SortableList({ initialItems, onReorder }) {
const {
items,
onDragStart,
onDragOver,
onDrop
} = useListDnD({
initialItems,
onReorder: (newItems) => {
console.log('Nouvel ordre:', newItems);
onReorder(newItems);
}
});
return (
{items.map((item, index) => (
onDragStart(e, index)}
onDragOver={onDragOver}
onDrop={(e) => onDrop(e, index)}
>
{item.name}
))}
);
}
```
## Tableau récapitulatif
| Hook | Usage | Exemple |
| --- | --- | --- |
| useNavigation | Navigation | nav.navigate('/products') |
| useLibConfig | Config app | config.debug |
| useIntl | Formatage | intl.DateTimeFormat(date) |
| useWindow | Responsive | orientation, windowDimension, scroll, darkMode |
| useFile | Fichiers | resizeImage(file, options) |
| useDb | IndexedDB | db.items.add(item) |
| useAnimation | Animations | start (boolean) |
| useListDnD | Drag & drop | onDragStart, onDragOver, onDrop |
## Points clés à retenir
1. **useNavigation** pour la navigation programmatique
2. **useIntl** pour le formatage localisé
3. **useWindow** pour le responsive design
4. **useFile** pour le traitement d'images
5. **useDb** pour le stockage local (offline)
[← Chapitre précédent](/training/module7-smartcommon-hooks/etat) | [Retour au module](/training/module7-smartcommon-hooks) | [Module suivant : Backend API ->](/training/module8-backend-api)
- [Chapitre 4 : Synchronisation](https://smartmaker.org/training/module7-smartcommon-hooks/sync)
# 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` n'est pas le seul dispositif de synchronisation de SmartMaker,
> et il ne convient pas à tous les besoins. Avant de le choisir, lisez
> [Synchronisation offline](/front/synchronisation), qui compare les trois
> dispositifs disponibles.
## useSyncClient
Hook principal pour la synchronisation offline-first.
### Import
```javascript
import { useSyncClient } from '@cap-rel/smartcommon';
```
### Configuration
```javascript
function MyApp() {
const {
isOnline,
isSyncing,
pendingCount,
sync,
create,
update,
remove,
upsert,
getConflicts,
resolveConflict
} = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty', 'contact', 'product']
});
return (
Status : {isOnline ? 'En ligne' : 'Hors ligne'}
Modifications en attente : {pendingCount}
);
}
```
### Enregistrer le client, une étape obligatoire
Avant toute synchronisation, le client doit être enregistré auprès du serveur
pour obtenir son `client_uuid`. **`sync()` ne le fait pas tout seul** : sans cet
appel, ni le push ni le pull n'aboutissent.
```javascript
function SyncBootstrap({ deviceUuid }) {
const { isInitialized, isRegistered, register } = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty']
});
useEffect(() => {
if (isInitialized && !isRegistered && deviceUuid) {
register(deviceUuid);
}
}, [isInitialized, isRegistered, deviceUuid, register]);
return null;
}
```
Le `deviceUuid` vient du JWT SmartAuth, après identification de l'appareil.
### Paramètres
| Paramètre | Type | Défaut | 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 |
| autoSync | boolean | true | Synchronise au retour en ligne, après 2 s de stabilité |
| syncInterval | number | null | Synchronisation périodique, en millisecondes |
| onConflict | function | null | Appelée avec les conflits détectés |
| onSyncStart | function | null | Début de synchronisation |
| onSyncComplete | function | null | Fin de synchronisation |
| onSyncError | function | null | Échec de synchronisation |
| dbName | string | smartauth_sync | Nom de la base IndexedDB locale |
### Valeurs retournées
| Propriété | Type | Description |
| --- | --- | --- |
| isOnline | boolean | Statut de connexion du navigateur |
| isServerReachable | boolean/null | Serveur joignable |
| checkNow | function | Forcer une vérification de connectivité |
| isInitialized | boolean | Moteur et stockage local prêts |
| isRegistered | boolean | Client enregistré auprès du serveur |
| isSyncing | boolean | Synchronisation en cours |
| lastSyncTime | number | Horodatage de la dernière synchronisation |
| pendingCount | number | Nombre de modifications en attente |
| conflictsCount | number | Nombre de conflits non résolus |
| syncError | Error | Dernière erreur rencontrée |
| register | function | Enregistrer le client (`register(deviceUuid)`) |
| sync | function | Push puis pull |
| push | function | Push seul |
| pull | function | Pull seul |
| 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) |
| getEntity | function | Lire une entité locale |
| queryEntities | function | Lire et filtrer les entités locales |
| getConflicts | function | Récupérer les conflits |
| resolveConflict | function | Résoudre un conflit |
| getStatus | function | État complet du moteur |
| reset | function | Effacer toutes les données de synchronisation locales |
### Créer une entité
```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 (
{/* ... */}
En attente de sync : {pendingCount}
);
}
```
### Modifier et supprimer
```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 (
Modifier
Supprimer
);
}
```
### 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à.
```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
```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 (
{isSyncing ? 'Synchronisation...' : `Synchroniser (${pendingCount})`}
);
}
```
## ConflictResolver
Composant UI pour résoudre les conflits de synchronisation.
### Import
```javascript
import { ConflictResolver } from '@cap-rel/smartcommon';
```
### Utilisation
```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, data) => {
await resolveConflict(conflictId, resolution, data);
await loadConflicts();
};
if (conflicts.length === 0) {
return Aucun conflit
;
}
return (
);
}
```
### Props
| Prop | Type | Description |
| --- | --- | --- |
| conflicts | array | Liste des conflits à afficher |
| onResolve | function | `(conflictId, resolution, data)` |
| onCancel | function | Ferme le résolveur |
| renderField | function | Rendu personnalisé d'un champ (optionnel) |
| labels | object | Libellés de l'interface (optionnel, français par défaut) |
### Structure d'un conflit
```javascript
{
conflict_id: 'conflict_123',
table: 'thirdparty',
object_id: 456,
client_data: { name: 'Version locale', /* ... */ },
server_data: { name: 'Version serveur', /* ... */ },
client_tms: '2026-02-14 10:00:00',
server_tms: '2026-02-14 09:45:00',
field_conflicts: ['name'],
created_at: '2026-02-14T10:01:00.000Z'
}
```
### Résolutions possibles
- **'client'** : garder la version locale
- **'server'** : garder la version serveur
- fusion : passer `'client'` en résolution et l'objet fusionné en troisième
argument
```javascript
await resolveConflict(conflict.conflict_id, 'client', {
...conflict.server_data,
label: conflict.client_data.label
});
```
## useOnlineStatus
Hook pour détecter le statut online/offline avec health check serveur optionnel.
### Import
```javascript
import { useOnlineStatus } from '@cap-rel/smartcommon';
```
### Utilisation simple
```javascript
function NetworkStatus() {
const { isOnline, isOffline } = useOnlineStatus();
return (
{isOnline ? 'En ligne' : 'Hors ligne'}
);
}
```
### Avec health check serveur
```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 (
Navigateur : {isOnline ? 'En ligne' : 'Hors ligne'}
Serveur : {isServerReachable ? 'Accessible' : 'Inaccessible'}
Dernière vérification : {new Date(lastCheck).toLocaleTimeString()}
Vérifier maintenant
);
}
```
### 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
```javascript
import { useCachedQuery, CACHE_STRATEGIES } from '@cap-rel/smartcommon';
```
### Stratégies disponibles
| Stratégie | Description |
| --- | --- |
| NETWORK_FIRST | Réseau d'abord, cache en fallback |
| CACHE_FIRST | Cache d'abord si valide, sinon réseau |
| STALE_WHILE_REVALIDATE | Afficher le cache, rafraîchir en arrière-plan |
### Exemple : Cache-first pour dictionnaires
```javascript
function CountrySelect() {
const { data: countries, isLoading, isFromCache } = useCachedQuery({
db,
store: 'queryCache',
key: 'countries',
fetchFn: () => api.get('dictionaries/countries').json(),
strategy: CACHE_STRATEGIES.CACHE_FIRST,
ttl: 86400000 // 24h
});
if (isLoading) return ;
return (
{countries.map(c => (
{c.label}
))}
);
}
```
### Exemple : Stale-while-revalidate pour config
```javascript
function AppConfig() {
const {
data: config,
isStale,
refetch,
invalidate
} = useCachedQuery({
db,
store: 'queryCache',
key: 'app-config',
fetchFn: () => api.get('config').json(),
strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE,
staleTime: 300000 // 5 min
});
return (
{isStale &&
Mise à jour en cours...
}
Forcer le rafraîchissement
);
}
```
### Paramètres
| Paramètre | Type | Défaut | Description |
| --- | --- | --- | --- |
| db | object | - | Instance Dexie, telle que retournée par useDb |
| 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
```javascript
import { useAuthenticatedImage } from '@cap-rel/smartcommon';
```
### Utilisation
```javascript
function UserAvatar({ userId }) {
const { src, isLoading, isFromCache, error } = useAuthenticatedImage({
db,
store: 'imageCache',
url: `/api/users/${userId}/photo`,
token: accessToken,
placeholder: '/images/default-avatar.png',
ttl: 86400000, // 24h
staleTime: 3600000 // 1h
});
if (isLoading) return ;
return ;
}
```
### 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 :
```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
```javascript
import { useEffect } from 'react';
import {
useApi,
useSyncClient,
useOnlineStatus,
useCachedQuery,
useDb,
CACHE_STRATEGIES,
Page,
Block,
List,
ListItem,
Button
} from '@cap-rel/smartcommon';
function ThirdpartyList({ deviceUuid }) {
const api = useApi();
// Ce store ne sert qu'au cache de useCachedQuery. useSyncClient, lui,
// gère sa propre base IndexedDB (smartauth_sync).
const db = useDb({
name: 'myApp',
version: 1,
stores: {
queryCache: 'key'
}
});
const { isOnline } = useOnlineStatus({
healthCheckUrl: '/api/smartauth/sync/status'
});
const {
sync,
isSyncing,
pendingCount,
isInitialized,
isRegistered,
register
} = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty']
});
const {
data: thirdparties,
isLoading,
isFromCache
} = useCachedQuery({
db,
store: 'queryCache',
key: 'thirdparties',
fetchFn: () => api.private.get('thirdparties').json(),
strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE
});
// Enregistrement du client, sans quoi aucune synchronisation n'aboutit
useEffect(() => {
if (isInitialized && !isRegistered && deviceUuid) {
register(deviceUuid);
}
}, [isInitialized, isRegistered, deviceUuid, register]);
return (
{isOnline ? 'En ligne' : 'Hors ligne'}
{isFromCache && ' (cache)'}
{pendingCount > 0 && (
Sync ({pendingCount})
)}
{thirdparties?.map(t => (
{t.name}
))}
);
}
```
Aucun effet ne déclenche ici la synchronisation au retour en ligne : `autoSync`
est actif par défaut et s'en charge, après un délai de stabilité de 2 secondes
et seulement s'il reste des changements en attente.
## Points clés à retenir
1. **useSyncClient** pour les opérations CRUD offline-capable
2. **register(deviceUuid)** avant toute synchronisation, sans exception
3. **useOnlineStatus** pour détecter la connectivité
4. **useCachedQuery** pour le cache intelligent avec stratégies
5. **useAuthenticatedImage** pour les images protégées
6. **ConflictResolver** pour la résolution de conflits UI
7. Configurer les stores IndexedDB pour le cache, en gardant à l'esprit que
`useSyncClient` gère sa propre base à part
[← Chapitre précédent](/training/module7-smartcommon-hooks/utilitaires) | [Retour au module](/training/module7-smartcommon-hooks)
- [Chapitre 1 : Routage](https://smartmaker.org/training/module8-backend-api/routage)
# Chapitre 1 : Routage
Le fichier `api.php` définit toutes les routes de l'API.
## Structure de base
```php
$items], 200];
return [['id' => $newId], 201]; // Created
return ['Updated', 200];
// Erreurs
return ['Not Found', 404];
return ['Bad Request', 400];
return ['Forbidden', 403];
return ['Server Error', 500];
```
## Points clés à retenir
1. **Route::action()** pour définir chaque endpoint
2. **true** en dernier paramètre pour les routes authentifiées
3. **Paramètres dynamiques** avec {nom}
4. **Fallback** obligatoire en fin de fichier
5. Le controller reçoit tout dans **$payload**
[← Retour au module](/training/module8-backend-api) | [Chapitre suivant : Controllers ->](/training/module8-backend-api/controllers)
- [Chapitre 2 : Controllers](https://smartmaker.org/training/module8-backend-api/controllers)
# Chapitre 2 : Controllers
Les controllers contiennent la logique métier de l'API.
## Structure d'un controller
```php
$items], 200];
}
}
```
## Variables globales disponibles
Dans les routes protégées :
- `$db` : connexion base de données Dolibarr
- `$user` : utilisateur Dolibarr connecté (via JWT)
- `$conf` : configuration Dolibarr
- `$langs` : traductions
## CRUD complet
```php
query($sql);
$products = [];
if ($resql) {
$mapper = new dmProduct();
while ($obj = $db->fetch_object($resql)) {
$product = new \Product($db);
$product->fetch($obj->rowid);
$products[] = $mapper->exportMappedData($product);
}
}
return [['products' => $products], 200];
}
/**
* Détail d'un produit
*/
public function show($payload = null)
{
global $db;
$id = $payload['id'] ?? null;
if (!$id) {
return ['ID required', 400];
}
require_once DOL_DOCUMENT_ROOT . '/product/class/product.class.php';
$product = new \Product($db);
$res = $product->fetch($id);
if ($res <= 0) {
return ['Product not found', 404];
}
// Charger les extrafields
$product->fetch_optionals();
$mapper = new dmProduct();
$data = $mapper->exportMappedData($product);
return [$data, 200];
}
/**
* Créer un produit
*/
public function create($payload = null)
{
global $db, $user;
// Validation
if (empty($payload['label'])) {
return ['Label required', 400];
}
require_once DOL_DOCUMENT_ROOT . '/product/class/product.class.php';
$product = new \Product($db);
$product->ref = $payload['ref'] ?? '';
$product->label = $payload['label'];
$product->description = $payload['description'] ?? '';
$product->price = $payload['price'] ?? 0;
$product->status = $payload['status'] ?? 1;
// Créer en base
$res = $product->create($user);
if ($res < 0) {
return ['Error creating product: ' . $product->error, 500];
}
// Sauvegarder les extrafields
if (!empty($payload['extrafields'])) {
foreach ($payload['extrafields'] as $key => $value) {
$product->array_options['options_' . $key] = $value;
}
$product->insertExtraFields();
}
return [['id' => $res], 201];
}
/**
* Mettre à jour un produit
*/
public function update($payload = null)
{
global $db, $user;
$id = $payload['id'] ?? null;
if (!$id) {
return ['ID required', 400];
}
require_once DOL_DOCUMENT_ROOT . '/product/class/product.class.php';
$product = new \Product($db);
$res = $product->fetch($id);
if ($res <= 0) {
return ['Product not found', 404];
}
// Mettre à jour les champs fournis
if (isset($payload['label'])) {
$product->label = $payload['label'];
}
if (isset($payload['description'])) {
$product->description = $payload['description'];
}
if (isset($payload['price'])) {
$product->price = $payload['price'];
}
if (isset($payload['status'])) {
$product->status = $payload['status'];
}
$res = $product->update($product->id, $user);
if ($res < 0) {
return ['Error updating product: ' . $product->error, 500];
}
// Mettre à jour les extrafields
if (!empty($payload['extrafields'])) {
$product->fetch_optionals();
foreach ($payload['extrafields'] as $key => $value) {
$product->array_options['options_' . $key] = $value;
}
$product->updateExtraFields();
}
return ['Updated', 200];
}
/**
* Supprimer un produit
*/
public function delete($payload = null)
{
global $db, $user;
$id = $payload['id'] ?? null;
if (!$id) {
return ['ID required', 400];
}
require_once DOL_DOCUMENT_ROOT . '/product/class/product.class.php';
$product = new \Product($db);
$res = $product->fetch($id);
if ($res <= 0) {
return ['Product not found', 404];
}
// Vérifier les droits
if (!$user->hasRight('produit', 'supprimer')) {
return ['Permission denied', 403];
}
$res = $product->delete($user);
if ($res < 0) {
return ['Error deleting product: ' . $product->error, 500];
}
return ['Deleted', 200];
}
}
```
## Recherche avec filtres
```php
/**
* Recherche avancée
*/
public function search($payload = null)
{
global $db;
require_once DOL_DOCUMENT_ROOT . '/product/class/product.class.php';
// Filtres
$search = $payload['search'] ?? '';
$category = $payload['category'] ?? null;
$status = $payload['status'] ?? null;
$minPrice = $payload['minPrice'] ?? null;
$maxPrice = $payload['maxPrice'] ?? null;
// Pagination
$limit = min($payload['limit'] ?? 50, 100); // Max 100
$offset = $payload['offset'] ?? 0;
// Construction de la requête
$sql = "SELECT p.rowid FROM " . MAIN_DB_PREFIX . "product as p";
// Jointure catégorie si nécessaire
if ($category) {
$sql .= " INNER JOIN " . MAIN_DB_PREFIX . "categorie_product as cp";
$sql .= " ON p.rowid = cp.fk_product";
}
$sql .= " WHERE p.entity IN (" . getEntity('product') . ")";
// Filtres
if ($search) {
$sql .= " AND (p.label LIKE '%" . $db->escape($search) . "%'";
$sql .= " OR p.ref LIKE '%" . $db->escape($search) . "%')";
}
if ($category) {
$sql .= " AND cp.fk_categorie = " . (int) $category;
}
if ($status !== null) {
$sql .= " AND p.tosell = " . (int) $status;
}
if ($minPrice !== null) {
$sql .= " AND p.price >= " . (float) $minPrice;
}
if ($maxPrice !== null) {
$sql .= " AND p.price <= " . (float) $maxPrice;
}
$sql .= " ORDER BY p.label ASC";
$sql .= " LIMIT " . (int) $limit;
$sql .= " OFFSET " . (int) $offset;
$resql = $db->query($sql);
$products = [];
$mapper = new dmProduct();
while ($obj = $db->fetch_object($resql)) {
$product = new \Product($db);
$product->fetch($obj->rowid);
$products[] = $mapper->exportMappedData($product);
}
return [['products' => $products], 200];
}
```
## Gestion des fichiers
```php
/**
* Télécharger un fichier
*/
public function download($payload = null)
{
global $conf, $db;
$element = $payload['element']; // ex: 'product'
$parentId = $payload['parentId'];
$ref = $payload['ref']; // nom du fichier
// Construire le chemin
$dir = $conf->$element->dir_output;
$filepath = $dir . '/' . $parentId . '/' . $ref;
if (!file_exists($filepath)) {
return ['File not found', 404];
}
// Retourner le fichier en base64
$content = file_get_contents($filepath);
$mime = mime_content_type($filepath);
$base64 = base64_encode($content);
return [[
'filename' => $ref,
'mime' => $mime,
'content' => 'data:' . $mime . ';base64,' . $base64
], 200];
}
/**
* Uploader un fichier
*/
public function upload($payload = null)
{
global $conf, $user;
$element = $payload['element'];
$parentId = $payload['parentId'];
$filename = $payload['filename'];
$content = $payload['content']; // base64
// Décoder le contenu
$data = base64_decode(preg_replace('#^data:.+;base64,#', '', $content));
// Chemin de destination
$dir = $conf->$element->dir_output . '/' . $parentId;
// Créer le dossier si nécessaire
if (!is_dir($dir)) {
dol_mkdir($dir);
}
$filepath = $dir . '/' . $filename;
// Sauvegarder
if (file_put_contents($filepath, $data) === false) {
return ['Error saving file', 500];
}
return [['path' => $filepath], 201];
}
```
## Vérification des droits
```php
public function delete($payload = null)
{
global $db, $user;
// Vérifier un droit spécifique
if (!$user->hasRight('monmodule', 'delete')) {
return ['Permission denied', 403];
}
// Vérifier si admin
if (!$user->admin) {
return ['Admin required', 403];
}
// Suite du code...
}
```
## Logging
```php
public function create($payload = null)
{
global $db, $user;
// Log pour debug
dol_syslog("ProductController::create by " . $user->login, LOG_DEBUG);
// En cas d'erreur
dol_syslog("ProductController::create error: " . $product->error, LOG_ERR);
// ...
}
```
## Points clés à retenir
1. **Retourner [data, code]** : toujours un tableau avec données et code HTTP
2. **$payload** contient les paramètres d'URL et le body JSON
3. **Utiliser les mappers** pour convertir les objets Dolibarr
4. **Valider les entrées** avant traitement
5. **Vérifier les droits** pour les actions sensibles
[← Chapitre précédent](/training/module8-backend-api/routage) | [Retour au module](/training/module8-backend-api) | [Chapitre suivant : Mappers ->](/training/module8-backend-api/mappers)
- [Chapitre 3 : Mappers](https://smartmaker.org/training/module8-backend-api/mappers)
# Chapitre 3 : Mappers
Les mappers (classes `dm*`) transforment les objets Dolibarr en JSON optimisé pour React.
## Pourquoi des mappers ?
Les objets Dolibarr ont des noms de champs souvent cryptiques (`fk_soc`, `rowid`, etc.). Les mappers :
- Renomment les champs (rowid -> id)
- Résolvent les clés étrangères (fk_soc -> objet société)
- Transforment les données (fichier -> base64)
- Filtrent les champs exposés
## Structure de base
```php
clé React
protected $listOfPublishedFields = [
'rowid' => 'id',
'ref' => 'ref',
'label' => 'label',
'description' => 'description',
'price' => 'price',
'price_ttc' => 'price_ttc',
'tva_tx' => 'vat_rate',
'stock_reel' => 'stock',
'tosell' => 'status',
'date_creation' => 'created_at',
'tms' => 'updated_at'
];
public function __construct()
{
global $langs;
$langs->load("products");
$this->boot();
}
}
```
## Mapping des champs
### Champs simples
```php
protected $listOfPublishedFields = [
'rowid' => 'id', // Renommage
'nom' => 'name',
'address' => 'address', // Même nom
'email' => 'email'
];
```
### Clés étrangères
Les clés étrangères (fk_*) sont automatiquement résolues :
```php
protected $listOfPublishedFields = [
// Résolution automatique
'fk_soc' => 'thirdparty', // ID -> objet société
'fk_pays' => 'country', // ID -> nom du pays
'fk_user' => 'user', // ID -> objet utilisateur
'fk_project' => 'project' // ID -> objet projet
];
```
### Extrafields
```php
protected $listOfPublishedFields = [
// Champs standards
'rowid' => 'id',
'label' => 'label',
// Extrafields (préfixe options_)
'options_color' => 'color',
'options_size' => 'size',
'options_myfield' => 'my_custom_field'
];
```
## Transformation de valeurs
### Méthodes fieldFilterValueXXX
Pour transformer une valeur, créez une méthode `fieldFilterValueXXX` où XXX est le nom du champ :
```php
/**
* Transformer une date
*/
public function fieldFilterValueDateCreation($object)
{
if (empty($object->date_creation)) {
return null;
}
return dol_print_date($object->date_creation, 'dayhour');
}
/**
* Formater un prix
*/
public function fieldFilterValuePrice($object)
{
return (float) $object->price;
}
/**
* Statut en texte
*/
public function fieldFilterValueStatus($object)
{
$statuses = [
0 => 'draft',
1 => 'validated',
2 => 'closed'
];
return $statuses[$object->status] ?? 'unknown';
}
```
### Logo en base64
```php
public function fieldFilterValueLogo($object)
{
global $conf;
$dir = $conf->societe->multidir_output[$object->entity];
$logoPath = $dir . "/" . $object->id . "/logos/" . $object->logo;
if (!file_exists($logoPath)) {
return null;
}
$type = pathinfo($logoPath, PATHINFO_EXTENSION);
$content = file_get_contents($logoPath);
return 'data:image/' . $type . ';base64,' . base64_encode($content);
}
```
### Photos attachées
```php
public function fieldFilterValuePhotos($object)
{
global $conf;
$photos = [];
$dir = $conf->product->dir_output . '/' . $object->ref;
if (!is_dir($dir)) {
return $photos;
}
$files = scandir($dir);
foreach ($files as $file) {
if (preg_match('/\.(jpg|jpeg|png|gif)$/i', $file)) {
$path = $dir . '/' . $file;
$type = pathinfo($path, PATHINFO_EXTENSION);
$content = file_get_contents($path);
$photos[] = [
'name' => $file,
'src' => 'data:image/' . $type . ';base64,' . base64_encode($content)
];
}
}
return $photos;
}
```
### Contacts associés
```php
public function fieldFilterValueContacts($object)
{
$contacts = $object->liste_contact(-1, 'external');
return array_map(function($c) {
return [
'id' => $c['id'],
'name' => $c['firstname'] . ' ' . $c['lastname'],
'email' => $c['email'],
'phone' => $c['phone']
];
}, $contacts);
}
```
## Override de types
```php
protected $parentFieldsOverride = [
'duree' => ['type' => 'duration', 'required' => 'required'],
'contacts' => ['type' => 'array'],
'status' => ['type' => 'select'],
'price' => ['type' => 'price']
];
```
## Objets avec lignes
Pour les factures, commandes, etc. avec des lignes :
```php
'id',
'ref' => 'ref',
'fk_soc' => 'thirdparty',
'total_ht' => 'total_ht',
'total_ttc' => 'total_ttc',
'fk_statut' => 'status',
'date' => 'date'
];
// Mapping des champs de lignes
protected $listOfPublishedFieldsForLines = [
'id' => 'id',
'desc' => 'description',
'qty' => 'quantity',
'subprice' => 'unit_price',
'total_ht' => 'total_ht',
'total_ttc' => 'total_ttc',
'tva_tx' => 'vat_rate'
];
// Description des champs de lignes (pour le formulaire côté React)
protected $parentFieldsForLines = [
'id' => ['type' => 'integer', 'label' => 'ID', 'visible' => -1],
'desc' => ['type' => 'html', 'label' => 'Description', 'visible' => 1],
'qty' => ['type' => 'integer', 'label' => 'Quantité', 'visible' => 1],
'subprice' => ['type' => 'price', 'label' => 'Prix unitaire', 'visible' => 1]
];
// Titre de la section lignes
protected $parentLabelForLines = "Lignes de facture";
public function __construct()
{
global $langs;
$langs->load("bills");
$this->boot();
}
}
```
## Utilisation dans un controller
```php
public function show($payload = null)
{
global $db;
$id = $payload['id'];
require_once DOL_DOCUMENT_ROOT . '/compta/facture/class/facture.class.php';
$invoice = new \Facture($db);
$res = $invoice->fetch($id);
if ($res <= 0) {
return ['Invoice not found', 404];
}
// Charger les données additionnelles
$invoice->fetch_optionals(); // Extrafields
$invoice->fetch_lines(); // Lignes
// Mapper
$mapper = new dmInvoice();
$data = $mapper->exportMappedData($invoice);
return [$data, 200];
}
```
## Extrafields dynamiques
```php
public function __construct()
{
global $conf, $langs;
$langs->load("products");
// Ajouter les extrafields depuis la configuration
$extrafields = getDolGlobalString('MYMODULE_PRODUCT_EXTRAFIELDS');
if (!empty($extrafields)) {
foreach (explode(',', $extrafields) as $field) {
$key = "options_" . trim($field);
$this->listOfPublishedFields[$key] = trim($field);
}
}
$this->boot();
}
```
## Exemple complet
```php
'id',
'nom' => 'name',
'name_alias' => 'alias',
'address' => 'address',
'zip' => 'zip',
'town' => 'city',
'fk_pays' => 'country',
'phone' => 'phone',
'email' => 'email',
'url' => 'website',
'siren' => 'siren',
'siret' => 'siret',
'tva_intra' => 'vat_number',
'logo' => 'logo',
'status' => 'status',
'date_creation' => 'created_at',
// Extrafields
'options_category' => 'category'
];
protected $parentFieldsOverride = [
'logo' => ['type' => 'image']
];
public function __construct()
{
global $langs;
$langs->load("companies");
$this->boot();
}
/**
* Logo en base64
*/
public function fieldFilterValueLogo($object)
{
global $conf;
if (empty($object->logo)) {
return null;
}
$dir = $conf->societe->multidir_output[$object->entity];
$path = $dir . "/" . $object->id . "/logos/" . $object->logo;
if (!file_exists($path)) {
return null;
}
$ext = pathinfo($path, PATHINFO_EXTENSION);
$content = file_get_contents($path);
return 'data:image/' . $ext . ';base64,' . base64_encode($content);
}
/**
* Statut en texte
*/
public function fieldFilterValueStatus($object)
{
return $object->status == 1 ? 'active' : 'inactive';
}
}
```
## Points clés à retenir
1. **listOfPublishedFields** définit le mapping clé Dolibarr -> clé React
2. **fieldFilterValueXXX** transforme une valeur spécifique
3. **fetch_optionals()** charge les extrafields
4. **fetch_lines()** charge les lignes pour les objets composés
5. Les clés étrangères (fk_*) sont résolues automatiquement
[← Controllers](/training/module8-backend-api/controllers) | [Retour au module](/training/module8-backend-api) | [Extrafields ->](/training/module8-backend-api/extrafields)
- [Chapitre 1 : Backend](https://smartmaker.org/training/module9-integration/backend)
# Chapitre 1 : Backend
## 1. Classe Dolibarr (optionnel)
Si vous utilisez un objet Dolibarr existant (Product, Facture, etc.), passez cette étape.
Pour un nouvel objet :
```php
db = $db;
}
public function create($user, $notrigger = 0)
{
$this->ref = $this->getNextNumRef();
$this->date_creation = dol_now();
$this->fk_user = $user->id;
$sql = "INSERT INTO " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " (ref, label, description, fk_user, status, priority,";
$sql .= " date_start, date_end, date_creation, entity)";
$sql .= " VALUES (";
$sql .= "'" . $this->db->escape($this->ref) . "',";
$sql .= "'" . $this->db->escape($this->label) . "',";
$sql .= "'" . $this->db->escape($this->description) . "',";
$sql .= (int) $this->fk_user . ",";
$sql .= (int) $this->status . ",";
$sql .= (int) $this->priority . ",";
$sql .= ($this->date_start ? "'" . $this->db->idate($this->date_start) . "'" : "NULL") . ",";
$sql .= ($this->date_end ? "'" . $this->db->idate($this->date_end) . "'" : "NULL") . ",";
$sql .= "'" . $this->db->idate($this->date_creation) . "',";
$sql .= $conf->entity;
$sql .= ")";
$resql = $this->db->query($sql);
if ($resql) {
$this->id = $this->db->last_insert_id(MAIN_DB_PREFIX . $this->table_element);
return $this->id;
}
$this->error = $this->db->lasterror();
return -1;
}
public function fetch($id, $ref = '')
{
$sql = "SELECT * FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE entity IN (" . getEntity($this->element) . ")";
if ($id) {
$sql .= " AND rowid = " . (int) $id;
} elseif ($ref) {
$sql .= " AND ref = '" . $this->db->escape($ref) . "'";
}
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql)) {
$obj = $this->db->fetch_object($resql);
$this->id = $obj->rowid;
$this->ref = $obj->ref;
$this->label = $obj->label;
$this->description = $obj->description;
$this->fk_user = $obj->fk_user;
$this->status = $obj->status;
$this->priority = $obj->priority;
$this->date_start = $this->db->jdate($obj->date_start);
$this->date_end = $this->db->jdate($obj->date_end);
$this->date_creation = $this->db->jdate($obj->date_creation);
$this->tms = $this->db->jdate($obj->tms);
return 1;
}
return 0;
}
public function update($user, $notrigger = 0)
{
$sql = "UPDATE " . MAIN_DB_PREFIX . $this->table_element . " SET";
$sql .= " label = '" . $this->db->escape($this->label) . "',";
$sql .= " description = '" . $this->db->escape($this->description) . "',";
$sql .= " status = " . (int) $this->status . ",";
$sql .= " priority = " . (int) $this->priority . ",";
$sql .= " date_start = " . ($this->date_start ? "'" . $this->db->idate($this->date_start) . "'" : "NULL") . ",";
$sql .= " date_end = " . ($this->date_end ? "'" . $this->db->idate($this->date_end) . "'" : "NULL");
$sql .= " WHERE rowid = " . (int) $this->id;
$resql = $this->db->query($sql);
if ($resql) {
return 1;
}
$this->error = $this->db->lasterror();
return -1;
}
public function delete($user, $notrigger = 0)
{
$sql = "DELETE FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE rowid = " . (int) $this->id;
$resql = $this->db->query($sql);
if ($resql) {
return 1;
}
$this->error = $this->db->lasterror();
return -1;
}
}
```
## 2. Routes API
```php
id;
$sql .= " AND entity IN (" . getEntity('task') . ")";
if ($status !== null) {
$sql .= " AND status = " . (int) $status;
}
$sql .= " ORDER BY priority DESC, date_start ASC";
$sql .= " LIMIT " . (int) $limit;
$resql = $db->query($sql);
$tasks = [];
$mapper = new dmTask();
while ($obj = $db->fetch_object($resql)) {
$task = new \Task($db);
$task->fetch($obj->rowid);
$tasks[] = $mapper->exportMappedData($task);
}
return [['tasks' => $tasks], 200];
}
public function show($payload = null)
{
global $db, $user;
$id = $payload['id'] ?? null;
require_once DOL_DOCUMENT_ROOT . '/custom/monmodule/class/task.class.php';
$task = new \Task($db);
$res = $task->fetch($id);
if ($res <= 0) {
return ['Task not found', 404];
}
// Vérifier que c'est bien la tâche de l'utilisateur
if ($task->fk_user != $user->id) {
return ['Forbidden', 403];
}
$mapper = new dmTask();
return [$mapper->exportMappedData($task), 200];
}
public function create($payload = null)
{
global $db, $user;
if (empty($payload['label'])) {
return ['Label required', 400];
}
require_once DOL_DOCUMENT_ROOT . '/custom/monmodule/class/task.class.php';
$task = new \Task($db);
$task->label = $payload['label'];
$task->description = $payload['description'] ?? '';
$task->status = $payload['status'] ?? 1;
$task->priority = $payload['priority'] ?? 0;
$task->date_start = $payload['date_start'] ?? null;
$task->date_end = $payload['date_end'] ?? null;
$res = $task->create($user);
if ($res < 0) {
return ['Error: ' . $task->error, 500];
}
return [['id' => $res], 201];
}
public function update($payload = null)
{
global $db, $user;
$id = $payload['id'] ?? null;
require_once DOL_DOCUMENT_ROOT . '/custom/monmodule/class/task.class.php';
$task = new \Task($db);
$res = $task->fetch($id);
if ($res <= 0) {
return ['Task not found', 404];
}
if ($task->fk_user != $user->id) {
return ['Forbidden', 403];
}
// Mise à jour des champs
if (isset($payload['label'])) $task->label = $payload['label'];
if (isset($payload['description'])) $task->description = $payload['description'];
if (isset($payload['status'])) $task->status = $payload['status'];
if (isset($payload['priority'])) $task->priority = $payload['priority'];
if (isset($payload['date_start'])) $task->date_start = $payload['date_start'];
if (isset($payload['date_end'])) $task->date_end = $payload['date_end'];
$res = $task->update($user);
if ($res < 0) {
return ['Error: ' . $task->error, 500];
}
return ['Updated', 200];
}
public function delete($payload = null)
{
global $db, $user;
$id = $payload['id'] ?? null;
require_once DOL_DOCUMENT_ROOT . '/custom/monmodule/class/task.class.php';
$task = new \Task($db);
$res = $task->fetch($id);
if ($res <= 0) {
return ['Task not found', 404];
}
if ($task->fk_user != $user->id) {
return ['Forbidden', 403];
}
$res = $task->delete($user);
if ($res < 0) {
return ['Error: ' . $task->error, 500];
}
return ['Deleted', 200];
}
}
```
## 4. Mapper
```php
'id',
'ref' => 'ref',
'label' => 'label',
'description' => 'description',
'status' => 'status',
'priority' => 'priority',
'date_start' => 'date_start',
'date_end' => 'date_end',
'date_creation' => 'created_at'
];
public function __construct()
{
$this->boot();
}
public function fieldFilterValueStatus($object)
{
$labels = [
0 => 'draft',
1 => 'todo',
2 => 'done'
];
return $labels[$object->status] ?? 'unknown';
}
public function fieldFilterValueDateStart($object)
{
return $object->date_start ? date('Y-m-d', $object->date_start) : null;
}
public function fieldFilterValueDateEnd($object)
{
return $object->date_end ? date('Y-m-d', $object->date_end) : null;
}
}
```
## Test de l'API
Avec curl ou Postman :
```bash
# Login
curl -X POST https://monsite.com/modules/monmodule/pwa/api.php \
-H "Content-Type: application/json" \
-d '{"login":"user@example.com","password":"secret"}'
# Liste des tâches
curl https://monsite.com/modules/monmodule/pwa/api.php?route=tasks \
-H "Authorization: Bearer "
# Créer une tâche
curl -X POST https://monsite.com/modules/monmodule/pwa/api.php?route=tasks \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{"label":"Ma tâche","priority":1}'
```
[← Retour au module](/training/module9-integration) | [Chapitre suivant : Frontend ->](/training/module9-integration/frontend)
- [Chapitre 2 : Frontend](https://smartmaker.org/training/module9-integration/frontend)
# Chapitre 2 : Frontend
## 1. Configuration des routes React
```javascript
// components/app/Router/index.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { PublicRoutes, PrivateRoutes } from './Guards';
import { LoginPage } from '../../pages/public/LoginPage';
import { TasksPage } from '../../pages/private/TasksPage';
import { TaskDetailPage } from '../../pages/private/TaskDetailPage';
import { TaskFormPage } from '../../pages/private/TaskFormPage';
import { Error404Page } from '../../pages/errors/Error404Page';
export const Router = () => {
return (
}>
} />
}>
} />
} />
} />
} />
} />
);
};
```
## 2. Page de liste
```javascript
// components/pages/private/TasksPage/index.jsx
import { useEffect } from 'react';
import {
Page,
Block,
List,
ListItem,
Button,
Spinner,
Tag
} from '@cap-rel/smartcommon';
import { useApi, useStates, useNavigation } from '@cap-rel/smartcommon';
import { FiPlus, FiCheck, FiClock } from 'react-icons/fi';
export const TasksPage = () => {
const api = useApi();
const nav = useNavigation();
const st = useStates({
initialStates: {
tasks: [],
loading: true,
error: null,
filter: 'all' // all, todo, done
}
});
useEffect(() => {
loadTasks();
}, [st.get('filter')]);
const loadTasks = async () => {
st.set('loading', true);
try {
const params = {};
if (st.get('filter') === 'todo') params.status = 1;
if (st.get('filter') === 'done') params.status = 2;
const data = await api.private.get('tasks', {
searchParams: params
}).json();
st.set('tasks', data.tasks);
} catch (err) {
st.set('error', err.message);
} finally {
st.set('loading', false);
}
};
const toggleStatus = async (task) => {
const newStatus = task.status === 'done' ? 1 : 2;
try {
await api.private.put(`tasks/${task.id}`, {
json: { status: newStatus }
});
loadTasks();
} catch (err) {
alert('Erreur: ' + err.message);
}
};
const getStatusColor = (status) => {
switch (status) {
case 'done': return 'green';
case 'todo': return 'blue';
default: return 'gray';
}
};
if (st.get('loading') && st.get('tasks').length === 0) {
return ;
}
return (
{/* Filtres */}
{['all', 'todo', 'done'].map(f => (
st.set('filter', f)}
>
{f === 'all' ? 'Toutes' : f === 'todo' ? 'À faire' : 'Terminées'}
))}
{/* Liste */}
{st.get('tasks').length === 0 ? (
Aucune tâche
) : (
{st.get('tasks').map(task => (
nav.navigate(`/tasks/${task.id}`)}
icon={task.status === 'done' ? FiCheck : FiClock}
chevron
actions={
{task.status}
}
/>
))}
)}
{/* Bouton ajout */}
nav.navigate('/tasks/new')}
className="rounded-full w-14 h-14"
>
);
};
```
## 3. Page de détail
```javascript
// components/pages/private/TaskDetailPage/index.jsx
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import {
Page,
Block,
Button,
Spinner,
Tag,
Popup
} from '@cap-rel/smartcommon';
import { useApi, useStates, useNavigation } from '@cap-rel/smartcommon';
import { FiEdit, FiTrash2, FiCheck } from 'react-icons/fi';
export const TaskDetailPage = () => {
const { id } = useParams();
const api = useApi();
const nav = useNavigation();
const st = useStates({
initialStates: {
task: null,
loading: true,
error: null,
showDeletePopup: false
}
});
useEffect(() => {
loadTask();
}, [id]);
const loadTask = async () => {
try {
const data = await api.private.get(`tasks/${id}`).json();
st.set('task', data);
} catch (err) {
st.set('error', err.message);
} finally {
st.set('loading', false);
}
};
const toggleComplete = async () => {
const task = st.get('task');
const newStatus = task.status === 'done' ? 1 : 2;
try {
await api.private.put(`tasks/${id}`, {
json: { status: newStatus }
});
loadTask();
} catch (err) {
alert('Erreur: ' + err.message);
}
};
const handleDelete = async () => {
try {
await api.del(`tasks/${id}`);
nav.navigate('/');
} catch (err) {
alert('Erreur: ' + err.message);
}
};
if (st.get('loading')) {
return ;
}
if (st.get('error')) {
return (
Erreur: {st.get('error')}
);
}
const task = st.get('task');
return (
{/* Statut */}
{task.status === 'done' ? 'Terminée' : 'À faire'}
Ref: {task.ref}
{/* Description */}
{task.description && (
{task.description}
)}
{/* Dates */}
{task.date_start && (
Début: {task.date_start}
)}
{task.date_end && (
Fin: {task.date_end}
)}
{/* Actions */}
{task.status === 'done' ? 'Réouvrir' : 'Terminer'}
nav.navigate(`/tasks/${id}/edit`)}
>
Modifier
st.set('showDeletePopup', true)}
>
{/* Popup suppression */}
st.set('showDeletePopup', false)}
title="Confirmer la suppression"
>
Supprimer cette tâche ?
st.set('showDeletePopup', false)}>
Annuler
Supprimer
);
};
```
## 4. Formulaire
```javascript
// components/pages/private/TaskFormPage/index.jsx
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import {
Page,
Block,
Form,
Input,
Textarea,
Select,
Calendar,
Button,
Spinner
} from '@cap-rel/smartcommon';
import { useApi, useStates, useNavigation, useForm } from '@cap-rel/smartcommon';
export const TaskFormPage = () => {
const { id } = useParams();
const isEdit = !!id;
const api = useApi();
const nav = useNavigation();
const st = useStates({
initialStates: {
loading: isEdit,
submitting: false,
defaultValues: null
}
});
useEffect(() => {
if (isEdit) {
loadTask();
}
}, [id]);
const loadTask = async () => {
try {
const data = await api.private.get(`tasks/${id}`).json();
st.set('defaultValues', {
label: data.label,
description: data.description || '',
priority: data.priority || 0,
date_start: data.date_start || '',
date_end: data.date_end || ''
});
} catch (err) {
alert('Erreur: ' + err.message);
nav.navigate('/');
} finally {
st.set('loading', false);
}
};
const handleSubmit = async (values) => {
st.set('submitting', true);
try {
if (isEdit) {
await api.private.put(`tasks/${id}`, { json: values });
} else {
await api.private.post('tasks', { json: values });
}
nav.navigate('/');
} catch (err) {
alert('Erreur: ' + err.message);
} finally {
st.set('submitting', false);
}
};
if (st.get('loading')) {
return ;
}
return (
nav.navigate(-1)}
>
Annuler
{isEdit ? 'Enregistrer' : 'Créer'}
);
};
```
## 5. Configuration globale
```javascript
// src/appConfig.js
export const config = {
debug: import.meta.env.DEV,
api: {
prefixUrl: import.meta.env.VITE_API_URL,
timeout: 30000,
paths: {
login: "login",
logout: "logout",
refresh: "refresh"
}
},
storage: {
local: ["session"]
},
globalState: {
reducers: {
session: null
}
},
pages: {
"*": "fade"
}
};
```
[← Chapitre précédent](/training/module9-integration/backend) | [Retour au module](/training/module9-integration) | [Chapitre suivant : Déploiement ->](/training/module9-integration/deploiement)
- [Chapitre 3 : Déploiement](https://smartmaker.org/training/module9-integration/deploiement)
# Chapitre 3 : Déploiement
## 1. Variables d'environnement
### Développement
```bash
# mobile/.env.development
VITE_API_URL=http://localhost/dolibarr/custom/monmodule/pwa/api.php
```
### Production
```bash
# mobile/.env.production
VITE_API_URL=https://mondomaine.com/custom/monmodule/pwa/api.php
```
## 2. Build de production
```bash
cd mobile
# Installer les dépendances
npm install
# Build de production
npm run build
```
Les fichiers sont générés dans `mobile/dist/`.
## 3. Déployer les fichiers
Copier le contenu de `mobile/dist/` vers `pwa/dist/` :
```bash
# Depuis le dossier mobile/
cp -r dist/* ../pwa/dist/
```
Ou utiliser un script npm :
```json
{
"scripts": {
"build": "vite build",
"deploy": "vite build && cp -r dist/* ../pwa/dist/"
}
}
```
## 4. Structure finale
```
monmodule/
├── pwa/
│ ├── api.php
│ └── dist/
│ ├── index.html
│ └── assets/
│ ├── index-abc123.js
│ └── index-def456.css
└── ...
```
## 5. Configuration serveur
### Apache (.htaccess)
Dans `pwa/dist/.htaccess` :
```apache
RewriteEngine On
RewriteBase /custom/monmodule/pwa/dist/
# Rediriger vers index.html pour le routage React
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.html [L]
# Cache des assets
ExpiresActive On
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
```
### Nginx
```nginx
location /custom/monmodule/pwa/dist/ {
try_files $uri $uri/ /custom/monmodule/pwa/dist/index.html;
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
## 6. Accès à l'application
L'application est accessible à :
```
https://mondomaine.com/custom/monmodule/pwa/dist/
```
## 7. PWA (Progressive Web App)
### Manifest
Créer `public/manifest.json` :
```json
{
"name": "Mon Application",
"short_name": "MonApp",
"description": "Description de l'application",
"start_url": "/custom/monmodule/pwa/dist/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1976d2",
"icons": [
{
"src": "images/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "images/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
```
### Service Worker (optionnel)
Pour le mode offline, utiliser `vite-plugin-pwa` :
```bash
npm install vite-plugin-pwa
```
```javascript
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Mon Application',
short_name: 'MonApp',
theme_color: '#1976d2'
}
})
]
});
```
## 8. Checklist de déploiement
- [ ] Variables d'environnement de production configurées
- [ ] Build exécuté sans erreur
- [ ] Fichiers copiés dans pwa/dist/
- [ ] .htaccess configuré (Apache)
- [ ] API accessible (test avec curl)
- [ ] Application accessible dans le navigateur
- [ ] Login fonctionnel
- [ ] HTTPS activé
## 9. Debug en production
### Logs Dolibarr
```php
// Dans un controller
dol_syslog("Debug: " . json_encode($payload), LOG_DEBUG);
```
Les logs sont dans `documents/dolibarr.log`.
### Console navigateur
En production, les erreurs JavaScript sont visibles dans la console du navigateur (F12).
[← Chapitre précédent](/training/module9-integration/frontend) | [Retour au module](/training/module9-integration) | [Module suivant : Fonctionnalités avancées ->](/training/module10-fonctionnalites-avancees)
- [Chapitre 1 : Mode offline](https://smartmaker.org/training/module10-fonctionnalites-avancees/offline)
# Chapitre 1 : Mode offline
Ce chapitre traite d'un cas précis : **rejouer plus tard une action métier**
qu'un utilisateur a déclenchée sans connexion. Clôturer une intervention,
ajouter une pièce consommée, faire remonter une photo annotée et signée.
Ce n'est pas le seul dispositif offline de SmartMaker, et souvent ce n'est pas
le bon. Lisez [Synchronisation offline](/front/synchronisation) avant de vous
engager.
## Choisir, d'abord
| Ce que vous voulez faire | Ce qu'il faut utiliser |
| --- | --- |
| Créer et modifier des objets Dolibarr hors connexion, avec gestion de conflits | [`useSyncClient`](/training/module7-smartcommon-hooks/sync), rien à écrire |
| Embarquer un référentiel consultable hors connexion, avec ses images | [`useReferenceSync`](/front/synchronisation-reference), rien à écrire |
| Rejouer une action métier soumise à des règles serveur | Ce chapitre |
Le critère est simple. Si l'action se réduit à "ces champs valent maintenant
ces valeurs", `useSyncClient` la traite déjà, et mieux que du code écrit à la
main. Si l'action est un geste que le serveur doit valider, séquencer ou
refuser, aucun moteur générique ne peut la porter : il faut une file d'attente
dans le module.
> N'écrivez pas une file maison qui se contente de rejouer des `POST` et des
> `PUT` de champs. C'est exactement ce que `useSyncClient` fait, avec en plus
> les tombstones, la détection de conflits et la reprise sur coupure.
## Le principe
```
1. L'utilisateur déclenche l'action, avec ou sans réseau
2. L'action est écrite en local comme une ligne de file d'attente
3. L'interface affiche la ligne comme "en attente de synchronisation"
4. Au retour du réseau, la file est vidée en appelant les endpoints métier
5. Le serveur reconnaît un éventuel rejeu et ne duplique rien
6. Une ligne en échec reste visible, avec son message, pour un nouvel essai
```
Tout repose sur le point 5. Sans lui, une coupure au mauvais moment crée des
doublons, et le pattern ne tient pas.
## Le contrat d'idempotence
C'est la partie à concevoir en premier, avant toute ligne de code React.
### Création : un identifiant fabriqué par le client
Le client génère un `client_uuid` au moment où il met l'action en file. Cet
identifiant sert à la fois de clé primaire locale et de clé d'idempotence côté
serveur.
```javascript
// src/lib/taskSync.js
import { db } from 'src/db';
import { generateUuidV4 } from './clientUuid';
export const enqueueTask = async (payload, projectId) => {
const clientUuid = payload.client_uuid || generateUuidV4();
const row = {
id: clientUuid,
payload: { ...payload, client_uuid: clientUuid, fk_project: projectId },
projectId,
op: 'create',
status: 'pending',
attempts: 0,
lastAttemptAt: null,
lastError: null,
serverId: null,
updatedAt: Date.now()
};
await db.taskQueue.put(row);
notifyChange();
return clientUuid;
};
```
Le `put` sur l'identifiant rend l'enfilement lui-même idempotent : ré-enfiler la
même action écrase la ligne au lieu d'en créer une deuxième.
Côté serveur, le contrôleur cherche d'abord si le `client_uuid` existe déjà :
```php
$existing = \MyModuleTask::fetchByClientUuid($db, $entity, $clientUuid);
if ($existing !== null) {
// Rejeu : on renvoie l'objet existant, sans rien créer
return [$mapper->exportMappedData($existing), 200];
}
```
La convention est de répondre **201** pour une création réelle et **200** pour
un rejeu reconnu. Le client traite les deux comme un succès.
Prévoyez la colonne dès le départ, avec un index unique :
```sql
-- sql/llx_mymodule_task.key.sql
CREATE UNIQUE INDEX uk_mymodule_task_client_uuid
ON llx_mymodule_task (entity, client_uuid);
```
### Un en-tête pour signaler le rejeu
Un `POST` arrivant d'une file différée n'a pas la même confiance qu'un `POST`
arrivant d'un écran en ligne. Le prix d'une pièce calculé il y a trois heures
sur un appareil hors connexion, par exemple, ne doit pas faire autorité.
La convention SmartMaker est un en-tête `X-Offline-Sync: 1`, que le contrôleur
utilise pour recalculer côté serveur ce qui doit l'être et ignorer les valeurs
sensibles du corps de la requête.
```javascript
await api.private.post('task', {
json: item.payload,
headers: { 'X-Offline-Sync': '1' }
});
```
### Modification : attention aux transitions non rejouables
Un `PUT /task/{id}` qui écrit des champs se rejoue sans dommage. Une
**transition d'état** ne se rejoue pas : clôturer une fiche déjà close renvoie
une erreur, typiquement un 409.
Ce 409 n'est pas un échec. L'état visé est atteint. Traitez-le comme un succès
terminal, sans quoi la ligne reste coincée dans la file et l'objet reste
affiché comme "en attente" indéfiniment.
```javascript
} catch (err) {
const status = err?.response?.status ?? null;
// 409 sur une clôture : la fiche est déjà close, l'objectif est atteint
if (status === 409 && item.op === 'close') {
await db.taskQueue.delete(item.id);
ok += 1;
continue;
}
console.error('taskSync.syncQueue: sync failed', { id: item.id, status, err });
// ... marquage en erreur
}
```
## Le schéma de la file
```javascript
import { useDb } from '@cap-rel/smartcommon';
const db = useDb({
name: 'monApp',
version: 1,
stores: {
// Données métier lues par les écrans
tasks: 'id, ref, label, status, updatedAt',
// File d'attente, indexée sur le client_uuid
taskQueue: 'id, status, projectId, updatedAt'
}
});
```
| Champ | Rôle |
| --- | --- |
| `id` | Le `client_uuid`, clé d'idempotence |
| `payload` | Le corps prêt à être envoyé, sans champ transitoire d'interface |
| `op` | `create`, `update`, `close`... route vers le bon endpoint |
| `status` | `pending`, `syncing`, `synced`, `error` |
| `attempts` | Compteur de tentatives automatiques |
| `lastAttemptAt` | Horodatage, sert à détecter les lignes zombies |
| `lastError` | Message d'erreur, affiché à l'utilisateur |
| `serverId` | Identifiant serveur, renseigné après la première réussite |
Nettoyez le `payload` des champs d'affichage au moment de l'enfilement, pas au
moment de l'envoi : la ligne stockée doit être prête à partir telle quelle.
## Vider la file
```javascript
export const MAX_AUTO_ATTEMPTS = 5;
export const syncQueue = async ({ api, includeMaxedOut = false } = {}) => {
if (!api || typeof api.post !== 'function') {
console.error('taskSync.syncQueue: missing api.post');
return { ok: 0, ko: 0, skipped: 0 };
}
// Les lignes bloquées en "syncing" viennent d'une application tuée en
// plein vol : le rejeu étant idempotent, on les remet en "pending".
await reviveStuckSyncing();
const all = await db.taskQueue.toArray();
const pending = all.filter(r => r.status === 'pending' || r.status === 'error');
let ok = 0, ko = 0, skipped = 0;
for (const item of pending) {
if (!includeMaxedOut && item.attempts >= MAX_AUTO_ATTEMPTS) {
skipped += 1;
continue;
}
await db.taskQueue.update(item.id, {
status: 'syncing',
lastAttemptAt: Date.now(),
updatedAt: Date.now()
});
try {
const isUpdate = item.op === 'update' && Number(item.serverId) > 0;
const res = isUpdate
? await api.private.put(`task/${item.serverId}`, {
json: item.payload,
headers: { 'X-Offline-Sync': '1' }
})
: await api.private.post('task', {
json: item.payload,
headers: { 'X-Offline-Sync': '1' }
});
await db.taskQueue.delete(item.id);
await db.tasks.put({ ...item.payload, id: res.id, synced: true });
ok += 1;
} catch (err) {
const message = err?.apiMessage || err?.message || String(err);
console.error('taskSync.syncQueue: sync failed', { id: item.id, message });
await db.taskQueue.update(item.id, {
status: 'error',
attempts: (item.attempts ?? 0) + 1,
lastError: message,
updatedAt: Date.now()
});
ko += 1;
}
}
notifyChange();
return { ok, ko, skipped };
};
```
Trois décisions méritent d'être expliquées.
**Le marquage en `syncing` avant l'appel.** Il évite qu'un second déclenchement
concurrent reprenne la même ligne. En contrepartie, une application tuée pendant
la requête laisse une ligne bloquée dans cet état : c'est le rôle de
`reviveStuckSyncing`, qui repasse en `pending` toute ligne `syncing` dont le
`lastAttemptAt` remonte à plus de quelques minutes. Le rejeu étant idempotent,
cette reprise est sans risque.
**Le plafond de tentatives.** Au-delà de `MAX_AUTO_ATTEMPTS`, les passes
automatiques laissent la ligne tranquille. Sans plafond, une ligne
définitivement invalide, refusée par une règle métier, serait renvoyée à chaque
retour de réseau jusqu'à la fin des temps. L'utilisateur garde la main avec un
bouton de reprise manuelle, qui remet le compteur à zéro.
**L'erreur est journalisée avant d'être stockée.** Une file qui échoue en
silence est impossible à diagnostiquer sur le terrain.
## Prévenir l'interface
La file est modifiée depuis plusieurs endroits, y compris en arrière-plan. Les
écrans qui affichent un compteur ou un badge "en attente" doivent se
rafraîchir sans faire de scrutation.
```javascript
export const TASK_QUEUE_EVENT = 'task-queue-changed';
export const notifyChange = () => {
if (typeof window === 'undefined') return;
try {
window.dispatchEvent(new Event(TASK_QUEUE_EVENT));
} catch (err) {
console.error('taskSync.notifyChange: dispatch failed', err);
}
};
```
Côté composant :
```javascript
import { useEffect } from 'react';
import { useStates } from '@cap-rel/smartcommon';
import { db } from 'src/db';
import { TASK_QUEUE_EVENT } from 'src/lib/taskSync';
export const PendingBadge = () => {
const st = useStates({ initialStates: { count: 0 } });
useEffect(() => {
const refresh = async () => {
const rows = await db.taskQueue.toArray();
st.set('count', rows.filter(r => r.status !== 'synced').length);
};
refresh();
window.addEventListener(TASK_QUEUE_EVENT, refresh);
return () => window.removeEventListener(TASK_QUEUE_EVENT, refresh);
}, []);
if (st.get('count') === 0) return null;
return {st.get('count')} en attente ;
};
```
## Déclencher la vidange
Trois déclencheurs, cumulables :
```javascript
import { useEffect } from 'react';
import { useApi, useOnlineStatus } from '@cap-rel/smartcommon';
import { syncQueue } from 'src/lib/taskSync';
export const useTaskQueueDrain = () => {
const api = useApi();
const { isOnline, isServerReachable } = useOnlineStatus({
healthCheckUrl: '/api/health'
});
// 1. Au retour du réseau
useEffect(() => {
if (isOnline && isServerReachable) {
syncQueue({ api }).catch(err =>
console.error('useTaskQueueDrain: drain failed', err)
);
}
}, [isOnline, isServerReachable]);
// 2. Au geste de rafraîchissement de l'utilisateur, et 3. sur un bouton
// de reprise manuelle, en passant includeMaxedOut à true
return {
drain: () => syncQueue({ api }),
retryAll: () => syncQueue({ api, includeMaxedOut: true })
};
};
```
Préférez `useOnlineStatus` à l'événement `online` du navigateur. Ce dernier ne
dit rien de la joignabilité réelle du serveur : un portail captif d'hôtel le
déclenche, alors qu'aucune requête ne passera.
## Files dépendantes : le cas des fichiers
Un geste métier embarque souvent une photo ou une signature, dont l'envoi est
lui-même différé. L'action ne peut alors partir qu'une fois ses fichiers
remontés et leurs identifiants connus.
La solution est de garder dans la ligne la liste des envois en attente :
```javascript
const row = {
id: clientUuid,
payload: body,
// Champs dont l'envoi est différé : ils portent un identifiant local
// mais pas encore d'identifiant serveur
pendingUploads: ['signature', 'photo_before'],
status: 'pending'
};
```
La vidange saute les lignes dont `pendingUploads` n'est pas vide. À chaque envoi
de fichier réussi, le champ correspondant du `payload` est complété et l'entrée
retirée de la liste. Quand elle se vide, la ligne devient éligible et peut
partir immédiatement.
## Pièges classiques
**Écraser les modifications locales au rafraîchissement.** Un
`db.tasks.clear()` suivi d'un `bulkAdd` des données serveur détruit tout ce que
l'utilisateur a saisi hors connexion et qui n'est pas encore parti. Fusionnez au
lieu de remplacer, ou ne rafraîchissez que les objets absents de la file.
**Verrouiller ou non l'objet en attente.** Un objet dont l'action est en file
peut être ré-édité par l'utilisateur, ce qui produit deux actions concurrentes
sur le même objet. Le plus simple est de le passer en lecture seule tant que sa
ligne est en file, et de l'annoncer clairement dans l'interface.
**Faire confiance à `navigator.onLine`.** Il ne reflète que l'état de
l'interface réseau, pas la joignabilité du serveur.
**Oublier que la file survit à une mise à jour de l'application.** Elle vit dans
IndexedDB, pas dans le cache du Service Worker. C'est une bonne nouvelle, mais
cela veut dire qu'un changement du format de `payload` doit prévoir une
migration pour les lignes déjà en attente sur les appareils.
## Points clés à retenir
1. **Choisir le bon dispositif** avant d'écrire une file : dans la plupart des
cas, `useSyncClient` répond déjà au besoin
2. **L'idempotence d'abord** : un `client_uuid` fabriqué par le client, un index
unique côté serveur, un rejeu qui répond 200 sans dupliquer
3. **Signaler le rejeu** au serveur avec `X-Offline-Sync`, pour qu'il recalcule
ce qui ne doit pas venir du client
4. **Traiter les transitions non rejouables** : un 409 sur une clôture déjà
faite est un succès
5. **Plafonner les tentatives** et offrir une reprise manuelle
6. **Journaliser chaque échec** avant de le stocker
7. **Prévenir l'interface par événement**, sans scrutation
[<- Retour au module](/training/module10-fonctionnalites-avancees) | [Chapitre suivant : Internationalisation ->](/training/module10-fonctionnalites-avancees/i18n)
- [Chapitre 2 : Internationalisation (i18n)](https://smartmaker.org/training/module10-fonctionnalites-avancees/i18n)
# Chapitre 2 : Internationalisation (i18n)
SmartCommon utilise i18next pour les traductions.
## Structure des fichiers
```
mobile/
├── public/
│ └── locales/
│ ├── fr/
│ │ └── translation.json
│ └── en/
│ └── translation.json
└── src/
└── i18n/
└── index.js
```
## Configuration i18next
```javascript
// src/i18n/index.js
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'fr',
supportedLngs: ['fr', 'en'],
debug: import.meta.env.DEV,
backend: {
loadPath: '/locales/{{lng}}/translation.json'
},
interpolation: {
escapeValue: false
}
});
export default i18n;
```
## Fichiers de traduction
### Français
```json
{
"common": {
"loading": "Chargement...",
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"close": "Fermer",
"confirm": "Confirmer",
"yes": "Oui",
"no": "Non"
},
"auth": {
"login": "Connexion",
"logout": "Déconnexion",
"email": "Email",
"password": "Mot de passe",
"loginButton": "Se connecter",
"loginError": "Identifiants incorrects"
},
"tasks": {
"title": "Mes tâches",
"new": "Nouvelle tâche",
"edit": "Modifier la tâche",
"label": "Titre",
"description": "Description",
"priority": "Priorité",
"status": {
"todo": "À faire",
"done": "Terminée"
},
"empty": "Aucune tâche",
"deleteConfirm": "Supprimer cette tâche ?"
},
"errors": {
"network": "Erreur de connexion",
"notFound": "Page non trouvée",
"server": "Erreur serveur"
}
}
```
### Anglais
```json
{
"common": {
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"confirm": "Confirm",
"yes": "Yes",
"no": "No"
},
"auth": {
"login": "Login",
"logout": "Logout",
"email": "Email",
"password": "Password",
"loginButton": "Sign in",
"loginError": "Invalid credentials"
},
"tasks": {
"title": "My tasks",
"new": "New task",
"edit": "Edit task",
"label": "Title",
"description": "Description",
"priority": "Priority",
"status": {
"todo": "To do",
"done": "Done"
},
"empty": "No tasks",
"deleteConfirm": "Delete this task?"
},
"errors": {
"network": "Network error",
"notFound": "Page not found",
"server": "Server error"
}
}
```
## Utilisation dans les composants
### Avec useTranslation
```javascript
import { useTranslation } from 'react-i18next';
import { Page, Block, Button } from '@cap-rel/smartcommon';
export const TasksPage = () => {
const { t } = useTranslation();
return (
{t('tasks.new')}
{tasks.length === 0 && (
{t('tasks.empty')}
)}
);
};
```
### Avec paramètres
```json
{
"tasks": {
"count": "{{count}} tâche",
"count_plural": "{{count}} tâches",
"greeting": "Bonjour {{name}}"
}
}
```
```javascript
const { t } = useTranslation();
// Avec paramètre
t('tasks.greeting', { name: 'Jean' }) // "Bonjour Jean"
// Pluriel
t('tasks.count', { count: 1 }) // "1 tâche"
t('tasks.count', { count: 5 }) // "5 tâches"
```
## Changer de langue
```javascript
import { useTranslation } from 'react-i18next';
import { useGlobalStates } from '@cap-rel/smartcommon';
import { Select } from '@cap-rel/smartcommon';
export const LanguageSelector = () => {
const { i18n } = useTranslation();
const gst = useGlobalStates();
const changeLanguage = (lng) => {
i18n.changeLanguage(lng);
gst.local.set('settings', { ...gst.get('settings'), lng });
};
return (
changeLanguage(e.target.value)}
options={[
{ value: 'fr', label: 'Français' },
{ value: 'en', label: 'English' }
]}
/>
);
};
```
## Formatage des dates et nombres
Utiliser `useIntl` de SmartCommon :
```javascript
import { useIntl } from '@cap-rel/smartcommon';
function DateDisplay({ date }) {
const intl = useIntl();
// Utilise la locale courante
const formatted = intl.DateTimeFormat(date, undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
return {formatted} ;
}
```
## Points clés à retenir
1. Fichiers JSON dans **public/locales/{lng}/**
2. **useTranslation** pour accéder aux traductions
3. **t('key')** pour traduire une chaîne
4. **Paramètres** avec {{ }} dans les traductions
5. **i18n.changeLanguage()** pour changer de langue
[← Chapitre précédent](/training/module10-fonctionnalites-avancees/offline) | [Retour au module](/training/module10-fonctionnalites-avancees) | [Chapitre suivant : Autres fonctionnalités ->](/training/module10-fonctionnalites-avancees/autres)
- [Chapitre 3 : Autres fonctionnalités](https://smartmaker.org/training/module10-fonctionnalites-avancees/autres)
# Chapitre 3 : Autres fonctionnalités
## Géolocalisation
### Obtenir la position
```javascript
import { useState } from 'react';
import { Button, Block } from '@cap-rel/smartcommon';
function LocationButton({ onLocation }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const getLocation = () => {
if (!navigator.geolocation) {
setError('Géolocalisation non supportée');
return;
}
setLoading(true);
setError(null);
navigator.geolocation.getCurrentPosition(
(position) => {
setLoading(false);
onLocation({
lat: position.coords.latitude,
lng: position.coords.longitude,
accuracy: position.coords.accuracy
});
},
(err) => {
setLoading(false);
setError(err.message);
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}
);
};
return (
Obtenir ma position
{error && {error}
}
);
}
```
### Composant Gps de SmartCommon
```javascript
import { Gps } from '@cap-rel/smartcommon';
console.log(coords)}
/>
```
## Upload de fichiers
### PhotosUploader
```javascript
import { PhotosUploader } from '@cap-rel/smartcommon';
console.log(files)}
/>
```
### FilesUploader
```javascript
import { FilesUploader } from '@cap-rel/smartcommon';
```
### Redimensionner une image
```javascript
import { useFile } from '@cap-rel/smartcommon';
function ImageUploader() {
const { resizeImage } = useFile();
const handleFile = async (e) => {
const file = e.target.files[0];
const base64 = await resizeImage(file, {
maxWidth: 1200,
maxHeight: 1200,
quality: 80
});
// Envoyer au serveur
await api.private.post('files', {
json: {
filename: file.name,
content: base64
}
});
};
return ;
}
```
## Thème sombre
### Configuration globale
```javascript
// appConfig.js
export const config = {
globalState: {
reducers: {
settings: {
theme: 'light' // 'light' ou 'dark'
}
}
}
};
```
### Appliquer le thème
```javascript
import { useEffect } from 'react';
import { useGlobalStates } from '@cap-rel/smartcommon';
function ThemeProvider({ children }) {
const gst = useGlobalStates();
useEffect(() => {
document.documentElement.classList.toggle(
'dark',
gst.get('settings')?.theme === 'dark'
);
}, [gst.get('settings')?.theme]);
return children;
}
```
### Toggle de thème
```javascript
import { useGlobalStates } from '@cap-rel/smartcommon';
import { Boolean } from '@cap-rel/smartcommon';
function ThemeToggle() {
const gst = useGlobalStates();
const settings = gst.get('settings');
const toggleTheme = () => {
gst.local.set('settings', {
...settings,
theme: settings.theme === 'light' ? 'dark' : 'light'
});
};
return (
);
}
```
## Signature manuscrite
```javascript
import { SignaturePad } from '@cap-rel/smartcommon';
```
## Scan de code-barres
Utiliser une bibliothèque comme `@zxing/browser` :
```bash
npm install @zxing/browser @zxing/library
```
```javascript
import { useState, useRef, useEffect } from 'react';
import { BrowserMultiFormatReader } from '@zxing/browser';
function BarcodeScanner({ onScan }) {
const videoRef = useRef(null);
const [scanning, setScanning] = useState(false);
useEffect(() => {
let reader;
if (scanning) {
reader = new BrowserMultiFormatReader();
reader.decodeFromVideoDevice(
undefined,
videoRef.current,
(result, error) => {
if (result) {
onScan(result.getText());
setScanning(false);
}
}
);
}
return () => {
if (reader) {
reader.reset();
}
};
}, [scanning]);
return (
{scanning ? (
) : (
setScanning(true)}>
Scanner un code
)}
);
}
```
## Notifications locales
```javascript
function requestNotificationPermission() {
if ('Notification' in window) {
Notification.requestPermission().then(permission => {
console.log('Permission:', permission);
});
}
}
function showNotification(title, body) {
if (Notification.permission === 'granted') {
new Notification(title, { body });
}
}
```
## Points clés à retenir
1. **Géolocalisation** avec navigator.geolocation ou composant Gps
2. **Upload** avec PhotosUploader, FilesUploader, useFile
3. **Thème sombre** via useGlobalStates et classes CSS
4. **Signature** avec SignaturePad
5. Intégrer des **bibliothèques tierces** selon les besoins
[← Chapitre précédent](/training/module10-fonctionnalites-avancees/i18n) | [Retour au module](/training/module10-fonctionnalites-avancees) | [Module suivant : Bonnes pratiques ->](/training/module11-bonnes-pratiques)
- [Extrafields et formulaires dynamiques](https://smartmaker.org/training/module8-backend-api/extrafields)
# Extrafields et formulaires dynamiques
> Les extrafields Dolibarr permettent d'ajouter des champs personnalisés aux objets métier. SmartMaker offre un système complet pour les exposer à l'application React avec génération automatique de formulaires.
## Vue d'ensemble
Le système extrafields SmartMaker fonctionne en trois étapes :
```
1. Admin Dolibarr : Configure quels extrafields exposer (RO/RW)
↓
2. Mapper PHP : Lit la configuration, expose les métadonnées
↓
3. React : Génère dynamiquement les formulaires
```
## 1. Configuration admin
### Page de configuration
SmartBoot génère une page d'administration `admin/smartmaker.php` qui permet de configurer les extrafields exposés à l'application.
```php
// Définir les objets Dolibarr à configurer
$elementsToConfig = array(
'fichinter' => 'Interventions',
'societe' => 'Tiers',
'projet_task' => 'Tâches',
);
```
Cette page affiche tous les extrafields de chaque objet avec deux colonnes :
| Option | Description |
| --- | --- |
| **RO** (Read-Only) | Le champ est visible dans l'application mais non modifiable |
| **RW** (Read-Write) | Le champ est visible ET modifiable |
### Stockage de la configuration
La configuration est stockée dans les constantes Dolibarr :
```
MONMODULE_SMARTMAKER_EXTRAFIELDS_RO = "champ1,champ2,champ3"
MONMODULE_SMARTMAKER_EXTRAFIELDS_RW = "champ4,champ5"
```
## 2. Mapper PHP (dmGenericObject)
### Structure de base
Le mapper hérite de `dmBase` et utilise `dmTrait` pour la gestion des extrafields :
```php
'id',
'ref' => 'ref',
'description' => 'description',
'fk_statut' => 'status',
];
// Champs modifiables via API
protected $writableFields = [];
}
```
### Chargement automatique des extrafields
Le constructeur charge la configuration depuis Dolibarr :
```php
public function __construct()
{
global $db;
$this->db = $db;
// Extrafields en lecture seule
$extRO = getDolGlobalString('MONMODULE_SMARTMAKER_EXTRAFIELDS_RO');
if (!empty($extRO)) {
foreach (explode(',', $extRO) as $field) {
$field = trim($field);
if (!empty($field)) {
$key = 'options_' . $field;
$this->listOfPublishedFields[$key] = $key;
}
}
}
// Extrafields en lecture-écriture
$extRW = getDolGlobalString('MONMODULE_SMARTMAKER_EXTRAFIELDS_RW');
if (!empty($extRW)) {
foreach (explode(',', $extRW) as $field) {
$field = trim($field);
if (!empty($field)) {
$key = 'options_' . $field;
$this->listOfPublishedFields[$key] = $key;
$this->writableFields[] = $key;
}
}
}
$this->boot();
}
```
### Méthode objectDesc()
La méthode `objectDesc()` (fournie par `dmTrait`) retourne les métadonnées des champs pour le frontend :
```php
// Dans le controller
$mapping = new dmItem();
$config = $mapping->objectDesc();
// Retourne un tableau avec les infos de chaque champ :
// [
// 'options_niveau' => [
// 'type' => 'select',
// 'label' => 'Niveau',
// 'required' => true,
// 'options' => ['1' => 'Facile', '2' => 'Moyen', '3' => 'Difficile'],
// 'writable' => true,
// ],
// ...
// ]
```
## 3. Controller : Exposer la configuration
Le controller doit retourner la configuration au frontend :
```php
class ItemController
{
private $mapping;
public function __construct()
{
$this->mapping = new dmItem();
}
// GET /items (liste + config)
public function index($arr = null)
{
// ... charger les items ...
return [[
'statusCode' => 200,
'items' => $items,
'config' => $this->mapping->objectDesc(), // Métadonnées des champs
], 200];
}
// GET /items/123 (détail + config)
public function show($arr = null)
{
$id = $arr['id'] ?? 0;
// ... charger l'item ...
return [[
'statusCode' => 200,
'item' => $this->mapping->map($dolibarrObject),
'config' => $this->mapping->objectDesc(),
], 200];
}
}
```
## 4. Frontend : Formulaires dynamiques
### Stockage de la configuration
Stocker la configuration reçue de l'API dans Redux ou le state local :
```jsx
// Dans le composant ou via Redux
const [config, setConfig] = useState({});
useEffect(() => {
api.private.get('items')
.then(response => {
setConfig(response.config || {});
// ... traiter les items ...
});
}, []);
```
### FormComponentsMap
SmartCommon fournit `FormComponentsMap` qui mappe les types Dolibarr vers les composants React :
```jsx
import { FormComponentsMap } from "@cap-rel/smartcommon";
// FormComponentsMap retourne le composant approprié selon le type :
// 'varchar' -> Input
// 'text' -> Textarea
// 'int' -> Input type="number"
// 'double' -> Input type="number" step="0.01"
// 'date' -> DatePicker
// 'datetime' -> DateTimePicker
// 'boolean' -> Checkbox
// 'select' -> Select
// 'sellist' -> Select (avec options depuis la DB)
// 'radio' -> RadioGroup
// 'checkbox' -> CheckboxGroup
// 'link' -> SearchSelect (lien vers autre objet)
```
### Génération dynamique du formulaire
```jsx
const DynamicForm = ({ config, values, onChange }) => {
return (
{Object.entries(config).map(([fieldName, fieldConfig]) => {
// Récupérer le composant selon le type
const Component = FormComponentsMap(fieldConfig.type);
if (!Component) return null;
return (
onChange(fieldName, val)}
required={fieldConfig.required}
disabled={!fieldConfig.writable}
options={fieldConfig.options} // Pour select/radio
/>
);
})}
);
};
```
### Exemple complet avec useApi
```jsx
import { useStates, useApi, Page, Button, Block } from "@cap-rel/smartcommon";
import { FormComponentsMap } from "@cap-rel/smartcommon";
export const ItemEditPage = ({ itemId }) => {
const { states, set } = useStates({
item: null,
config: {},
isLoading: true,
isSaving: false,
});
const { item, config, isLoading, isSaving } = states;
const api = useApi();
// Charger l'item et sa configuration
useEffect(() => {
api.private.get(`items/${itemId}`)
.then(response => {
set("item", response.item);
set("config", response.config);
})
.finally(() => set("isLoading", false));
}, [itemId]);
// Modifier une valeur
const handleFieldChange = (fieldName, value) => {
set("item", { ...item, [fieldName]: value });
};
// Sauvegarder
const handleSave = () => {
set("isSaving", true);
api.private.put(`items/${itemId}`, item)
.then(() => {
// Succès
})
.finally(() => set("isSaving", false));
};
if (isLoading) return Chargement...
;
return (
{/* Champs standards */}
{/* Extrafields dynamiques */}
{Object.entries(config).map(([fieldName, fieldConfig]) => {
// Ne montrer que les extrafields (commencent par options_)
if (!fieldName.startsWith('options_')) return null;
const Component = FormComponentsMap(fieldConfig.type);
if (!Component) return null;
return (
handleFieldChange(fieldName, val)}
disabled={!fieldConfig.writable}
required={fieldConfig.required}
options={fieldConfig.options}
/>
);
})}
Enregistrer
);
};
```
## Types d'extrafields supportés
| Type Dolibarr | Composant React | Notes |
| --- | --- | --- |
| varchar | Input | Texte court |
| text | Textarea | Texte long |
| int | Input (number) | Entier |
| double | Input (number) | Décimal avec step |
| date | DatePicker | Date seule |
| datetime | DateTimePicker | Date et heure |
| boolean | Checkbox | Oui/Non |
| select | Select | Liste déroulante |
| sellist | Select | Liste depuis SQL |
| radio | RadioGroup | Boutons radio |
| checkbox | CheckboxGroup | Cases à cocher multiples |
| link | SearchSelect | Lien vers objet Dolibarr |
| price | Input (number) | Prix avec formatage |
## Bonnes pratiques
### Sécurité
- Ne jamais exposer d'extrafields sensibles (mots de passe, tokens)
- Vérifier les permissions côté backend avant modification
- Valider les données reçues du frontend
### Performance
- Mettre en cache la configuration si elle ne change pas souvent
- Ne charger que les extrafields nécessaires
### UX
- Grouper les extrafields par catégorie si nombreux
- Utiliser des labels clairs et traduits
- Afficher les champs obligatoires distinctement
## Résumé
Le système extrafields SmartMaker permet de :
1. **Configurer** via l'admin Dolibarr quels champs exposer
2. **Mapper** automatiquement avec le constructeur dmGenericObject
3. **Exposer** les métadonnées via objectDesc()
4. **Générer** dynamiquement les formulaires React avec FormComponentsMap
Ce système évite de modifier le code à chaque ajout d'extrafield : la configuration admin suffit.
[← Mappers](/training/module8-backend-api/mappers) | [Module 9 : Intégration ->](/training/module9-integration)
## [Démonstration](https://smartmaker.org/demo/) (demonstration)
Testez les applications SmartMaker en ligne sur dolitest.fr : SmartInterventions et OfflinePropale, avec un Dolibarr complet et son application mobile.
## Other documents
- [SmartMaker ou DoliMobile ?](https://smartmaker.org/)
# SmartMaker ou DoliMobile ?

Au départ le projet portait le nom de DoliMobile et puis vu que la solution n'est pas que pour les périphériques mobiles et que le nom DoliMobile a été "piqué" par un autre projet nous avons changé de nom pour SmartMaker.
Néanmoins vous trouverez peut-être encore le nom DoliMobile dans nos publications, merci de ne pas nous en vouloir et de faire vous-même la transposition : DoliMobile = ancien nom de SmartMaker.
SmartMaker est le résultat d'un long travail de réflexion visant à apporter à l'écosystème Dolibarr une pile technique pour que les développeurs puissent proposer des interface mobiles natives en complément du Module Builder.
L'objectif est de permettre à tout développeur dolibarr de proposer des PWA (Progressive Web App) et non des applications déployées via les portail des GAFAM (et donc soumises à leurs conditions).
## Historique rapide
Tout a commencé lors d'un devcamp dolibarr et a donné naissance à un GIFF (voir https://www.dolibarr.fr/forum/t/giff-socle-technique-mobile/43476) qui n'a malheureusement pas été financé ni porté par un réel groupe de partenaires dolibarr : https://decidim-dolibarr.informatique-libre.com/assemblies/assemblee-giff-socle-mobile sans doute par manque de moyens humains et une gestion des priorités dans le contexte actuel (facture électronique, certification du logiciel de caisse entre autre).
## Motivations
Dolibarr est un projet libre et nous pensons qu'il faut proposer une alternative décentralisée à nos utilisateurs pour ne pas les laisser se faire enfermer dans les écosystèmes des GAFAM comme c'est le cas lorsqu'on propose une application native diffusée sur Google play ou apple iTunes.
Ainsi une application PWA développée à l'aide de SmartMaker sera simplement hébergée sur le dolibarr de l'utilisateur comme tout module dolibarr classique.
L'utilisateur peut s'il le souhaite "ajouter une icône de lancement" sur le "bureau" de son smartphone (ou de ceux de ses utilisateurs) rendant ainsi l'accès à la PWA aussi simple qu'une application native.
Pour simplifier également le processus nous vous invitons à générer un qrcode du chemin d'hébergement de la PWA ainsi les utilisateurs n'auront qu'a flasher le qrcode pour y avoir accès, encore plus facile que de faire une recherche sur le portail des GAFAM.
Pour plus de détails concernant les PWA vous pouvez consulter la fiche wikipédia https://fr.wikipedia.org/wiki/Progressive_web_app
SmartMaker est un projet porté par CAP-REL (preferred partner dolibarr) complètement ouvert et dont nous espérons voir le plus possible de choses intégrées dans le projet Dolibarr.
## Backoffice (PHP)
SmartMaker s'intègre dans un module normal dolibarr (par exemple généré avec le module builder natif de dolibarr) et propose l'utilisation d'API(s) côté backoffice pour rendre accessible les éléments dont l'application frontale a besoin.
Vous pouvez ainsi développer dans votre module tout ce que vous voulez exposer à l'application frontale dans le langage du coeur de dolibarr.
### Authentification
La pile d'authentification est détaillée dans le projet SmartAuth : https://inligit.fr/cap-rel/dolibarr/plugin-smartauth/
L'API native de dolibarr n'est à notre avis pas suffisante et nous a conduit à l'implémentation proposée dans [SmartAuth](/smartauth).
Note: Le contenu du projet SmartAuth a été prévu pour être intégré dans le cœur de dolibarr où tout au moins nous espérons voir arriver dans le cœur une solution couvrant le même périmètre fonctionnel
.
### API métier pour votre application
SmartMaker vous offre un cadre de développement moderne pour maximiser votre efficacité.
SmartMaker apporte un routeur PHP comme vous pouvez en trouver dans des projets PHP modernes tels que symfony ou Laravel. Par exemple:

Lié à ce routeur un autoloader PSR-4 est également livré vous permettant de mettre en place une arborescence de fichiers logique répondant aux points d'entrées de votre table de routage PHP.
(Les fichiers suffixés de Controller.php sont donc les classes controleur indiquées dans le routeur)

Ainsi la ligne suivante du routeur
```PHP
Route::get('home', HomeController::class, 'index', true);
```
Implémente la méthode "GET /home" et fait appel à la fonction "index" de la classe HomeController ... tout est structuré et vous permet d'avoir un code facile à lire et à maintenir.
La classe `HomeController` étant tout simplement implémentée dans le fichier `./smartmaker-api/HomeController.php` et sera chargée automatiquement par un autoloader (PSR-4). Note: Vous trouverez l'autoloader dans le fichier `smartmaker-api-prepend.php`
## Front en react
Le front est développé en react et de nombreux objets prêts à l'emploi sont proposés.
Note: Si vous n'avez jamais mis les pieds dans le monde du développement mobile : votre code source pour la plate-forme mobile sera compilé (pour résumer le résultat sera un gros fichier "js"). Il est donc important de maîtriser la chaîne complète pour pouvoir implémenter l'application mobile.
## Des outils complémentaires
SmartMaker est livré avec des outils complémentaires dont en particulier un fichier Makefile qui fera sourire les "vieux" développeurs (make est particulièrement utilisé dans le développement C/C++).
En effet, au lieu de devoir lancer des commandes compliquées nous avons tout regroupés dans le fichier Makefile qui vous permet de lancer des commandes plus simple. Make étant en mesure de vérifier un certain nombre de choses et d'automatiser des actions un peu comme un script.
Ainsi par exemple pour compiler l'application mobile pwa et la lancer en mode développeur vous pouvez lancer uniquement la commande
`> make pwadev`
Pour plus de détails, regardez le fichier Makefile.dist
Note: Si vous avez des adaptations "locales" à implémenter vous pouvez le faire dans un nouveau fichier Makefile.local qui sera automatiquement inclus lors de l'appel à la commande make !