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.