@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 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
- ### Utilisation avec ta propre UI de filtre
141
-
142
- Si le système de filtre par défaut ne convient pas, utilise le hook et les composants séparément :
143
-
144
- ```tsx
145
- import { useDataTable, Table, Pagination } from '@benjosivo/table-query/react';
146
-
147
- function CommandesTable() {
148
- const table = useDataTable({ fetchData: fetchCommandes });
149
- const [recherche, setRecherche] = useState('');
150
-
151
- const rechercher = () => {
152
- table.replaceFilters({ reference: [`/*/${recherche}/*/`] });
153
- // ou, filtre par filtre : table.setColumnFilter('statut', ['en_cours']);
154
- };
155
-
156
- return (
157
- <>
158
- <input value={recherche} onChange={(e) => setRecherche(e.target.value)} />
159
- <button onClick={rechercher}>Rechercher</button>
160
-
161
- <Table
162
- items={table.items}
163
- fieldsType={table.fieldsType}
164
- sortColumn={table.sortColumn}
165
- sortDirection={table.sortDirection}
166
- onSort={table.toggleSort}
167
- />
168
-
169
- <Pagination
170
- page={table.page}
171
- totalPages={table.totalPages}
172
- perPage={table.perPage}
173
- count={table.count}
174
- onChangePerPage={table.changePerPage}
175
- onFirst={table.firstPage}
176
- onPrevious={table.previousPage}
177
- onNext={table.nextPage}
178
- onLast={table.lastPage}
179
- />
180
- </>
181
- );
182
- }
183
- ```
184
-
185
- `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.
186
-
187
- ### Format attendu par `fetchData`
188
-
189
- ```ts
190
- interface FetchParams {
191
- limit: number;
192
- offset: number;
193
- sorting?: string;
194
- filtre?: Record<string, unknown>;
195
- setFilter?: boolean;
196
- }
197
-
198
- interface FetchResult<T> {
199
- items: T[];
200
- count: number;
201
- fieldsType?: { fieldType: string }[]; // 'DATE' | 'DATETIME' | 'JSON' | 'FILE' | 'BLOB' | ...
202
- filtre?: FilterConfig; // renvoyé par le serveur si des filtres avancés sont demandés
203
- cleRecupFiltre?: string; // utilisé en interne pour le polling du cache
204
- }
205
- ```
206
-
207
- C'est exactement la forme renvoyée par `reqTableQuery` côté serveur.
208
-
209
- ---
210
-
211
- ## Build
212
-
213
- ```bash
214
- npm run build # compile src/react et src/server dans dist/
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;
@@ -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({ fetchData, advancedFilters, rowsPerPageOptions, defaultRowsPerPage, fetchFilterConfig });
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);
@@ -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;
@@ -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
- const idKey = items[0] ? Object.keys(items[0])[0] : null;
12
- return (_jsxs("table", { children: [_jsx("thead", { className: 'tableHeader', children: _jsx("tr", { children: columns
13
- .filter((col, i) => (paramFilter ? paramFilter[i].type !== 'HIDE' : true))
14
- .map((col, i) => (_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)] }) }, i))) }) }), _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) => (_jsx("tr", { style: { cursor: onRowClick ? 'pointer' : 'default' }, onClick: () => idKey && onRowClick?.(row[idKey], row), children: columns.map((col, colIdx) => {
15
- if (paramFilter && paramFilter[colIdx].type.trim() === 'HIDE')
16
- return;
17
- return (_jsx("td", { children: _jsx(Cell, { value: row[col], fieldType: fieldsType[colIdx]?.fieldType, onImagePreview: onImagePreview }) }, colIdx));
18
- }) }, idKey ? String(row[idKey]) : i)))] })] }));
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
  }
@@ -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';
@@ -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
- export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions = [10, 25, 50, 100, 250, 500, 1000], defaultRowsPerPage = 100, fetchFilterConfig, }) {
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
- setItems(result.items || []);
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,
@@ -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;
@@ -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
+ }
@@ -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 === 'Documents')
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, { title: titleSQLCacheQuery, data: payload, expiration: Date.now() + keepCache });
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, { title: titreRecupFiltres, data: { status: `Waiting` }, expiration: Date.now() + 60 * 60 * 1000 });
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, { title: keepCache.titleSQLCacheFiltre, data: filtres, expiration: Date.now() + keepCache.keepCache });
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
- return `(${value.map((val) => singleCondition(key, String(val).replaceAll('/*/', '%'))).join(' OR ')})`;
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.2",
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",