# SmartMaker.org > SmartMaker est un framework open source (AGPL-3.0) pour construire des applications web progressives (PWA) connectées à Dolibarr ERP/CRM, en React 19 et PHP. Documentation, guides pas à pas et formation. Also available in: en (https://smartmaker.org/en/llms.txt), es (https://smartmaker.org/es/llms.txt), de (https://smartmaker.org/de/llms.txt), it (https://smartmaker.org/it/llms.txt), nl (https://smartmaker.org/nl/llms.txt) ## [](https://smartmaker.org/) - [](https://smartmaker.org/) ## [SmartAuth](https://smartmaker.org/smartauth/) (librairie) SmartAuth est le module d'authentification JWT pour Dolibarr, conçu pour les applications SmartMaker. ## [SmartMaker - Back (PHP)](https://smartmaker.org/back/) (librairie) La partie backoffice de SmartMaker s'intègre dans un module Dolibarr standard. - [Mapping Dolibarr - React](https://smartmaker.org/back/mapping-dolibarr-react) # Mapping Dolibarr - React Pour exposer les objets Dolibarr vers l'application React, SmartMaker utilise des classes de mapping préfixées `dm`. Un mapper décrit **quels champs sont publiés**, **sous quel nom** et **lesquels sont modifiables**. ## Avant d'écrire un mapper : vérifiez qu'il n'existe pas déjà SmartAuth livre une **bibliothèque de mappers pour les objets du coeur Dolibarr**, dans `smartauth/dolMapping/`. Ne réécrivez pas le vôtre pour un objet standard. | Domaine | Mappers disponibles | | --- | --- | | Documents commerciaux | `dmInvoice`, `dmOrder`, `dmProposal`, `dmContract`, `dmSupplierInvoice`, `dmSupplierOrder`, `dmSupplierProposal`, `dmShipment`, `dmReception`, `dmDeliveryNote` | | Tiers et contacts | `dmThirdparty`, `dmContact`, `dmSupplier`, `dmUser` | | Catalogue et stock | `dmProduct`, `dmCategory`, `dmWarehouse`, `dmStockMovement` | | Production | `dmMo`, `dmBom` | | Adhérents | `dmMember`, `dmMemberType`, `dmSubscription`, `dmDonation` | | Projet et planning | `dmProject`, `dmTask`, `dmAgendaEvent`, `dmIntervention`, `dmExpenseReport`, `dmTicket` | | Comptabilité | `dmBankAccount`, `dmBank`, `dmCompanyBankAccount`, `dmMulticurrency` | | Dictionnaires | `dmC*` : `dmCcountry`, `dmCstate`, `dmCpaymentterm`, `dmCunits`, etc. (lecture seule) | > [!IMPORTANT] > Un module n'écrit un mapper que pour **ses objets métier propres**. Pour un objet du coeur, il consomme le mapper SmartAuth, éventuellement en en héritant. Un bug corrigé dans `dolMapping/` profite à tous les modules ; un mapper dupliqué localement recrée la dette que la centralisation a supprimée. ### Et souvent, pas de mapper du tout Pour les objets du coeur, SmartAuth expose une **façade REST générique** qui s'appuie sur ces mappers. Un module n'a alors ni CRUD, ni recherche, ni pagination à écrire : ``` GET objects/{objtype} liste paginee (filtres, tri, recherche) GET objects/{objtype}/describe schema des champs (objectDesc) GET objects/{objtype}/{id} un objet POST objects/{objtype} creation PATCH objects/{objtype}/{id} mise a jour DELETE objects/{objtype}/{id} suppression ``` Les documents à lignes ajoutent `objects/{objtype}/{id}/lines`, les workflows `objects/{objtype}/{id}/actions/{action}`, les factures `objects/{objtype}/{id}/payments`. Écrivez donc un mapper quand vous avez **votre propre classe Dolibarr** à exposer. C'est le cas traité dans le reste de cette page. ## Déclarer un mapper ### Structure minimale ``` nom API protected $listOfPublishedFields = [ 'rowid' => 'id', 'ref' => 'ref', 'label' => 'label', 'description' => 'description', 'fk_soc' => 'thirdparty', 'fk_statut' => 'status', 'date_creation' => 'created_at', 'note_public' => 'public_note', 'note_private' => 'private_note', ]; // Allowlist d'ecriture : noms DOLIBARR, jamais noms API protected $writableFields = [ 'label', 'description', 'note_public', ]; public function __construct() { global $langs; $langs->load("mymodule@mymodule"); $this->boot(); } } ``` L'appel à `boot()` en fin de constructeur est obligatoire. ### Propriétés reconnues | Propriété | Obligatoire | Rôle | | --- | --- | --- | | `$type` | oui | `object` ou `dict` | | `$dolibarrClassName` | oui pour `object` | nom exact de la classe Dolibarr représentée | | `$listOfPublishedFields` | oui | map nom Dolibarr vers nom API | | `$writableFields` | non, défaut `[]` | allowlist d'écriture, en noms Dolibarr | | `$listOfDerivedFields` | non | champs calculés sans colonne source | | `$parentTableElementToUseForExtraFields` | non | rattachement des extrafields | | `$parentClassName` | non | **uniquement** pour un mapper de sous-objet ou de ligne | | `$parentClassNameForLines` | non | classe Dolibarr des lignes | | `$listOfPublishedFieldsForLines` | non | map des champs de lignes | | `$parentLabelForLines` | non | clé API sous laquelle les lignes sont publiées | | `$parentFieldsOverride` | non | patch de la définition d'un champ (type, required...) | ### Piège : $dolibarrClassName n'est pas déduit du nom du mapper > [!WARNING] > `$dolibarrClassName` est **obligatoire** sur tout mapper de type `object`. Il n'est pas déduit du nom de la classe, et pour cause : la déduction serait fausse dans la plupart des cas. | Mapper | Classe Dolibarr réelle | Ce qu'une déduction donnerait | | --- | --- | --- | | `dmThirdparty` | `Societe` | `Thirdparty` | | `dmInvoice` | `Facture` | `Invoice` | | `dmOrder` | `Commande` | `Order` | | `dmProposal` | `Propal` | `Proposal` | | `dmIntervention` | `Fichinter` | `Intervention` | | `dmWarehouse` | `Entrepot` | `Warehouse` | | `dmMember` | `Adherent` | `Member` | | `dmShipment` | `Expedition` | `Shipment` | Le mapper valide sa déclaration au `boot()` et lève une `LogicException` explicite si `$dolibarrClassName` manque, ou si la classe annoncée n'existe pas (typiquement, un `dol_include_once` oublié). ### Piège : ne jamais utiliser $parentClassName sur un objet de premier niveau `$parentClassName` sert **uniquement** à un mapper de sous-objet ou de ligne, pour remonter à son parent. Un mapper d'en-tête ne doit pas le déclarer. ``` // FAUX : Product n'a pas de parent, et la propriete est mauvaise class dmProduct extends dmBase { use dmTrait; protected $parentClassName = 'Product'; } // CORRECT class dmProduct extends dmBase { use dmTrait; protected $type = 'object'; protected $dolibarrClassName = 'Product'; } // CORRECT : mapper de ligne, avec un vrai parent class dmFichinterLigne extends dmBase { use dmTrait; protected $type = 'object'; protected $dolibarrClassName = 'FichinterLigne'; protected $parentClassName = 'Fichinter'; } ``` Retenez la distinction en une phrase : `$dolibarrClassName` répond à "qui suis-je ?", `$parentClassName` à "qui est mon parent ?". Déclarer les deux identiques lève une `LogicException` au boot. ## Lecture : exportMappedData() `exportMappedData()` convertit un objet Dolibarr en objet JSON aux noms de champs API. ``` $object = new \MyObject($db); $object->fetch($id); $object->fetch_optionals(); // extrafields $object->fetch_lines(); // lignes, si l'objet en a $mapper = new dmMyObject(); $payload = $mapper->exportMappedData($object); ``` Ce que la méthode fait au passage : - elle résout les clés étrangères déclarées dans le `$fields` Dolibarr, sur deux niveaux de profondeur au maximum - elle inclut les extrafields déclarés en `options_xxx` - elle inclut les catégories associées quand l'objet en a (produit, tiers, contact, adhérent) - elle expose `nb_linked_files`, et la liste complète des fichiers si `$mapper->withFiles = true` ### Décrire le schéma au front `objectDesc()` renvoie la description structurelle de l'objet : champs, types API, libellés traduits, position d'affichage. C'est ce qui permet au front de générer formulaires et listes sans coder les champs en dur. Le résultat est calculé une fois au boot et mis en cache. ``` $mapper = new dmMyObject(); $schema = $mapper->objectDesc(); ``` ## Écriture : writableFields et importMappedData() `importMappedData()` est l'inverse de `exportMappedData()` : il prend un payload API et renvoie un `stdClass` aux noms Dolibarr, prêt à être appliqué sur l'objet. ``` $mapper = new dmMyObject(); try { $sanitized = $mapper->importMappedData($payload); } catch (\SmartAuth\DolibarrMapping\MapperValidationException $e) { return [['errors' => $e->getErrors()], 400]; } $object = new \MyObject($db); $object->fetch($id); foreach (get_object_vars($sanitized) as $field => $value) { $object->$field = $value; } $object->update($user); ``` ### Le contrat - tout champ absent de `$writableFields` est **rejeté**, pas ignoré - tous les rejets sont collectés et reportés en une seule exception, pas au premier venu - les noms API sont automatiquement réinversés en noms Dolibarr - chaque valeur est castée selon le type déclaré dans le `$fields` Dolibarr : `integer` vers `int`, `price` ou `double` vers `float`, `bool*` vers 0 ou 1, `date` vers timestamp - la clé `lines` lève toujours une exception : les lignes ne passent pas par `importMappedData()` Un mapper qui ne déclare pas `$writableFields` est **entièrement en lecture seule**. C'est le comportement par défaut, et il est volontaire. ### Piège : writableFields contient des noms Dolibarr > [!WARNING] > Chaque entrée de `$writableFields` doit être une **clé** de `$listOfPublishedFields` (le nom Dolibarr), jamais une valeur (le nom API). C'est un bug silencieux : le champ est rejeté, aucune erreur n'est visible côté client, et l'objet n'est jamais mis à jour. ``` protected $listOfPublishedFields = [ 'nom' => 'name', 'email' => 'email', ]; // CORRECT protected $writableFields = ['nom', 'email']; // FAUX : 'name' est le nom API, pas le nom Dolibarr protected $writableFields = ['name']; ``` Ce piège a été rencontré trois fois en production avant d'être verrouillé. Le boot lève désormais une `LogicException` listant les entrées fautives. ### Ce que importMappedData() ne fait pas - pas de validation métier : le mapper assainit et caste, Dolibarr valide à la persistance - pas d'écriture des lignes : passer par `addline()` et `updateline()` - cast au mieux : une chaîne `"abc"` dans un champ entier devient `0`, sans avertissement ## Transformer une valeur : fieldFilterValueXxx() Pour transformer un champ avant export, déclarez une méthode publique `fieldFilterValue` suivie du nom Dolibarr du champ en CamelCase. Le nom de la méthode **est** le contrat : pas d'annotation, pas d'enregistrement. ``` /** * Renvoie une URL signee au lieu du nom de fichier brut. */ public function fieldFilterValueLogo($object, $value) { return '/upload/societe/' . $object->id . '/' . urlencode($value); } /** * Recupere les contacts associes. */ public function fieldFilterValueContacts($object, $value) { return $object->liste_contact(-1, 'external'); } ``` Cas d'usage typiques : convertir un timestamp, traduire un code en libellé, calculer une valeur dérivée d'un autre champ. ### Champs dérivés sans colonne Dolibarr Pour publier une clé qui n'est adossée à aucune colonne, déclarez-la dans `$listOfDerivedFields` et non dans `$listOfPublishedFields`. La méthode `fieldFilterValueXxx()` est alors appelée sans vérification préalable de la présence du champ source. > [!WARNING] > Ne renvoyez pas d'images en base64 dans un champ de liste. Une liste de 200 tiers avec leur logo inline sature la réponse et la base locale de la PWA. La convention est de publier une **URL de média** et, pour les listes, une vignette `logo_mini`. ## Extrafields Les extrafields se publient comme des champs normaux, en préfixant leur nom par `options_` : ``` protected $listOfPublishedFields = [ 'options_mymodule_address' => 'intervention_address', 'options_mymodule_date_inter' => 'date_intervention', ]; ``` Le rattachement se fait par `$parentTableElementToUseForExtraFields`, qui doit valoir exactement la colonne `elementtype` de `llx_extrafields` pour cet objet. ### Extrafields configurables par l'administrateur Le squelette SmartBoot montre le pattern : deux constantes listent les extrafields à exposer, en lecture seule et en lecture-écriture, et le constructeur les ajoute au mapping. ``` public function __construct() { global $db; $this->db = $db; $extRO = getDolGlobalString('MYMODULE_SMARTMAKER_EXTRAFIELDS_RO'); if (!empty($extRO)) { foreach (explode(',', $extRO) as $field) { $field = trim($field); if (!empty($field)) { $key = 'options_' . $field; $this->listOfPublishedFields[$key] = $key; } } } $extRW = getDolGlobalString('MYMODULE_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(); } ``` > [!NOTE] > Les extrafields eux-mêmes ne se créent jamais en SQL. Ils se déclarent via `$extrafields->addExtraField(...)` dans le `init()` du descripteur de module. ## Objets avec lignes ``` // Classe Dolibarr des lignes protected $parentClassNameForLines = 'MyObjectLine'; // Description des champs de lignes, pour generer le formulaire protected $parentFieldsForLines = [ 'id' => ['type' => 'integer', 'label' => 'ID', 'visible' => -1, 'position' => 10], 'date' => ['type' => 'datetime', 'label' => 'Date', 'visible' => 1, 'position' => 50], 'desc' => ['type' => 'html', 'label' => 'Description', 'visible' => 1, 'position' => 105], 'qty' => ['type' => 'integer', 'label' => 'Quantity', 'visible' => 1, 'position' => 110], ]; // Mapping des champs de lignes protected $listOfPublishedFieldsForLines = [ 'id' => 'id', 'date' => 'date', 'desc' => 'description', 'qty' => 'quantity', 'subprice' => 'unit_price', 'total_ht' => 'total', ]; // Cle API sous laquelle les lignes sont publiees protected $parentLabelForLines = "linesDetail"; ``` Les lignes s'exposent en lecture par ce mécanisme. En écriture, elles passent par les méthodes Dolibarr natives `addline()`, `updateline()` et `deleteline()`, ou par les routes `objects/{objtype}/{id}/lines` de la façade pour les objets du coeur. > [!NOTE] > `fetch()` ne charge pas toujours les lignes. Appelez `fetch_lines()` avant l'export, sinon l'objet sort sans ses lignes. ## Ajuster la description d'un champ `$parentFieldsOverride` patche la définition d'un champ remontée depuis Dolibarr, sans toucher à la classe en amont. ``` protected $parentFieldsOverride = [ 'duree' => ['type' => 'duration', 'required' => 'required'], 'contacts' => ['type' => 'array'], 'fk_user' => ['type' => 'select'], ]; ``` Typiquement : rendre une durée stockée en secondes comme un champ `duration` côté front, ou imposer un champ que Dolibarr considère optionnel. ## Convention de nommage des champs API Le mapper est l'endroit où l'on quitte le vocabulaire Dolibarr. Respectez la convention commune, sinon deux modules publieront le même objet sous deux formes. | Dolibarr | API | | --- | --- | | `rowid` | `id` | | `ref_client` | `customer_ref` | | `nom` | `name` | | `town` | `city` | | `fk_pays` | `country` | | `fk_departement` | `state` | | `phone_mobile` | `mobile` | | `url` | `website` | | `datec` ou `date_creation` | `created_at` | | `tms` | `updated_at` | | `note_public` | `public_note` | | `note_private` | `private_note` | | `statut` ou `status` | `status` | Un mapper qui publie un champ de statut expose aussi `status_label`, le libellé localisé, quand le client le demande explicitement. > [!WARNING] > Côté PWA, ne stockez en base locale **que** le nom publié par l'API. Conserver l'alias Dolibarr "au cas où" fabrique une base où la même donnée vit sous deux clés selon son origine. Cas vécu : une liste de tiers affichait "Sans nom" sur toutes ses lignes parce qu'un écran lisait `nom` là où le mapper publie `name`, pendant qu'un autre écran affichait les mêmes tiers correctement. ## Utilisation dans un Controller ``` public function show($payload = null) { global $db; $id = (int) $payload['id']; $object = new \MyObject($db); if ($object->fetch($id) <= 0) { dol_syslog(__METHOD__ . ' fetch failed for id=' . $id, LOG_ERR); return [['error' => 'not found'], 404]; } $object->fetch_optionals(); $object->fetch_lines(); $mapper = new dmMyObject(); return [$mapper->exportMappedData($object), 200]; } public function update($payload = null) { global $db, $user; $mapper = new dmMyObject(); try { $sanitized = $mapper->importMappedData($payload); } catch (\SmartAuth\DolibarrMapping\MapperValidationException $e) { dol_syslog(__METHOD__ . ' rejected fields: ' . $e->getMessage(), LOG_WARNING); return [['errors' => $e->getErrors()], 400]; } $object = new \MyObject($db); if ($object->fetch((int) $payload['id']) <= 0) { dol_syslog(__METHOD__ . ' fetch failed', LOG_ERR); return [['error' => 'not found'], 404]; } foreach (get_object_vars($sanitized) as $field => $value) { $object->$field = $value; } if ($object->update($user) <= 0) { dol_syslog(__METHOD__ . ' update failed: ' . $object->error, LOG_ERR); return [['error' => $object->error], 500]; } return [$mapper->exportMappedData($object), 200]; } ``` ## Dictionnaires Un mapper de dictionnaire décrit une ligne d'une table `llx_c_*`. Conventions : - `protected $type = 'dict';` (le terme `dictionary` est obsolète) - `$dolibarrClassName` déclaré **si** Dolibarr fournit une classe dédiée (`Ccountry`, `Cstate`, `PaymentTerm`, `CUnits`...), absent sinon - `$writableFields` reste vide : les dictionnaires se gèrent depuis l'administration Dolibarr - exposer au minimum `code` et `label` ## Le mapper et la synchronisation offline Un point que l'on découvre souvent trop tard : la synchronisation offline n'a **pas** de couche de mapping à elle. Elle passe par vos mappers `Dm`, les mêmes que la façade REST synchrone. ### Le registre des objets syncables `ObjectRegistry.php`, côté SmartAuth, est la source unique de vérité. Il déclare pour chacun des 26 types intégrés la classe Dolibarr, la table, les droits requis et le mapper : ```php 'thirdparty' => [ 'class' => 'Societe', 'table' => 'societe', 'mapper' => '\\SmartAuth\\DolibarrMapping\\dmThirdparty', 'rights' => [ /* ... */ ], ], ``` Ce même registre sert la façade REST et le moteur de synchronisation. Il n'y a donc aucun risque qu'un objet se comporte différemment selon la porte d'entrée. ### Ce que le mapper décide pour la synchronisation | Étape | Ce qui s'applique | | --- | --- | | Pull | La ligne SQL est rechargée puis passée par `exportMappedData()`. Le client reçoit les noms de champs API publiés, pas les colonnes Dolibarr | | Push | L'allowlist d'écriture **est** `$writableFields`. Il n'existe pas de seconde liste propre à la synchronisation | | Cloisonnement | Pour une table sans colonne `entity`, l'isolation multi-société vient de `isolationWhereSql()` du mapper | Trois conséquences pratiques : - **Un mapper sans `$writableFields` est en lecture seule, donc non synchronisable en écriture.** Le pull fonctionnera, le push rejettera tout. - Un champ absent de `$writableFields` est rejeté **silencieusement** au push, exactement comme sur la façade REST. Le client ne voit pas d'erreur et l'objet n'est pas mis à jour. - Si aucun mapper ne résout pour un type sans colonne `entity`, le moteur ne tire rien et refuse toute écriture. Ce comportement fail-closed est délibéré : mieux vaut ne rien synchroniser que fuiter les données d'une autre entité. ### Déclarer un objet custom syncable Un module ajoute ses propres types via le hook `smartmaker_registerSyncableObjects`. Le mapper se déclare exactement comme pour un type intégré : ```php public function smartmaker_registerSyncableObjects($parameters, &$object, &$action, $hookmanager) { $object['mymodule_product'] = [ 'class' => 'Product', 'file' => DOL_DOCUMENT_ROOT.'/product/class/product.class.php', 'table' => 'product', 'element' => 'product', 'module' => 'product', 'mapper' => '\\SmartAuth\\DolibarrMapping\\dmProduct', 'pull_where' => 'tosell = 1', 'rights' => [ 'read' => ['produit', 'lire'], 'create' => ['produit', 'creer'], 'update' => ['produit', 'creer'], 'delete' => ['produit', 'supprimer'], ], ]; return 0; } ``` L'intérêt d'un type dédié plutôt que du type natif `product` est le `pull_where` : il restreint le jeu de données embarqué sans affecter les autres consommateurs de `product`. Le serveur publie aussi les exclusions dans la liste des suppressions, ce qui évite au client d'avoir à nettoyer ses orphelins. Un client découvre les types disponibles, et ses droits effectifs sur chacun, via `GET /sync/objects`. Coder un périmètre de synchronisation en dur côté PWA n'est donc pas nécessaire. > Le contrôleur de documents de SmartAuth, lui, ne connaît que les types > **natifs** (`product`, `category`, `thirdparty`, `project`, `intervention`). > Un type custom vaut pour les entités, pas pour le téléchargement des fichiers > joints. Voir > [Synchronisation - catalogue de référence](/front/synchronisation-reference). ## Pièges à connaître | Symptôme | Cause | | --- | --- | | `LogicException` au premier `new dmXxx()` | `$dolibarrClassName` manquant, ou classe non chargée par `dol_include_once` | | `LogicException` mentionnant le parent | `$parentClassName` déclaré sur un mapper d'en-tête, ou égal à `$dolibarrClassName` | | un champ modifiable est ignoré sans erreur | `$writableFields` contient le nom API au lieu du nom Dolibarr | | l'objet sort sans ses lignes | `fetch_lines()` non appelé avant l'export | | les extrafields sont absents | `fetch_optionals()` non appelé, ou `$parentTableElementToUseForExtraFields` incorrect | | réponse énorme et PWA lente | images en base64 inline dans un champ de liste | | liste front vide ou "Sans nom" | la PWA lit le nom Dolibarr au lieu du nom API publié | | le push offline n'écrit rien, sans erreur | `$writableFields` vide, ou contenant le nom API au lieu du nom Dolibarr | | un type syncable ne remonte aucune donnée | aucun mapper ne résout pour un type sans colonne `entity` : le moteur est fail-closed | ## Voir aussi - [Back (PHP)](/back) - Routes et Controllers - [Requêtes API](/front/requetes-api) - côté React - [Synchronisation offline](/front/synchronisation) - le mapper vu du client - [SmartAuth](/smartauth) - socle et mappers du coeur Dolibarr - [Formation - Mappers](/training/module8-backend-api/mappers) ## [SmartMaker - Front (React)](https://smartmaker.org/front/) (librairie) La partie front de SmartMaker est construite avec React 19, Vite, et TailwindCSS 4. - [Animations de pages](https://smartmaker.org/front/animations) # Animations de pages SmartCommon utilise **Framer Motion** pour animer les transitions entre pages. Les animations sont configurables par route. ## Configuration de base Dans la configuration du Provider : ``` const config = { pages: { "*": "fade" // Animation par défaut pour toutes les pages } }; ``` ## Animations disponibles | Animation | Description | Effet | | --- | --- | --- | | `fade` | Fondu enchaîné | Opacité 0 -> 1 | | `slideLeft` | Glissement gauche | Entrée par la droite | | `slideRight` | Glissement droite | Entrée par la gauche | | `zoom` | Effet de zoom | Scale 0.9 -> 1 | ## Configuration par route Vous pouvez définir des animations différentes selon la route d'origine et de destination : ``` const config = { pages: { // Depuis la page d'accueil "/": { "/dashboard": "slideLeft", // Vers dashboard: glisse à gauche "/settings": "slideLeft", // Vers settings: glisse à gauche "*": "fade" // Vers autres: fondu }, // Depuis le dashboard "/dashboard": { "/": "slideRight", // Retour accueil: glisse à droite "/details": "slideLeft", // Vers détails: glisse à gauche "*": "fade" }, // Depuis les paramètres "/settings": { "/": "slideRight", "*": "fade" }, // Pour toutes les autres pages "*": "fade" } }; ``` ## Logique de navigation L'animation est choisie selon ce schéma : 1. Cherche une config pour la page actuelle 2. Si trouvée, cherche une animation pour la page précédente 3. Si pas trouvée, utilise `*` de la page actuelle 4. Si pas de config pour la page actuelle, utilise `*` global ### Exemple Navigation de `/dashboard` vers `/` : ``` pages: { "/": { "/dashboard": "slideRight", // <- Cette animation sera utilisée "*": "fade" }, "/dashboard": { "/": "slideRight", "*": "fade" } } ``` La page `/` s'affiche avec `slideRight` car on vient de `/dashboard`. ## Désactiver les animations sur desktop Par défaut, les animations de glissement sont remplacées par `fade` sur desktop pour une meilleure UX : ``` // Dans le composant Page (comportement interne) if (device?.type === "desktop") { return "fade"; } ``` ## Utiliser le composant Page Le composant `Page` gère automatiquement les animations : ``` import { Page, Block } from '@cap-rel/smartcommon'; import { useLocation } from 'react-router-dom'; const Dashboard = () => { const location = useLocation(); return ( Contenu du dashboard ); }; ``` > [!IMPORTANT] > Le prop `location` est requis pour que les animations fonctionnent correctement. ## Personnaliser les animations Vous pouvez créer des animations personnalisées en modifiant les variants Framer Motion : ``` // Animations par défaut dans SmartCommon const animations = { slideRight: { initial: { x: "50%", opacity: 0 }, animate: { x: 0, opacity: 1, transition: { duration: 0.15, ease: "easeInOut" } }, exit: { x: "50%", opacity: 0, transition: { duration: 0.15, ease: "easeInOut" } } }, slideLeft: { initial: { x: "-50%", opacity: 0 }, animate: { x: 0, opacity: 1, transition: { duration: 0.15, ease: "easeInOut" } }, exit: { x: "-50%", opacity: 0, transition: { duration: 0.15, ease: "easeInOut" } } }, fade: { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: 0.15, ease: "easeOut" } }, exit: { opacity: 0, transition: { duration: 0.15, ease: "easeOut" } } }, zoom: { initial: { scale: 0.9, opacity: 0 }, animate: { scale: 1, opacity: 1 }, exit: { scale: 0.9, opacity: 0 }, transition: { duration: 0.2, ease: "easeOut" } } }; ``` ## Hook useAnimation Pour des animations personnalisées dans vos composants : ``` import { useAnimation } from '@cap-rel/smartcommon'; const MyComponent = () => { const { start, animations, setAnimations } = useAnimation({ fadeIn: { value: false, state: null }, slideIn: { value: false, state: null } }); useEffect(() => { if (start) { // Déclencher l'animation après le premier rendu setAnimations(prev => ({ ...prev, fadeIn: { ...prev.fadeIn, state: 'visible' } })); } }, [start]); return ( Contenu animé ); }; ``` ## Animations avec Framer Motion Pour des animations plus complexes, utilisez directement Framer Motion : ``` import { motion, AnimatePresence } from 'framer-motion'; const MyList = ({ items }) => ( {items.map(item => ( {item.name} ))} ); ``` ## Exemples de configurations ### Application mobile classique Navigation hiérarchique (liste -> détail -> sous-détail) : ``` pages: { "/": { "*": "slideLeft" }, "/items": { "/": "slideRight", "/items/*": "slideLeft", "*": "fade" }, "/items/*": { "/items": "slideRight", "*": "fade" }, "*": "fade" } ``` ### Application avec onglets Navigation entre onglets sans animation de glissement : ``` pages: { "/home": { "*": "fade" }, "/search": { "*": "fade" }, "/profile": { "*": "fade" }, "/settings": { "*": "slideLeft" // Seul settings a une animation différente }, "*": "fade" } ``` ### Désactiver toutes les animations ``` pages: { "*": "fade" // Utiliser uniquement fade (le plus discret) } // Ou modifier la durée à 0 // (nécessite de modifier les animations dans le code) ``` ## Voir aussi - [Configuration](/front/configuration) - Configuration du Provider - [SmartCommon](/front/smartcommon) - Composant Page - [Framer Motion](https://www.framer.com/motion/) - Documentation officielle - [Architecture](https://smartmaker.org/front/architecture) # Architecture Cette page décrit l'organisation des fichiers d'une application SmartMaker. Contrairement à un projet React ordinaire, **l'emplacement des fichiers y a des conséquences techniques** : il conditionne la capacité à extraire une fonctionnalité vers une autre application, et un mauvais découpage de la couche base de données provoque une erreur de chargement difficile à diagnostiquer. ## Vue d'ensemble ``` mobile/ ├── public/ │ ├── images/ # icones PWA et images statiques │ └── locales/ │ └── / │ └── .json # un fichier par langue ET par feature ├── src/ │ ├── api/ │ │ ├── index.js │ │ └── mapping/ │ │ └── .js # mapping backend <-> front, un par feature │ ├── db/ │ │ ├── index.js # instanciation de Db │ │ └── stores/ │ │ └── / │ │ ├── indexes.js # schema Dexie, AUCUN import │ │ ├── useDb.jsx # hook CRUD metier │ │ └── index.js # barrel │ ├── components/ │ │ ├── app/ # Router, Provider, Head, Toaster │ │ ├── layouts/ # mises en page partagees │ │ ├── global/ # composants transverses de l'application │ │ └── pages/ │ │ ├── public// # pages non authentifiees │ │ ├── private// # pages authentifiees │ │ └── errors// │ ├── global-state/ │ │ └── slices/ # etat UI uniquement │ ├── hooks/ # hooks transverses de l'application │ ├── i18n/ │ │ └── index.js │ ├── utils/ │ │ ├── constants/ │ │ ├── functions/ │ │ └── maps/ │ │ ├── form.jsx # type de champ -> composant de saisie │ │ └── list.jsx # type de colonne -> composant de cellule │ ├── assets/ # fichiers pris en charge par la compilation │ ├── appConfig.js │ ├── main.jsx # point d'entree │ └── sw.js # Service Worker (mode injectManifest) ├── .env # variables d'environnement, non versionne ├── .env.example ├── index.html ├── eslint.config.js ├── package.json └── vite.config.js ``` Une application générée par SmartBoot arrive avec cette arborescence déjà en place, avec un exemple par emplacement : un store `users` complet (`indexes.js` et `useDbUsers`), un `api/mapping/` commenté, les maps `form.jsx` et `list.jsx`, et cinq namespaces de traduction. Vous la remplissez, vous n'avez pas à la créer. ## Le principe directeur : une fonctionnalité, un dossier Une fonctionnalité métier (interventions, tâches, devis, inventaire) doit pouvoir être **copiée telle quelle** vers une autre application SmartMaker. Concrètement, elle se répartit sur trois emplacements et trois seulement : | Emplacement | Contenu | | --- | --- | | `db/stores//` | schéma Dexie et hook CRUD | | `api/mapping/.js` | conversion entre le format backend et le format front | | `locales//.json` | traductions du namespace de la fonctionnalité | Le test est simple : copier ces trois éléments vers un autre projet, ajouter la route, et cela doit fonctionner sans autre modification. Si un import "fuit" vers un fichier propre à l'application d'origine, c'est un défaut à corriger. ## La couche données ### Tout le CRUD dans un hook Chaque fonctionnalité expose un hook `useDb` qui porte l'intégralité des accès à la base locale. ``` // src/db/stores/interventions/useDbInterventions.jsx import { db } from "src/db"; export const useDbInterventions = () => { const list = async (filters) => { /* ... */ }; const get = async (id) => { /* ... */ }; const create = async (payload) => { /* ... */ }; const update = async (id, payload) => { /* ... */ }; const remove = async (id) => { /* ... */ }; return { list, get, create, update, remove }; }; ``` Le hook ne connaît que la classe `Db`, `useGlobalStates` et `useApi`. Rien d'autre du projet hôte. > [!WARNING] > Ne mettez jamais d'appel Dexie directement dans un composant de page. C'est l'anti-pattern le plus fréquent, et c'est celui qui rend une fonctionnalité impossible à extraire ensuite. Le nom du hook suit la forme `useDb` : `useDbInterventions`, `useDbTasks`, `useDbProducts`. Les formes `useServices` que l'on trouve dans d'anciens projets sont du legacy à renommer. ### Le fichier indexes.js, et pourquoi il est obligatoire > [!IMPORTANT] > Le schéma Dexie de chaque fonctionnalité doit vivre dans un fichier `indexes.js` **sans aucun import**, et `db/index.js` doit l'importer directement, sans passer par le barrel. Sans cette séparation, le graphe d'imports forme un cycle : ``` db/index.js -> import { tasksIndexes } from "./stores" (barrel) -> stores/tasks/index.js export const tasksIndexes = "..." export * from "./useDbTasks" <- tire le hook -> useDbTasks.jsx import { db } from "src/db" <- retour au depart ``` À la compilation, ce cycle est partiellement remonté par Vite et produit une erreur `Cannot access 'tasksIndexes' before initialization` au chargement du bundle. Le symptôme est cruel : `npm run build` réussit, puisqu'il ne charge pas le bundle, et l'application plante au démarrage avec une erreur qui semble venir d'ailleurs (authentification cassée, boucle de connexion). La parade, qui rompt le cycle en faisant de `indexes.js` une feuille du graphe : ``` // src/db/stores/tasks/indexes.js -- ZERO import export const tasksIndexes = "++id, ref, status, updatedAt"; ``` ``` // src/db/index.js -- importe les FEUILLES, pas le barrel import { tasksIndexes } from "./stores/tasks/indexes"; import { projectsIndexes } from "./stores/projects/indexes"; export const db = new Db({ name: "myapp", version: 1, stores: { tasks: tasksIndexes, projects: projectsIndexes }, }).db; export * from "./stores"; // acceptable ici : db est deja construit ``` Le barrel `stores//index.js` reste utilisable normalement par les pages et les autres hooks. Seul `db/index.js` doit court-circuiter. ### Où va quelle donnée | Nature | Emplacement | | --- | --- | | donnée métier persistée (interventions, devis, photos) | `Db` (Dexie), via `useDb` | | état d'interface persistant (thème, langue, filtres, dernière page) | `useGlobalStates` (Redux et redux-persist) | | état d'interface éphémère (formulaire en cours, modale ouverte) | `useState` ou `useStates` local | > [!WARNING] > Ne placez jamais une liste d'objets métier dans un slice Redux. Elle doit vivre dans Dexie, et les composants la lisent via le hook de la fonctionnalité. ## Organiser les composants C'est la question qui revient le plus souvent. La règle de classement tient en une phrase : **on range un composant selon sa portée, pas selon son sujet**. | Dossier | Ce qu'on y met | Test d'appartenance | | --- | --- | --- | | `components/app/` | la plomberie de l'application : `Router`, composition des providers, `Head`, `Toaster` | il n'y en a qu'un seul exemplaire dans l'application | | `components/layouts/` | mises en page partagées par plusieurs pages | il enveloppe des pages, il n'en est pas une | | `components/global/` | composants transverses réutilisés par plusieurs pages | il est importé par au moins deux pages | | `components/pages///` | une page, montée sur une route | il correspond à une entrée du `Router` | Les pages se rangent ensuite par visibilité : `public/` pour ce qui est accessible sans authentification, `private/` pour le reste, `errors/` pour les pages d'erreur. Ce découpage n'est pas décoratif : il reflète ce que protège le `RouteGuard` et rend immédiatement visible qu'une page est exposée. ### Un composant, un dossier Chaque composant a son propre dossier avec un `index.jsx`, même s'il est seul. Cela permet d'ajouter plus tard, au même endroit, ses styles, ses sous-composants et ses tests, sans rien déplacer ni corriger un seul import. ``` components/pages/private/InterventionPage/ ├── index.jsx # la page ├── Header/ │ └── index.jsx # sous-composant propre a cette page └── LinesTable/ └── index.jsx ``` > [!TIP] > Un sous-composant utilisé par une seule page reste **dans le dossier de cette page**. Il ne monte dans `components/global/` que le jour où une deuxième page l'utilise. Remonter par anticipation encombre l'espace commun de composants qui ne sont partagés par personne. ### Une page ne doit rien savoir de l'application Une page métier doit pouvoir être montée dans le routeur d'une autre application sans modification. À faire : - recevoir ses dépendances par les hooks SmartCommon (`useNavigation`, `useApi`, `useGlobalStates`) ou par ses props - passer les composants de formulaire par la map `utils/maps/form.jsx`, pour que l'application hôte puisse les surcharger par type de champ - exposer les chemins de navigation dans une fonction, plutôt que d'écrire `navigate('/interventions/123')` en dur À ne pas faire : - importer `appConfig` depuis une page métier - lire un slice Redux propre à l'application hôte ## Traductions : un namespace par fonctionnalité ``` public/locales/ ├── fr/ │ ├── common.json │ ├── interventions.json │ └── products.json └── en/ ├── common.json ├── interventions.json └── products.json ``` ``` const { t } = useTranslation('interventions'); ``` Tous les namespaces doivent être déclarés dans la configuration i18next pour être préchargés : c'est indispensable au fonctionnement hors ligne, le precache du Service Worker les embarquant via son motif `**/*.json`. > [!WARNING] > Sans `Suspense`, ajoutez `react: { useSuspense: false }` à la configuration i18next. Sinon une page atteinte par navigation directe avant la fin du chargement de son namespace reste figée sur les clés brutes. Le `keyPrefix` ne doit jamais être écrit en dur dans un hook réutilisable : il se reçoit en paramètre, avec une valeur par défaut. ``` // A eviter const useIntStatuses = () => useTranslation(undefined, { keyPrefix: 'intStatuses' }); // Preferable const useIntStatuses = (keyPrefix = 'intStatuses') => useTranslation(undefined, { keyPrefix }); ``` ## Configuration : aucune valeur en dur Tout ce qui change d'un projet ou d'un environnement à l'autre passe par une variable d'environnement Vite ou par une prop de provider. | Variable | Rôle | | --- | --- | | `VITE_API_URL` | URL du backend ; forcée en relatif au build de production | | `VITE_APP_NAME` | nom de l'application | | `VITE_APP_VERSION` | version et numéro de build, injectés par le Makefile | | `VITE_LOCALES` | liste des langues, dérivée des sous-dossiers de `public/locales/` | `appConfig.js` peut centraliser ces valeurs, mais ne doit être importé que depuis la coquille de l'application, jamais depuis une fonctionnalité métier. ## Les fichiers à la racine de mobile/ | Fichier | Rôle | | --- | --- | | `.env` | variables d'environnement, ignoré par Git | | `.env.example` | modèle du `.env`, versionné | | `index.html` | page dans laquelle React monte l'application ; porte le lien vers le manifest | | `vite.config.js` | configuration de Vite, y compris celle de la PWA | | `eslint.config.js` | configuration du lint | | `package.json` | dépendances et scripts | ## Checklist de relecture À passer avant d'ouvrir une demande de fusion, ou en reprenant un projet existant : - [ ] tout le CRUD métier est dans `useDb`, aucun appel Dexie dans une page - [ ] chaque `db/stores//` a son `indexes.js` sans import, et `db/index.js` importe les feuilles - [ ] le mapping backend/front est isolé dans `api/mapping/.js` - [ ] aucune donnée métier dans un slice Redux - [ ] aucune page métier n'importe `appConfig` ni un slice propre à l'application - [ ] un namespace i18n par fonctionnalité, tous déclarés dans la configuration - [ ] aucun `fetch` ni `axios` : tout passe par `useApi` - [ ] aucune URL ni aucun port en dur - [ ] un composant, un dossier ; les sous-composants restent chez leur page tant qu'ils ne servent qu'à elle ## Voir aussi - [Composants et pages](/front/composants-et-pages) - [Stockage de données](/front/stockage-de-donnees) - [Synchronisation offline](/front/synchronisation) - [Traductions](/front/traductions) - [PWA](/front/pwa) - Service Worker et démarrage hors ligne - [Formation - Bonnes pratiques](/training/module11-bonnes-pratiques) - [Astuces](https://smartmaker.org/front/astuces) # Astuces Cette page regroupe des astuces et bonnes pratiques pour le développement avec SmartMaker. ## Utiliser la configuration SmartCommon utilise `LibConfigProvider` pour centraliser la configuration de l'application. ``` // src/appConfig.js export const appConfig = { debug: true, // Active les logs de debug api: { prefixUrl: import.meta.env.VITE_API_URL, timeout: 30000, debug: true } }; ``` ``` // src/App.jsx import { Provider } from '@cap-rel/smartcommon'; import { appConfig } from './appConfig'; export const App = () => { return ( {/* Votre application */} ); }; ``` Pour accéder à la configuration dans un composant : ``` import { useLibConfig } from '@cap-rel/smartcommon'; const MyComponent = () => { const config = useLibConfig(); console.log(config.debug); // true }; ``` ## Composant Head Pour modifier le titre et les meta de la page, utilisez `react-helmet` : ``` import { Helmet } from 'react-helmet'; export const MyPage = () => { return ( <> Ma Page - Mon App {/* Contenu de la page */} ); }; ``` ## Composant Toaster SmartCommon intègre `react-hot-toast` pour les notifications. ``` import toast from 'react-hot-toast'; // Notification de succès toast.success('Enregistré avec succès !'); // Notification d'erreur toast.error('Une erreur est survenue'); // Notification personnalisée toast('Message neutre', { icon: '👋', duration: 4000, }); // Notification avec promesse toast.promise( saveData(), { loading: 'Enregistrement...', success: 'Données enregistrées', error: 'Échec de l\'enregistrement', } ); ``` Le `Toaster` est automatiquement inclus dans le `Provider` de SmartCommon. ## Utiliser les variables d'environnement Dans un environnement **Vite**, les variables d'environnement doivent être préfixées de `VITE_`. ``` # .env VITE_API_URL=https://api.example.com VITE_APP_VERSION=1.0.0 VITE_APP_NAME=Mon Application ``` On peut ainsi les importer avec `import.meta.env` : ``` // src/utils/constants/vite.js export const API_URL = import.meta.env.VITE_API_URL; export const APP_VERSION = import.meta.env.VITE_APP_VERSION; export const APP_NAME = import.meta.env.VITE_APP_NAME; ``` > [!IMPORTANT] > Ne jamais commiter le fichier `.env`. Utilisez `.env.example` comme modèle. ## Fichiers de traductions publiques Les fichiers de traduction peuvent être chargés dynamiquement depuis le dossier `public` : ``` public/ locales/ fr.json en.json es.json ``` Configuration i18next : ``` // src/i18n/index.js import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import HttpBackend from 'i18next-http-backend'; i18n .use(HttpBackend) .use(initReactI18next) .init({ fallbackLng: 'fr', backend: { loadPath: '/locales/{{lng}}.json', }, }); export { i18n }; ``` ## Faire des fichiers de traductions "namespace" Pour organiser les traductions par fonctionnalité : ``` public/ locales/ fr/ common.json login.json dashboard.json en/ common.json login.json dashboard.json ``` Configuration : ``` i18n.init({ ns: ['common', 'login', 'dashboard'], defaultNS: 'common', backend: { loadPath: '/locales/{{lng}}/{{ns}}.json', }, }); ``` Utilisation : ``` const { t } = useTranslation('login'); // ou const { t } = useTranslation(['login', 'common']); ``` ## Utiliser des préfixes avec useTranslation Pour éviter de répéter le chemin des clés : ``` // Sans préfixe const { t } = useTranslation(); t('loginPage.form.emailInput.label'); t('loginPage.form.emailInput.placeholder'); t('loginPage.form.passwordInput.label'); // Avec préfixe const { t } = useTranslation('translation', { keyPrefix: 'loginPage.form' }); t('emailInput.label'); t('emailInput.placeholder'); t('passwordInput.label'); ``` ## Fichiers CSS publiques Les fichiers CSS dans `public/` ne sont pas traités par Vite et sont servis tels quels : ``` public/ css/ custom-theme.css ``` Pour les charger dynamiquement : ``` // Charger un thème CSS au runtime const loadTheme = (themeName) => { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = `/css/${themeName}.css`; document.head.appendChild(link); }; ``` ## Retirer Tailwind CSS Si vous ne souhaitez pas utiliser Tailwind CSS : 1. Supprimer les dépendances : ``` npm uninstall tailwindcss @tailwindcss/vite ``` 2. Modifier `vite.config.js` : ``` import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; // Retirer: import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [ react(), // Retirer: tailwindcss(), ] }); ``` 3. Supprimer les imports Tailwind dans vos fichiers CSS : ``` /* Retirer: @import "tailwindcss"; */ ``` ## Importer les fichiers CSS entre eux ### Avec Tailwind CSS 4 ``` /* src/assets/styles/style.css */ @import "tailwindcss"; @layer theme, base, components; @import "./theme.css" layer(theme); @import "./base.css" layer(base); @import "./components.css" layer(components); ``` ### En CSS classique ``` /* src/assets/styles/style.css */ @import "./variables.css"; @import "./base.css"; @import "./components.css"; ``` ## Faire un listener de changement de thème Pour détecter et réagir aux changements de thème (clair/sombre) : ``` import { useEffect, useState } from 'react'; export const useThemeDetector = () => { const [isDark, setIsDark] = useState( window.matchMedia('(prefers-color-scheme: dark)').matches ); useEffect(() => { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handleChange = (e) => { setIsDark(e.matches); }; mediaQuery.addEventListener('change', handleChange); return () => mediaQuery.removeEventListener('change', handleChange); }, []); return isDark; }; ``` Utilisation : ``` const MyComponent = () => { const isDarkMode = useThemeDetector(); return (
Mode actuel : {isDarkMode ? 'Sombre' : 'Clair'}
); }; ``` Pour changer le thème manuellement avec `useGlobalStates` : ``` import { useGlobalStates } from '@cap-rel/smartcommon'; const ThemeSwitcher = () => { const gst = useGlobalStates(); const theme = gst.get('settings.theme') || 'light'; const toggleTheme = () => { gst.local.set('settings.theme', theme === 'light' ? 'dark' : 'light'); }; useEffect(() => { document.documentElement.setAttribute('data-theme', theme); }, [theme]); return ( ); }; ``` ## Voir aussi - [SmartCommon](/front/smartcommon) - Liste des composants - [Hooks](/front/hooks) - Documentation des hooks - [Thèmes](/front/themes) - Personnalisation des thèmes - [Calendar](https://smartmaker.org/front/calendar) # Calendar SmartCommon fournit deux composants de calendrier. Ils partagent la même base mais ne répondent pas au même besoin. | Composant | Usage | | --- | --- | | `Calendar` | saisie d'une date dans un formulaire | | `PlainCalendar` | vue calendrier autonome, avec possibilité d'afficher des événements et de sélectionner un intervalle | ## Calendar Sélecteur de date en vue mensuelle, avec navigation mois par mois et sélecteur d'année. Il s'intègre automatiquement à un `
` via `useField`. ``` import { Calendar } from '@cap-rel/smartcommon'; console.log(month)} onYearChange={(year) => console.log(year)} /> ``` ### Props principales | Prop | Type | Description | | --- | --- | --- | | `name` | string | nom du champ dans le formulaire | | `value` | string | date sélectionnée, au format ISO `YYYY-MM-DD` | | `defaultValue` | string | valeur initiale en mode non contrôlé | | `onChange` | function | appelée à chaque sélection | | `yearsInterval` | array | bornes du sélecteur d'année, défaut `[2000, 2030]` | | `items` | array | événements à marquer dans la grille | | `onMonthChange` | function | appelée à chaque changement de mois | | `onYearChange` | function | appelée à chaque changement d'année | ### Format de la valeur > [!IMPORTANT] > La valeur est une **chaîne ISO** `"YYYY-MM-DD"`, ou `null`. Un objet `Date` natif n'est pas accepté ; convertissez-le vous-même. ``` const iso = new Date().toISOString().slice(0, 10); ``` ### Limites connues - sélection d'une seule date, pas d'intervalle (utilisez `PlainCalendar` avec `interval`) - pas de désactivation de dates par prop (week-ends, jours fériés, dates passées) - pas d'heure ni de minutes : c'est un sélecteur de date - la locale d'affichage suit celle du navigateur et n'est pas configurable - aucune validation native : validez côté parent, ou dans le `onPreSubmit` du `` ## PlainCalendar Vue calendrier autonome, hors contexte de formulaire. Elle accepte la sélection d'un intervalle et l'affichage d'éléments datés. ``` import { PlainCalendar } from '@cap-rel/smartcommon'; import { useSmartcommonLabels } from 'src/hooks/useSmartcommonLabels'; const labels = useSmartcommonLabels(); ``` | Prop | Type | Description | | --- | --- | --- | | `value` | string ou array | date, ou couple de dates si `interval` | | `onChange` | function | appelée à chaque sélection | | `interval` | bool | active la sélection d'un intervalle, défaut `false` | | `items` | array | éléments datés à afficher dans la grille | | `yearsInterval` | array | bornes du sélecteur d'année | | `labels` | object | libellés de l'interface | ### Libellés et traduction `PlainCalendar` fait partie des composants qui embarquent leurs propres libellés, en anglais par défaut. Passez-lui le bundle de la langue active plutôt que de coder les textes en dur : ``` import { locales, useGlobalStates } from "@cap-rel/smartcommon"; export const useSmartcommonLabels = () => { const gst = useGlobalStates(); const lang = gst.get("user.settings.lang") ?? gst.get("publicSettings.lang") ?? "en"; return locales[lang] ?? locales.en; }; ``` > [!WARNING] > N'écrivez jamais `locales.fr` en dur dans une page : les utilisateurs des autres langues se retrouveraient avec un calendrier en français. ### Comportement des jours voisins Cliquer sur un jour du mois précédent ou suivant, dans les cases qui complètent la première et la dernière semaine, déplace la grille sur ce mois **et** sélectionne le jour. ## Voir aussi - [SmartCommon](/front/smartcommon) - liste des composants - [Composants et pages](/front/composants-et-pages) - les formulaires - [Variants de composants](/front/variants) - personnaliser l'apparence - [Traductions](/front/traductions) - [Composants avancés (high-level)](https://smartmaker.org/front/composants-avances) # Composants avancés (high-level) SmartCommon expose plusieurs composants "métier" qui encapsulent un flux complet (login, identification d'appareil, scan de QR/codes-barres, navigation dans un catalogue produit, annotation de photos, modal "À propos"). Plutôt que de réinventer ces écrans dans chaque application, on les injecte directement. Tous suivent la même convention : - Aucun `useTranslation()` interne -> les libellés passent par une prop `labels` - Slots de styling (`*Props`) spreadés sur les éléments cibles avec `twMerge` - `onSuccess` / `onError` / `onClose` au lieu d'effets de bord internes - Composent les primitives ``, ` ); }; ``` ### Composant Input personnalisé ``` // src/components/form/Input/index.jsx export const Input = (props) => { const { label, id, error, ...inputProps } = props; return (
{label && ( )} {error && ( {error} )}
); }; ``` ## Structure d'une page ### Page simple ``` // src/components/pages/private/HomePage/index.jsx import { useGlobalStates, useNavigation } from '@cap-rel/smartcommon'; export const HomePage = () => { const nav = useNavigation(); const gst = useGlobalStates(); const session = gst.get('session'); return (
{/* Header */}

Accueil

{/* Content */}

Bienvenue {session?.user?.name}

{/* Navigation */}
); }; ``` ### Page avec chargement de données ``` // src/components/pages/private/ItemsPage/index.jsx import { useApi, useGlobalStates, useNavigation } from '@cap-rel/smartcommon'; import { useEffect, useState } from 'react'; import { Card } from '../../ui/Card'; export const ItemsPage = () => { const api = useApi(); const nav = useNavigation(); const gst = useGlobalStates(); const items = gst.get('items') ?? []; const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchItems = async () => { setLoading(true); setError(null); try { const data = await api.get('items'); gst.set('items', data); } catch (err) { setError(err.message); } finally { setLoading(false); } }; fetchItems(); }, []); if (loading) { return (
⟳
); } if (error) { return (
Erreur: {error}
); } return (

Mes items

{items.map((item) => ( nav.navigate(`/items/${item.id}`)} className="cursor-pointer hover:shadow-lg transition-shadow" >

{item.label}

{item.description}

))}
); }; ``` ## Utiliser les layouts ### Créer un layout ``` // src/components/layouts/MainLayout/index.jsx import { useNavigation } from '@cap-rel/smartcommon'; export const MainLayout = ({ children, title, showBack = true }) => { const nav = useNavigation(); return (
{/* Header */}
{showBack && ( )}

{title}

{/* Content */}
{children}
{/* Bottom navigation */}
); }; ``` ### Utiliser un layout ``` // src/components/pages/private/SettingsPage/index.jsx import { MainLayout } from '../../layouts/MainLayout'; import { useGlobalStates } from '@cap-rel/smartcommon'; import { useTranslation } from 'react-i18next'; export const SettingsPage = () => { const { t, i18n } = useTranslation(); const gst = useGlobalStates(); const theme = gst.get('settings.theme'); return (
{/* Langue */}

{t('settings.language')}

{/* Thème */}

{t('settings.theme')}

); }; ``` ## Patterns courants ### Formulaire ``` import { useForm, useApi, useNavigation } from '@cap-rel/smartcommon'; import { Form, Input, Button } from '@cap-rel/smartcommon'; const ContactForm = () => { const api = useApi(); const nav = useNavigation(); const form = useForm({ defaultValues: { name: '', email: '' } }); const handleSubmit = async (data) => { await api.post('contacts', { json: data }); nav.navigate('/contacts'); }; return ( ); }; ``` ### Modal/Drawer avec animation ``` import { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; const Modal = ({ isOpen, onClose, children }) => { return ( {isOpen && ( <> {/* Backdrop */} {/* Content */} {children} )} ); }; ``` ### Liste avec état vide ``` const ItemsList = ({ items }) => { if (items.length === 0) { return (

📭

Aucun élément pour le moment

); } return (
{items.map(item => ( ))}
); }; ``` ## Utiliser SmartCommon Préférez les composants SmartCommon aux composants customs quand c'est possible : ``` import { Form, Input, Select, Checkbox, Button, Card, Modal, } from '@cap-rel/smartcommon'; ``` Voir [SmartCommon](/front/smartcommon) pour la liste complète. ## Voir aussi - [SmartCommon](/front/smartcommon) - Composants prêts à l'emploi - [Hooks](/front/hooks) - Hooks disponibles - [Routage](/front/routage) - Navigation entre pages - [Animations](/front/animations) - Transitions de pages - [Configuration du Provider](https://smartmaker.org/front/configuration) # Configuration du Provider Le `Provider` de SmartCommon accepte un objet de configuration qui permet de personnaliser le comportement de l'application. ## Configuration de base ``` // src/App.jsx import { Provider } from '@cap-rel/smartcommon'; import "@cap-rel/smartcommon/dist/smartcommon-style.css"; const config = { debug: true, api: { prefixUrl: import.meta.env.VITE_API_URL, timeout: 30000 } }; export const App = () => ( {/* Votre application */} ); ``` ## Props du Provider | Prop | Type | Description | | --- | --- | --- | | `config` | object | Configuration de l'application (voir ci-dessous) | | `children` | ReactNode | Contenu de l'application | | `onError` | Function | Callback en cas d'erreur globale | | `errorFallback` | any | Contenu de fallback en cas d'erreur | | `ErrorFallbackComponent` | Component | Composant de fallback en cas d'erreur | | `pwaUpdate` | object | Configuration des mises à jour PWA (passée à `usePWAUpdate`) | ### pwaUpdate Permet de configurer le comportement des mises à jour PWA directement sur le Provider : ``` console.log('Mise à jour disponible'), onUpdateActivated: () => console.log('Mise à jour activée') }} > {/* ... */} ``` ## ConfirmProvider Le `ConfirmProvider` est inclus automatiquement dans le `Provider`. Il accepte un prop `labels` pour personnaliser les textes des boutons : ``` {/* ... */} ``` ## Configuration complète Voici toutes les options disponibles : ``` const config = { // Mode debug - active les logs dans la console debug: true, // Configuration des composants components: { // Thème actif theme: "default", // Définition des thèmes personnalisés themes: { default: { /* ... */ }, dark: { /* ... */ } }, // Variants personnalisés pour les composants variants: { Button: { primary: { className: "bg-blue-500 text-white" }, secondary: { className: "bg-gray-500 text-white" } } }, // Configuration TailwindCSS tailwindCss: { mergedClass: {} } }, // Configuration i18n i18n: { translated: true }, // Configuration du stockage storage: { db: { compression: {} }, local: { compression: {} }, session: { compression: {} } }, // Configuration de l'état global globalState: { reducers: {} }, // Configuration API api: { // URL de base de l'API prefixUrl: "https://api.example.com", // Timeout des requêtes (ms) timeout: 30000, // Mode debug pour les requêtes API debug: true, // Mapping de chemins personnalisés paths: { login: "auth/login", logout: "auth/logout" }, // Gestion d'erreurs personnalisée errors: { 401: (error) => console.log("Non autorisé"), 500: (error) => console.log("Erreur serveur") } }, // Configuration des animations de pages pages: { "/": { "/dashboard": "slideLeft", "/settings": "slideLeft", "*": "fade" }, "/dashboard": { "/": "slideRight", "*": "fade" }, "*": "fade" } }; ``` ## Options détaillées ### debug Active les logs de debug dans la console pour tous les hooks et composants. ``` debug: true // Active tous les logs debug: false // Désactive les logs (production) ``` ### api Configuration du client HTTP (ky). | Option | Type | Description | | --- | --- | --- | | `prefixUrl` | string | URL de base pour toutes les requêtes | | `timeout` | number | Timeout en millisecondes (défaut: 30000) | | `debug` | boolean | Active les logs des requêtes API | | `paths` | object | Mapping de chemins personnalisés | | `errors` | object | Handlers d'erreurs par code HTTP | ### pages Configuration des animations de transition entre pages. Voir [Animations](/front/animations) pour plus de détails. | Animation | Description | | --- | --- | | `fade` | Fondu enchaîné | | `slideLeft` | Glissement vers la gauche | | `slideRight` | Glissement vers la droite | | `zoom` | Effet de zoom | ### components Personnalisation des composants SmartCommon. #### themes Définition de thèmes personnalisés : ``` themes: { light: { primary: "#3b82f6", secondary: "#6b7280", background: "#ffffff" }, dark: { primary: "#60a5fa", secondary: "#9ca3af", background: "#1f2937" } } ``` #### variants Variants personnalisés pour les composants : ``` variants: { Button: { primary: { className: "bg-primary text-white hover:bg-primary/90" }, danger: { className: "bg-red-500 text-white hover:bg-red-600" } }, Input: { outlined: { className: "border-2 border-gray-300 rounded-lg" } } } ``` ### storage Configuration du stockage (localStorage, sessionStorage, IndexedDB). ``` storage: { db: { compression: { enabled: true, threshold: 1024 // Compresser si > 1KB } }, local: { compression: { enabled: false } } } ``` ### globalState Configuration de l'état global Redux. ``` globalState: { reducers: { // Reducers personnalisés à ajouter au store myCustomReducer: myReducerFunction } } ``` ## Accéder à la configuration Utilisez le hook `useLibConfig` pour accéder à la configuration : ``` import { useLibConfig } from '@cap-rel/smartcommon'; const MyComponent = () => { const config = useLibConfig(); console.log(config.debug); console.log(config.api.prefixUrl); return (/* ... */); }; ``` ## Exemple complet ``` // src/App.jsx import { Provider } from '@cap-rel/smartcommon'; import "@cap-rel/smartcommon/dist/smartcommon-style.css"; import { Router } from './components/app/Router'; const config = { debug: import.meta.env.DEV, api: { prefixUrl: import.meta.env.VITE_API_URL, timeout: 30000, debug: import.meta.env.DEV }, pages: { "/login": { "*": "fade" }, "*": { "/login": "fade", "*": "slideLeft" } } }; export const App = () => ( ); ``` ## Voir aussi - [Hooks](/front/hooks) - Documentation des hooks - [Animations](/front/animations) - Animations de pages - [Astuces](/front/astuces) - Bonnes pratiques - [Debug et Logs](https://smartmaker.org/front/debug) # Debug et Logs SmartCommon fournit un système de logs colorés pour faciliter le debug de votre application. ## Activer le mode debug Dans la configuration du Provider : ``` const config = { debug: true, // Active les logs globalement api: { debug: true // Active les logs API spécifiquement } }; ``` En développement, utilisez la variable d'environnement : ``` const config = { debug: import.meta.env.DEV // true en dev, false en prod }; ``` ## Utilitaire log SmartCommon exporte un utilitaire `log` avec des méthodes colorées : ``` import { log } from '@cap-rel/smartcommon'; // Logs d'état log.state('Changement d\'état:', newState); log.globalState('État global mis à jour:', globalState); // Logs de cycle de vie log.effect('useEffect déclenché'); log.page('Page chargée: /dashboard'); // Logs de statut log.success('Opération réussie'); log.error('Une erreur est survenue:', error); log.warning('Attention:', message); log.info('Information:', data); // Logs API log.apiLoading('GET', '/api/items'); log.apiSuccess('GET - 200', '/api/items'); log.apiError('GET - 404', '/api/items'); // Logs base de données log.db('INSERT:', item); // Logs de localisation log.location('Navigation vers:', path); // Log personnalisé log.custom('MON_TAG', 'purple', 'Mon message', data); ``` ## Couleurs des logs Chaque type de log a une couleur distincte dans la console : | Méthode | Couleur | Usage | | --- | --- | --- | | `log.state` | Bleu | Changements d'état local | | `log.globalState` | Cyan foncé | Changements d'état global | | `log.effect` | Violet | Effets React (useEffect) | | `log.page` | Orange | Chargement de pages | | `log.success` | Vert | Opérations réussies | | `log.error` | Rouge | Erreurs | | `log.warning` | Doré | Avertissements | | `log.info` | Gris | Informations | | `log.apiLoading` | Gris | Requête API en cours | | `log.apiSuccess` | Vert | Requête API réussie | | `log.apiError` | Rouge | Requête API échouée | | `log.db` | Bleu nuit | Opérations IndexedDB | | `log.location` | Rose | Navigation | ## createLogger - Logger avec namespace Pour les projets plus complexes, `createLogger` permet de créer un logger avec un namespace dédié. Les logs peuvent être filtrés par namespace via `localStorage.LOG_FILTER`. ``` import { createLogger } from '@cap-rel/smartcommon'; const logger = createLogger('MyModule'); logger.info('Module initialisé'); logger.success('Données chargées'); logger.error('Échec du chargement'); logger.warning('Données périmées'); logger.state('Changement local:', newState); logger.globalState('Changement global:', data); logger.effect('useEffect déclenché'); logger.page('/dashboard'); logger.db('INSERT', item); // API (premier paramètre = label dynamique) logger.apiLoading('GET /items'); logger.apiSuccess('GET /items', data); logger.apiError('GET /items', error); // Personnalisé logger.custom('PERF', 'orange', 'Rendu en 12ms'); // Groupes logger.group('Initialisation'); logger.info('Étape 1'); logger.info('Étape 2'); logger.groupEnd(); ``` ### Filtrer par namespace Dans la console du navigateur : ``` localStorage.LOG_FILTER = 'MyModule,Auth'; // Affiche uniquement ces namespaces localStorage.LOG_LEVEL = 'warn'; // debug | info | warn | error ``` ## DebugConsole - Console intégrée Le composant `DebugConsole` affiche les logs SmartCommon directement dans l'application (FAB flottant `>_` en bas à gauche, panneau avec toolbar de recherche/filtres, logs colorés auto-scrollés). ### Activation La `DebugConsole` est montée par le `Provider` **uniquement si la prop `debug` est passée explicitement** (ce n'est PAS `config.debug` qui la déclenche, mais la prop du Provider lui-même) : ``` import { Provider } from '@cap-rel/smartcommon'; // Active uniquement en mode développement // Toujours active ``` La prop `config.debug` reste indépendante : elle contrôle la verbosité des logs produits par les hooks SmartCommon (useStates, useGlobalStates, useApi, etc.), indépendamment de l'affichage de la `DebugConsole`. ### Props | Prop | Type | Défaut | Description | | --- | --- | --- | --- | | `defaultOpen` | boolean | false | Console ouverte au démarrage | | `position` | string | "bottom" | Position : "bottom", "top", "left", "right" | | `height` | string/number | "40vh" | Hauteur de la console | | `maxLogs` | number | 500 | Nombre maximum de logs conservés | | `showFab` | boolean | true | Afficher le bouton flottant ">_" | ### Utilisation manuelle (sans Provider) ``` import { DebugConsole } from '@cap-rel/smartcommon'; ``` La console peut être détachée dans une fenêtre séparée via le bouton de détachement. Les logs sont partagés entre les fenêtres via `BroadcastChannel`. ## DebugWarnings - Breakpoints automatiques sur warnings React Le composant `DebugWarnings` intercepte `console.error` pendant qu'il est monté et déclenche `debugger` dès qu'un warning React connu est émis. Les DevTools (Firefox ou Chrome) mettent alors l'exécution en pause pile au moment du warning, avec la pile React complète accessible pour identifier le composant fautif. Quand les DevTools sont fermées, `debugger` est un `no-op` : aucun impact sur l'exécution normale. ### Activation Comme la `DebugConsole`, `DebugWarnings` est automatiquement monté par le `Provider` **quand la prop `debug` est passée** : ``` ``` Inactif en production (quand `debug` est `false` / absent). Aucune configuration supplémentaire nécessaire dans les projets SmartMaker. ### Warnings interceptés Les motifs suivants déclenchent un breakpoint automatique : - `Received NaN for the value attribute` - NaN passé à un `` - `value prop on input should not be null` - `value={null}` sur un input - `changing a controlled input to be uncontrolled` - `value` passe de défini à `undefined` - `changing an uncontrolled input to be controlled` - inverse - `Each child in a list should have a unique "key"` - `key` manquante dans un `.map()` - `Maximum update depth exceeded` - boucle infinie de re-renders - `Cannot update a component` - setState pendant le render d'un autre composant - `Warning: Failed prop type` - prop-types violé - `Warning: React does not recognize` - prop non standard passée au DOM ### Diagnostic automatique avant le breakpoint Avant de déclencher `debugger`, `DebugWarnings` affiche un `console.group` orange contenant : - **Raw console.error args** : tableau brut des arguments de React (utile si React inclut un `componentStack`) - **React hint** : extraction du "Check the render method of `XXX`" quand React le mentionne - **Suspects ciblés** : pour les warnings `value` et `NaN`, la liste des fibers dont `memoizedProps.value` correspond au problème, avec l'owner React et la source `.jsx` - **Dump complet des fibers de formulaire** (`console.table`) : tous les ``, `