@benjosivo/table-query 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +288 -214
- package/dist/react/DataTable.d.ts +1 -1
- package/dist/react/DataTable.js +11 -3
- package/dist/react/Table.d.ts +17 -3
- package/dist/react/Table.js +47 -12
- package/dist/react/index.d.ts +1 -1
- package/dist/react/types.d.ts +10 -0
- package/dist/react/useDataTable.d.ts +8 -1
- package/dist/react/useDataTable.js +81 -2
- package/dist/react/utils.d.ts +4 -0
- package/dist/react/utils.js +11 -0
- package/dist/server/index.js +23 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,215 +1,289 @@
|
|
|
1
|
-
# @benjosivo/table-query
|
|
2
|
-
|
|
3
|
-
Tableau de données triable, filtrable et paginé, en deux parties indépendantes :
|
|
4
|
-
|
|
5
|
-
- **`@benjosivo/table-query/server`** — logique de requêtage côté serveur (Express + MySQL) : pagination, tri, filtres, calcul des valeurs disponibles pour chaque colonne, mise en cache optionnelle.
|
|
6
|
-
- **`@benjosivo/table-query/react`** — composants et hook React pour afficher ce type de données : tableau, pagination, filtre rapide par colonne, panneau de filtres avancés (multiselect / plage numérique / plage de dates).
|
|
7
|
-
|
|
8
|
-
Les deux parties fonctionnent ensemble mais peuvent aussi être utilisées séparément (par exemple le composant React seul, avec ton propre backend).
|
|
9
|
-
|
|
10
|
-
## Installation
|
|
11
|
-
|
|
12
|
-
```bash
|
|
13
|
-
npm install @benjosivo/table-query
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
Selon la partie utilisée, il faut aussi avoir installé dans le projet :
|
|
17
|
-
|
|
18
|
-
| Partie utilisée | Dépendances nécessaires |
|
|
19
|
-
|---|---|
|
|
20
|
-
| `/server` | `express`, `@benjosivo/mysql` |
|
|
21
|
-
| `/react` | `react` (v18+) |
|
|
22
|
-
|
|
23
|
-
Ce sont des `peerDependencies` : le package ne les installe pas lui-même, il utilise celles déjà présentes dans ton projet.
|
|
24
|
-
|
|
25
|
-
---
|
|
26
|
-
|
|
27
|
-
## Côté serveur
|
|
28
|
-
|
|
29
|
-
### Créer le module
|
|
30
|
-
|
|
31
|
-
```ts
|
|
32
|
-
import { createTableQueryModule } from '@benjosivo/table-query/server';
|
|
33
|
-
|
|
34
|
-
const { router, reqTableQuery } = createTableQueryModule({
|
|
35
|
-
// Formate une date/heure au format attendu par MySQL
|
|
36
|
-
convertToMySQLDateTime: (date) => /* ta fonction */,
|
|
37
|
-
|
|
38
|
-
// Enveloppe un handler Express (gestion d'erreurs, etc.)
|
|
39
|
-
wrapRouteHandler: (fn) => /* ta fonction */,
|
|
40
|
-
|
|
41
|
-
// Optionnel : uniquement nécessaire si tu veux pouvoir utiliser le cache (voir plus bas)
|
|
42
|
-
cache: {
|
|
43
|
-
getSQLCache: (key) => /* lit une entrée de cache */,
|
|
44
|
-
setSQLCache: (key, value) => /* écrit une entrée de cache */,
|
|
45
|
-
deleteSQLCache: (key) => /* supprime une entrée de cache */,
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
`router` expose la route `GET /getFiltres`, utilisée en interne pour récupérer les filtres calculés en tâche de fond. Monte-le sur le chemin de ton choix :
|
|
51
|
-
|
|
52
|
-
```ts
|
|
53
|
-
app.use('/tableCreation/api', router);
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
### Répondre à une requête de table
|
|
57
|
-
|
|
58
|
-
```ts
|
|
59
|
-
app.post('/api/commandes', wrapRouteHandler(async (req, res) => {
|
|
60
|
-
const result = await reqTableQuery({
|
|
61
|
-
query: `SELECT c.*, COUNT(*) OVER() AS TotalCount FROM commandes c`,
|
|
62
|
-
req,
|
|
63
|
-
// Un type de filtre par colonne renvoyée par la requête (sauf la 1ère, l'identifiant)
|
|
64
|
-
paramFilter: ['MULTISELECT', 'HIDE', 'DATE', 'MULTISELECT'],
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
if (result.error) return res.status(result.status ?? 500).send(result.error);
|
|
68
|
-
res.json(result.data);
|
|
69
|
-
}));
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
`reqTableQuery` lit `limit`, `offset`, `sorting`, `filtre` et `setFilter` dans `req.body` (ou `req.query`) — c'est exactement ce que le composant React `DataTable`/`useDataTable` envoie, donc les deux côtés s'emboîtent directement.
|
|
73
|
-
|
|
74
|
-
### Types de filtre disponibles (`paramFilter`)
|
|
75
|
-
|
|
76
|
-
| Type | Effet |
|
|
77
|
-
|---|---|
|
|
78
|
-
| `'MULTISELECT'` | Liste de valeurs distinctes de la colonne, sélection multiple |
|
|
79
|
-
| `'UNGROUP_MULTISELECT'` | Comme `MULTISELECT`, mais éclate les valeurs séparées par `, ` dans une même cellule |
|
|
80
|
-
| `'SLIDER'` | Plage numérique (min/max) |
|
|
81
|
-
| `'DATE'` / `'DATETIME'` | Plage de dates |
|
|
82
|
-
| `'JSON'` / `'FILE'` | Pas de filtre, juste un rendu spécifique côté React |
|
|
83
|
-
| `'HIDE'` | Colonne sans filtre |
|
|
84
|
-
| `null` | Colonne sans filtre particulier |
|
|
85
|
-
|
|
86
|
-
### Le cache (`useCache`)
|
|
87
|
-
|
|
88
|
-
Le cache est activé **par appel**, pas globalement :
|
|
89
|
-
|
|
90
|
-
```ts
|
|
91
|
-
reqTableQuery({ query, req, paramFilter, useCache: true }); // utilise le cache
|
|
92
|
-
reqTableQuery({ query, req, paramFilter, useCache: false }); // toujours une donnée fraîche
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
- Si `cache` a été fourni à `createTableQueryModule`, `useCache` vaut `true` par défaut.
|
|
96
|
-
- Si `cache` n'a pas été fourni, `useCache` vaut `false` par défaut (aucun Redis requis).
|
|
97
|
-
- Demander `useCache: true` sans avoir fourni `cache` lève une erreur explicite.
|
|
98
|
-
- Avec `useCache: false`, les valeurs de filtre disponibles sont calculées et renvoyées directement dans la réponse. Avec `useCache: true`, elles sont calculées en tâche de fond et le composant React va les chercher via `/getFiltres` (polling automatique, rien à faire côté appelant).
|
|
99
|
-
|
|
100
|
-
---
|
|
101
|
-
|
|
102
|
-
## Côté React
|
|
103
|
-
|
|
104
|
-
### Utilisation simple (tout inclus)
|
|
105
|
-
|
|
106
|
-
```tsx
|
|
107
|
-
import { DataTable } from '@benjosivo/table-query/react';
|
|
108
|
-
|
|
109
|
-
function CommandesTable() {
|
|
110
|
-
return (
|
|
111
|
-
<DataTable
|
|
112
|
-
fetchData={(params) =>
|
|
113
|
-
fetch('/api/commandes', {
|
|
114
|
-
method: 'POST',
|
|
115
|
-
headers: { 'Content-Type': 'application/json' },
|
|
116
|
-
body: JSON.stringify(params),
|
|
117
|
-
}).then((res) => (res.ok ? res.json() : null))
|
|
118
|
-
}
|
|
119
|
-
onRowClick={(id, row) => console.log('ligne cliquée', id, row)}
|
|
120
|
-
advancedFilters
|
|
121
|
-
/>
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
#### Props de `<DataTable />`
|
|
127
|
-
|
|
128
|
-
| Prop | Type | Description |
|
|
129
|
-
|---|---|---|
|
|
130
|
-
| `fetchData` | `(params) => Promise<FetchResult \| null>` | **Obligatoire.** Appelée à chaque changement de page/tri/filtre. |
|
|
131
|
-
| `onRowClick` | `(id, row) => void` | Appelée au clic sur une ligne |
|
|
132
|
-
| `filterEnabled` | `boolean` (défaut `true`) | Affiche le bouton de filtre rapide par colonne |
|
|
133
|
-
| `sortingEnabled` | `boolean` (défaut `true`) | Active le tri au clic sur l'en-tête |
|
|
134
|
-
| `advancedFilters` | `boolean` (défaut `false`) | Affiche le panneau de filtres avancés (nécessite `setFilter`/`paramFilter` côté serveur) |
|
|
135
|
-
| `height` | `string` (défaut `'76vh'`) | Hauteur max de la zone de défilement |
|
|
136
|
-
| `rowsPerPageOptions` | `number[]` | Choix disponibles pour le nombre de lignes par page |
|
|
137
|
-
| `defaultRowsPerPage` | `number` | Valeur par défaut |
|
|
138
|
-
| `onImagePreview` | `(src) => void` | Appelée au clic sur une image (sinon ouverture dans un nouvel onglet) |
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
1
|
+
# @benjosivo/table-query
|
|
2
|
+
|
|
3
|
+
Tableau de données triable, filtrable et paginé, en deux parties indépendantes :
|
|
4
|
+
|
|
5
|
+
- **`@benjosivo/table-query/server`** — logique de requêtage côté serveur (Express + MySQL) : pagination, tri, filtres, calcul des valeurs disponibles pour chaque colonne, mise en cache optionnelle.
|
|
6
|
+
- **`@benjosivo/table-query/react`** — composants et hook React pour afficher ce type de données : tableau, pagination, filtre rapide par colonne, panneau de filtres avancés (multiselect / plage numérique / plage de dates).
|
|
7
|
+
|
|
8
|
+
Les deux parties fonctionnent ensemble mais peuvent aussi être utilisées séparément (par exemple le composant React seul, avec ton propre backend).
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @benjosivo/table-query
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Selon la partie utilisée, il faut aussi avoir installé dans le projet :
|
|
17
|
+
|
|
18
|
+
| Partie utilisée | Dépendances nécessaires |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `/server` | `express`, `@benjosivo/mysql` |
|
|
21
|
+
| `/react` | `react` (v18+) |
|
|
22
|
+
|
|
23
|
+
Ce sont des `peerDependencies` : le package ne les installe pas lui-même, il utilise celles déjà présentes dans ton projet.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Côté serveur
|
|
28
|
+
|
|
29
|
+
### Créer le module
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createTableQueryModule } from '@benjosivo/table-query/server';
|
|
33
|
+
|
|
34
|
+
const { router, reqTableQuery } = createTableQueryModule({
|
|
35
|
+
// Formate une date/heure au format attendu par MySQL
|
|
36
|
+
convertToMySQLDateTime: (date) => /* ta fonction */,
|
|
37
|
+
|
|
38
|
+
// Enveloppe un handler Express (gestion d'erreurs, etc.)
|
|
39
|
+
wrapRouteHandler: (fn) => /* ta fonction */,
|
|
40
|
+
|
|
41
|
+
// Optionnel : uniquement nécessaire si tu veux pouvoir utiliser le cache (voir plus bas)
|
|
42
|
+
cache: {
|
|
43
|
+
getSQLCache: (key) => /* lit une entrée de cache */,
|
|
44
|
+
setSQLCache: (key, value) => /* écrit une entrée de cache */,
|
|
45
|
+
deleteSQLCache: (key) => /* supprime une entrée de cache */,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`router` expose la route `GET /getFiltres`, utilisée en interne pour récupérer les filtres calculés en tâche de fond. Monte-le sur le chemin de ton choix :
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
app.use('/tableCreation/api', router);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Répondre à une requête de table
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
app.post('/api/commandes', wrapRouteHandler(async (req, res) => {
|
|
60
|
+
const result = await reqTableQuery({
|
|
61
|
+
query: `SELECT c.*, COUNT(*) OVER() AS TotalCount FROM commandes c`,
|
|
62
|
+
req,
|
|
63
|
+
// Un type de filtre par colonne renvoyée par la requête (sauf la 1ère, l'identifiant)
|
|
64
|
+
paramFilter: ['MULTISELECT', 'HIDE', 'DATE', 'MULTISELECT'],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (result.error) return res.status(result.status ?? 500).send(result.error);
|
|
68
|
+
res.json(result.data);
|
|
69
|
+
}));
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`reqTableQuery` lit `limit`, `offset`, `sorting`, `filtre` et `setFilter` dans `req.body` (ou `req.query`) — c'est exactement ce que le composant React `DataTable`/`useDataTable` envoie, donc les deux côtés s'emboîtent directement.
|
|
73
|
+
|
|
74
|
+
### Types de filtre disponibles (`paramFilter`)
|
|
75
|
+
|
|
76
|
+
| Type | Effet |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `'MULTISELECT'` | Liste de valeurs distinctes de la colonne, sélection multiple |
|
|
79
|
+
| `'UNGROUP_MULTISELECT'` | Comme `MULTISELECT`, mais éclate les valeurs séparées par `, ` dans une même cellule |
|
|
80
|
+
| `'SLIDER'` | Plage numérique (min/max) |
|
|
81
|
+
| `'DATE'` / `'DATETIME'` | Plage de dates |
|
|
82
|
+
| `'JSON'` / `'FILE'` | Pas de filtre, juste un rendu spécifique côté React |
|
|
83
|
+
| `'HIDE'` | Colonne sans filtre |
|
|
84
|
+
| `null` | Colonne sans filtre particulier |
|
|
85
|
+
|
|
86
|
+
### Le cache (`useCache`)
|
|
87
|
+
|
|
88
|
+
Le cache est activé **par appel**, pas globalement :
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
reqTableQuery({ query, req, paramFilter, useCache: true }); // utilise le cache
|
|
92
|
+
reqTableQuery({ query, req, paramFilter, useCache: false }); // toujours une donnée fraîche
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- Si `cache` a été fourni à `createTableQueryModule`, `useCache` vaut `true` par défaut.
|
|
96
|
+
- Si `cache` n'a pas été fourni, `useCache` vaut `false` par défaut (aucun Redis requis).
|
|
97
|
+
- Demander `useCache: true` sans avoir fourni `cache` lève une erreur explicite.
|
|
98
|
+
- Avec `useCache: false`, les valeurs de filtre disponibles sont calculées et renvoyées directement dans la réponse. Avec `useCache: true`, elles sont calculées en tâche de fond et le composant React va les chercher via `/getFiltres` (polling automatique, rien à faire côté appelant).
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Côté React
|
|
103
|
+
|
|
104
|
+
### Utilisation simple (tout inclus)
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
import { DataTable } from '@benjosivo/table-query/react';
|
|
108
|
+
|
|
109
|
+
function CommandesTable() {
|
|
110
|
+
return (
|
|
111
|
+
<DataTable
|
|
112
|
+
fetchData={(params) =>
|
|
113
|
+
fetch('/api/commandes', {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify(params),
|
|
117
|
+
}).then((res) => (res.ok ? res.json() : null))
|
|
118
|
+
}
|
|
119
|
+
onRowClick={(id, row) => console.log('ligne cliquée', id, row)}
|
|
120
|
+
advancedFilters
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
#### Props de `<DataTable />`
|
|
127
|
+
|
|
128
|
+
| Prop | Type | Description |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `fetchData` | `(params) => Promise<FetchResult \| null>` | **Obligatoire.** Appelée à chaque changement de page/tri/filtre. |
|
|
131
|
+
| `onRowClick` | `(id, row) => void` | Appelée au clic sur une ligne |
|
|
132
|
+
| `filterEnabled` | `boolean` (défaut `true`) | Affiche le bouton de filtre rapide par colonne |
|
|
133
|
+
| `sortingEnabled` | `boolean` (défaut `true`) | Active le tri au clic sur l'en-tête |
|
|
134
|
+
| `advancedFilters` | `boolean` (défaut `false`) | Affiche le panneau de filtres avancés (nécessite `setFilter`/`paramFilter` côté serveur) |
|
|
135
|
+
| `height` | `string` (défaut `'76vh'`) | Hauteur max de la zone de défilement |
|
|
136
|
+
| `rowsPerPageOptions` | `number[]` | Choix disponibles pour le nombre de lignes par page |
|
|
137
|
+
| `defaultRowsPerPage` | `number` | Valeur par défaut |
|
|
138
|
+
| `onImagePreview` | `(src) => void` | Appelée au clic sur une image (sinon ouverture dans un nouvel onglet) |
|
|
139
|
+
| `selectable` | `boolean` (défaut `false`) | Affiche une colonne de checkbox (voir « Sélection de lignes ») |
|
|
140
|
+
| `selectionColumnPosition` | `number \| 'start' \| 'end'` (défaut `'start'`) | Position de la colonne de checkbox parmi les colonnes visibles |
|
|
141
|
+
| `selectedIds` | `any[]` | Sélection contrôlée par le parent (voir plus bas) |
|
|
142
|
+
| `onSelectionChange` | `(ids, rows) => void` | Appelée à chaque changement de sélection, avec les ids et les lignes complètes |
|
|
143
|
+
|
|
144
|
+
### Sélection de lignes (checkbox)
|
|
145
|
+
|
|
146
|
+
Avec `selectable`, une colonne de checkbox est ajoutée. La checkbox de l'en-tête coche/décoche **toutes les lignes affichées** (la page courante) et passe en état indéterminé quand seule une partie l'est.
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
const [selection, setSelection] = useState<any[]>([]);
|
|
150
|
+
|
|
151
|
+
<DataTable
|
|
152
|
+
fetchData={fetchCommandes}
|
|
153
|
+
selectable
|
|
154
|
+
selectionColumnPosition='start' // 'start' | 'end' | index (ex. 2)
|
|
155
|
+
onSelectionChange={(ids, rows) => {
|
|
156
|
+
setSelection(ids); // ids = valeur de la 1ère colonne de chaque ligne
|
|
157
|
+
console.log(rows); // les lignes entières, comme dans onRowClick
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- **Identifiant d'une ligne** : la valeur de sa **première colonne** — la même règle que `onRowClick`. La colonne peut être masquée (`HIDE`), l'identifiant reste utilisable.
|
|
163
|
+
- **Position** : `'start'` (défaut), `'end'`, ou un index 0-based **parmi les colonnes visibles** (`2` = 3ᵉ colonne ; les colonnes `HIDE` ne comptent pas). Un index hors limites est ramené au début ou à la fin.
|
|
164
|
+
- **Entre les pages** : la sélection est cumulative — on peut cocher des lignes page 1, aller page 2, et `onSelectionChange` renvoie l'ensemble (ids **et** lignes complètes, même celles qui ne sont plus affichées).
|
|
165
|
+
- **Changement de tri ou de filtre** : le jeu de données n'est plus le même, la sélection est donc vidée et `onSelectionChange([], [])` est appelée. Changer de page ou le nombre de lignes par page ne la vide pas.
|
|
166
|
+
- Cliquer une checkbox ne déclenche pas `onRowClick`.
|
|
167
|
+
|
|
168
|
+
#### Sélection contrôlée
|
|
169
|
+
|
|
170
|
+
Si le parent passe `selectedIds`, c'est lui qui détient l'état : le tableau n'affiche que ce qu'on lui donne. Pratique pour cocher des lignes par programme ou vider la sélection après une action groupée.
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
const [selection, setSelection] = useState<any[]>([]);
|
|
174
|
+
|
|
175
|
+
const supprimer = async () => {
|
|
176
|
+
await fetch('/api/commandes/suppression', { method: 'POST', body: JSON.stringify({ ids: selection }) });
|
|
177
|
+
setSelection([]); // on vide la sélection nous-mêmes
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
<DataTable
|
|
181
|
+
fetchData={fetchCommandes}
|
|
182
|
+
selectable
|
|
183
|
+
selectedIds={selection}
|
|
184
|
+
onSelectionChange={(ids) => setSelection(ids)}
|
|
185
|
+
/>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Utilisation avec ta propre UI de filtre
|
|
189
|
+
|
|
190
|
+
Si le système de filtre par défaut ne convient pas, utilise le hook et les composants séparément :
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
import { useDataTable, Table, Pagination } from '@benjosivo/table-query/react';
|
|
194
|
+
|
|
195
|
+
function CommandesTable() {
|
|
196
|
+
const table = useDataTable({ fetchData: fetchCommandes });
|
|
197
|
+
const [recherche, setRecherche] = useState('');
|
|
198
|
+
|
|
199
|
+
const rechercher = () => {
|
|
200
|
+
table.replaceFilters({ reference: [`/*/${recherche}/*/`] });
|
|
201
|
+
// ou, filtre par filtre : table.setColumnFilter('statut', ['en_cours']);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<>
|
|
206
|
+
<input value={recherche} onChange={(e) => setRecherche(e.target.value)} />
|
|
207
|
+
<button onClick={rechercher}>Rechercher</button>
|
|
208
|
+
|
|
209
|
+
<Table
|
|
210
|
+
items={table.items}
|
|
211
|
+
fieldsType={table.fieldsType}
|
|
212
|
+
sortColumn={table.sortColumn}
|
|
213
|
+
sortDirection={table.sortDirection}
|
|
214
|
+
onSort={table.toggleSort}
|
|
215
|
+
/>
|
|
216
|
+
|
|
217
|
+
<Pagination
|
|
218
|
+
page={table.page}
|
|
219
|
+
totalPages={table.totalPages}
|
|
220
|
+
perPage={table.perPage}
|
|
221
|
+
count={table.count}
|
|
222
|
+
onChangePerPage={table.changePerPage}
|
|
223
|
+
onFirst={table.firstPage}
|
|
224
|
+
onPrevious={table.previousPage}
|
|
225
|
+
onNext={table.nextPage}
|
|
226
|
+
onLast={table.lastPage}
|
|
227
|
+
/>
|
|
228
|
+
</>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`useDataTable` gère la pagination, le tri et l'appel réseau ; il n'impose aucune UI de filtre — `table.setColumnFilter(colonne, valeur)` et `table.replaceFilters(objet)` permettent de piloter le filtrage depuis n'importe quelle interface.
|
|
234
|
+
|
|
235
|
+
Le hook gère aussi la sélection, que tu utilises `<DataTable />` ou le `<Table />` nu :
|
|
236
|
+
|
|
237
|
+
```tsx
|
|
238
|
+
const table = useDataTable({ fetchData, onSelectionChange: (ids, rows) => console.log(ids, rows) });
|
|
239
|
+
|
|
240
|
+
<Table
|
|
241
|
+
items={table.items}
|
|
242
|
+
fieldsType={table.fieldsType}
|
|
243
|
+
selectable
|
|
244
|
+
selectionColumnPosition='end'
|
|
245
|
+
selectedIds={table.selectedIds}
|
|
246
|
+
onToggleRow={table.setRowSelected}
|
|
247
|
+
onToggleAllRows={table.setAllRowsSelected}
|
|
248
|
+
/>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
| Valeur renvoyée | Type | Description |
|
|
252
|
+
|---|---|---|
|
|
253
|
+
| `selectedIds` | `any[]` | Ids sélectionnés (1ère colonne de chaque ligne) |
|
|
254
|
+
| `selectedRows` | `T[]` | Les lignes complètes correspondantes, pages précédentes comprises |
|
|
255
|
+
| `isRowSelected` | `(row) => boolean` | |
|
|
256
|
+
| `setRowSelected` | `(row, selected) => void` | |
|
|
257
|
+
| `toggleRowSelection` | `(row) => void` | |
|
|
258
|
+
| `setAllRowsSelected` | `(selected) => void` | Toutes les lignes affichées ; les autres pages ne sont pas touchées |
|
|
259
|
+
| `clearSelection` | `() => void` | Vide la sélection |
|
|
260
|
+
|
|
261
|
+
### Format attendu par `fetchData`
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
interface FetchParams {
|
|
265
|
+
limit: number;
|
|
266
|
+
offset: number;
|
|
267
|
+
sorting?: string;
|
|
268
|
+
filtre?: Record<string, unknown>;
|
|
269
|
+
setFilter?: boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface FetchResult<T> {
|
|
273
|
+
items: T[];
|
|
274
|
+
count: number;
|
|
275
|
+
fieldsType?: { fieldType: string }[]; // 'DATE' | 'DATETIME' | 'JSON' | 'FILE' | 'BLOB' | ...
|
|
276
|
+
filtre?: FilterConfig; // renvoyé par le serveur si des filtres avancés sont demandés
|
|
277
|
+
cleRecupFiltre?: string; // utilisé en interne pour le polling du cache
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
C'est exactement la forme renvoyée par `reqTableQuery` côté serveur.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Build
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
npm run build # compile src/react et src/server dans dist/
|
|
215
289
|
```
|
|
@@ -6,4 +6,4 @@ import type { DataTableProps } from './types.js';
|
|
|
6
6
|
* If you want your own filter UI, don't use this component: compose `useDataTable` +
|
|
7
7
|
* `Table` + `Pagination` directly instead (see README "Using the pieces separately").
|
|
8
8
|
*/
|
|
9
|
-
export declare function DataTable<T extends Record<string, any>>({ fetchData, onRowClick, filterEnabled, sortingEnabled, advancedFilters, height, rowsPerPageOptions, defaultRowsPerPage, onImagePreview, fetchFilterConfig, }: DataTableProps<T>): import("react").JSX.Element;
|
|
9
|
+
export declare function DataTable<T extends Record<string, any>>({ fetchData, onRowClick, filterEnabled, sortingEnabled, advancedFilters, height, rowsPerPageOptions, defaultRowsPerPage, onImagePreview, fetchFilterConfig, selectable, selectionColumnPosition, selectedIds, onSelectionChange, }: DataTableProps<T>): import("react").JSX.Element;
|
package/dist/react/DataTable.js
CHANGED
|
@@ -12,15 +12,23 @@ import { Pagination } from './Pagination.js';
|
|
|
12
12
|
* If you want your own filter UI, don't use this component: compose `useDataTable` +
|
|
13
13
|
* `Table` + `Pagination` directly instead (see README "Using the pieces separately").
|
|
14
14
|
*/
|
|
15
|
-
export function DataTable({ fetchData, onRowClick, filterEnabled = true, sortingEnabled = true, advancedFilters = false, height = '76vh', rowsPerPageOptions, defaultRowsPerPage, onImagePreview, fetchFilterConfig, }) {
|
|
16
|
-
const table = useDataTable({
|
|
15
|
+
export function DataTable({ fetchData, onRowClick, filterEnabled = true, sortingEnabled = true, advancedFilters = false, height = '76vh', rowsPerPageOptions, defaultRowsPerPage, onImagePreview, fetchFilterConfig, selectable = false, selectionColumnPosition = 'start', selectedIds, onSelectionChange, }) {
|
|
16
|
+
const table = useDataTable({
|
|
17
|
+
fetchData,
|
|
18
|
+
advancedFilters,
|
|
19
|
+
rowsPerPageOptions,
|
|
20
|
+
defaultRowsPerPage,
|
|
21
|
+
fetchFilterConfig,
|
|
22
|
+
selectedIds,
|
|
23
|
+
onSelectionChange,
|
|
24
|
+
});
|
|
17
25
|
const [openFilterCol, setOpenFilterCol] = useState(null);
|
|
18
26
|
const [filterAnchor, setFilterAnchor] = useState(null);
|
|
19
27
|
const uniqueValuesFor = (column) => {
|
|
20
28
|
const values = table.items.map((row) => String(row[column] ?? ''));
|
|
21
29
|
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
|
|
22
30
|
};
|
|
23
|
-
return (_jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'flex-start', maxHeight: height }, children: [advancedFilters && table.filterConfig && (_jsx(FilterPanel, { filterConfig: table.filterConfig, activeFilters: table.filters, onChange: table.setColumnFilter, onClearAll: table.clearAllFilters })), _jsxs("div", { className: 'frame', style: { maxHeight: '-webkit-fill-available', minWidth: '10vw' }, children: [_jsx("div", { style: { maxHeight: '-webkit-fill-available', overflowY: 'auto', overflowX: 'auto' }, children: _jsx(Table, { items: table.items, fieldsType: table.fieldsType, paramFilter: table.paramFilter, sortColumn: table.sortColumn, sortDirection: table.sortDirection, sortingEnabled: sortingEnabled, onSort: (col) => table.toggleSort(col), onRowClick: onRowClick, onImagePreview: onImagePreview, renderHeaderExtra: filterEnabled && !advancedFilters
|
|
31
|
+
return (_jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'flex-start', maxHeight: height }, children: [advancedFilters && table.filterConfig && (_jsx(FilterPanel, { filterConfig: table.filterConfig, activeFilters: table.filters, onChange: table.setColumnFilter, onClearAll: table.clearAllFilters })), _jsxs("div", { className: 'frame', style: { maxHeight: '-webkit-fill-available', minWidth: '10vw' }, children: [_jsx("div", { style: { maxHeight: '-webkit-fill-available', overflowY: 'auto', overflowX: 'auto' }, children: _jsx(Table, { items: table.items, fieldsType: table.fieldsType, paramFilter: table.paramFilter, sortColumn: table.sortColumn, sortDirection: table.sortDirection, sortingEnabled: sortingEnabled, onSort: (col) => table.toggleSort(col), onRowClick: onRowClick, onImagePreview: onImagePreview, selectable: selectable, selectionColumnPosition: selectionColumnPosition, selectedIds: table.selectedIds, onToggleRow: table.setRowSelected, onToggleAllRows: table.setAllRowsSelected, renderHeaderExtra: filterEnabled && !advancedFilters
|
|
24
32
|
? (col) => (_jsx("button", { style: { padding: 5, marginTop: 0 }, className: 'btnDataFilterTable', onClick: (e) => {
|
|
25
33
|
setOpenFilterCol(col);
|
|
26
34
|
setFilterAnchor(e.currentTarget);
|
package/dist/react/Table.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type { FieldTypeInfo, ParamFilter, SortDirection } from './types.js';
|
|
2
|
+
import type { FieldTypeInfo, ParamFilter, SelectionColumnPosition, SortDirection } from './types.js';
|
|
3
3
|
export interface TableProps<T extends Record<string, any>> {
|
|
4
4
|
items: T[];
|
|
5
5
|
fieldsType?: FieldTypeInfo[];
|
|
@@ -13,10 +13,24 @@ export interface TableProps<T extends Record<string, any>> {
|
|
|
13
13
|
onImagePreview?: (src: string) => void;
|
|
14
14
|
/** Slot to render your own control in a column header (e.g. your own filter icon/input). Receives the column name. */
|
|
15
15
|
renderHeaderExtra?: (column: string) => ReactNode;
|
|
16
|
+
/** Add a checkbox column; the header checkbox selects/deselects every displayed row. */
|
|
17
|
+
selectable?: boolean;
|
|
18
|
+
/** Where the checkbox column goes among the visible columns: 'start' (default), 'end', or a 0-based index. */
|
|
19
|
+
selectionColumnPosition?: SelectionColumnPosition;
|
|
20
|
+
/** Ids (value of each row's first column) of the currently selected rows. */
|
|
21
|
+
selectedIds?: any[];
|
|
22
|
+
/** Called when a row checkbox is toggled. */
|
|
23
|
+
onToggleRow?: (row: T, selected: boolean) => void;
|
|
24
|
+
/** Called when the header checkbox is toggled; applies to every displayed row. */
|
|
25
|
+
onToggleAllRows?: (selected: boolean) => void;
|
|
26
|
+
/** Labels for the checkboxes (accessibility). */
|
|
27
|
+
selectAllLabel?: string;
|
|
28
|
+
selectRowLabel?: string;
|
|
16
29
|
}
|
|
17
30
|
/**
|
|
18
|
-
* Just the `<table>` — header, sorting, rows, cell formatting (dates, JSON, lazy images)
|
|
31
|
+
* Just the `<table>` — header, sorting, rows, cell formatting (dates, JSON, lazy images),
|
|
32
|
+
* optional checkbox column.
|
|
19
33
|
* No filter UI, no pagination: bring your own and drive `items`/`fieldsType` yourself
|
|
20
34
|
* (e.g. from `useDataTable`, or from any other data source).
|
|
21
35
|
*/
|
|
22
|
-
export declare function Table<T extends Record<string, any>>({ items, fieldsType, paramFilter, sortColumn, sortDirection, onSort, sortingEnabled, onRowClick, onImagePreview, renderHeaderExtra, }: TableProps<T>): import("react").JSX.Element;
|
|
36
|
+
export declare function Table<T extends Record<string, any>>({ items, fieldsType, paramFilter, sortColumn, sortDirection, onSort, sortingEnabled, onRowClick, onImagePreview, renderHeaderExtra, selectable, selectionColumnPosition, selectedIds, onToggleRow, onToggleAllRows, selectAllLabel, selectRowLabel, }: TableProps<T>): import("react").JSX.Element;
|
package/dist/react/Table.js
CHANGED
|
@@ -1,19 +1,54 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo } from 'react';
|
|
2
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
3
3
|
import { Cell } from './Cell.js';
|
|
4
|
+
import { getRowId, selectionKey } from './utils.js';
|
|
4
5
|
/**
|
|
5
|
-
* Just the `<table>` — header, sorting, rows, cell formatting (dates, JSON, lazy images)
|
|
6
|
+
* Just the `<table>` — header, sorting, rows, cell formatting (dates, JSON, lazy images),
|
|
7
|
+
* optional checkbox column.
|
|
6
8
|
* No filter UI, no pagination: bring your own and drive `items`/`fieldsType` yourself
|
|
7
9
|
* (e.g. from `useDataTable`, or from any other data source).
|
|
8
10
|
*/
|
|
9
|
-
export function Table({ items, fieldsType = [], paramFilter, sortColumn, sortDirection, onSort, sortingEnabled = true, onRowClick, onImagePreview, renderHeaderExtra, }) {
|
|
10
|
-
const columns = useMemo(() => (items[0] ? Object.keys(items[0]) : paramFilter && paramFilter.length > 0 ? paramFilter.map((el) => el.nom) : []), [items]);
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
export function Table({ items, fieldsType = [], paramFilter, sortColumn, sortDirection, onSort, sortingEnabled = true, onRowClick, onImagePreview, renderHeaderExtra, selectable = false, selectionColumnPosition = 'start', selectedIds, onToggleRow, onToggleAllRows, selectAllLabel = 'Tout sélectionner', selectRowLabel = 'Sélectionner la ligne', }) {
|
|
12
|
+
const columns = useMemo(() => (items[0] ? Object.keys(items[0]) : paramFilter && paramFilter.length > 0 ? paramFilter.map((el) => el.nom) : []), [items, paramFilter]);
|
|
13
|
+
/** Columns actually rendered, keeping their original index (fieldsType/paramFilter are indexed on it). */
|
|
14
|
+
const visibleColumns = useMemo(() => columns.map((col, index) => ({ col, index })).filter(({ index }) => paramFilter?.[index]?.type.trim() !== 'HIDE'), [columns, paramFilter]);
|
|
15
|
+
const selectedKeys = useMemo(() => new Set((selectedIds ?? []).map(selectionKey)), [selectedIds]);
|
|
16
|
+
const displayedCount = items.length;
|
|
17
|
+
const selectedOnPage = useMemo(() => items.filter((row) => selectedKeys.has(selectionKey(getRowId(row)))).length, [items, selectedKeys]);
|
|
18
|
+
const allDisplayedSelected = displayedCount > 0 && selectedOnPage === displayedCount;
|
|
19
|
+
const someDisplayedSelected = selectedOnPage > 0 && !allDisplayedSelected;
|
|
20
|
+
/** -1 when there is no checkbox column, otherwise its slot among the visible columns. */
|
|
21
|
+
const selectionIndex = useMemo(() => {
|
|
22
|
+
if (!selectable)
|
|
23
|
+
return -1;
|
|
24
|
+
if (selectionColumnPosition === 'end')
|
|
25
|
+
return visibleColumns.length;
|
|
26
|
+
if (typeof selectionColumnPosition === 'number')
|
|
27
|
+
return Math.min(Math.max(0, Math.trunc(selectionColumnPosition)), visibleColumns.length);
|
|
28
|
+
return 0;
|
|
29
|
+
}, [selectable, selectionColumnPosition, visibleColumns.length]);
|
|
30
|
+
const withSelectionCell = (cells, selectionCell) => {
|
|
31
|
+
if (selectionIndex < 0)
|
|
32
|
+
return cells;
|
|
33
|
+
const next = [...cells];
|
|
34
|
+
next.splice(selectionIndex, 0, selectionCell);
|
|
35
|
+
return next;
|
|
36
|
+
};
|
|
37
|
+
const headerCells = visibleColumns.map(({ col }) => (_jsx("th", { "data-sort": col, children: _jsxs("div", { className: 'flex-row nowrap', style: { justifyContent: 'space-between', alignItems: 'center' }, children: [_jsxs("div", { className: 'flex-row nowrap clickOnSort', style: { cursor: sortingEnabled ? 'pointer' : 'default' }, onClick: () => sortingEnabled && onSort?.(col), children: [_jsx("span", { translate: 'yes', children: col }), sortingEnabled && (_jsx("span", { className: 'sort-icon', children: sortColumn === col ? (sortDirection === 'ASC' ? '\u2B06' : '\u2B07') : '\u2B07\u2B06' }))] }), renderHeaderExtra?.(col)] }) }, col)));
|
|
38
|
+
const headerSelectionCell = (_jsx("th", { className: 'selectionColumn', style: { width: '1%' }, children: _jsx(SelectionCheckbox, { checked: allDisplayedSelected, indeterminate: someDisplayedSelected, disabled: displayedCount === 0, label: selectAllLabel, onChange: (checked) => onToggleAllRows?.(checked) }) }, '__selection__'));
|
|
39
|
+
return (_jsxs("table", { children: [_jsx("thead", { className: 'tableHeader', children: _jsx("tr", { children: withSelectionCell(headerCells, headerSelectionCell) }) }), _jsxs("tbody", { style: { maxHeight: 'stretch', maxWidth: 'stretch', overflow: 'auto' }, children: [items.length === 0 && (_jsx("tr", { children: _jsx("td", { colSpan: 100, children: "No data found" }) })), items.map((row, i) => {
|
|
40
|
+
const id = getRowId(row);
|
|
41
|
+
const cells = visibleColumns.map(({ col, index }) => (_jsx("td", { children: _jsx(Cell, { value: row[col], fieldType: fieldsType[index]?.fieldType, onImagePreview: onImagePreview }) }, col)));
|
|
42
|
+
const selectionCell = (_jsx("td", { className: 'selectionColumn', onClick: (e) => e.stopPropagation(), children: _jsx(SelectionCheckbox, { checked: selectedKeys.has(selectionKey(id)), label: selectRowLabel, onChange: (checked) => onToggleRow?.(row, checked) }) }, '__selection__'));
|
|
43
|
+
return (_jsx("tr", { style: { cursor: onRowClick ? 'pointer' : 'default' }, onClick: () => id !== undefined && onRowClick?.(id, row), children: withSelectionCell(cells, selectionCell) }, id !== undefined ? String(id) : i));
|
|
44
|
+
})] })] }));
|
|
45
|
+
}
|
|
46
|
+
function SelectionCheckbox({ checked, indeterminate = false, disabled = false, label, onChange, }) {
|
|
47
|
+
const ref = useRef(null);
|
|
48
|
+
// `indeterminate` only exists on the DOM node, not as an attribute.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (ref.current)
|
|
51
|
+
ref.current.indeterminate = indeterminate && !checked;
|
|
52
|
+
}, [indeterminate, checked]);
|
|
53
|
+
return (_jsx("input", { ref: ref, type: 'checkbox', className: 'selectionCheckbox', checked: checked, disabled: disabled, "aria-label": label, title: label, style: { cursor: disabled ? 'default' : 'pointer', margin: 0 }, onClick: (e) => e.stopPropagation(), onChange: (e) => onChange(e.target.checked) }));
|
|
19
54
|
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -6,4 +6,4 @@ export type { PaginationProps } from './Pagination.js';
|
|
|
6
6
|
export { useDataTable } from './useDataTable.js';
|
|
7
7
|
export { FilterModal } from './FilterModal.js';
|
|
8
8
|
export { FilterPanel } from './FilterPanel.js';
|
|
9
|
-
export type { DataTableProps, FetchParams, FetchResult, FieldTypeInfo, FilterConfig, FilterFieldConfig, SortDirection } from './types.js';
|
|
9
|
+
export type { DataTableProps, FetchParams, FetchResult, FieldTypeInfo, FilterConfig, FilterFieldConfig, ParamFilter, SelectionColumnPosition, SortDirection, } from './types.js';
|
package/dist/react/types.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export type ParamFilter = {
|
|
|
35
35
|
type: FilterFieldType;
|
|
36
36
|
};
|
|
37
37
|
export type FilterConfig = Record<string, FilterFieldConfig>;
|
|
38
|
+
/** Where the selection (checkbox) column is inserted among the visible columns. */
|
|
39
|
+
export type SelectionColumnPosition = number | 'start' | 'end';
|
|
38
40
|
export interface DataTableProps<T extends Record<string, any>> {
|
|
39
41
|
/** Called every time the table needs data (page change, sort, filter, page size). */
|
|
40
42
|
fetchData: (params: FetchParams) => Promise<FetchResult<T> | null>;
|
|
@@ -54,4 +56,12 @@ export interface DataTableProps<T extends Record<string, any>> {
|
|
|
54
56
|
onImagePreview?: (src: string) => void;
|
|
55
57
|
/** Optional endpoint to lazily fetch filter definitions using cleRecupFiltre. */
|
|
56
58
|
fetchFilterConfig?: (cleRecupFiltre: string) => Promise<FilterConfig>;
|
|
59
|
+
/** Show a checkbox column; the header checkbox selects/deselects every displayed row. Default false. */
|
|
60
|
+
selectable?: boolean;
|
|
61
|
+
/** Where the checkbox column goes among the visible columns: 'start' (default), 'end', or a 0-based index. */
|
|
62
|
+
selectionColumnPosition?: SelectionColumnPosition;
|
|
63
|
+
/** Controlled selection: ids (value of each row's first column) of the selected rows. Leave undefined to let the table hold the selection itself. */
|
|
64
|
+
selectedIds?: any[];
|
|
65
|
+
/** Called on every selection change, with the selected ids and the matching rows (same shape as onRowClick). */
|
|
66
|
+
onSelectionChange?: (ids: any[], rows: T[]) => void;
|
|
57
67
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DataTableProps, FieldTypeInfo, FilterConfig, SortDirection, ParamFilter } from './types.js';
|
|
2
|
-
export declare function useDataTable<T extends Record<string, any>>({ fetchData, advancedFilters, rowsPerPageOptions, defaultRowsPerPage, fetchFilterConfig, }: Pick<DataTableProps<T>, 'fetchData' | 'advancedFilters' | 'rowsPerPageOptions' | 'defaultRowsPerPage' | 'fetchFilterConfig'>): {
|
|
2
|
+
export declare function useDataTable<T extends Record<string, any>>({ fetchData, advancedFilters, rowsPerPageOptions, defaultRowsPerPage, fetchFilterConfig, selectedIds: controlledSelectedIds, onSelectionChange, }: Pick<DataTableProps<T>, 'fetchData' | 'advancedFilters' | 'rowsPerPageOptions' | 'defaultRowsPerPage' | 'fetchFilterConfig' | 'selectedIds' | 'onSelectionChange'>): {
|
|
3
3
|
items: T[];
|
|
4
4
|
count: number;
|
|
5
5
|
fieldsType: FieldTypeInfo[];
|
|
@@ -13,6 +13,13 @@ export declare function useDataTable<T extends Record<string, any>>({ fetchData,
|
|
|
13
13
|
sortDirection: SortDirection;
|
|
14
14
|
filters: Record<string, unknown>;
|
|
15
15
|
filterConfig: FilterConfig | null;
|
|
16
|
+
selectedIds: any[];
|
|
17
|
+
selectedRows: T[];
|
|
18
|
+
isRowSelected: (row: T) => boolean;
|
|
19
|
+
toggleRowSelection: (row: T) => void;
|
|
20
|
+
setRowSelected: (row: T, selected: boolean) => void;
|
|
21
|
+
setAllRowsSelected: (selected: boolean) => void;
|
|
22
|
+
clearSelection: () => void;
|
|
16
23
|
toggleSort: (column: string, forceDirection?: SortDirection) => void;
|
|
17
24
|
setColumnFilter: (key: string, value: unknown) => void;
|
|
18
25
|
replaceFilters: (next: Record<string, unknown>) => void;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
|
|
2
|
+
import { getRowId, selectionKey } from './utils.js';
|
|
3
|
+
export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions = [10, 25, 50, 100, 250, 500, 1000], defaultRowsPerPage = 100, fetchFilterConfig, selectedIds: controlledSelectedIds, onSelectionChange, }) {
|
|
3
4
|
const [items, setItems] = useState([]);
|
|
4
5
|
const [count, setCount] = useState(0);
|
|
5
6
|
const [fieldsType, setFieldsType] = useState([]);
|
|
@@ -14,6 +15,14 @@ export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions =
|
|
|
14
15
|
const [cleRecupFiltre, setCleRecupFiltre] = useState();
|
|
15
16
|
// Guards against a slow, stale request overwriting a newer one.
|
|
16
17
|
const requestId = useRef(0);
|
|
18
|
+
// Selection is uncontrolled unless the parent passes `selectedIds`.
|
|
19
|
+
const [internalSelectedIds, setInternalSelectedIds] = useState([]);
|
|
20
|
+
const isSelectionControlled = controlledSelectedIds !== undefined;
|
|
21
|
+
const selectedIds = isSelectionControlled ? controlledSelectedIds : internalSelectedIds;
|
|
22
|
+
const selectedKeys = useMemo(() => new Set(selectedIds.map(selectionKey)), [selectedIds]);
|
|
23
|
+
/** Every row loaded since the last selection reset, so a row selected on a page we left
|
|
24
|
+
* can still be handed back to the parent in full. */
|
|
25
|
+
const knownRows = useRef(new Map());
|
|
17
26
|
const sorting = useMemo(() => (sortColumn ? `\`${sortColumn}\` ${sortDirection}` : ''), [sortColumn, sortDirection]);
|
|
18
27
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(count / perPage)), [count, perPage]);
|
|
19
28
|
const load = useCallback(async (targetPage) => {
|
|
@@ -35,7 +44,13 @@ export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions =
|
|
|
35
44
|
setCount(0);
|
|
36
45
|
return;
|
|
37
46
|
}
|
|
38
|
-
|
|
47
|
+
const rows = result.items || [];
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
const rowId = getRowId(row);
|
|
50
|
+
if (rowId !== undefined)
|
|
51
|
+
knownRows.current.set(selectionKey(rowId), row);
|
|
52
|
+
}
|
|
53
|
+
setItems(rows);
|
|
39
54
|
setCount(result.count ?? 0);
|
|
40
55
|
setFieldsType(result.fieldsType || []);
|
|
41
56
|
setParamFilter(result.paramFilter || []);
|
|
@@ -96,6 +111,63 @@ export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions =
|
|
|
96
111
|
/** Replace the whole filter object at once — handy for a custom search UI that
|
|
97
112
|
* doesn't reason "per column" and just wants to hand over its own `filtre` payload. */
|
|
98
113
|
const replaceFilters = useCallback((next) => setFilters(next), []);
|
|
114
|
+
// ==================== SELECTION ====================
|
|
115
|
+
const rowsOf = useCallback((ids) => {
|
|
116
|
+
const rows = [];
|
|
117
|
+
for (const id of ids) {
|
|
118
|
+
const row = knownRows.current.get(selectionKey(id));
|
|
119
|
+
if (row)
|
|
120
|
+
rows.push(row);
|
|
121
|
+
}
|
|
122
|
+
return rows;
|
|
123
|
+
}, []);
|
|
124
|
+
const selectedRows = useMemo(() => rowsOf(selectedIds), [selectedIds, items, rowsOf]);
|
|
125
|
+
const applySelection = useCallback((nextIds) => {
|
|
126
|
+
if (!isSelectionControlled)
|
|
127
|
+
setInternalSelectedIds(nextIds);
|
|
128
|
+
onSelectionChange?.(nextIds, rowsOf(nextIds));
|
|
129
|
+
}, [isSelectionControlled, onSelectionChange, rowsOf]);
|
|
130
|
+
const isRowSelected = useCallback((row) => selectedKeys.has(selectionKey(getRowId(row))), [selectedKeys]);
|
|
131
|
+
const setRowSelected = useCallback((row, selected) => {
|
|
132
|
+
const id = getRowId(row);
|
|
133
|
+
if (id === undefined)
|
|
134
|
+
return;
|
|
135
|
+
const key = selectionKey(id);
|
|
136
|
+
knownRows.current.set(key, row);
|
|
137
|
+
if (selected === selectedKeys.has(key))
|
|
138
|
+
return;
|
|
139
|
+
applySelection(selected ? [...selectedIds, id] : selectedIds.filter((v) => selectionKey(v) !== key));
|
|
140
|
+
}, [selectedIds, selectedKeys, applySelection]);
|
|
141
|
+
const toggleRowSelection = useCallback((row) => setRowSelected(row, !isRowSelected(row)), [setRowSelected, isRowSelected]);
|
|
142
|
+
/** Select/deselect every row of the current page; rows selected on other pages are left alone. */
|
|
143
|
+
const setAllRowsSelected = useCallback((selected) => {
|
|
144
|
+
const pageIds = [];
|
|
145
|
+
for (const row of items) {
|
|
146
|
+
const id = getRowId(row);
|
|
147
|
+
if (id === undefined)
|
|
148
|
+
continue;
|
|
149
|
+
knownRows.current.set(selectionKey(id), row);
|
|
150
|
+
pageIds.push(id);
|
|
151
|
+
}
|
|
152
|
+
const pageKeys = new Set(pageIds.map(selectionKey));
|
|
153
|
+
const others = selectedIds.filter((id) => !pageKeys.has(selectionKey(id)));
|
|
154
|
+
applySelection(selected ? [...others, ...pageIds] : others);
|
|
155
|
+
}, [items, selectedIds, applySelection]);
|
|
156
|
+
const clearSelection = useCallback(() => {
|
|
157
|
+
if (selectedIds.length > 0)
|
|
158
|
+
applySelection([]);
|
|
159
|
+
}, [selectedIds, applySelection]);
|
|
160
|
+
// The selection spans pages, but a new sort/filter means a different dataset: start over.
|
|
161
|
+
const firstDatasetRender = useRef(true);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (firstDatasetRender.current) {
|
|
164
|
+
firstDatasetRender.current = false;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
knownRows.current.clear();
|
|
168
|
+
clearSelection();
|
|
169
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
170
|
+
}, [sorting, filters]);
|
|
99
171
|
const changePerPage = useCallback((n) => setPerPage(n), []);
|
|
100
172
|
return {
|
|
101
173
|
items,
|
|
@@ -111,6 +183,13 @@ export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions =
|
|
|
111
183
|
sortDirection,
|
|
112
184
|
filters,
|
|
113
185
|
filterConfig,
|
|
186
|
+
selectedIds,
|
|
187
|
+
selectedRows,
|
|
188
|
+
isRowSelected,
|
|
189
|
+
toggleRowSelection,
|
|
190
|
+
setRowSelected,
|
|
191
|
+
setAllRowsSelected,
|
|
192
|
+
clearSelection,
|
|
114
193
|
toggleSort,
|
|
115
194
|
setColumnFilter,
|
|
116
195
|
replaceFilters,
|
package/dist/react/utils.d.ts
CHANGED
|
@@ -4,3 +4,7 @@ export declare function looksLikeFile(str: string): boolean;
|
|
|
4
4
|
/** Returns HTML with <span class="..."> wrapping each JSON token, for use with dangerouslySetInnerHTML. */
|
|
5
5
|
export declare function syntaxHighlightJSON(json: string): string;
|
|
6
6
|
export declare function truncatedJSON(value: unknown, maxLen?: number): string;
|
|
7
|
+
/** Id of a row: the value of its first column — same rule as onRowClick. */
|
|
8
|
+
export declare function getRowId(row: Record<string, any> | undefined): any;
|
|
9
|
+
/** Ids come from the data (numbers, strings, ...) and can be compared to values given by the parent, so normalize them. */
|
|
10
|
+
export declare function selectionKey(id: any): string;
|
package/dist/react/utils.js
CHANGED
|
@@ -38,3 +38,14 @@ export function truncatedJSON(value, maxLen = 200) {
|
|
|
38
38
|
const truncated = str.length > maxLen ? `${str.slice(0, maxLen)}...` : str;
|
|
39
39
|
return syntaxHighlightJSON(truncated);
|
|
40
40
|
}
|
|
41
|
+
/** Id of a row: the value of its first column — same rule as onRowClick. */
|
|
42
|
+
export function getRowId(row) {
|
|
43
|
+
if (!row)
|
|
44
|
+
return undefined;
|
|
45
|
+
const key = Object.keys(row)[0];
|
|
46
|
+
return key === undefined ? undefined : row[key];
|
|
47
|
+
}
|
|
48
|
+
/** Ids come from the data (numbers, strings, ...) and can be compared to values given by the parent, so normalize them. */
|
|
49
|
+
export function selectionKey(id) {
|
|
50
|
+
return String(id);
|
|
51
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -124,12 +124,17 @@ export function createTableQueryModule(deps) {
|
|
|
124
124
|
if (tableValues.length === 0)
|
|
125
125
|
return { empty: true, payload: { items: tableValues, count: 0, fieldsType: reqRows.fieldsType } };
|
|
126
126
|
Object.keys(tableValues[0]).forEach((col, i) => {
|
|
127
|
-
if (col === '
|
|
127
|
+
if (col.toLowerCase().trim() === 'documents')
|
|
128
128
|
reqRows.fieldsType[i].fieldType = 'FILE';
|
|
129
129
|
});
|
|
130
130
|
payload = { items: tableValues, count: reqRows.rows[0]?.TotalCount ?? 0, fieldsType: reqRows.fieldsType };
|
|
131
131
|
if (cache && keepCache) {
|
|
132
|
-
await cache.setSQLCache(titleSQLCacheQuery, {
|
|
132
|
+
await cache.setSQLCache(titleSQLCacheQuery, {
|
|
133
|
+
title: titleSQLCacheQuery,
|
|
134
|
+
data: payload,
|
|
135
|
+
expiration: Date.now() + keepCache,
|
|
136
|
+
tables: reqRows.tables,
|
|
137
|
+
});
|
|
133
138
|
}
|
|
134
139
|
}
|
|
135
140
|
return { payload };
|
|
@@ -139,7 +144,11 @@ export function createTableQueryModule(deps) {
|
|
|
139
144
|
const columns = Object.keys(payload.items[0]);
|
|
140
145
|
if (cleRecupFiltre && cache) {
|
|
141
146
|
const titreRecupFiltres = `tableQuery:recupFiltres:${cleRecupFiltre}`;
|
|
142
|
-
await cache.setSQLCache(titreRecupFiltres, {
|
|
147
|
+
await cache.setSQLCache(titreRecupFiltres, {
|
|
148
|
+
title: titreRecupFiltres,
|
|
149
|
+
data: { status: `Waiting` },
|
|
150
|
+
expiration: Date.now() + 60 * 60 * 1000,
|
|
151
|
+
});
|
|
143
152
|
}
|
|
144
153
|
const filtres = await computeFiltersViaTempTable({ baseQuery, argsQuery, columns, paramFilter });
|
|
145
154
|
if (cleRecupFiltre && cache) {
|
|
@@ -147,7 +156,11 @@ export function createTableQueryModule(deps) {
|
|
|
147
156
|
await cache.setSQLCache(titreRecupFiltres, { title: titreRecupFiltres, data: { filtres }, expiration: Date.now() + 60 * 60 * 1000 });
|
|
148
157
|
}
|
|
149
158
|
if (keepCache && cache) {
|
|
150
|
-
await cache.setSQLCache(keepCache.titleSQLCacheFiltre, {
|
|
159
|
+
await cache.setSQLCache(keepCache.titleSQLCacheFiltre, {
|
|
160
|
+
title: keepCache.titleSQLCacheFiltre,
|
|
161
|
+
data: filtres,
|
|
162
|
+
expiration: Date.now() + keepCache.keepCache,
|
|
163
|
+
});
|
|
151
164
|
}
|
|
152
165
|
return filtres;
|
|
153
166
|
}
|
|
@@ -155,6 +168,8 @@ export function createTableQueryModule(deps) {
|
|
|
155
168
|
if (Object.keys(filtres).length === 0)
|
|
156
169
|
return '';
|
|
157
170
|
function singleCondition(key, val) {
|
|
171
|
+
if (!val)
|
|
172
|
+
return undefined;
|
|
158
173
|
const lower = val.toLowerCase();
|
|
159
174
|
if (lower === 'null' || lower === 'vide')
|
|
160
175
|
return `\`${key}\` IS NULL`;
|
|
@@ -166,7 +181,10 @@ export function createTableQueryModule(deps) {
|
|
|
166
181
|
}
|
|
167
182
|
const conditions = Object.entries(filtres).map(([key, value]) => {
|
|
168
183
|
if (Array.isArray(value)) {
|
|
169
|
-
|
|
184
|
+
if (value.length > 0)
|
|
185
|
+
return `(${value.map((val) => singleCondition(key, String(val).replaceAll('/*/', '%'))).join(' OR ')})`;
|
|
186
|
+
else
|
|
187
|
+
return undefined;
|
|
170
188
|
}
|
|
171
189
|
if (value !== null && typeof value === 'object' && 'min' in value && 'max' in value) {
|
|
172
190
|
const min = value.date ? `'${deps.convertToMySQLDateTime(value.min)}'` : value.min;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@benjosivo/table-query",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Table triable/filtrable/paginée : hook + composants React d'un côté (`/react`), logique SQL de pagination/tri/filtres côté serveur de l'autre (`/server`). Un projet peut n'utiliser qu'un des deux côtés.",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"homepage": "https://github.com/benjosivo/table-query#readme",
|