Notation en point

Petit article vite fait en complément des rendus de cellules :

Pour rappel mes rendus de cellules fonctionnent avec le prototype suivant :

static fn(rowData: any, field: string): string { }

rowData contient l’objet de la ligne du tableau, à savoir un objet potentiellement complexe, et field contient le champs à rendre.

Mais

Mais si on a un objet complexe et que l’on souhaite rendre une sous-valeur, par exemple rue de notre adresse ?

{
  adresse: {
    rue: string
    numero: string
    ...
  }
  ...
}

Si je fais rowData[‘rue’] on est d’accord que ça nous donnera une erreur d’index non-trouvé ?

Notation en point

Une notation en point c’est, dans notre exemple : 'adresse.rue', c’est à dire la description du chemin pour avoir notre contenu à partir de la racine de l’objet.

Mais si on fait rowData[‘adresse.rue’], ça restera une erreur d’index non-trouvé ! Pourquoi ? Parce que ! Il ne va pas décortiquer tout seul votre clef, ça serait trop facile…

À noter que PrimeFace le traite par défaut quand vous utilisez leur système, mais dès qu’on part en « fait maison », mode dynamique, extension des templates, etc. ben zou ça ne fonctionne plus tout seul et il n’y a pas de service appelable pour vous aider.

Donc on va le faire nous même.

Résoudre la notation en point

Visuellement c’est simple, je veux descendre d’un cran à chaque point, sur base de la clef courante. Donc une récursive à plat en quelque sorte. Sans chipoter voici ma solution :

static solveDotNotation<T>(rowData: any, field: string) : T | undefined {
  const keys = field.split('.')

  let err = false
  let obj = rowData
  for (let ki in keys) {
    const k = keys[ki]

    if (obj[k]) {
      // Continue to dig next key
      obj = obj[k]
    } else {
      err = true
      break
    }
  }

  return err ? undefined : obj as T
}

On divise field sur le point pour avoir une liste de clefs (keys) puis on parcours la liste. On a une variable obj, tel un pointeur mobile, initialement positionné à la racine de rowData, qui va évoluer à chaque passage de la boucle pour devenir le sous-élément. Si on ne trouve pas une étape on sort le plus vite possible, sinon on continue de descendre dans le terrier du lapin blanc.

En cas d’erreur, ici le choix est fait de renvoyer undefined qui permet, dans mon cas, de pouvoir jouer avec la notation de chainage optionnel (?.) dont voici mon exemple dans le cadre de mon rendu de valeurs ontologiques.

static OntologyValues(rowData: any, field: string): string {
  return CellRenderer.solveDotNotation<OntologyValue[]>(rowData, field)?.map<string>(ov=> ov.value || ov.object).join("\n<br/>") || ""
}

Un système de tabs en Angular+PrimeNg

J’ai été amené à réaliser en Vue3 un système de tabs avec une zone d’actions (ZA) qui s’affiche à droite des onglets. Ceci n’étant pas proposé par le composant de base de PrimeVue. À nos claviers !

Schéma de principe

Étant sur mon projet OntologyMyAdmin en Angular et donc avec PrimeNg, c’est à l’inverse de certains articles « Angular vers Vue » que je vais réaliser un système de tabs en Angular sur base de l’expérience en Vue que j’ai mené il y a une bonne année.

En Vue, dans les grandes lignes, sur base d’une props contenant la liste des ids des tabs avec leur options (visible, désactivé, classe CSS, …), on boucle dessus pour générer les onglets et pour générer les slots nommé sur base des ids, du coup la magie opère après l’init et les templates se placent bien dans leur slot avec gestion par le composant tabs. La zone action fonctionne de la même manière avec les templates suffixés « -actions » par exemple. Le code est minimaliste et la logique se trouve dans la données préparée et la boucle de traitement.

En Angular ce n’est pas tout à fait la même musique, on ne peut pas avoir de ng-content au nom dynamique. Pour ne pas vous faire languir, il faut passer par l’exploration des enfants de notre composant (@ContentChildren) par ce qu’on appelle la projection de contenu.

Là où ça se corse c’est : comment ? Il vous faudra une directive ou un composant pour mapper l’annotation afin qu’elle trouve quelque chose, et ce, pas avant votre évènement ngAfterContentInit.

@ContentChildren(TabComponent) tabs!: QueryList<TabComponent>
@ContentChildren(TabActionComponent) tabsAction!: QueryList<TabActionComponent>

Grâce à cette façon de faire, au lieu d’avoir une liste de tabs en input comme en Vue, on pilotera nos onglets par leurs attributs directement. Dans l’exemple ci-dessous vous verrez qu’on lui indique son titre et un id personnalisé pour lier son action (les autres récupérant un id automatique).

<oma-tabs>
  <oma-tab title="struct">
    <h2>Tab sans actions</h2>
  </oma-tab>

  <oma-tab title="items" tabId="items">
    <h2>Tab avec action</h2>
  </oma-tab>
  <oma-tab-action tabId="items">
    <p-button label="actionA" />
  </oma-tab-action>

  <oma-tab title="options" tabId="options">
    <h2>Autre tab avec action</h2>
  </oma-tab>
  <oma-tab-action tabId="options">
    <p-button label="actionB" />
  </oma-tab-action>
</oma-tabs>
Exemple de résultat du système de tabs

En positionnant un ng-content avec l’attribut select du type de votre composant, vous garantissez que seuls ceux-là défini se retrouveront dans cet espace, comme un filtre.

<div class="tabs-actions">
  <div class="flex justify-between">
    <ul class="list-none">
      @for (t of tabs; track t.id; let idx = $index) {
        <li class="inline-block" [ngClass]="{ selected: selectedTabId === t.id }">
          <p-button [label]="t.title" (onClick)="changeTab(t)" />
        </li>
      }
    </ul>
    <div class="text-right self-center">
      <ng-content select="oma-tab-action" />
    </div>
  </div>
  <div>
    <ng-content select="oma-tab" />
  </div>
</div>

Il nous reste à initialiser les valeurs par défaut, qui apparait, etc.

@Input()
selected: number = 0

protected selectedTabId: number | string = 0

ngAfterContentInit() {
  let indexing = 0;
  const fnIndexing = (t: TabCommonComponent) => {
    if (!t.id) {
      t.id = indexing++
    }
  }
  this.tabs.forEach(fnIndexing)
  this.tabsAction.forEach(fnIndexing)

  this.selectedTabId = this.selected || this.tabs.first.id || 0
  const tabActive = this.tabs.find(t => this.selectedTabId === t.id)
  if (tabActive) {
    tabActive.active = true
  }
  const tabActionActive = this.tabsAction.find(t => this.selectedTabId === t.id)
  if (tabActionActive) {
    tabActionActive.active = true
  }
}

Le changement de tab fait suite à l’initialisation, c’est la même chose, on clique, on reset l’état et on active l’onglet voulu et sa zone d’action si disponible.

changeTab(tab: TabComponent) {
  if (this.selectedTabId != tab.id) {
    this.selectedTabId = tab.id

    // Change visibility
    const fnChange = (t: TabCommonComponent) => {
      t.active = t.id === this.selectedTabId
    }
    this.tabs.forEach(fnChange)
    this.tabsAction.forEach(fnChange)

    // TODO on change
  }
}

Notez le TODO qui précise qu’il faudra remonter un événement pour l’host dans le cas où ceci peut avoir un intérêt.

C’est tout, il n’y a rien de plus, c’est aussi simple 🙂 Par contre je peux vous parler d’héritage de composant afin de ne pas se répéter inutilement (DRY).

Héritage de composant

En effet, nous avons 2 composants enfant de notre tabs : le tab lui-même et le tab-action (ZA). Ils ont tout deux besoin d’être activé et d’avoir un id. Seul le tab aura un titre par exemple qui permet de générer son onglet.

<div [ngClass]="{ hidden: !active }">
  <ng-content />
</div>
@Component({
  selector: 'oma-tab-common',
  imports: [
    NgClass
  ],
  templateUrl: "tab-common.component.html"
})
export class TabCommonComponent {
  @Input('tabId') id: number | string = 0
  @Input() active: boolean = false
}

Notez que l’attribut « id » est déjà prévu par HTML, il nous faut donc le nommer autrement, mais en interne vous faites ce que vous voulez.

Maintenant que vous avez votre composant commun, héritons-le !

@Component({
  selector: 'oma-tab',
  imports: [
    NgClass
  ],
  templateUrl: "tab-common.component.html"
})
export class TabComponent extends TabCommonComponent {
  @Input() title: string = ""
}
@Component({
  selector: 'oma-tab-action',
  imports: [
    NgClass
  ],
  templateUrl: "tab-common.component.html"
})
export class TabActionComponent extends TabCommonComponent {

}

Et c’est tout ! À nouveau ^^.

Lazy loading

En complément on peut parler de lazy loading, c’est à dire le fait de ne charger le contenu du tab que si on l’affiche. Ceci permettant, par exemple, de ne pas faire de grosses requêtes dans mon second tab inutilement. Ceci en complément d’un cache par exemple si on compte répéter les aller-retours entre les tabs. On peut aussi considérer la mise à jour d’un tab quand on y revient, c’est à dire qu’au lieu de le masquer/afficher (css hidden) on peut utiliser une directive conditionnelle (@if) afin de relancer le tab (son contenu) à son prochain affichage.

@if ((lazy && active) || !lazy) {
  <div [ngClass]="{ hidden: !active }">
    <ng-content />
  </div>
}

Si on active le côté lazy, le @if jouera le rôle de constucteur/destructeur du composant qui repartira à zéro à chaque chargement de l’onglet.

Mais si on veut que ça n’arrive que la première fois, il faudra ajouter une notion de en cours de chargement/chargé. On peut combiner différentes idées. Et, effectivement, ici, c’est intéressant pour ne pas relancer l’ontologie à chaque fois, mais de la charger quand même au besoin. Mais comment le tab sait qu’il est en cours de chargement ou chargé. On peut juste dire que lors de la première activation, on le note pour ne pas rejouer le @if. Mais on pourrait aller plus loin avec notion de chargement, si l’enfant pouvait nous en informer.

Soit via une services partagé avec Id du système d’onglets, soit via le contenu poussé dans l’onglet piloté en extérieur, les 2 sont valables, tout dépend du besoin.

Signal

Vous l’avez remarqué, le code est à l’ancienne, pas de Signal dedans, juste @Input(), et pour une raison toute bête, Signal ne permet pas de faire ainsi. L’usage de input() génère un InputSignal qui ne peut être modifié en tant que propriété. Du coup une alternative est de transformer input() en model(), un ModelSignal(), qui lui le peut. Au moment d’écrire cet article, j’ai eu quelques déboires avec signal et ses contraintes, ce qui a été résolu et donc je vous met en gist la version signal :), et ici quelques exemple de transformations.

  selected = input(0)
  selectedTabId = signal<number | string>(0)

  ngAfterContentInit() {
    let indexing = 0;
    const fnIndexing = (t: TabCommonComponent) => {
      if (!t.id()) {
        t.id.set(indexing++)
      }
    }
    this.tabs.forEach(fnIndexing)
    this.tabsAction.forEach(fnIndexing)

    this.selectedTabId.set(this.selected() || this.tabs.first.id() || 0)
    const tabActive = this.tabs.find(t => this.selectedTabId() === t.id())
    if (tabActive) {
      tabActive.active.set(true)
    }
    const tabActionActive = this.tabsAction.find(t => this.selectedTabId() === t.id())
    if (tabActionActive) {
      tabActionActive.active.set(true)
    }
  }

  changeTab(tab: TabComponent) {
    if (this.selectedTabId() != tab.id()) {
      this.selectedTabId.set(tab.id())

      // Change visibility
      const fnChange = (t: TabCommonComponent) => {
        t.active.set(t.id() === this.selectedTabId())
      }
      this.tabs.forEach(fnChange)
      this.tabsAction.forEach(fnChange)

      // TODO on change
    }
  }
export class TabCommonComponent {
  id = model<number | string>(0, {
    alias: 'tabId'
  })

  active = model(false)
  lazy = model(false)

  constructor() {
    effect(() => {
      const active = this.active()

      active ? this.onActive.emit(active) : this.onInactive.emit(active)

      if (active) {
        this.lazy.set(false)
      }
    });
  }

  onActive = output<boolean>()
  onInactive = output<boolean>()
}

REST API avec Node/TS/Express + OpenAPI

Évolution de la solution précédente reprise à zéro. Donc un serveur Node avec Typescript ce coup-ci, dont les routes sont pilotées par l’openAPI.

On commence avec le tuto bien foutu « How to set up TypeScript with Node.js and Express » de LogRocket. Sur lequel on va reprendre la solution précédente morceau par morceau, et c’est là que ça devient intéressant.

En résumé, nous aurons 2 @types à ajouter, ainsi que concurrently pour essayer, lors des installs pour que ça fonctionne et adapter les 2 codes principaux.

npm i -D concurrently
npm i @types/cors
npm i @types/swagger-ui-express

Il faudra également faire attention à votre git bash, dans le fichier .bash_profile ou rc, activer le support des couleurs pour concurrently.

export FORCE_COLOR="1"

Le makeApp s’adapte sous forme d’une classe TS

import SwaggerParser from "@apidevtools/swagger-parser";
import SwaggerRoutes from "swagger-routes-express"
import express from "express";
import cors from "cors"
import swaggerUi from "swagger-ui-express"

export class MakeApp {
    static async make() {
        const parser = new SwaggerParser()
        const apiDescription = await parser.validate('./api/openapi.yaml')
        const connect = SwaggerRoutes.connector({
            getCore: async (req, res, next) => {
                console.log("getCore")
                res.json([])
            }
        }, apiDescription, {
            onCreateRoute: (method, descriptor) => {
                const [path, ...handlers] = descriptor
                console.log('created route', method, path, handlers)
            }
        })
        const app = express()

...

        return app
    }
}

Et l’index de bootstrap légèrement adapté

import { MakeApp } from "./makeApp";
import dotenv from "dotenv"

dotenv.config()

const port = process.env.PORT || 3000

MakeApp.make()
    .then(app => app.listen(port))
    .then(() => {
        console.log(`[server]: Server is running at http://localhost:${port}`)
    })
    .catch(err => {
        console.error('Caught error', err)
    })

Pour les détails je vous invite à consulter le GitHub du projet ;). L’API a été simplifiée un peu et l’ajout de nodemon et concurrently.

Pas de contrôleur séparé dans ce test, flemme, tout simplement, le test est fonctionnel et on verra si je le pousse plus loin en terme de comparatif.

Être piloté par openAPI ou par le code et générer son swagger ?

C’est la question, vous trouverez la seconde option partout mais j’aimais l’idée que l’on puisse architecturer de manière agnostique son API et l’implémenter ensuite en collant les méthodes aux routes. Il est vrai que pour les mises à jour fréquentes, générer la doc sur base du code est plus simple, mais l’erreur est plus vite faite aussi me semble-t-il.

« Bricoler ainsi » ou framework prêt à l’emploi ?

Là encore, bonne question, ici j’ai un truc qui fonctionne, dans le bon esprit Node mais qui reste un assemblage dépendant de paquets divers et qui ne seront plus mis à jour (vérifié pour certains). De par leur nature et pour quoi ils ont été conçus, et pour ce que l’on souhaite faire avec dans le futur. Du coup j’ai regarder les frameworks TS tel que Nest ou Foal. Reste à déterminer avec 1 test de chaque lequel correspondrait au projet cible :).

Nest basé sur l’idée Angular est très populaire donc je trouverais facilement de la doc, quant à Foal c’est le petit nouveau full TS prometteur avec pas mal de built-in, mais à comparer avec le même type de test que cet article, en y ajoutant l’auth et la sécu pour les besoins clefs de base. Une connexion db et tester leur ORM pourrait également être un bon point et faire un article ensuite :).

Liste des ressources utilisées

Et si on revenait un peu à TARS

Mélange des origines avec la map de test

Petit point de situation suite à la mise à jour Angular 17. J’ai donc repris le POC TARS et sa carte de départ, j’ai nettoyé quelques POCs (CodingPark et le début du système de particules) et … ça ne marche plus :/ ?

Plus d’une fois j’ai eu le malheur d’un craquage complet avec un beau message d’erreur… et là je valide le fait que le message d’erreur est effectivement amélioré. Alors merci chère mémoire de ne pas m’aider à me rappeler pourquoi j’ai coder ça ainsi et merci les 4 ans passés à évoluer et donc ne plus écrire ou penser de la même manière. Ceci dit j’étais quand même dans le pétrin.

Je dirais principalement que le fait de ne plus compter sur les effets de bords du typage aide pas mal à avoir des soucis, du coup merci le mode stricte ^^. Puis surtout, en terme de documentation c’est tellement plus confortable.

C’est là aussi que l’on voit que l’on code en « happy path » et sans gestion rigoureuse d’erreur sur projet perso. Le « je sais ce que je fais car je l’ai fait » c’est bien, mais 4 ans plus tard… comment dire, ça laisse à désirer. Du coup « oui », TARS a des lacunes. J’ai tenté de dire que le sol était un sol et il n’aimait pas, imaginez ma surprise… et il fallait lui dire que c’était une « image »… logique ! Et pourquoi ? Du fait que la description de l’élément ne répondait pas au standard ISO(métrique) demandé, mais sans gestion d’erreur ça explose ailleurs et tu peux chercher longtemps pourquoi.

Comme le fait de cibler un élément d’un tableau sur son index, de modifier le tableau et d’avoir une erreur d’accès à une méthode sur une instance… tellement évident… heureusement ça c’est le côté POC pour générer la map et tester le moteur, l’idée étant de passer par l’éditeur ensuite qui lui ne fera pas d’erreur. Mais son opérateur, ça…

Ah et en passant, tant qu’à faire de l’amélioration générale, n’oubliez pas que A || B n’est pas pareil que A ?? B (petit lien sympa). Et que les ternaires A ? A : B peuvent devenir A ?? B.

Mais aussi que les pipes RxJs peuvent s’agrémenter de code perso et ainsi pouvoir charger un Object { a: 1, b: 2 } vers un tableau [{ name: a, value: 1 }, {...}].

Ou encore qu’on ne peut pas retourner la déclaration d’un enum pour accéder à ses clefs (return SO0), mais qu’il faudra retourner ses clefs directement (return Object.values(SO0)).

Enfin voilà, en gros, tout est une bonne grosse question de type et de ce que l’on peut faire avec. Il y aurait encore à en dire, mais au final je n’ai pas retenu ces directions, et sans les notes il sera dur de vous en montrer plus.

Il y a bien sûr encore pas mal de boulot, je vais repasser progressivement, à temps perdu, dans l’ensemble du POC et de TARS lui-même, faire un coup de clean et de typage massif, gestion d’erreur(s), et surement de refactorisation plus ou moins importante, par exemple la suppression du mode 2D, ce qui aiderait quelques petite performances; mais aussi le fait rencontré que le validateur de chemin n’ait pas l’information disponible de la différence de hauteur/niveau/élévation entre 2 Coords en comparaison, et qu’il sera difficile en l’état d’y arriver. Du coup, on reparle de théorie du système, doit-il gérer Z ou pas, gros débat (ombre portée dynamique, lumière, changement de niveau (hauteur), …).

Quand on pense qu’au départ de ce moment je voulais juste reprendre le dev de l’éditeur…

Y replonger, bien que difficile, chronophage et casse-gueule avec le temps et la mentalité différente aujourd’hui, est amusant, un petit challenge sur le côté, et surtout : il fonctionne, pour ce qu’il est, ses limites et possibilités.

Je me tâte toujours, est-ce que Nahyan peut être exploré en isométrique ou doit-on absolument le voir en 3D ?

Angular : 4 ans, 9 versions

Début 2019 on a Angular 8, et en cette fin 2023 on a reçus la 17, soit 9 versions ! Et pourquoi je vous dis ça ? Tout simplement car j’ai voulu mettre à jour le POC de TARS, et ça ne s’est pas du tout passé comme prévu malgré l’usage du site de migration qui est bien fait.

Le truc c’est que ce n’est pas exempt de soucis qui se pose au fil du temps. Par exemple, certaines commandes vont exécuter l’install (npm i), mais il vous faudra reprendre à la main en précisant le flag --legacy-peer-devs ou encore des résolutions d’arbre de dépendances qui ne se font plus correctement.

J’ai réussi non sans mal à passer à la version 11 mais pas plus loin, la quantité de conflits ou de soucis a explosé, pourtant le projet n’a rien de particulier d’un point de vue Angular.

Du coup j’ai opté pour une autre approche. Le projet n’utilise Angular que comme une coquille de lancement avec l’usage de l’injecteur et du client HTTP. J’ai donc créé un projet frais et j’ai replacé le code en dedans et là : BOOM ! Fallait pas oublier qu’on était passé en mode stricte, ou que le côté standalone a changé la manière de monter un module ou de gérer les composants, bref il ne s’en sortait plus.

On sort l’huile de coude, on repasse partout, on s’arrache les cheveux sur le typage fort qui me manquait à l’époque et on solutionne avec quelques generic. On ajuste la nouvelle mouture standalone avec les imports et providers locaux et ça tourne ! Enfin, oui, mais ça nécessitera quelques heures avant que le builder arrête de péter une durite. À savoir que si vous avez un couac il peut perdre la compréhension de tout les tags Angular et votre erreur c’est 1 détails quelques part… Bonne chance.

J’en profite pour vous filer un tuyaux : penser à définir vos composant en standalone, rajouter ce qu’il vous faut localement comme CommonModule ou HttpClientModule.

Un truc qui continue de m’agacer, c’est ce double état undefined et null, car si vous spécifiez un attribut de classe comme optionnel vous utiliserez un ? à sa fin, mais si vous l’assignez suite à un find d’un tableau par exemple, ce qui peut vous rendre votre type ou null, vous aurez un conflit possible de type, sauf si on parle de test(s) avant assignation ou autre écriture à rallonge; ça manque quand même de cohérence.

Enfin soit, c’était amusant d’y retoucher, avec l’objectif de reprendre son éditeur en main, toujours pour ce projet éternel qu’est Nahyan.

Angular 17 recèle d’un tas de nouveautés qui, je pense, pourraient, avoir leur rôle à jouer, voire en refactorisation, mais je m’avance un peu. Par exemple Signal, la sortie de Zone, la nouvelle syntaxe de templating, etc.

TipTap Kodak

TipTap est un éditeur WYSIWYG que l’on doit mettre en place à la main (headless) basé sur ProseMirror. En gros vous avez un kit de départ et après vous activez des extensions. Dans mon cas, je l’ai utilisé dans un contexte Vue3 avec Prime pour en faire un composant d’édition en mode JSON dans le but de l’envoyer vers une moulinette pour obtenir un PDF en sortie et au passage variabiliser la structure. Partageons quelques XPs !

Ce qu’il faut savoir c’est que tout se base sur un JSON (JSONContent) qui est traduit en HTML au sein de l’éditeur via les extensions qui parsent et traduisent chaque élément de la structure en balises et attributs. En soit, sorti de sa boite, c’est très simple de mise en place et d’usage, on dit à un bouton d’exécuter une commande et suivant la sélection dans notre texte, l’effet sera appliqué. Là où cela se complique c’est de bien comprendre ces fameuses extensions que l’on voudra s’empresser de coder et modifier pour obtenir nos résultats attendus.

La documentation, bien que bien faite, manque de cas d’usage quand on descend dans le terrier du lapin blanc. Là on peut s’arracher les cheveux. D’un côté on a l’implémentation de ProseMirror par TipTap et de l’autre la couche de TipTap pour organiser leurs extensions/marks/nodes, et là rien n’est évident. Et pour ne rien arranger, HTML est déjà assez spécifique sur quasi chaque tag :

  • Un titre, c’est un tag H + un chiffre de 1 à 6. Donc un niveau de titre à transformer au rendu.
  • Une liste, c’est un bloc contenant des items, qui à leur tour contiendront du contenu (JSONContent déclare les contenu en tableau) et ce contenu est forcément un paragraphe qui contient un noeud texte.
  • Le gras, italique, barré ou souligné altèrent un sous ensemble de contenu (la sélection) dans un nouveau noeud texte avec une marque (mark) qui sera traduite au rendu (ajout de balise, attribut, …)
  • Le surlignage par exemple, comme dit ci-avant, prendra en plus un attribut (la couleur)

Et là on reste sur la base. Dans mon cas j’ai dû jouer sur de l’indentation de contenu, de la variabilisation de contenu et un saut de page.

Saut de page

Pour le saut de page j’ai opté pour transformer le hr (setHorizontalRule) en repère interprétable dans la moulinette PDF, et en CSS c’est également un tag visible manipulable. Ça c’est du détournement du fait que je n’avais pas l’usage du tag existant, facile.

Indentation

Pour l’indentation, c’est une autre affaire, et là on creuse les extensions. Je me suis basé sur l’extension de Evan Payne, en ajoutant un set défini pour fixer l’indentation directement à telle position.

    return {
      indent: changeIndent(1),
      outdent: changeIndent(-1),
      setIndent:
        (level: number) =>
        ({ tr, state, dispatch }) => {
          const { selection } = state
          tr = tr.setSelection(selection)
          tr = updateIndentLevel(tr, level, true)

          if (tr.docChanged) {
            dispatch?.(tr)
            return true
          }

          return false
        },
    }

Une extension

Une extension c’est un nouveau module pour tiptap.

export const Indent = Extension.create<IndentOptions>({

On déclare en amont les options du module (IndentOptions) qui contiendra les variables que l’on peut définir via la configuration (quand on le déclare dans l’éditeur), tel que

const editor = useEditor({
  extensions: [
    StarterKit.configure({
      listItem: false
    }),
    Underline,
    Highlight.configure({ multicolor: true }),
    TextAlign.configure({ types: ["heading", "paragraph"] }),

On s’ajoute au module en définissant l’interface des commandes qui seront implémentées par notre extension

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    indent: {
      indent: () => ReturnType
      outdent: () => ReturnType
      setIndent: (level: number) => ReturnType
    }
  }
}

Pour le reste je vous laisse dans les mains de la documentation dédiée aux extensions.

Variabilisation

Enfin, pour la variabilisation, là on doit parler de plusieurs choses :

  • Remplacement de valeurs, par exemple identifées par [mavariable] ou {…} et autres possibilités
  • Dynamiser du contenu :
    • Section visible, ou pas, selon un paramètre (condition)
    • Répéter une section autant de fois que le paramètre contient d’éléments et remplacer le contenu (cf. premier point de la liste). (boucle)

Attribut

Voilà bien un beau problème, comment identifier un noeud de notre JSONContent et le mettre en rapport avec une variable pour le conditionner ? De plus, ce noeud doit être édité par l’interface éditeur et non en direct dans le JSON. Notre objectif va donc, suivant la position du curseur, d’éditer un attribut sur le bloc courant afin de laisser un marqueur identifiable pour effectuer le remplacement.

Pour faire très très simple, TipTap a une méthode

updateAttributes(tag, { varName: newVarName })

Et donc il faut connaitre le tag d’application, mais aussi que celui-ci connaisse l’attribut, sinon cela sera sans impact.

Dans le cas d’un item de liste, on peut créer une extension d’une extension existante, on l’agrémente d’un nouvel attribut

export const CustomListItem = ListItem.extend({
  addAttributes() {
    return {
      varName: {
        default: null
      }
    }
  }
})

Plus simple tu meurs, au final, car pour arriver à ce résultat…

Du coup, si un bouton déclenche une popup pour nous demander le nom d’une variable et que l’on souhaite l’appliquer sur notre élément de liste

editor.value?.chain().focus().updateAttributes("listItem", { varName: "mavariable" }).run()
État de l’éditeur (mode navigateur)
État du JSONContent

En l’état, nous pouvons détecter ce noeud et boucler une liste d’éléments par exemple, vive la récursivité, en mappant le contenu de cet élément de base comme template pour les variables et structure.

Bloc de section

Est-ce que l’on peut mettre un attribut sur un autre type de bloc pour obtenir un autre comportement : oui, mais… Dans notre cas, nous voulons qu’un ensemble d’élément soit masqué/visible selon une condition ou répéter si la variable conduit à un tableau. Nous allons devoir ajouter une encapsulation et intégrer notre variable comme vue précédemment. À l’image d’HTML, nous déclarerons une balise « section » pour représenter ce regroupement.

Cette fois-ci nous voulons créé un nouveau noeud et non étendre, du coup le code n’est pas le même

export const Section = Node.create<SectionOptions>({
  name: "section",

  addOptions() {
    return {
      HTMLAttributes: {}
    }
  },

  content: "block+",

  group: "block",

  addAttributes() {
    return {
      varName: {
        default: null
      }
    }
  },

Et on utilisera la commande toggleWrap pour encapsuler/désencapsuler (toggle) dans notre déclaration de commande pour notre noeud.

  renderHTML({ node, HTMLAttributes }) {
    return [`section`, mergeAttributes(HTMLAttributes, { varName: node.attrs.varName }), 0]
  },

  addCommands() {
    return {
      setSection:
        (attributes) =>
        ({ commands }) => {
          return commands.toggleWrap(this.name, attributes)
        }
    }
  }

On appellera notre commande en lui passant le nom de notre propriété et sa valeur, ceci dit, nous aurions pu utiliser toggleWrap sur notre nouveau noeud puis updateAttribute.

Évidemment en comprenant l’effet toggle, nous aurions peut-être mieux fait de prévoir un wrapIn (encapsuler) et un lift (remonter/désencapsuler) et écrire de nouvelle commande en faisant des appels à ProseMirror. Ceci ne dépend que de ce que vous voulez proposer et couvrir comme besoin.

Modification du JSONContent

Maintenant que l’on a nos attributs de variable et nos 2 objets outils, nous allons pouvoir opérer. Soit on passe par la récursive qui transforme notre JSONContent en PDF et on interprète nos cas supplémentaires, soit, pour l’avoir en éditeur on parse et transforme le JSONContent directement, ce que nous ferons (mais les 2 fonctionnes pour l’avoir réalisé).

En gros on a notre structure source et on va créer, étage par étage notre nouvelle structure en modifiant le contenu au passage et on en profitera également pour effectuer les remplacements de valeurs.

str.replace(new RegExp("\\[" + k + "\\]", "g"), map[k] as string)

Design

L’avantage secondaire d’avoir des attributs ou balises identifiables sera la possibilité via CSS d’agrémenter votre visuel et indiquer là où il y a une variabilisation.

La difficulté sera de faire correspondre votre résultat visuel entre l’éditeur et votre sortie PDF ou web.

Conclusion

TipTap semble facile, l’est un peu puis devient bien complexe quand on veut creuser, j’ai l’impression de dire ça à chaque fois que je creuse ahah. Je vous recommande de lire leur code et d’aller creuser du côté de ProseMirror pour parfaire le niveau de vos devs et de votre compréhension.

jsPDF le nouveau FPDF

FPDF pour moi reste une référence en terme de librairie bas niveau pour créer vos PDF et j’avais fait mon premier système (et d’autres) de facturation avec; et récemment j’ai découvert un successeur plutôt digne côté front : jsPDF.

Quand on parle de bas niveau c’est d’une part car vous avez la main et devez gérer quasi absolument tout vous-même (coordonnées x,y, occupation de l’espace, vos marges, vos retours à la ligne ou saut de page, …) et de l’autre car la librairie va écrire pour vous le code PDF natif (https://pdfa.org/).

jsPDF n’est pas le nouveau messie dépourvu d’erreur, elle en a son lot, parfois même agaçante vous demandant pas mal d’effort ou d’ingéniosité, mais dans le paysage actuel c’est une base plus que correcte. Attention toute fois de ne pas confondre convertisseur de contenu HTML vers PDF et jsPDF qui vous permet de le créer/composer. Les convertisseurs, au super rendu et 3 lignes de code, utilisent la librairie canvas pour faire une forme de capture d’écran et injecter l’image dans un PDF, joli mais sans accessibilité (sélection du texte, c’est une image) ni modifications (pour la même raison que c’est une image).

Bien que la documentation existe, elle reste sommaire et j’ai moulte fois dû, et au final préféré, une lecture directe du code disponible sur github. Il est certain que si vous entrez dans des réflexions de faisabilité, de type de paramètre ou de compréhension, vous descendiez à ce niveau régulièrement. Notamment un éternel sujet complexe : la taille d’un texte.

But de l’article

Cet article à pour but de dégrossir des points d’attention pour ceux qui veulent se lancer dans l’écriture de PDF. Il n’est pas aisé sans notion infographique minimum de concevoir un document qui aura du sens et respectera quelques notions « élémentaires ». J’entend par là la notion de marges, de taille de texte, de police d’écriture et de son style, ou encore d’interlignage, d’alignement, d’indentation et j’en passe.

Les points

Dans ce monde vous aurez accès à divers unité, mais je trouve qu’il est plus aisé de rester natif et d’utiliser les points, tel qu’un millimètre = 2.83465 points (se référer aux pouces du coup). Ou encore, une A4, aura pour largeur 595 pts et une hauteur de 842 pts. Et pour compléter ce sujet, un pixel aura un rapport de 0.75 pour 1 pts (pensez à l’unité de vos images par exemple).

Les couleurs

Elles peuvent être gérée par code hexadécimal (#333333) ou par RGB/RGBA (channel(s) en jsPDF), ainsi nous utiliserons une structure, qui prendra place en ch1, ch2, ch3 et ch4 tel que :

export interface RGBColor {
    r: number,
    g: number,
    b: number,
    a?: number
}

Classe de base

Afin de mieux gérer notre contexte, je vous suggère de créer une classe qui vous permettra de conserver votre position x, y, vos marges, les dimensions calculées d’espace disponible, puis la fonte choisie, sa taille, son interlignage, etc.

import { jsPDF } from "jspdf";

export interface Margins {
    left: number
    right: number
    top: number
    bottom: number
}

export const DIM_A4_POINTS = { width: 595, height: 842 }
export const DIM_MM_POINTS = 2.83465
export const DIM_PX_POINTS = 0.75 // 1.333

export class KPdf {
    doc: jsPDF

    margins!: Margins // Defined in constructor

    x: number
    y: number
    pageWidth!: number // Updated by constructor call
    pageHeight!: number // Updated by constructor call

    fontSize: number = 10
    lineHeight: number = 12

    constructor() {
        this.doc = new jsPDF("p", "pt", "a4")
        this.setMargins(20 * DIM_MM_POINTS)

        this.x = this.margins.left
        this.y = this.margins.top
    }

    setMargins(margins: number): KPdf;
    setMargins(left: number, right?: number, top?: number, bottom?: number): KPdf {
        this.margins = right ? { left, right, top, bottom } as Margins
            : { left: left, right: left, top: left, bottom: left } as Margins
        this.updatePageDims()
        return this
    }

    updatePageDims(): void {
        // A4 forced format on portait orientation
        this.pageWidth = DIM_A4_POINTS.width - this.margins.left - this.margins.right
        this.pageHeight = DIM_A4_POINTS.height - this.margins.top - this.margins.bottom
    }
...

Cadeau, je vous ai résumé ici quelques éléments de base, voyons ça ensemble.

Marges

Les marges, donc l’espace réservé depuis chaque bord de votre feuille, seront quasi indispensable dans vos calculs et positionnements, l’objet va nous aider à nous y retrouver. L’interface est claire assez, il vous manquera un getMargins() pour l’usage mais je vous laisse l’ajouter ;).

J’ai utilisé ici un double setter permettant de setter les 4 bords d’un coup, égaux entre eux, comme dans le constructeur ou de pouvoir appeler sa version individuelle. Un énième alternative serait le passage d’un objet typé Margins, amusez vous, c’est selon vos besoins :).

Noter le type de retour (KPdf), pour ainsi pouvoir chaîner vos appels, quel confort quand même ;).

Constructeur

Le but est de créer notre base jsPDF correctement paramétré selon nos habitudes, besoins et choix technique, dans notre cas des pages en portait (« p »), en utilisant l’unité point (« pt ») et de dimension A4 (« a4 »).

Une fois établi, on met des marges par défaut, typiquement comme tous les traitement de textes qui vous proposent un modèle clef en main par défaut que personne ne retouche, sauf des gars comme nous ^^. C’est là qu’une première magie peut s’opérer, si on a des marges, connait alors notre origine (x,y) et l’espace disponible pour y mettre notre contenu.

J’ai également mis une taille de caractère (fontSize) à 10pt et son interlignage (lineHeight) à 12 en prenant l’échelle 1.2.

Et voilà vous êtes parti, bon amusement !

… si seulement ^^

Agrémenter KPdf

Vous avez une base, que vous ferez évoluer selon vos besoins au cas par cas de vos projets, perso je me suis mis des getter/setter pour x, y, xy, fontSize, lineHeight, mais aussi pour définir une en-tête et un pied de page. Là, je vous parlerai de delegate ou callback, un moyen de définir de manière extérieur un contenu à une méthode interne actuellement non définie.

export type HeaderPrototype = (pdf: jsPDF) => void

export class KPdf {
...

    headerFct?: HeaderPrototype
    footerFct?: HeaderPrototype

    setHeaderFct(fct: HeaderPrototype): KPdf {
        this.headerFct = fct
        return this
    }

    setFooterFct(fct: HeaderPrototype): KPdf {
        this.footerFct = fct
        return this
    }

    addHeader(): KPdf {
        if (this.headerFct) {
            this.headerFct(this.doc)
        }
        return this
    }

    addFooter(): KPdf {
        if (this.footerFct) {
            this.footerFct(this.doc)
        }
        return this
    }

    newPage(): KPdf {
        this.doc.addPage()
        this.addHeader().addFooter()
        this.setXY(
            this.margins.left,
            this.margins.top
        )
        return this
    }

...

Exemple ici avec la méthode newPage qui ajoutera une page au document final et appellera de toutes façons l’ajout d’une en-tête et pied de page, évidemment si définit par l’utilisateur (détecté en interne de méthode). Bonus ici, reset des x et y au saut de page.

En démarrage par contre, car jsPDF crée une page par défaut, il faut les ajouter à la main, tel que :

const kpdf = new KPdf()

// Setters, margins
...

// Manage first page
kpdf.addHeader().addFooter()

Gestion du texte

J’ai eu 2 cas différent en terme de traitement de contenu, un premier avec une source Tiptap, dont je parlerai surement dans un autre article sous forme de retour d’expérience, et un autre avec une gestion de cellule de tableau maison, dont je vais un peu vous faire quelques retours.

On utilisera la fonction text pour écrire du texte dans notre PDF et petit conseil en passant, mettez l’option baseline à « top » mieux gérer votre x/y. Autre chose à savoir, si vous voulez aligner à droite, votre x est le x en fin de ligne/bloc et il faut également mettre l’option align à « right » du coup.

Retour à la ligne

Ce n’est pas aussi bête qu’il n’y parait, même si la fonction text à l’air complète elle manque clairement de documentation, cas d’usages clairs.

C’est quoi un retour à la ligne, c’est d’arriver au bout de notre espace disponible, de forcer un retour chariot au début de notre ligne (x), quel qu’il soit (pas forcément la marge gauche, surtout si indentation), de passer une hauteur de ligne (augmenter notre y) et de continuer d’écrire notre contenu (on parle ici de rendu du texte que l’on veut injecter dans le PDF).

La question est comment on sait ça ? Posez-vous juste la question. Vous avez une somme de caractères (un paragraphe par exemple), j’imagine que vous connaissez la taille du texte et que vous êtes prêt de ce côté kpdf.doc.setFontSize(12) par exemple. Sans faire du suspens inutile, je vous conseille splitTextToSize qui va vous rendre un tableau de string coupé à distance maximum (votre pageWidth par exemple ou la largeur d’une cellule). Si vous injectez le résultat directement dans text, pensez à préciser l’option lineHeightFactor (1.2 dans notre cas), personnellement, pour d’autres raisons, j’ai géré à la main l’écriture de chaque ligne, donc j’utilise ma variable lineHeight et je positionne mon x et y également à la main.

Gras/italique

Si vous avez du gras ou de l’italique dans votre texte d’origine vous risquez d’avoir des surprises, déjà la fonction text ne gère qu’une configuration à la fois, c’est à dire que si j’ai une phrase telle que : « mon contenu est composé », j’aurais virtuellement 3 configurations :

  • « mon » : texte normal
  • « contenu » : texte gras
  • « est composé »: texte normal

Attention, ici on parle au sein d’une seule ligne, c’est encore plus fun (compliqué) quand on gère ça en multilignes. Qu’importe votre façon de gérer cette structure de bloc, vous allez devoir boucler sur cette liste, calculer pour chaque la longueur du bloc de texte et ainsi gérer un x progressive par appel de la méthode text. Grossièrement on aurait (on suppose la gestion des espaces entre blocs) :

this.doc.text("mon ", x, y, { baseline: "top" })
// Change style
this.doc.text("contenu ", x + w1, y, { baseline: "top" })
// Change style again
this.doc.text("est composé", x + w1 + w2, y, { baseline: "top" })

On voit facilement l’intérêt de la boucle pour faire progresser x successivement. Cela suppose que votre gestion de bloc retiendra la largeur de celui-ci une fois calculé.

Multiligne

Si on pousse le bouchon où le dernier bloc est trop large par rapport à votre pageWidth/taille de bloc, alors on peut subdiviser en coupant au mot (textPart.split(‘ ‘)) et tester chaque mot un à un, puis forcer le retour à la ligne et reprendre. Vous aurez un algo maison combinant par exemple getStringUnitWidth ou encore splitTextToSize et votre gestion d’effets.

Dans mon cas Tiptap, ayant dû gérer pareil cas, j’ai fortement utilisé getStringUnitWidth, mais qui a sa limite concernant le fait qu’il ne gère pas le gras/italique, il faut appliquer un facteur empirique (~1.08) ou trouver le descriptif par caractère de la fonte utilisée pour les cas : gras, italique, gras+italique; et réécrire le calcul de largeur d’un texte donné. C’était le même soucis avec FPDF, tout dépend des métas de la fonte utilisée, mais là j’admet que ça dépasse mon niveau sur le sujet.

Saut de page

Comme on l’a vu pour le retour à la ligne, c’est pareil en y, si on voit que ce que l’on va écrire va déborder au delà de la marge inférieure alors il nous faut créer une nouvelle page tel que vu en exemple de code plus haut, avec en-tête et pied de page, repositionner x et y et reprendre notre rendu. Plus simple quand on a compris le retour à la ligne.

Il faudra cependant prêter attention à l’indentation. Par exemple, si vous êtes en train d’écrire les éléments d’une liste, vous aurez l’indentation pour dessiner la bulle par exemple, et si vous gérer plusieurs niveaux d’imbrication alors là encore plus (pensez récursivité). Hors des listes, l’indentation existe telle que dans un traitement de ligne quand vous utilisez la tabulation ou une règle de positionnement de début de paragraphe.

Tableaux

Un tableau c’est quoi ? Au delà d’une structure à 2 dimensions, ce sont des espaces délimités en largeur (mais pas que, parfois) dans lesquels doit prendre place un contenu. Sans parler des considérations esthétique, de formes et de couleurs, nos cellules sont des blocs, successifs pour lesquels notre logique de retour à la ligne revient.

L’astuce esthétique et logique (x,y) nous oblige à garder en mémoire quelle cellule aura pris le plus de hauteur parmi la ligne courante, sinon, par exemple, si vous faites un zèbre (couleur de fond de ligne) comment l’appliquer sans créer des trous dans certaines cellule ? Vous êtes obligé de faire une passe de calcul (découpe des textes, retours à la lignes, gestion des blocs, …), puis une seconde passe de rendu en profitant des données calculées (couleur de fond, positionnement x,y).

Répétition de l’en-tête

Quand on parle de tableau on oublie souvent de définir ce qu’il se passe quand on saute de page. On aurait tendance à continuer de faire le rendu des cellules sans se tracasser, un peu comme certains tableurs, mais pourquoi ne pas répéter votre en-tête de tableau ? Cela demande une gymnastique à l’image de notre en-tête/pied de page, même logique, un poil plus complexe.

export class KPdf {

    cellPaddings!: Margins

    tableHeaderInitFct?: HeaderPrototype
    tableContentInitFct?: HeaderPrototype

    constructor() {

        this.setCellPaddings(0.5 * DIM_MM_POINTS)

    }

    // Table part
    addTableHead(columns: TableColumns[]): KPdf {
        if (this.tableHeaderInitFct) {
            this.tableHeaderInitFct(this.doc)
        }

        // X, Y, fontSize and LineHeight already set by user
        this.y += this.cellPaddings.top

        columns.forEach((c) => {
            const cellW = this.pageWidth * c.width

            this.doc.text(
                c.title,
                this.x + (c.align === "right" ? cellW - this.cellPaddings.right : this.cellPaddings.left),
                this.y,
                {
                    baseline: "top",
                    align: c.align
                }
            )

            this.x += cellW
        })

        this.y += this.lineHeight + this.cellPaddings.bottom

        return this
    }

    addTableContent(columns: TableColumns[], data: any[], zebraColor: RGBColor = { r: 240, g: 240, b: 240 }): KPdf {
        if (this.tableContentInitFct) {
            this.tableContentInitFct(this.doc)
        }

        let zebra = true

        data.forEach((d) => {
            let maxCellHeight = 0


            // Page break
            if (this.y >= this.margins.top + this.pageHeight) {
                this.newPage()
                this.addTableHead(columns)
                if (this.tableContentInitFct) {
                    this.tableContentInitFct(this.doc)
                }
            }
        })

        return this
    }

...

Donc entre 2 lignes du contenu de votre tableau, on va créer une nouvelle page, remettre l’en-tête et le pied de page, puis on va rappeler l’en-tête de tableau, qui va rappeler le style définit dans une fonction spécifique (et externe), puis remettre notre configuration de corps de tableau et reprendre notre rendu.

Notez que pour le coup nous aurons un nouveau jeu de marges à tenir en compte : le padding des cellules.

Sources

J’ai créé un gist publique avec le code de la classe k-pdf au complet pour ceux que ça intéresse, merci de mentionner le github de l’auteur en cas d’usage ;). J’ai également ajouté un code illustrant l’usage de ce qui a été vu ici, nettoyé, cela va sans dire.

Conclusions

Comme dit, cet article visait à dégrossir le sujet tout en faisant un retour d’expériences. Il n’y a certes pas qu’une façon de faire et le code peut sûrement être amélioré, n’hésitez pas à m’en faire part.

Images

Sujet quelque peu embêtant, car la seule manière trouvée efficace est la base64, pointer vers un asset local (VueJs, Vite) vers addImage ne fonctionne pas.

jsPDF
    .addImage(
        "data:image/png;base64,...",
        "PNG",
        x,
        y,
        wpx * DIM_PX_POINTS,
        hpx * DIM_PX_POINTS
    )

Ziggymacell on the prime route encore

Ou Ziggy route dans une cellule Datatable PrimeVue en mode composant de cellule.

Je ne vais pas répéter ici ce qui a été vu précédemment, ce que l’on cherche à faire est un composant utile et générique pour pouvoir rediriger l’utilisateur vers une route nommée avec les paramètres adéquats (variables donc) avec un libellé pouvant être basé sur les données de la lignes (rowData).

La route

Ziggy va alimenter notre objet route. Ce dernier contiendra la définition de notre router Laravel (pour rappel quand même). On a une route nommée : maroute-edit = /maroute/{id} .

Le composant de lien de cellule

On a besoin de lui passer l’url et le texte à afficher, mais on aimerait profiter du rowData pour construire ce texte et cette url (du fait des paramètres à passer), il nous faut donc un moyen d’intervention du côté de l’appelant.

Pour le texte on peut imaginer que le paramètre ne soit pas le libellé directement mais une fonction callback recevant rowData en params, nous permettant de retourner le string que l’on aura produit potentiellement avec.

Pour l’url c’est plus compliqué, on sait juste que l’on veut travailler avec des routes nommées, donc une propriété qui recevra le nom de la route, mais quid des paramètres ? Dans mon cas j’ai eu un besoin de l’attribut de route ‘id’, mais dans mon rowData c’est l’attribut xyz_id qui matchait et non l’id de ma row, du coup il nous faudrait un mapper qui serait un tableau de clefs avec la valeur à prendre dans le rowData.

<script setup lang="ts">
import { PropType } from "vue";

const props = defineProps({
    to: {
        type: String,
        required: true
    },
    text: {
        type: Function as PropType<(rowData: any) => string>,
        required: true
    },
    toParams: {
        type: Array as PropType<{ key: string, value: string }[]>
    },
    rowData: {
        type: Object,
        required: true
    }
})

function getRouteParams(): Object {
    const params = {}
    props.toParams?.forEach((tp) => {
        params[tp.key] = props.rowData![tp.value]
    })
    return params
}
</script>

<template>
    <Link :href="route(to, getRouteParams())">{{ text(rowData) }}</Link>
</template>

On notera que c’est Link et non router-link qui officie du coup, comme vu précédemment.

Rassemblement !

    {
        field: 'li_created_at',
        header: 'Dernière action',
        sortable: true,
        components: [
            {
                component: markRaw(LinkCell),
                props: {
                    to: "invoice-edit",
                    toParams: [
                        { key: 'id', value: 'li_id' }
                    ],
                    text: (rowData: any) => {
                        return rowData.li_num + ' (' + (new Date(rowData.li_created_at).toLocaleDateString()) + ')'
                    }
                }
            } as CellComponent
        ]
    }

En gros l’astuce sera dans props où l’on passera le nom de la route, le mapping de paramètres et la fonction callback pour rédiger le contenu. Ainsi on aura dans mon cas ce rendu avec lien fonctionnel.

ESLint, un emmerdeur qui a souvent raison

Ceci fait suite à l’article Goo 3 avec Laravel 9, Sail, Inertia, Vue3, tailwindcss, Vite et PrimeVue, car on a oublié un morceau : le linter; analyseur syntaxique aidant à améliorer la qualité de son développement en respectant certains standards et bonnes pratiques. Cet oubli est dû on montage manuel de la solution, alors qu’en passant par la ligne de commande il est proposé parmi une suite d’options.

Mise en place

Pour vue, voici directement le linter et son plugin, en suivant leur guide, s’en suit le fichier de configuration que l’on peut personnaliser pour coller à ses besoins.

npm install --save-dev eslint eslint-plugin-vue
module.exports = {
    extends: [
        // add more generic rulesets here, such as:
        // 'eslint:recommended',
        'plugin:vue/vue3-recommended',
        // 'plugin:vue/recommended' // Use this if you are using Vue.js 2.x.
    ],
    rules: {
        // override/add rules settings here, such as:
        // 'vue/no-unused-vars': 'error'
    }
}

Ça c’est la doc, mais évidemment ça ne se passera pas forcément bien car on est pas issu d’un projet généré par la ligne de commande, ben oui. J’ai donc cherché à comprendre un peu pourquoi le linter ne comprend pas Typescript, ni même module de son propre fichier de configuration. Ça commence mal.

Du coup, j’ai trouvé le npm @vue/eslint-config-typescript qui nous aide pas mal. J’ai installé et créé le fichier .eslintrc.cjs et j’y ai mis ce qu’ils indiquent :

/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")

module.exports = {
    extends: [
        'eslint:recommended',
        'plugin:vue/vue3-essential',
        '@vue/eslint-config-typescript'
    ]
}

Utilisant InteliJ, il vous faudra surement activer l’usage de la config.

Aménagements pour respecter le linter

C’est parti pour modifier pas mal de code, je vais donc corriger les blocs de code vu dans l’article PrimeVue : rendu et composant de cellule et vous expliquer la raison si elle n’est pas évidente.

Je commence par les modèles du tableau, component et son type Object devient any, du fait que <component> peut accepter un string en paramètre de :is.

Notez également la modification au niveau du prototype de renderer en transformant Object par un type plus précis, un tuple permettant de décrire la structure {clef: {}, clef2: {}, ...}, vous retrouverez cette modification plus loin également.

export interface ColDef {
    field: string
    header: string
    renderer?: (rowData: {[key: string]: any}, field?: string) => string
    sortable?: boolean
    components?: CellComponent[]
}

export interface CellComponent {
    component: any
    props?: Object
    on?: Object
    model?: Object
}

Ensuite, coté composant simple-table, différents points se présentent. D’abord les v-for sur composant inconnu ou <component> aiment avoir une clef d’identification unique (v-bind:key).

...
        <Column v-for="(c, ci) in cols" v-bind:key="ci" :field="c.field" :header="c.header" :sortable="c.sortable">
            <template #body="colProps" v-if="c.components">
                <component v-for="(comp, i) in c.components"
                           v-bind:key="i"
...

Le linter râlera également sur certaines valeurs par défaut des props ou du type de data. default est une fonction et data de rowClass trouve une définition any dans leur code et rien ailleurs.

...
    colsDef: {
        type: Array as PropType<ColDef[]>,
        required: true,
        default: () => []
    },
...
    rowClass: {
        type: Function,
        default: (data: any) => '' // Cf primevue github
    }
...

map râlait déjà que son hôte ne soit pas obligatoirement défini, ce qui est faux car required dans les props, j’imagine qu’il ne fait pas le liens, Angular a ce genre de soucis aussi. Et pour faire propre vu que l’on rend un objet neuf au retour de map, on le type.

...
const cols: ComputedRef<ColDef[]> = computed<ColDef[]>(() => {
    return props.colsDef!.map((c) => ({
...
    }) as ColDef)
})

const interceptEvents = (events: { [key: string]: Function } | undefined, rowData: Object, field: string): Object => {
    if (!events) {
        return {}
    }

    const interceptedEvents: { [key: string]: Function } = {}
    Object.keys(events).forEach((e) => {
        interceptedEvents[e] = (ev: Object) => {
            return events[e](ev, rowData, field)
        }
    })
    return interceptedEvents
}
...

Nous revoilà avec notre tuple, cette fois adapté pour les events. Et vu qu’on pourrait ne pas en donner, mais qu’on l’utilise, il vaut mieux avoir un contrôle et un retour propre. Attention que si on avait utilisé l’écriture events?: il aurait râlé car les paramètres facultatifs doivent se trouver après les impératifs. Et pour bien faire on type ev en Object.

Conclusion

Comme vous l’aurez peut-être expérimenté ou lu ici, si ce n’est pas fait à votre place, ce n’est pas forcément évident à mettre en route et à définir.

Il y a moyen de perdre beaucoup de temps si on ne s’y prend pas correctement dès le départ, c’est un réflexe à avoir. Et ici ce ne sont que quelques broutilles, ça fonctionne MAIS ce n’était pas assez bien. Et même si c’est peu de chose, cela aura pris à un padawan testeur une bonne après-midi.

PrimeVue : rendu et composant de cellule

Cette article donne suite à l’article PrimeNg vs AG Grid : rendu et composant de cellule sur une continuité de l’article Goo 3 avec Laravel 9, Sail, Inertia, Vue3, tailwindcss, Vite et PrimeVue.

Pensez à installer Vue.js devtools sur votre navigateur, ça vous sera utile pour voir que les données sont bien là, ou non.

Nous allons donc utiliser le DataTable de PrimeVue tel que nous l’avons fait dans le premier article et en faire un composant dynamique générique. Et malheureusement ce n’est pas un copier-coller de la version Angular, ça serait trop simple ;). Notez que je ne réexpliquerai pas tout vu que le détail se trouve dans les articles cités.

Contexte de base

On a donc un composant vue qui contient le DataTable, et une page qui appellera ce composant. Les données seront fournie automatiquement par Inertia, nous nous concentrerons sur le typage Typescript, la structure du composant et le raisonnement.

Le tableau avec rendu de cellule

Composant du tableau

Commençons par notre nouveau composant simple-table. Il s’agit donc de configurer un DataTable basique et de dynamiser les colonnes, pour cela on utilise une boucle v-for sur un composant Column en lui passant les paramètres qui nous intéressent. Ensuite, on peut se pencher sur le rendu de la valeur de la cellule via le template #body.

<template>
    <DataTable :value="props.data" sortField="props.defaultSort" :sortOrder="-1" responsiveLayout="scroll"
               stripedRows :rowClass="props.rowClass">
        <Column v-for="c in cols" :field="c.field" :header="c.header" :sortable="c.sortable">
            <template #body="colProps">
                {{ c.renderer(colProps.data, c.field) }}
            </template>
        </Column>
    </DataTable>
</template>

En option j’ai ajouté le célèbre zèbre de lignes et un argument rowClass, qui permet de préciser une méthode pour faire varier le design de la ligne.

Pour rappel nous sommes en mode composition et en TypeScript. Il nous faut maintenant définir les propriétés de notre composant, tel que data pour les données, colsDef pour la définition des colonnes, defaultSort pour la colonne à trier par défaut et rowClass mentionné juste avant. Notez qu’on en profite pour typer fortement les données que l’on requiert.

<script setup lang="ts">
import {computed, ComputedRef, PropType} from "vue";
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import {ColDef} from "../Models/table";
import {CellRenderer} from "../Services/cell-renderer";

const props = defineProps({
    data: {
        type: Array,
        required: true
    },
    colsDef: {
        type: Array as PropType<ColDef[]>,
        required: true,
        default: []
    },
    defaultSort: {
        type: String,
        required: false,
        default: 'id'
    },
    rowClass: {
        type: Function,
        default: (data) => ''
    }
})

const cols: ComputedRef<ColDef[]> = computed<ColDef[]>(() => {
    return props.colsDef.map((c) => ({
        ...c,
        sortable: c.sortable == null ? false : c.sortable,
        renderer: c.renderer == null ? CellRenderer.none : c.renderer
    }))
})
</script>

L’idée reste d’envoyer un minimum de configuration et d’en déduire les valeurs par défaut, tel que la méthode de rendu de cellule par défaut ou le fait de pouvoir trier ou non une colonne. Pour ceci on passera par une variable computed , sans oublier de la typer, pour générer la liste des configurations de colonnes utilisées par DataTable.

En parlant de typage, voici l’interface utilisée pour la définition de colonnes :

export interface ColDef {
    field: string
    header: string
    renderer?: (rowData: Object, field?: string) => string
    sortable?: boolean
}

La class du CellRenderer est la même que dans la version Angular.

Appel du composant et configuration

Pour l’appel c’est simple, on mentionne notre composant en lui passant les paramètres que l’on veut. Classique quoi, mais en Vue.

<template>
...
    <simple-table
        :cols-def="colsDef"
        :data="fakeInvoices"
        default-sort="created_at"
        :row-class="rowClass"
    ></simple-table>
...
</template>

Côté code j’ai préparé de fausses données que j’ai simplifié pour l’exemple, ainsi que les méthodes utilitaires ou de rendu. Nous sommes dans un contexte de logiciel comptable et on affiche ici des factures, on aura 4 colonnes qui auront besoin d’afficher une valeur présentable, tel que la date ou le statut.

<script lang="ts" setup>
import BasicLayout from '../Layouts/basic.vue'
import Button from 'primevue/button';
import SimpleTable from "../Components/simple-table.vue";
import {Ref, ref} from "vue";
import {Invoice} from "../Models/invoice";
import {ColDef} from "../Models/table";
import {CellRenderer} from "../Services/cell-renderer";

const fakeInvoices: Ref<Invoice[]> = ref([
    {
        num: '123416789',
        client_id: 1,
        status: 1,
        client: {
            name: 'Client 1'
        },
        created_at: '2023-01-01T13:16:12.000000Z'
    },
    {
        num: '124518963',
        client_id: 2,
        status: 0,
        client: {
            name: 'Client 2'
        },
        created_at: '2023-01-01T13:16:12.000000Z'
    }
])

let colsDef: Ref<ColDef[]> = ref([
    {
        field: 'num',
        header: 'N°',
        sortable: true
    }, {
        field: 'client_id',
        header: 'Client',
        sortable: true,
        renderer: (rowData, field) => {
            return rowData?.client.name
        }
    }, {
        field: 'created_at',
        header: 'Crée le',
        sortable: true,
        renderer: CellRenderer.date
    }, {
        field: 'status',
        header: 'Statut',
        sortable: true,
        renderer: (rowData, field) => {
            return (rowData[field]) ? 'Payé' : 'Non-payé'
        }
    }
])

function rowClass(data: Invoice): string {
    return data.status ? 'paid' : 'unpaid'
}
</script>

Composant de cellule

On va d’abord modifier l’interface colDef pour intégrer cette histoire de composant.

export interface ColDef {
    field: string
    header: string
    renderer?: (rowData: Object, field?: string) => string
    sortable?: boolean
    components?: CellComponent[]
}

export interface CellComponent {
    component: Object
    props?: Object
    on?: Object
    model?: Object
}

Notez que par rapport à la version Angular j’ai modifié l’interface CellComponent pour coller à la suite et à l’univers Vue. Il nous faut maintenant modifier notre composant simple-table.

...
        <Column v-for="c in cols" :field="c.field" :header="c.header" :sortable="c.sortable">
            <template #body="colProps" v-if="c.components">
                <component v-for="comp in c.components"
                           :is="comp.component"
                           v-bind="comp.props"
                           v-on="comp.on"
                ></component>
            </template>
            <template #body="colProps" v-else>
                {{ c.renderer(colProps.data, c.field) }}
            </template>
        </Column>
...

On conditionnera l’usage au niveau template avec l’option components définie, sinon le rendu de cellule fera son office. Notez la simplicité, et donc la complexité/contrainte, de ce composant <component>. On doit lui envoyer un composant et ses propriétés (ainsi que son modèle si on regarde la définition totale). Là normalement vous raccrochez le wagon de l’interface, il nous reste donc l’application de tout ceci.

...
    }, {
        field: '',
        header: 'Actions',
        components: [
            {
                component: markRaw(Button),
                props: {
                    class: 'p-button-rounded p-button-secondary',
                    icon: 'pi pi-user'
                },
                on: {
                    click: (e) => {
                        console.log(e)
                    }
                }
            }
        ]
    }
...

On ajoute une colonne d’actions à notre définition et on souhaite un bouton icône, là j’utilise simplement un Button de PrimeVue tel quel et je précise en props l’icône désirée et le style du bouton via sa classe, c’est pas plus sorcier au final. Il reste le markRaw qui est la réponse à une erreur qui se produit sans ^^, à priori la belle manière, je n’ai pas creusé ce coup-ci.

Le bouton s’affiche bel et bien en lieu et place
Contenu de l’événement renvoyé quand on clique sur le bouton

Passer les valeurs de la ligne

Notre composant aimera surement savoir son contexte : la ligne courante du tableau et la colonne dans laquelle il se trouve, ce sont des bons repères pour agir ;). On commencera par étendre le v-bind avec le spread opérator, mais ensuite pour les events c’est plus compliqué, nous allons les intercepter.

...
                <component v-for="comp in c.components"
                           :is="comp.component"
                           v-bind="{...comp.props, rowData: colProps.data, field: c.field}"
                           v-on="interceptEvents(comp.on, colProps.data, c.field)"
                ></component>
...
...
const interceptEvents = (events: Object, rowData: Object, field: string): Object => {
    const interceptedEvents = {}
    Object.keys(events).forEach((e) => {
        interceptedEvents[e] = (ev) => {
            return events[e](ev, rowData, field)
        }
    })
    return interceptedEvents
}
...

On redéfinit les définitions d’appel des événements pour ajouter 2 paramètres, la ligne et son champs courant. En ayant préalablement vérifié sa structure. Et du coup l’usage change ainsi :

...
                on: {
                    click: (e, rowData: Invoice, field: string) => {
                        console.log(e, rowData, field)
                    }
                }
...
On observe bien 3 paramètres logué dans la console.

Sources additionnelles

Conclusion

Nous voilà donc avec un tout nouveau, mais non-réinventé, composant tableau dynamique générique, en Vue pour le coup.

Reste à développer ou explorer les limites pour d’autres types de composants à envoyer au tableau.