@benjosivo/table-query 1.0.0

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 ADDED
@@ -0,0 +1,87 @@
1
+ # @benjosivo/table-query
2
+
3
+ Table triable/filtrable/paginée : hook + composants React d'un côté (`/react`), logique
4
+ SQL de pagination/tri/filtres côté serveur de l'autre (`/server`). Un projet peut
5
+ n'utiliser qu'un des deux côtés.
6
+
7
+ ```
8
+ src/
9
+ react/ → DataTable, Table, Pagination, useDataTable, FilterModal, FilterPanel, Cell, utils, types
10
+ server/ → createTableQueryModule (router Express + reqTableQuery)
11
+ ```
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install @benjosivo/table-query
17
+ ```
18
+
19
+ Peer dependencies : `react` (si tu utilises `/react`), `express` et `@benjosivo/mysql`
20
+ (si tu utilises `/server`) — ce sont des `peerDependencies` optionnelles, donc pas
21
+ besoin des trois si tu n'utilises qu'un des deux côtés.
22
+
23
+ ## Côté serveur
24
+
25
+ ```ts
26
+ import { createTableQueryModule } from '@benjosivo/table-query/server';
27
+ import { convertToMySQLDateTime, wrapRouteHandler } from './functions.js';
28
+ import { getSQLCache, setSQLCache, deleteSQLCache } from './redis.js';
29
+
30
+ const { router, reqTableQuery } = createTableQueryModule({
31
+ convertToMySQLDateTime,
32
+ wrapRouteHandler,
33
+ // Optionnel : à fournir seulement si tu veux pouvoir utiliser le cache quelque part.
34
+ cache: { getSQLCache, setSQLCache, deleteSQLCache },
35
+ });
36
+
37
+ app.use('/tableCreation/api', router);
38
+
39
+ app.post('/api/commandes', wrapRouteHandler(async (req, res) => {
40
+ const result = await reqTableQuery({
41
+ query: `SELECT c.*, COUNT(*) OVER() AS TotalCount FROM commandes c`,
42
+ req,
43
+ paramFilter: ['MULTISELECT', 'HIDE', 'DATE', 'MULTISELECT'],
44
+ useCache: true, // voir plus bas
45
+ });
46
+ if (result.error) return res.status(result.status ?? 500).send(result.error);
47
+ res.json(result.data);
48
+ }));
49
+ ```
50
+
51
+ ### Le paramètre `useCache`
52
+
53
+ `reqTableQuery({ ..., useCache })` :
54
+
55
+ - **`useCache: true`** (ou omis, si `cache` a été fourni à `createTableQueryModule`) —
56
+ comportement d'origine : résultats de requête et filtres mis en cache Redis, filtres
57
+ calculés en tâche de fond et récupérés via `/getFiltres` (polling).
58
+ - **`useCache: false`** — aucune lecture/écriture Redis, requête toujours fraîche, filtres
59
+ calculés et renvoyés directement dans la même réponse (pas de round-trip `/getFiltres`).
60
+ Utile pour un écran qui doit toujours montrer les données à l'instant T, ou pour un
61
+ projet qui n'a pas (encore) de Redis configuré.
62
+ - Si tu passes `useCache: true` sans avoir fourni `cache` à `createTableQueryModule`,
63
+ une erreur explicite est levée au lieu d'échouer silencieusement.
64
+
65
+ Le cache est donc décidé **par appel** (`reqTableQuery`), pas globalement pour tout le
66
+ module — tu peux avoir certains endpoints en cache et d'autres non avec le même module.
67
+
68
+ ## Côté React
69
+
70
+ ```tsx
71
+ import { DataTable } from '@benjosivo/table-query/react';
72
+
73
+ <DataTable
74
+ fetchData={(params) => fetch('/api/commandes', { method: 'POST', body: JSON.stringify(params) }).then((r) => r.ok ? r.json() : null)}
75
+ advancedFilters
76
+ />
77
+ ```
78
+
79
+ Ou en composant seulement `useDataTable` + `Table` + `Pagination` avec ta propre UI de
80
+ filtre (voir la conversation précédente pour l'exemple complet).
81
+
82
+ ## Build & publish
83
+
84
+ ```bash
85
+ npm run build # tsc → dist/react + dist/server
86
+ npm publish # même config GitHub Packages que @benjosivo/mysql
87
+ ```
@@ -0,0 +1,8 @@
1
+ import type { FieldTypeName } from './types.js';
2
+ interface CellProps {
3
+ value: unknown;
4
+ fieldType: FieldTypeName;
5
+ onImagePreview?: (src: string) => void;
6
+ }
7
+ export declare function Cell({ value, fieldType, onImagePreview }: CellProps): import("react").JSX.Element;
8
+ export {};
@@ -0,0 +1,37 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { formattedDate, isImage, looksLikeFile, truncatedJSON } from './utils.js';
4
+ export function Cell({ value, fieldType, onImagePreview }) {
5
+ fieldType = fieldType.trim();
6
+ if (fieldType === 'DATE')
7
+ return _jsx(_Fragment, { children: formattedDate(value, true) });
8
+ if (fieldType === 'DATETIME' || fieldType === 'TIMESTAMP')
9
+ return _jsx(_Fragment, { children: formattedDate(value) });
10
+ if (fieldType === 'JSON' && value)
11
+ return _jsx("span", { dangerouslySetInnerHTML: { __html: truncatedJSON(value) } });
12
+ if ((fieldType === 'BLOB' || fieldType === 'FILE') && typeof value === 'string' && looksLikeFile(value)) {
13
+ return (_jsx("div", { className: 'flex-row', children: value.split(';').map((file) => isImage(file) ? (_jsx(LazyImage, { src: file, onClick: () => (onImagePreview ? onImagePreview(file) : window.open(file, '_blank')) }, file)) : (_jsx("button", { onClick: () => window.open(file, '_blank'), children: file.split('/').pop() }, file))) }));
14
+ }
15
+ return _jsx(_Fragment, { children: value == null ? '' : String(value) });
16
+ }
17
+ function LazyImage({ src, onClick }) {
18
+ const ref = useRef(null);
19
+ const [visible, setVisible] = useState(false);
20
+ useEffect(() => {
21
+ const el = ref.current;
22
+ if (!el)
23
+ return;
24
+ const observer = new IntersectionObserver((entries) => {
25
+ if (entries[0].isIntersecting) {
26
+ setVisible(true);
27
+ observer.disconnect();
28
+ }
29
+ }, { rootMargin: '500px' });
30
+ observer.observe(el);
31
+ return () => observer.disconnect();
32
+ }, []);
33
+ return (_jsx("img", { ref: ref, src: visible ? src : undefined, style: { maxWidth: '3.75rem', maxHeight: '3.75rem', display: 'block', cursor: 'pointer' }, onClick: (e) => {
34
+ e.stopPropagation();
35
+ onClick();
36
+ } }));
37
+ }
@@ -0,0 +1,9 @@
1
+ import type { DataTableProps } from './types.js';
2
+ /**
3
+ * Batteries-included table: sorting, per-column quick filter, optional advanced filter
4
+ * panel, pagination and lazy-loaded image cells — all wired to `useDataTable` for you.
5
+ *
6
+ * If you want your own filter UI, don't use this component: compose `useDataTable` +
7
+ * `Table` + `Pagination` directly instead (see README "Using the pieces separately").
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;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { useDataTable } from './useDataTable.js';
4
+ import { FilterModal } from './FilterModal.js';
5
+ import { FilterPanel } from './FilterPanel.js';
6
+ import { Table } from './Table.js';
7
+ import { Pagination } from './Pagination.js';
8
+ /**
9
+ * Batteries-included table: sorting, per-column quick filter, optional advanced filter
10
+ * panel, pagination and lazy-loaded image cells — all wired to `useDataTable` for you.
11
+ *
12
+ * If you want your own filter UI, don't use this component: compose `useDataTable` +
13
+ * `Table` + `Pagination` directly instead (see README "Using the pieces separately").
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 });
17
+ const [openFilterCol, setOpenFilterCol] = useState(null);
18
+ const [filterAnchor, setFilterAnchor] = useState(null);
19
+ const uniqueValuesFor = (column) => {
20
+ const values = table.items.map((row) => String(row[column] ?? ''));
21
+ return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
22
+ };
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
24
+ ? (col) => (_jsx("button", { style: { padding: 5, marginTop: 0 }, className: 'btnDataFilterTable', onClick: (e) => {
25
+ setOpenFilterCol(col);
26
+ setFilterAnchor(e.currentTarget);
27
+ }, children: "\uD83D\uDF83" }))
28
+ : undefined }) }), _jsx(Pagination, { page: table.page, totalPages: table.totalPages, perPage: table.perPage, count: table.count, rowsPerPageOptions: table.rowsPerPageOptions, onChangePerPage: table.changePerPage, onFirst: table.firstPage, onPrevious: table.previousPage, onNext: table.nextPage, onLast: table.lastPage })] }), openFilterCol && (_jsx(FilterModal, { column: openFilterCol, values: uniqueValuesFor(openFilterCol), anchorEl: filterAnchor, onSort: (dir) => table.toggleSort(openFilterCol, dir), onApply: (selected) => table.setColumnFilter(openFilterCol, selected ?? undefined), onClose: () => setOpenFilterCol(null) })), table.loading && _jsx("div", { className: 'table-loading-overlay', children: "Chargement..." })] }));
29
+ }
@@ -0,0 +1,18 @@
1
+ import type { SortDirection } from './types.js';
2
+ interface FilterModalProps {
3
+ column: string;
4
+ /** Unique values found in the currently loaded rows for this column. */
5
+ values: string[];
6
+ anchorEl: HTMLElement | null;
7
+ onSort: (direction: SortDirection) => void;
8
+ onApply: (selected: string[] | null) => void;
9
+ onClose: () => void;
10
+ }
11
+ /**
12
+ * Quick filter popover shown when clicking the magnifier button in a column header.
13
+ * Mirrors the original modal: sort shortcuts + a searchable checkbox list of values
14
+ * seen on the current page (this is inherently page-scoped, same as the source code —
15
+ * pair it with the advanced FilterPanel for filters over the full dataset).
16
+ */
17
+ export declare function FilterModal({ column, values, anchorEl, onSort, onApply, onClose }: FilterModalProps): import("react").JSX.Element;
18
+ export {};
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ /**
4
+ * Quick filter popover shown when clicking the magnifier button in a column header.
5
+ * Mirrors the original modal: sort shortcuts + a searchable checkbox list of values
6
+ * seen on the current page (this is inherently page-scoped, same as the source code —
7
+ * pair it with the advanced FilterPanel for filters over the full dataset).
8
+ */
9
+ export function FilterModal({ column, values, anchorEl, onSort, onApply, onClose }) {
10
+ const [search, setSearch] = useState('');
11
+ const [checked, setChecked] = useState(new Set(values));
12
+ const modalRef = useRef(null);
13
+ const [style, setStyle] = useState({ visibility: 'hidden' });
14
+ useEffect(() => setChecked(new Set(values)), [values]);
15
+ useEffect(() => {
16
+ if (!anchorEl || !modalRef.current)
17
+ return;
18
+ const modalRect = modalRef.current.getBoundingClientRect();
19
+ const triggerRect = anchorEl.getBoundingClientRect();
20
+ const left = Math.max(0, triggerRect.left - modalRect.width + triggerRect.width);
21
+ const top = Math.min(window.scrollY + window.innerHeight - modalRect.height, triggerRect.bottom + 5 + window.scrollY);
22
+ setStyle({ position: 'absolute', top, left, zIndex: 1000 });
23
+ }, [anchorEl, values]);
24
+ useEffect(() => {
25
+ const handler = (e) => {
26
+ if (modalRef.current && !modalRef.current.contains(e.target) && e.target !== anchorEl)
27
+ onClose();
28
+ };
29
+ window.addEventListener('mousedown', handler);
30
+ return () => window.removeEventListener('mousedown', handler);
31
+ }, [anchorEl, onClose]);
32
+ const visibleValues = useMemo(() => ['Vide', ...values.filter((v) => v !== 'Vide' && v.toLowerCase().includes(search.toLowerCase()))], [values, search]);
33
+ const allChecked = visibleValues.every((v) => checked.has(v));
34
+ const toggleAll = (next) => {
35
+ setChecked((prev) => {
36
+ const copy = new Set(prev);
37
+ visibleValues.forEach((v) => (next ? copy.add(v) : copy.delete(v)));
38
+ return copy;
39
+ });
40
+ };
41
+ const toggleOne = (v) => {
42
+ setChecked((prev) => {
43
+ const copy = new Set(prev);
44
+ copy.has(v) ? copy.delete(v) : copy.add(v);
45
+ return copy;
46
+ });
47
+ };
48
+ return (_jsxs("div", { ref: modalRef, className: "modal flex-column", style: { minWidth: '5em', ...style }, children: [_jsxs("div", { className: "frame", children: [_jsx("button", { onClick: () => {
49
+ onSort('ASC');
50
+ onClose();
51
+ }, children: "Sort A to Z" }), _jsx("button", { onClick: () => {
52
+ onSort('DESC');
53
+ onClose();
54
+ }, children: "Sort Z to A" })] }), _jsxs("div", { className: "frame", style: { maxWidth: '33vh', minWidth: 100 }, children: [_jsx("button", { onClick: () => {
55
+ onApply(null);
56
+ onClose();
57
+ }, children: "Clear Filter" }), _jsx("input", { type: "text", placeholder: "Search", value: search, onChange: (e) => setSearch(e.target.value) }), _jsxs("div", { className: "frame", style: { maxHeight: '20vh', overflowY: 'auto', overflowX: 'hidden', gap: 5 }, children: [_jsxs("div", { className: "flex-row nowrap", style: { alignItems: 'center' }, children: [_jsx("input", { type: "checkbox", checked: allChecked, onChange: (e) => toggleAll(e.target.checked) }), _jsx("label", { style: { width: '100%' }, children: "Tout S\u00E9lectionner" })] }), visibleValues.map((v) => (_jsxs("div", { className: "flex-row nowrap", style: { alignItems: 'center' }, children: [_jsx("input", { type: "checkbox", checked: checked.has(v), onChange: () => toggleOne(v) }), _jsx("label", { style: { width: '100%' }, children: v === 'Vide' ? 'Vide' : v })] }, v || '(empty)')))] })] }), _jsxs("div", { className: "flex-row", children: [_jsx("button", { onClick: () => {
58
+ onApply(Array.from(checked));
59
+ onClose();
60
+ }, children: "OK" }), _jsx("button", { onClick: onClose, children: "Annuler" })] })] }));
61
+ }
@@ -0,0 +1,9 @@
1
+ import type { FilterConfig } from './types.js';
2
+ interface FilterPanelProps {
3
+ filterConfig: FilterConfig;
4
+ activeFilters: Record<string, unknown>;
5
+ onChange: (key: string, value: unknown) => void;
6
+ onClearAll: () => void;
7
+ }
8
+ export declare function FilterPanel({ filterConfig, activeFilters, onChange, onClearAll }: FilterPanelProps): import("react").JSX.Element;
9
+ export {};
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from 'react';
3
+ export function FilterPanel({ filterConfig, activeFilters, onChange, onClearAll }) {
4
+ const [open, setOpen] = useState(false);
5
+ const hasActive = Object.keys(activeFilters).length > 0;
6
+ return (_jsxs("div", { className: 'flex-column nowrap', style: { height: '-webkit-fill-available' }, children: [_jsx("button", { style: { maxWidth: '18rem' }, onClick: () => setOpen((o) => !o), children: "Filtres" }), open && (_jsxs("div", { className: 'responsive-panel frame nowrap active', style: { minWidth: 'min-content' }, children: [hasActive && _jsx("button", { onClick: onClearAll, children: "R\u00E9initialiser les filtres" }), Object.entries(filterConfig).map(([key, info]) => {
7
+ if (!info.type || info.type === 'HIDE')
8
+ return null;
9
+ return (_jsxs("div", { className: 'flex-column frame', children: [_jsx("span", { children: key }), activeFilters[key] !== undefined && (_jsx("button", { onClick: () => onChange(key, undefined), children: "R\u00E9initialiser le filtre" })), _jsxs("div", { className: 'filterOption', style: { maxWidth: '-webkit-fill-available' }, children: [['MULTISELECT', 'UNGROUP_MULTISELECT'].includes(info.type) && (_jsx(MultiSelectFilter, { filterKey: key, values: info.values, ungroup: info.type === 'UNGROUP_MULTISELECT', active: activeFilters[key], onApply: (vals) => onChange(key, vals.length ? vals : undefined) })), info.type === 'SLIDER' && (_jsx(SliderFilter, { values: info.values, onApply: (min, max) => onChange(key, { min, max }) })), ['DATE', 'DATETIME'].includes(info.type) && (_jsx(DateFilter, { type: info.type, values: info.values, onApply: (min, max) => onChange(key, { min, max, date: true }) }))] })] }, key));
10
+ })] }))] }));
11
+ }
12
+ // ==================== MULTISELECT ====================
13
+ function MultiSelectFilter({ values, active, ungroup, onApply, }) {
14
+ const [search, setSearch] = useState('');
15
+ // null = "everything selected" (no filter applied yet)
16
+ const [selected, setSelected] = useState(active ?? null);
17
+ const items = useMemo(() => {
18
+ const base = search ? values.filter((v) => v.toLowerCase().includes(search.toLowerCase())).slice(0, 400) : values.slice(0, 400);
19
+ const withActive = selected ? Array.from(new Set([...base, ...selected])) : base;
20
+ return ['Vide', ...withActive.filter((v) => v !== 'Vide')];
21
+ }, [values, search, selected]);
22
+ const allChecked = selected === null || items.every((v) => selected.includes(v));
23
+ const someChecked = selected !== null && items.some((v) => selected.includes(v));
24
+ const toggleAll = (checked) => setSelected(checked ? (search ? [...items] : null) : []);
25
+ const toggleOne = (val) => {
26
+ setSelected((prev) => {
27
+ const base = prev === null ? [...items] : prev;
28
+ const next = base.includes(val) ? base.filter((v) => v !== val) : [...base, val];
29
+ return !search && next.length === items.length ? null : next;
30
+ });
31
+ };
32
+ const apply = () => {
33
+ const toSend = (selected ?? items).map((v) => (v === 'Vide' || v === 'Non Vide' ? v : ungroup ? `/*/${v}/*/` : v));
34
+ onApply(selected === null ? [] : toSend);
35
+ };
36
+ return (_jsxs("div", { className: 'predefinedInfos frame', children: [_jsx("div", { className: 'input-field', children: _jsx("input", { type: 'text', placeholder: 'Rechercher', autoComplete: 'off', value: search, onChange: (e) => setSearch(e.target.value) }) }), _jsxs("div", { className: 'selectAllRow flex-row nowrap', style: { padding: '4px 0', borderBottom: '1px solid #ccc' }, children: [_jsx("input", { type: 'checkbox', checked: allChecked, ref: (el) => el && (el.indeterminate = !allChecked && someChecked), onChange: (e) => toggleAll(e.target.checked) }), _jsx("label", { style: { fontWeight: 'bold' }, children: "(S\u00E9lectionner tout)" })] }), _jsx("div", { style: { overflowY: 'auto', maxHeight: '20vh', maxWidth: '25rem' }, className: 'listPredefinedInfos flex-column nowrap', children: items.map((val) => (_jsxs("div", { className: 'flex-row nowrap', children: [_jsx("input", { type: 'checkbox', checked: selected === null || selected.includes(val), onChange: () => toggleOne(val) }), _jsx("label", { style: { cursor: 'pointer' }, onClick: () => toggleOne(val), children: val })] }, val || '(empty)'))) }), _jsx("button", { className: 'btn-accent', onClick: apply, children: "Appliquer" })] }));
37
+ }
38
+ // ==================== SLIDER (min/max range, no external dependency) ====================
39
+ function SliderFilter({ values, onApply }) {
40
+ const [min, setMin] = useState(values[0]);
41
+ const [max, setMax] = useState(values[1]);
42
+ const commit = (nextMin, nextMax) => {
43
+ setMin(nextMin);
44
+ setMax(nextMax);
45
+ onApply(nextMin, nextMax);
46
+ };
47
+ return (_jsxs("div", { style: { padding: '0 1rem 1rem 0' }, children: [_jsxs("div", { className: 'flex-row nowrap', style: { justifyContent: 'space-between' }, children: [_jsx("input", { type: 'number', className: 'inputSlider', value: min, min: values[0], max: max, onChange: (e) => commit(Number(e.target.value), max) }), _jsx("input", { type: 'number', className: 'inputSlider', value: max, min: min, max: values[1], onChange: (e) => commit(min, Number(e.target.value)) })] }), _jsx("input", { type: 'range', min: values[0], max: values[1], value: min, onChange: (e) => commit(Number(e.target.value), max) }), _jsx("input", { type: 'range', min: values[0], max: values[1], value: max, onChange: (e) => commit(min, Number(e.target.value)) })] }));
48
+ }
49
+ // ==================== DATE RANGE ====================
50
+ function DateFilter({ type, values, onApply }) {
51
+ const inputType = type === 'DATE' ? 'date' : 'datetime-local';
52
+ const fmt = (d) => {
53
+ const dt = new Date(d);
54
+ const iso = dt.toISOString().slice(0, -1);
55
+ return type === 'DATE' ? iso.split('T')[0] : iso.slice(0, 16);
56
+ };
57
+ const [min, setMin] = useState(fmt(values[0]));
58
+ const [max, setMax] = useState(fmt(values[1]));
59
+ const canApply = Boolean(min && max);
60
+ const apply = () => {
61
+ const maxDate = new Date(max);
62
+ if (type === 'DATE')
63
+ maxDate.setDate(maxDate.getDate() + 1);
64
+ onApply(new Date(min), maxDate);
65
+ };
66
+ return (_jsxs("div", { className: 'flex-column nowrap', children: [_jsxs("div", { className: 'flex-row nowrap', style: { justifyContent: 'space-between' }, children: [_jsx("input", { type: inputType, value: min, min: fmt(values[0]), max: fmt(values[1]), onChange: (e) => setMin(e.target.value) }), _jsx("input", { type: inputType, value: max, min: fmt(values[0]), max: fmt(values[1]), onChange: (e) => setMax(e.target.value) })] }), canApply && (_jsx("button", { className: 'btn-accent', onClick: apply, children: "Appliquer" }))] }));
67
+ }
@@ -0,0 +1,18 @@
1
+ export interface PaginationProps {
2
+ page: number;
3
+ totalPages: number;
4
+ perPage: number;
5
+ count: number;
6
+ rowsPerPageOptions?: number[];
7
+ onChangePerPage?: (n: number) => void;
8
+ onFirst: () => void;
9
+ onPrevious: () => void;
10
+ onNext: () => void;
11
+ onLast: () => void;
12
+ }
13
+ /**
14
+ * Page controls + rows-per-page select + "x → y / total" counter.
15
+ * Works with `useDataTable`'s return value, or with your own pagination state —
16
+ * it only needs plain numbers and callbacks.
17
+ */
18
+ export declare function Pagination({ page, totalPages, perPage, count, rowsPerPageOptions, onChangePerPage, onFirst, onPrevious, onNext, onLast, }: PaginationProps): import("react").JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Page controls + rows-per-page select + "x → y / total" counter.
4
+ * Works with `useDataTable`'s return value, or with your own pagination state —
5
+ * it only needs plain numbers and callbacks.
6
+ */
7
+ export function Pagination({ page, totalPages, perPage, count, rowsPerPageOptions = [10, 25, 50, 100, 250, 500, 1000], onChangePerPage, onFirst, onPrevious, onNext, onLast, }) {
8
+ return (_jsxs("div", { className: "flex-row", style: { alignItems: 'center', justifyContent: 'space-between' }, children: [onChangePerPage && (_jsxs("div", { className: "divSelectRowPerPage flex-row", style: { alignItems: 'center', gap: 1, display: totalPages <= 1 ? 'none' : 'flex' }, children: [_jsx("select", { className: "row-per-page", value: perPage, onChange: (e) => onChangePerPage(Number(e.target.value)), children: rowsPerPageOptions.map((v) => (_jsx("option", { value: v, children: v }, v))) }), _jsx("div", { children: "/page" })] })), _jsxs("span", { className: "totalLinesTable", children: [(page - 1) * perPage + 1, " \u279D ", Math.min(page * perPage, count), " / ", count] }), totalPages > 1 && (_jsxs("div", { className: "pagination", children: [_jsx("button", { className: "pagination-button", onClick: onFirst, disabled: page === 1, children: "\u00AB" }), _jsx("button", { className: "pagination-button", onClick: onPrevious, disabled: page === 1, children: "\u2039" }), _jsxs("span", { className: "current-page", children: ["Page ", page, "/", totalPages] }), _jsx("button", { className: "pagination-button", onClick: onNext, disabled: page === totalPages, children: "\u203A" }), _jsx("button", { className: "pagination-button", onClick: onLast, disabled: page === totalPages, children: "\u00BB" })] }))] }));
9
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { FieldTypeInfo, ParamFilter, SortDirection } from './types.js';
3
+ export interface TableProps<T extends Record<string, any>> {
4
+ items: T[];
5
+ fieldsType?: FieldTypeInfo[];
6
+ paramFilter?: ParamFilter[];
7
+ sortColumn?: string;
8
+ sortDirection?: SortDirection;
9
+ /** Called with the column name when its header is clicked (only if sortingEnabled). */
10
+ onSort?: (column: string) => void;
11
+ sortingEnabled?: boolean;
12
+ onRowClick?: (id: any, row: T) => void;
13
+ onImagePreview?: (src: string) => void;
14
+ /** Slot to render your own control in a column header (e.g. your own filter icon/input). Receives the column name. */
15
+ renderHeaderExtra?: (column: string) => ReactNode;
16
+ }
17
+ /**
18
+ * Just the `<table>` — header, sorting, rows, cell formatting (dates, JSON, lazy images).
19
+ * No filter UI, no pagination: bring your own and drive `items`/`fieldsType` yourself
20
+ * (e.g. from `useDataTable`, or from any other data source).
21
+ */
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;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
3
+ import { Cell } from './Cell.js';
4
+ /**
5
+ * Just the `<table>` — header, sorting, rows, cell formatting (dates, JSON, lazy images).
6
+ * No filter UI, no pagination: bring your own and drive `items`/`fieldsType` yourself
7
+ * (e.g. from `useDataTable`, or from any other data source).
8
+ */
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)))] })] }));
19
+ }
@@ -0,0 +1,9 @@
1
+ export { DataTable } from './DataTable.js';
2
+ export { Table } from './Table.js';
3
+ export type { TableProps } from './Table.js';
4
+ export { Pagination } from './Pagination.js';
5
+ export type { PaginationProps } from './Pagination.js';
6
+ export { useDataTable } from './useDataTable.js';
7
+ export { FilterModal } from './FilterModal.js';
8
+ export { FilterPanel } from './FilterPanel.js';
9
+ export type { DataTableProps, FetchParams, FetchResult, FieldTypeInfo, FilterConfig, FilterFieldConfig, SortDirection } from './types.js';
@@ -0,0 +1,6 @@
1
+ export { DataTable } from './DataTable.js';
2
+ export { Table } from './Table.js';
3
+ export { Pagination } from './Pagination.js';
4
+ export { useDataTable } from './useDataTable.js';
5
+ export { FilterModal } from './FilterModal.js';
6
+ export { FilterPanel } from './FilterPanel.js';
@@ -0,0 +1,57 @@
1
+ export type SortDirection = 'ASC' | 'DESC';
2
+ /** Matches the "fieldType" info your API already returns per column. */
3
+ export type FieldTypeName = 'DATE' | 'DATETIME' | 'TIMESTAMP' | 'JSON' | 'BLOB' | 'FILE' | string;
4
+ export interface FieldTypeInfo {
5
+ fieldType: FieldTypeName;
6
+ }
7
+ /** Body sent to your fetchData callback on every load. */
8
+ export interface FetchParams {
9
+ limit: number;
10
+ offset: number;
11
+ sorting?: string;
12
+ filtre?: Record<string, unknown>;
13
+ /** Ask the backend to (re)send the advanced filter definitions (filtre config). */
14
+ setFilter?: boolean;
15
+ }
16
+ export interface FetchResult<T> {
17
+ items: T[];
18
+ count: number;
19
+ /** One entry per column, in the same order as Object.keys(items[0]). */
20
+ fieldsType?: FieldTypeInfo[];
21
+ /** Advanced filter definitions, sent back by the API (see FilterFieldConfig). */
22
+ paramFilter?: ParamFilter[];
23
+ filtre?: FilterConfig;
24
+ /** Key to re-fetch filter definitions later via a dedicated endpoint. */
25
+ cleRecupFiltre?: string;
26
+ }
27
+ export type FilterFieldType = 'MULTISELECT' | 'UNGROUP_MULTISELECT' | 'SLIDER' | 'DATE' | 'DATETIME' | 'HIDE';
28
+ export interface FilterFieldConfig {
29
+ type: FilterFieldType;
30
+ /** MULTISELECT: string[]. SLIDER: [number, number]. DATE/DATETIME: [string, string]. */
31
+ values: any;
32
+ }
33
+ export type ParamFilter = {
34
+ nom: string;
35
+ type: FilterFieldType;
36
+ };
37
+ export type FilterConfig = Record<string, FilterFieldConfig>;
38
+ export interface DataTableProps<T extends Record<string, any>> {
39
+ /** Called every time the table needs data (page change, sort, filter, page size). */
40
+ fetchData: (params: FetchParams) => Promise<FetchResult<T> | null>;
41
+ /** Called when a row is clicked, with the value of the row's first column as id. */
42
+ onRowClick?: (id: any, row: T) => void;
43
+ /** Enable the per-column quick filter button (magnifier icon) in the header. Default true. */
44
+ filterEnabled?: boolean;
45
+ /** Enable click-to-sort on column headers. Default true. */
46
+ sortingEnabled?: boolean;
47
+ /** Enable the advanced filter side panel (multiselect / slider / date), driven by fieldsType.filtre. Default false. */
48
+ advancedFilters?: boolean;
49
+ /** CSS max-height for the scroll area, e.g. "76vh". */
50
+ height?: string;
51
+ rowsPerPageOptions?: number[];
52
+ defaultRowsPerPage?: number;
53
+ /** Called instead of the default new-tab preview when an image cell is clicked. */
54
+ onImagePreview?: (src: string) => void;
55
+ /** Optional endpoint to lazily fetch filter definitions using cleRecupFiltre. */
56
+ fetchFilterConfig?: (cleRecupFiltre: string) => Promise<FilterConfig>;
57
+ }
@@ -0,0 +1,2 @@
1
+ // ==================== TYPES ====================
2
+ export {};
@@ -0,0 +1,28 @@
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'>): {
3
+ items: T[];
4
+ count: number;
5
+ fieldsType: FieldTypeInfo[];
6
+ paramFilter: ParamFilter[];
7
+ loading: boolean;
8
+ page: number;
9
+ perPage: number;
10
+ rowsPerPageOptions: number[];
11
+ totalPages: number;
12
+ sortColumn: string;
13
+ sortDirection: SortDirection;
14
+ filters: Record<string, unknown>;
15
+ filterConfig: FilterConfig | null;
16
+ toggleSort: (column: string, forceDirection?: SortDirection) => void;
17
+ setColumnFilter: (key: string, value: unknown) => void;
18
+ replaceFilters: (next: Record<string, unknown>) => void;
19
+ clearFilter: (key: string) => void;
20
+ clearAllFilters: () => void;
21
+ changePerPage: (n: number) => void;
22
+ goToPage: (p: number) => Promise<void>;
23
+ nextPage: () => Promise<void>;
24
+ previousPage: () => Promise<void>;
25
+ firstPage: () => Promise<void>;
26
+ lastPage: () => Promise<void>;
27
+ refresh: () => Promise<void>;
28
+ };
@@ -0,0 +1,127 @@
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, }) {
3
+ const [items, setItems] = useState([]);
4
+ const [count, setCount] = useState(0);
5
+ const [fieldsType, setFieldsType] = useState([]);
6
+ const [paramFilter, setParamFilter] = useState([]);
7
+ const [loading, setLoading] = useState(false);
8
+ const [page, setPage] = useState(1);
9
+ const [perPage, setPerPage] = useState(defaultRowsPerPage);
10
+ const [sortColumn, setSortColumn] = useState('');
11
+ const [sortDirection, setSortDirection] = useState('ASC');
12
+ const [filters, setFilters] = useState({});
13
+ const [filterConfig, setFilterConfig] = useState(null);
14
+ const [cleRecupFiltre, setCleRecupFiltre] = useState();
15
+ // Guards against a slow, stale request overwriting a newer one.
16
+ const requestId = useRef(0);
17
+ const sorting = useMemo(() => (sortColumn ? `\`${sortColumn}\` ${sortDirection}` : ''), [sortColumn, sortDirection]);
18
+ const totalPages = useMemo(() => Math.max(1, Math.ceil(count / perPage)), [count, perPage]);
19
+ const load = useCallback(async (targetPage) => {
20
+ const id = ++requestId.current;
21
+ setLoading(true);
22
+ try {
23
+ const offset = (targetPage - 1) * perPage;
24
+ const result = await fetchData({
25
+ limit: perPage,
26
+ offset,
27
+ sorting: sorting || undefined,
28
+ filtre: Object.keys(filters).length > 0 ? filters : undefined,
29
+ setFilter: advancedFilters || undefined,
30
+ });
31
+ if (id !== requestId.current)
32
+ return; // a newer request already landed
33
+ if (!result) {
34
+ setItems([]);
35
+ setCount(0);
36
+ return;
37
+ }
38
+ setItems(result.items || []);
39
+ setCount(result.count ?? 0);
40
+ setFieldsType(result.fieldsType || []);
41
+ setParamFilter(result.paramFilter || []);
42
+ setPage(targetPage);
43
+ if (result.filtre)
44
+ setFilterConfig(result.filtre);
45
+ if (result.cleRecupFiltre)
46
+ setCleRecupFiltre(result.cleRecupFiltre);
47
+ }
48
+ finally {
49
+ if (id === requestId.current)
50
+ setLoading(false);
51
+ }
52
+ }, [fetchData, perPage, sorting, filters, advancedFilters]);
53
+ // Re-fetch whenever page size, sorting or filters change (always resets to page 1).
54
+ useEffect(() => {
55
+ load(1);
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps
57
+ }, [perPage, sorting, filters]);
58
+ // Lazily resolve filter definitions if the API only gave us a lookup key.
59
+ useEffect(() => {
60
+ if (!advancedFilters || filterConfig || !cleRecupFiltre || !fetchFilterConfig)
61
+ return;
62
+ let cancelled = false;
63
+ fetchFilterConfig(cleRecupFiltre).then((cfg) => {
64
+ if (!cancelled) {
65
+ setFilterConfig(cfg);
66
+ setCleRecupFiltre(undefined);
67
+ }
68
+ });
69
+ return () => {
70
+ cancelled = true;
71
+ };
72
+ }, [advancedFilters, filterConfig, cleRecupFiltre, fetchFilterConfig]);
73
+ const goToPage = useCallback((p) => load(Math.min(Math.max(1, p), totalPages)), [load, totalPages]);
74
+ const nextPage = useCallback(() => goToPage(page + 1), [goToPage, page]);
75
+ const previousPage = useCallback(() => goToPage(page - 1), [goToPage, page]);
76
+ const firstPage = useCallback(() => goToPage(1), [goToPage]);
77
+ const lastPage = useCallback(() => goToPage(totalPages), [goToPage, totalPages]);
78
+ const toggleSort = useCallback((column, forceDirection) => {
79
+ setSortColumn((prevCol) => {
80
+ setSortDirection((prevDir) => forceDirection ?? (prevCol === column ? (prevDir === 'ASC' ? 'DESC' : 'ASC') : 'ASC'));
81
+ return column;
82
+ });
83
+ }, []);
84
+ const setColumnFilter = useCallback((key, value) => {
85
+ setFilters((prev) => {
86
+ const next = { ...prev };
87
+ if (value === undefined || (Array.isArray(value) && value.length === 0))
88
+ delete next[key];
89
+ else
90
+ next[key] = value;
91
+ return next;
92
+ });
93
+ }, []);
94
+ const clearFilter = useCallback((key) => setColumnFilter(key, undefined), [setColumnFilter]);
95
+ const clearAllFilters = useCallback(() => setFilters({}), []);
96
+ /** Replace the whole filter object at once — handy for a custom search UI that
97
+ * doesn't reason "per column" and just wants to hand over its own `filtre` payload. */
98
+ const replaceFilters = useCallback((next) => setFilters(next), []);
99
+ const changePerPage = useCallback((n) => setPerPage(n), []);
100
+ return {
101
+ items,
102
+ count,
103
+ fieldsType,
104
+ paramFilter,
105
+ loading,
106
+ page,
107
+ perPage,
108
+ rowsPerPageOptions,
109
+ totalPages,
110
+ sortColumn,
111
+ sortDirection,
112
+ filters,
113
+ filterConfig,
114
+ toggleSort,
115
+ setColumnFilter,
116
+ replaceFilters,
117
+ clearFilter,
118
+ clearAllFilters,
119
+ changePerPage,
120
+ goToPage,
121
+ nextPage,
122
+ previousPage,
123
+ firstPage,
124
+ lastPage,
125
+ refresh: () => load(page),
126
+ };
127
+ }
@@ -0,0 +1,6 @@
1
+ export declare function formattedDate(value: unknown, dateOnly?: boolean): string;
2
+ export declare function isImage(path: string): boolean;
3
+ export declare function looksLikeFile(str: string): boolean;
4
+ /** Returns HTML with <span class="..."> wrapping each JSON token, for use with dangerouslySetInnerHTML. */
5
+ export declare function syntaxHighlightJSON(json: string): string;
6
+ export declare function truncatedJSON(value: unknown, maxLen?: number): string;
@@ -0,0 +1,40 @@
1
+ // ==================== UTILS ====================
2
+ export function formattedDate(value, dateOnly = false) {
3
+ if (!value)
4
+ return '';
5
+ const d = new Date(value);
6
+ if (Number.isNaN(d.getTime()))
7
+ return String(value);
8
+ const date = d.toLocaleDateString();
9
+ if (dateOnly)
10
+ return date;
11
+ return `${date} ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
12
+ }
13
+ export function isImage(path) {
14
+ return /\.(png|jpg|jpeg|svg|webp|gif)$/i.test(path);
15
+ }
16
+ export function looksLikeFile(str) {
17
+ if (!str)
18
+ return false;
19
+ const ext = /\.(txt|pdf|csv|json|xml|yaml|yml|ts|js|tsx|jsx|html|css|png|jpg|jpeg|gif|svg|zip|tar|gz|md|docx?|xlsx?|pptx?)$/i;
20
+ return str.split(';').every((f) => ext.test(f.trim()));
21
+ }
22
+ /** Returns HTML with <span class="..."> wrapping each JSON token, for use with dangerouslySetInnerHTML. */
23
+ export function syntaxHighlightJSON(json) {
24
+ const escaped = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
25
+ return escaped.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (m) => {
26
+ let cls = 'number';
27
+ if (/^"/.test(m))
28
+ cls = /:$/.test(m) ? 'key' : 'string';
29
+ else if (/true|false/.test(m))
30
+ cls = 'boolean';
31
+ else if (/null/.test(m))
32
+ cls = 'null';
33
+ return `<span class="${cls}">${m}</span>`;
34
+ });
35
+ }
36
+ export function truncatedJSON(value, maxLen = 200) {
37
+ const str = JSON.stringify(value, null, 2);
38
+ const truncated = str.length > maxLen ? `${str.slice(0, maxLen)}...` : str;
39
+ return syntaxHighlightJSON(truncated);
40
+ }
@@ -0,0 +1,41 @@
1
+ import type { CacheDeps, ParamFilterType, ReqTableQueryOptions, TableQueryDeps } from './types.js';
2
+ export type { CacheDeps, ParamFilterType, ReqTableQueryOptions, TableQueryDeps } from './types.js';
3
+ /**
4
+ * Creates the table-query module (router + query helpers) bound to this project's own
5
+ * date formatting / route-wrapping / cache implementations. Nothing here is hardcoded to
6
+ * a specific project — pass whatever you already have in `functions.js` / `redis.js`.
7
+ *
8
+ * Caching is opt-in per call (see `useCache` on `reqTableQuery`): if you never pass
9
+ * `deps.cache`, the module works exactly the same but always computes fresh.
10
+ */
11
+ export declare function createTableQueryModule(deps: TableQueryDeps): {
12
+ router: import("express-serve-static-core").Router;
13
+ reqTableQuery: (opt: ReqTableQueryOptions) => Promise<{
14
+ error: string;
15
+ status: number;
16
+ empty?: undefined;
17
+ data?: undefined;
18
+ } | {
19
+ empty: true;
20
+ data: any;
21
+ error?: undefined;
22
+ status?: undefined;
23
+ } | {
24
+ data: any;
25
+ error?: undefined;
26
+ status?: undefined;
27
+ empty?: undefined;
28
+ }>;
29
+ getFiltersOfQuery: (opt: {
30
+ payload: any;
31
+ baseQuery: string;
32
+ argsQuery?: any[];
33
+ paramFilter: ParamFilterType[];
34
+ cleRecupFiltre?: string;
35
+ cache?: CacheDeps;
36
+ keepCache?: {
37
+ keepCache: number;
38
+ titleSQLCacheFiltre: string;
39
+ };
40
+ }) => Promise<any>;
41
+ };
@@ -0,0 +1,240 @@
1
+ import { Router } from 'express';
2
+ import { connectionRollback, executeMySQLQuery, executeMySQLQuery2, executeMySQLQuery3 } from '@benjosivo/mysql';
3
+ import { createHash, randomUUID } from 'crypto';
4
+ /**
5
+ * Creates the table-query module (router + query helpers) bound to this project's own
6
+ * date formatting / route-wrapping / cache implementations. Nothing here is hardcoded to
7
+ * a specific project — pass whatever you already have in `functions.js` / `redis.js`.
8
+ *
9
+ * Caching is opt-in per call (see `useCache` on `reqTableQuery`): if you never pass
10
+ * `deps.cache`, the module works exactly the same but always computes fresh.
11
+ */
12
+ export function createTableQueryModule(deps) {
13
+ const router = Router();
14
+ router.get('/getFiltres', deps.wrapRouteHandler(getFiltres));
15
+ function resolveCache(useCache) {
16
+ const wantsCache = useCache ?? Boolean(deps.cache);
17
+ if (!wantsCache)
18
+ return null;
19
+ if (!deps.cache) {
20
+ throw new Error(`useCache: true a été demandé mais aucune implémentation de cache n'a été fournie à createTableQueryModule().`);
21
+ }
22
+ return deps.cache;
23
+ }
24
+ async function getFiltres(req, res) {
25
+ if (!deps.cache) {
26
+ return res.status(501).send(`Le cache n'est pas configuré pour ce module — /getFiltres n'est disponible qu'avec useCache.`);
27
+ }
28
+ if (!req.query || !req.query.cleRecupFiltre) {
29
+ return res.status(400).send(`La clé de récupération des filtres est manquante.`);
30
+ }
31
+ const keyRecupFiltres = `tableQuery:recupFiltres:${req.query.cleRecupFiltre}`;
32
+ let interval = null;
33
+ req.on('close', () => {
34
+ if (interval)
35
+ clearInterval(interval);
36
+ deps.cache.deleteSQLCache(keyRecupFiltres);
37
+ });
38
+ async function sendFiltres() {
39
+ const cacheFiltre = await deps.cache.getSQLCache(keyRecupFiltres);
40
+ if (!cacheFiltre) {
41
+ return req.closed ? null : res.status(410).send(`La clé de récupération des filtres a expiré. Veuillez rafraîchir la page.`);
42
+ }
43
+ if (!cacheFiltre.data.status && cacheFiltre.data.filtres) {
44
+ req.closed ? null : res.json(cacheFiltre.data.filtres);
45
+ deps.cache.deleteSQLCache(keyRecupFiltres);
46
+ if (interval)
47
+ clearInterval(interval);
48
+ return;
49
+ }
50
+ }
51
+ interval = setInterval(sendFiltres, 200);
52
+ sendFiltres();
53
+ }
54
+ async function reqTableQuery(opt) {
55
+ const { query, req, paramFilter, sort, argsQuery, keepCache = 5 * 60 * 1000 } = opt;
56
+ if (!req.body && !req.query) {
57
+ return { error: `req.query or req.body is missing`, status: 400 };
58
+ }
59
+ const cache = resolveCache(opt.useCache);
60
+ const reqInfo = req.body ?? req.query;
61
+ const limit = reqInfo.limit ? parseInt(reqInfo.limit, 10) : 1000;
62
+ const offset = reqInfo.offset ? parseInt(reqInfo.offset, 10) : 0;
63
+ const sorting = reqInfo.sorting || sort || '2';
64
+ const filtres = reqInfo.filtre ? (req.method === 'GET' ? JSON.parse(reqInfo.filtre) : reqInfo.filtre) : {};
65
+ const whereClause = buildWhereClause(filtres);
66
+ if (!paramFilter && reqInfo.setFilter) {
67
+ return { error: `"paramFilter" est obligatoire quand setFilter === true`, status: 400 };
68
+ }
69
+ // ── Paginated items ────────────────────────────────────────────────
70
+ const itemsSql = `${query} ${whereClause} ORDER BY ${sorting} LIMIT ${limit < 0 ? 0 : limit} OFFSET ${offset < 0 ? 0 : offset}`;
71
+ let { payload, status, error, empty } = await getDataFromQuery(itemsSql, argsQuery, cache ? keepCache : 0, cache);
72
+ payload.paramFilter = paramFilter
73
+ ? paramFilter.map((param, i) => {
74
+ return { nom: payload.fieldsType[i].fieldName, type: param };
75
+ })
76
+ : [];
77
+ if (empty)
78
+ return { empty, data: payload };
79
+ if (error && status)
80
+ return { error, status };
81
+ if (!reqInfo.setFilter)
82
+ return { data: payload };
83
+ // ── Filters ────────────────────────────────────────────────────────
84
+ const baseQuery = `${query} ${whereClause}`;
85
+ if (!cache) {
86
+ // No cache configured/requested for this call: compute synchronously and
87
+ // return directly, no polling round-trip via /getFiltres needed.
88
+ payload.filtre = await getFiltersOfQuery({ payload, baseQuery, argsQuery, paramFilter: paramFilter });
89
+ return { data: payload };
90
+ }
91
+ const keyFilter = createHash('sha256').update(baseQuery).digest('hex');
92
+ const titleSQLCacheFiltre = `tableQuery:filtres:${keyFilter}`;
93
+ const cachedFiltre = await cache.getSQLCache(titleSQLCacheFiltre);
94
+ if (cachedFiltre && keepCache) {
95
+ payload.filtre = cachedFiltre.data;
96
+ }
97
+ else {
98
+ payload.cleRecupFiltre = randomUUID();
99
+ getFiltersOfQuery({
100
+ payload,
101
+ baseQuery,
102
+ argsQuery,
103
+ paramFilter: paramFilter,
104
+ cleRecupFiltre: payload.cleRecupFiltre,
105
+ cache,
106
+ keepCache: { keepCache, titleSQLCacheFiltre },
107
+ });
108
+ }
109
+ return { data: payload };
110
+ }
111
+ // ── Helpers ────────────────────────────────────────────────────────────
112
+ async function getDataFromQuery(itemsSql, argsQuery, keepCache, cache) {
113
+ const titleSQLCacheQuery = `tableQuery:query:${createHash('sha256').update(itemsSql).digest('hex')}`;
114
+ const cachedQuery = cache && keepCache ? await cache.getSQLCache(titleSQLCacheQuery) : null;
115
+ let payload;
116
+ if (cachedQuery) {
117
+ payload = cachedQuery.data;
118
+ }
119
+ else {
120
+ const reqRows = await executeMySQLQuery3({ query: itemsSql, values: argsQuery, returnFieldTypes: true, returnListTables: true });
121
+ if (!reqRows.ok)
122
+ return { status: 500, error: reqRows.error };
123
+ const tableValues = reqRows.rows.map(({ TotalCount, ...rest }) => rest);
124
+ if (tableValues.length === 0)
125
+ return { empty: true, payload: { items: tableValues, count: 0, fieldsType: reqRows.fieldsType } };
126
+ Object.keys(tableValues[0]).forEach((col, i) => {
127
+ if (col === 'Documents')
128
+ reqRows.fieldsType[i].fieldType = 'FILE';
129
+ });
130
+ payload = { items: tableValues, count: reqRows.rows[0]?.TotalCount ?? 0, fieldsType: reqRows.fieldsType };
131
+ if (cache && keepCache) {
132
+ await cache.setSQLCache(titleSQLCacheQuery, { title: titleSQLCacheQuery, data: payload, expiration: Date.now() + keepCache });
133
+ }
134
+ }
135
+ return { payload };
136
+ }
137
+ async function getFiltersOfQuery(opt) {
138
+ const { payload, baseQuery, argsQuery, paramFilter, cleRecupFiltre, cache, keepCache } = opt;
139
+ const columns = Object.keys(payload.items[0]);
140
+ if (cleRecupFiltre && cache) {
141
+ const titreRecupFiltres = `tableQuery:recupFiltres:${cleRecupFiltre}`;
142
+ await cache.setSQLCache(titreRecupFiltres, { title: titreRecupFiltres, data: { status: `Waiting` }, expiration: Date.now() + 60 * 60 * 1000 });
143
+ }
144
+ const filtres = await computeFiltersViaTempTable({ baseQuery, argsQuery, columns, paramFilter });
145
+ if (cleRecupFiltre && cache) {
146
+ const titreRecupFiltres = `tableQuery:recupFiltres:${cleRecupFiltre}`;
147
+ await cache.setSQLCache(titreRecupFiltres, { title: titreRecupFiltres, data: { filtres }, expiration: Date.now() + 60 * 60 * 1000 });
148
+ }
149
+ if (keepCache && cache) {
150
+ await cache.setSQLCache(keepCache.titleSQLCacheFiltre, { title: keepCache.titleSQLCacheFiltre, data: filtres, expiration: Date.now() + keepCache.keepCache });
151
+ }
152
+ return filtres;
153
+ }
154
+ function buildWhereClause(filtres) {
155
+ if (Object.keys(filtres).length === 0)
156
+ return '';
157
+ function singleCondition(key, val) {
158
+ const lower = val.toLowerCase();
159
+ if (lower === 'null' || lower === 'vide')
160
+ return `\`${key}\` IS NULL`;
161
+ if (lower === 'notnull' || lower === 'non vide')
162
+ return `\`${key}\` IS NOT NULL`;
163
+ if (val.startsWith('!'))
164
+ return `\`${key}\` NOT LIKE '${val.replaceAll("'", "''")}'`;
165
+ return `\`${key}\` LIKE '${val.replaceAll("'", "''")}'`;
166
+ }
167
+ const conditions = Object.entries(filtres).map(([key, value]) => {
168
+ if (Array.isArray(value)) {
169
+ return `(${value.map((val) => singleCondition(key, String(val).replaceAll('/*/', '%'))).join(' OR ')})`;
170
+ }
171
+ if (value !== null && typeof value === 'object' && 'min' in value && 'max' in value) {
172
+ const min = value.date ? `'${deps.convertToMySQLDateTime(value.min)}'` : value.min;
173
+ const max = value.date ? `'${deps.convertToMySQLDateTime(value.max)}'` : value.max;
174
+ if (value.min && value.max)
175
+ return `(\`${key}\` BETWEEN ${min} AND ${max})`;
176
+ if (value.max && !value.min)
177
+ return `(\`${key}\` <= ${max})`;
178
+ if (value.min && !value.max)
179
+ return `(\`${key}\` >= ${min})`;
180
+ return undefined;
181
+ }
182
+ return singleCondition(key, String(value).replaceAll('/*/', '%'));
183
+ });
184
+ return `WHERE ${conditions.filter((el) => !!el).join(' AND ')}`;
185
+ }
186
+ async function computeFiltersViaTempTable(opt) {
187
+ const { baseQuery, argsQuery, columns, paramFilter } = opt;
188
+ const tmpTable = `tmp_filter_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
189
+ const createSql = `CREATE TEMPORARY TABLE \`${tmpTable}\` AS (${baseQuery})`;
190
+ const created = await executeMySQLQuery2({ query: createSql, values: argsQuery, connKey: true });
191
+ if (created.error)
192
+ return [];
193
+ const connKey = created.connKey;
194
+ try {
195
+ const results = await Promise.all(columns.map((col, index) => buildFilterQueryFromTempTable(col, index, paramFilter, tmpTable, connKey)));
196
+ const payload = {};
197
+ results.filter(Boolean).forEach((element) => {
198
+ const key = Object.keys(element)[0];
199
+ const values = Object.keys(element)[1];
200
+ payload[key] = values ? { type: element[key], values: element[values] } : { type: element[key] };
201
+ });
202
+ return payload;
203
+ }
204
+ finally {
205
+ await executeMySQLQuery(`DROP TEMPORARY TABLE IF EXISTS \`${tmpTable}\``, [], false);
206
+ connectionRollback(connKey);
207
+ }
208
+ }
209
+ async function buildFilterQueryFromTempTable(col, index, paramFilter, tmpTable, connKey) {
210
+ const type = paramFilter[index];
211
+ if (!col)
212
+ return null;
213
+ if (!type || type === 'HIDE' || type === 'JSON' || type === 'FILE')
214
+ return { [col]: type };
215
+ if (type === 'SLIDER' || type === 'DATE' || type === 'DATETIME') {
216
+ const sql = `SELECT MIN(\`${col}\`) AS min_val, MAX(\`${col}\`) AS max_val FROM \`${tmpTable}\``;
217
+ const result = await executeMySQLQuery2({ query: sql, connKey });
218
+ if (result.error || !result[0])
219
+ return null;
220
+ return { [col]: type, values: [Number(result[0].min_val), Number(result[0].max_val)] };
221
+ }
222
+ if (type === 'MULTISELECT') {
223
+ const sql = `SELECT DISTINCT COALESCE(\`${col}\`, 'Vide') AS val FROM \`${tmpTable}\` ORDER BY val`;
224
+ const result = await executeMySQLQuery2({ query: sql, connKey });
225
+ if (result.error)
226
+ return null;
227
+ return { [col]: type, values: result.map((r) => r.val) };
228
+ }
229
+ if (type === 'UNGROUP_MULTISELECT') {
230
+ const sql = `SELECT \`${col}\` AS val FROM \`${tmpTable}\` WHERE \`${col}\` IS NOT NULL`;
231
+ const result = await executeMySQLQuery2({ query: sql, connKey });
232
+ if (result.error)
233
+ return null;
234
+ const values = [...new Set(result.flatMap((r) => (r.val ? String(r.val).split(', ') : ['Vide'])))].sort();
235
+ return { [col]: type, values };
236
+ }
237
+ return null;
238
+ }
239
+ return { router, reqTableQuery, getFiltersOfQuery };
240
+ }
@@ -0,0 +1,27 @@
1
+ import type { Request, Response } from 'express';
2
+ export type ParamFilterType = 'HIDE' | 'SLIDER' | 'DATE' | 'DATETIME' | 'UNGROUP_MULTISELECT' | 'MULTISELECT' | null | 'JSON' | 'FILE';
3
+ export interface CacheDeps {
4
+ getSQLCache: (key: string) => Promise<any>;
5
+ setSQLCache: (key: string, value: any) => Promise<any>;
6
+ deleteSQLCache: (key: string) => Promise<any>;
7
+ }
8
+ export interface TableQueryDeps {
9
+ convertToMySQLDateTime: (d: any) => string;
10
+ wrapRouteHandler: (fn: (req: Request, res: Response) => Promise<any>) => any;
11
+ /** Only required if some callers pass `useCache: true` (default). Omit it entirely
12
+ * if this project never wants caching — every call then behaves as `useCache: false`. */
13
+ cache?: CacheDeps;
14
+ }
15
+ export interface ReqTableQueryOptions {
16
+ query: string;
17
+ req: Request;
18
+ paramFilter?: ParamFilterType[];
19
+ sort?: string;
20
+ argsQuery?: any[];
21
+ /** How long (ms) a cached result stays valid. Only used when `useCache` resolves to true. */
22
+ keepCache?: number;
23
+ /** Use the Redis cache for this call. Defaults to true if `deps.cache` was provided at
24
+ * module creation, false otherwise. Pass `false` explicitly to always force a fresh query
25
+ * even when the module has a cache configured (e.g. for a "live" screen). */
26
+ useCache?: boolean;
27
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@benjosivo/table-query",
3
+ "version": "1.0.0",
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
+ "keywords": [],
6
+ "homepage": "https://github.com/benjosivo/table-query#readme",
7
+ "bugs": {
8
+ "url": "https://github.com/benjosivo/table-query/issues"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/benjosivo/table-query.git"
13
+ },
14
+ "license": "ISC",
15
+ "author": "benjosivo",
16
+ "type": "module",
17
+ "exports": {
18
+ "./react": {
19
+ "types": "./dist/react/index.d.ts",
20
+ "default": "./dist/react/index.js"
21
+ },
22
+ "./server": {
23
+ "types": "./dist/server/index.d.ts",
24
+ "default": "./dist/server/index.js"
25
+ }
26
+ },
27
+ "main": "index.js",
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsc",
33
+ "prepublishOnly": "npm run build"
34
+ },
35
+ "dependencies": {
36
+ "@benjosivo/mysql": "^1.3.2"
37
+ },
38
+ "devDependencies": {
39
+ "@types/express": "^4.17.0",
40
+ "@types/react": "^18.0.0",
41
+ "typescript": "^5.4.0"
42
+ },
43
+ "peerDependencies": {
44
+ "express": "^4.0.0",
45
+ "react": "^18.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "express": {
49
+ "optional": true
50
+ },
51
+ "react": {
52
+ "optional": true
53
+ },
54
+ "@benjosivo/mysql": {
55
+ "optional": true
56
+ }
57
+ },
58
+ "publishConfig": {
59
+ "registry": "https://registry.npmjs.org"
60
+ }
61
+ }