@benjosivo/table-query 1.0.3 → 1.2.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 +430 -214
- package/dist/react/DataTable.d.ts +1 -1
- package/dist/react/DataTable.js +24 -4
- package/dist/react/FormattingModal.d.ts +16 -0
- package/dist/react/FormattingModal.js +106 -0
- package/dist/react/FormattingToolbar.d.ts +13 -0
- package/dist/react/FormattingToolbar.js +12 -0
- package/dist/react/Table.d.ts +23 -3
- package/dist/react/Table.js +67 -12
- package/dist/react/formatting.d.ts +47 -0
- package/dist/react/formatting.js +423 -0
- package/dist/react/index.d.ts +8 -1
- package/dist/react/index.js +4 -0
- package/dist/react/types.d.ts +69 -0
- package/dist/react/useDataTable.d.ts +10 -2
- package/dist/react/useDataTable.js +86 -2
- package/dist/react/useFormattingRules.d.ts +34 -0
- package/dist/react/useFormattingRules.js +121 -0
- package/dist/react/utils.d.ts +12 -0
- package/dist/react/utils.js +21 -0
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +24 -3
- package/dist/server/types.d.ts +22 -0
- package/package.json +61 -61
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
|
|
2
|
+
import { sanitizeFormattingRules } from './formatting.js';
|
|
3
|
+
import { getRowId, selectionKey } from './utils.js';
|
|
4
|
+
export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions = [10, 25, 50, 100, 250, 500, 1000], defaultRowsPerPage = 100, fetchFilterConfig, selectedIds: controlledSelectedIds, onSelectionChange, }) {
|
|
3
5
|
const [items, setItems] = useState([]);
|
|
4
6
|
const [count, setCount] = useState(0);
|
|
5
7
|
const [fieldsType, setFieldsType] = useState([]);
|
|
@@ -12,8 +14,17 @@ export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions =
|
|
|
12
14
|
const [filters, setFilters] = useState({});
|
|
13
15
|
const [filterConfig, setFilterConfig] = useState(null);
|
|
14
16
|
const [cleRecupFiltre, setCleRecupFiltre] = useState();
|
|
17
|
+
const [serverFormattingRules, setServerFormattingRules] = useState([]);
|
|
15
18
|
// Guards against a slow, stale request overwriting a newer one.
|
|
16
19
|
const requestId = useRef(0);
|
|
20
|
+
// Selection is uncontrolled unless the parent passes `selectedIds`.
|
|
21
|
+
const [internalSelectedIds, setInternalSelectedIds] = useState([]);
|
|
22
|
+
const isSelectionControlled = controlledSelectedIds !== undefined;
|
|
23
|
+
const selectedIds = isSelectionControlled ? controlledSelectedIds : internalSelectedIds;
|
|
24
|
+
const selectedKeys = useMemo(() => new Set(selectedIds.map(selectionKey)), [selectedIds]);
|
|
25
|
+
/** Every row loaded since the last selection reset, so a row selected on a page we left
|
|
26
|
+
* can still be handed back to the parent in full. */
|
|
27
|
+
const knownRows = useRef(new Map());
|
|
17
28
|
const sorting = useMemo(() => (sortColumn ? `\`${sortColumn}\` ${sortDirection}` : ''), [sortColumn, sortDirection]);
|
|
18
29
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(count / perPage)), [count, perPage]);
|
|
19
30
|
const load = useCallback(async (targetPage) => {
|
|
@@ -35,7 +46,13 @@ export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions =
|
|
|
35
46
|
setCount(0);
|
|
36
47
|
return;
|
|
37
48
|
}
|
|
38
|
-
|
|
49
|
+
const rows = result.items || [];
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
const rowId = getRowId(row);
|
|
52
|
+
if (rowId !== undefined)
|
|
53
|
+
knownRows.current.set(selectionKey(rowId), row);
|
|
54
|
+
}
|
|
55
|
+
setItems(rows);
|
|
39
56
|
setCount(result.count ?? 0);
|
|
40
57
|
setFieldsType(result.fieldsType || []);
|
|
41
58
|
setParamFilter(result.paramFilter || []);
|
|
@@ -44,6 +61,8 @@ export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions =
|
|
|
44
61
|
setFilterConfig(result.filtre);
|
|
45
62
|
if (result.cleRecupFiltre)
|
|
46
63
|
setCleRecupFiltre(result.cleRecupFiltre);
|
|
64
|
+
if (result.formattingRules)
|
|
65
|
+
setServerFormattingRules(sanitizeFormattingRules(result.formattingRules));
|
|
47
66
|
}
|
|
48
67
|
finally {
|
|
49
68
|
if (id === requestId.current)
|
|
@@ -96,6 +115,63 @@ export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions =
|
|
|
96
115
|
/** Replace the whole filter object at once — handy for a custom search UI that
|
|
97
116
|
* doesn't reason "per column" and just wants to hand over its own `filtre` payload. */
|
|
98
117
|
const replaceFilters = useCallback((next) => setFilters(next), []);
|
|
118
|
+
// ==================== SELECTION ====================
|
|
119
|
+
const rowsOf = useCallback((ids) => {
|
|
120
|
+
const rows = [];
|
|
121
|
+
for (const id of ids) {
|
|
122
|
+
const row = knownRows.current.get(selectionKey(id));
|
|
123
|
+
if (row)
|
|
124
|
+
rows.push(row);
|
|
125
|
+
}
|
|
126
|
+
return rows;
|
|
127
|
+
}, []);
|
|
128
|
+
const selectedRows = useMemo(() => rowsOf(selectedIds), [selectedIds, items, rowsOf]);
|
|
129
|
+
const applySelection = useCallback((nextIds) => {
|
|
130
|
+
if (!isSelectionControlled)
|
|
131
|
+
setInternalSelectedIds(nextIds);
|
|
132
|
+
onSelectionChange?.(nextIds, rowsOf(nextIds));
|
|
133
|
+
}, [isSelectionControlled, onSelectionChange, rowsOf]);
|
|
134
|
+
const isRowSelected = useCallback((row) => selectedKeys.has(selectionKey(getRowId(row))), [selectedKeys]);
|
|
135
|
+
const setRowSelected = useCallback((row, selected) => {
|
|
136
|
+
const id = getRowId(row);
|
|
137
|
+
if (id === undefined)
|
|
138
|
+
return;
|
|
139
|
+
const key = selectionKey(id);
|
|
140
|
+
knownRows.current.set(key, row);
|
|
141
|
+
if (selected === selectedKeys.has(key))
|
|
142
|
+
return;
|
|
143
|
+
applySelection(selected ? [...selectedIds, id] : selectedIds.filter((v) => selectionKey(v) !== key));
|
|
144
|
+
}, [selectedIds, selectedKeys, applySelection]);
|
|
145
|
+
const toggleRowSelection = useCallback((row) => setRowSelected(row, !isRowSelected(row)), [setRowSelected, isRowSelected]);
|
|
146
|
+
/** Select/deselect every row of the current page; rows selected on other pages are left alone. */
|
|
147
|
+
const setAllRowsSelected = useCallback((selected) => {
|
|
148
|
+
const pageIds = [];
|
|
149
|
+
for (const row of items) {
|
|
150
|
+
const id = getRowId(row);
|
|
151
|
+
if (id === undefined)
|
|
152
|
+
continue;
|
|
153
|
+
knownRows.current.set(selectionKey(id), row);
|
|
154
|
+
pageIds.push(id);
|
|
155
|
+
}
|
|
156
|
+
const pageKeys = new Set(pageIds.map(selectionKey));
|
|
157
|
+
const others = selectedIds.filter((id) => !pageKeys.has(selectionKey(id)));
|
|
158
|
+
applySelection(selected ? [...others, ...pageIds] : others);
|
|
159
|
+
}, [items, selectedIds, applySelection]);
|
|
160
|
+
const clearSelection = useCallback(() => {
|
|
161
|
+
if (selectedIds.length > 0)
|
|
162
|
+
applySelection([]);
|
|
163
|
+
}, [selectedIds, applySelection]);
|
|
164
|
+
// The selection spans pages, but a new sort/filter means a different dataset: start over.
|
|
165
|
+
const firstDatasetRender = useRef(true);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (firstDatasetRender.current) {
|
|
168
|
+
firstDatasetRender.current = false;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
knownRows.current.clear();
|
|
172
|
+
clearSelection();
|
|
173
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
|
+
}, [sorting, filters]);
|
|
99
175
|
const changePerPage = useCallback((n) => setPerPage(n), []);
|
|
100
176
|
return {
|
|
101
177
|
items,
|
|
@@ -111,6 +187,14 @@ export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions =
|
|
|
111
187
|
sortDirection,
|
|
112
188
|
filters,
|
|
113
189
|
filterConfig,
|
|
190
|
+
serverFormattingRules,
|
|
191
|
+
selectedIds,
|
|
192
|
+
selectedRows,
|
|
193
|
+
isRowSelected,
|
|
194
|
+
toggleRowSelection,
|
|
195
|
+
setRowSelected,
|
|
196
|
+
setAllRowsSelected,
|
|
197
|
+
clearSelection,
|
|
114
198
|
toggleSort,
|
|
115
199
|
setColumnFilter,
|
|
116
200
|
replaceFilters,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { FormattingRule } from './types.js';
|
|
2
|
+
export interface UseFormattingRulesOptions {
|
|
3
|
+
/** Rules set by the app (lowest priority). */
|
|
4
|
+
propsRules?: FormattingRule[];
|
|
5
|
+
/** Rules sent by the API. */
|
|
6
|
+
serverRules?: FormattingRule[];
|
|
7
|
+
/** When set, the user's rules persist in localStorage under this key. */
|
|
8
|
+
storageKey?: string;
|
|
9
|
+
/** Rehydrate the user's rules from your own backend — wins over localStorage. */
|
|
10
|
+
initialUserRules?: FormattingRule[];
|
|
11
|
+
/** Called on every change to the user's rules. */
|
|
12
|
+
onChange?: (rules: FormattingRule[]) => void;
|
|
13
|
+
}
|
|
14
|
+
export interface UseFormattingRulesResult {
|
|
15
|
+
/** Merged and ordered props -> server -> user, ready for <Table formattingRules>. */
|
|
16
|
+
rules: FormattingRule[];
|
|
17
|
+
/** The user-editable layer. */
|
|
18
|
+
userRules: FormattingRule[];
|
|
19
|
+
/** The props + server layers, read-only in the editor. */
|
|
20
|
+
inherited: FormattingRule[];
|
|
21
|
+
/** Ids of inherited rules the user has switched off. */
|
|
22
|
+
disabledIds: string[];
|
|
23
|
+
addRule: (rule: FormattingRule) => void;
|
|
24
|
+
updateRule: (id: string, patch: Partial<FormattingRule>) => void;
|
|
25
|
+
removeRule: (id: string) => void;
|
|
26
|
+
moveRule: (id: string, delta: 1 | -1) => void;
|
|
27
|
+
setRuleDisabled: (id: string, disabled: boolean) => void;
|
|
28
|
+
resetUserRules: () => void;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Owns the three formatting layers and their persistence. Lives in a hook rather than in
|
|
32
|
+
* `DataTable` so that a host composing `useDataTable` + `Table` by hand can reuse it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function useFormattingRules({ propsRules, serverRules, storageKey, initialUserRules, onChange, }: UseFormattingRulesOptions): UseFormattingRulesResult;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { newRuleId, sanitizeFormattingRules } from './formatting.js';
|
|
3
|
+
/** Namespaced so a storage key can't collide with the host app's own localStorage entries. */
|
|
4
|
+
const STORAGE_PREFIX = 'tableQuery:formatting:';
|
|
5
|
+
const STORAGE_VERSION = 1;
|
|
6
|
+
function serialize(rules, disabled) {
|
|
7
|
+
return JSON.stringify({ v: STORAGE_VERSION, rules, disabled });
|
|
8
|
+
}
|
|
9
|
+
/** Inherited rules need a stable id even when the source didn't give them one. */
|
|
10
|
+
function withFallbackIds(rules, source) {
|
|
11
|
+
if (!rules || rules.length === 0)
|
|
12
|
+
return [];
|
|
13
|
+
return rules.map((r, i) => (r.id ? r : { ...r, id: `${source}:${i}` }));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Owns the three formatting layers and their persistence. Lives in a hook rather than in
|
|
17
|
+
* `DataTable` so that a host composing `useDataTable` + `Table` by hand can reuse it.
|
|
18
|
+
*/
|
|
19
|
+
export function useFormattingRules({ propsRules, serverRules, storageKey, initialUserRules, onChange, }) {
|
|
20
|
+
const [userRules, setUserRules] = useState(() => (initialUserRules ? sanitizeFormattingRules(initialUserRules) : []));
|
|
21
|
+
const [disabledIds, setDisabledIds] = useState([]);
|
|
22
|
+
const fullKey = storageKey ? `${STORAGE_PREFIX}${storageKey}` : undefined;
|
|
23
|
+
// Hydration is STATE, not a ref: both effects run in the same commit on mount, so a ref
|
|
24
|
+
// flipped by the read effect would already read true in the write effect below — which
|
|
25
|
+
// would rewrite [] over the rules just loaded and fire a spurious onChange([]).
|
|
26
|
+
const [hydrated, setHydrated] = useState(false);
|
|
27
|
+
// What is already in storage, so an unchanged value never triggers a write or an onChange.
|
|
28
|
+
const lastWritten = useRef(null);
|
|
29
|
+
// Read post-mount, never in the useState initializer: with SSR the server renders no user
|
|
30
|
+
// rules, so reading during the first render would cause a hydration mismatch on the styles.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
// Baseline = the state as it stands before hydration, so the first write-effect pass is
|
|
33
|
+
// a no-op. Without it, mounting with nothing stored would fire onChange([]) and wipe the
|
|
34
|
+
// rules of a host that persists them server-side.
|
|
35
|
+
lastWritten.current = serialize(userRules, disabledIds);
|
|
36
|
+
if (initialUserRules || !fullKey || typeof window === 'undefined') {
|
|
37
|
+
setHydrated(true);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const raw = window.localStorage.getItem(fullKey);
|
|
42
|
+
if (raw) {
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
// An unknown version is ignored rather than migrated; the next write replaces it.
|
|
45
|
+
if (parsed && parsed.v === STORAGE_VERSION) {
|
|
46
|
+
const rules = sanitizeFormattingRules(parsed.rules);
|
|
47
|
+
const disabled = Array.isArray(parsed.disabled) ? parsed.disabled.filter((x) => typeof x === 'string') : [];
|
|
48
|
+
setUserRules(rules);
|
|
49
|
+
setDisabledIds(disabled);
|
|
50
|
+
lastWritten.current = serialize(rules, disabled);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// SSR, Safari private mode, blocked storage, corrupted JSON — all non-fatal.
|
|
56
|
+
}
|
|
57
|
+
setHydrated(true);
|
|
58
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
59
|
+
}, [fullKey]);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!hydrated)
|
|
62
|
+
return;
|
|
63
|
+
const serialized = serialize(userRules, disabledIds);
|
|
64
|
+
// Nothing actually changed (the mount pass, or a re-render): don't write, don't notify.
|
|
65
|
+
if (lastWritten.current === serialized)
|
|
66
|
+
return;
|
|
67
|
+
lastWritten.current = serialized;
|
|
68
|
+
if (fullKey && typeof window !== 'undefined') {
|
|
69
|
+
try {
|
|
70
|
+
window.localStorage.setItem(fullKey, serialized);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Quota exceeded or storage blocked: the rules still work for this session.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
onChange?.(userRules);
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, [userRules, disabledIds, fullKey, hydrated]);
|
|
79
|
+
const inherited = useMemo(() => [...withFallbackIds(propsRules, 'props'), ...withFallbackIds(serverRules, 'server')], [propsRules, serverRules]);
|
|
80
|
+
/**
|
|
81
|
+
* Precedence: props -> server -> user. Evaluation order IS precedence order, so a later
|
|
82
|
+
* rule wins per CSS property and an earlier `stopIfTrue` can block a later layer.
|
|
83
|
+
*/
|
|
84
|
+
const rules = useMemo(() => {
|
|
85
|
+
const disabled = new Set(disabledIds);
|
|
86
|
+
return [...inherited, ...userRules].filter((r) => r.enabled !== false && !(r.id && disabled.has(r.id)));
|
|
87
|
+
}, [inherited, userRules, disabledIds]);
|
|
88
|
+
const addRule = useCallback((rule) => {
|
|
89
|
+
setUserRules((prev) => [...prev, { ...rule, id: rule.id || newRuleId() }]);
|
|
90
|
+
}, []);
|
|
91
|
+
const updateRule = useCallback((id, patch) => {
|
|
92
|
+
setUserRules((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
|
93
|
+
}, []);
|
|
94
|
+
const removeRule = useCallback((id) => {
|
|
95
|
+
setUserRules((prev) => prev.filter((r) => r.id !== id));
|
|
96
|
+
}, []);
|
|
97
|
+
const moveRule = useCallback((id, delta) => {
|
|
98
|
+
setUserRules((prev) => {
|
|
99
|
+
const i = prev.findIndex((r) => r.id === id);
|
|
100
|
+
const j = i + delta;
|
|
101
|
+
if (i < 0 || j < 0 || j >= prev.length)
|
|
102
|
+
return prev;
|
|
103
|
+
const next = [...prev];
|
|
104
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
105
|
+
return next;
|
|
106
|
+
});
|
|
107
|
+
}, []);
|
|
108
|
+
const setRuleDisabled = useCallback((id, disabled) => {
|
|
109
|
+
setDisabledIds((prev) => {
|
|
110
|
+
const has = prev.includes(id);
|
|
111
|
+
if (disabled === has)
|
|
112
|
+
return prev;
|
|
113
|
+
return disabled ? [...prev, id] : prev.filter((x) => x !== id);
|
|
114
|
+
});
|
|
115
|
+
}, []);
|
|
116
|
+
const resetUserRules = useCallback(() => {
|
|
117
|
+
setUserRules([]);
|
|
118
|
+
setDisabledIds([]);
|
|
119
|
+
}, []);
|
|
120
|
+
return { rules, userRules, inherited, disabledIds, addRule, updateRule, removeRule, moveRule, setRuleDisabled, resetUserRules };
|
|
121
|
+
}
|
package/dist/react/utils.d.ts
CHANGED
|
@@ -4,3 +4,15 @@ 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;
|
|
11
|
+
/**
|
|
12
|
+
* Column names of a table: the keys of the first row, falling back to the filter
|
|
13
|
+
* definitions when there is no data. Shared by `Table` and the formatting editor so the
|
|
14
|
+
* two can't drift apart.
|
|
15
|
+
*/
|
|
16
|
+
export declare function columnNamesOf(items: Record<string, any>[], paramFilter?: {
|
|
17
|
+
nom: string;
|
|
18
|
+
}[]): string[];
|
package/dist/react/utils.js
CHANGED
|
@@ -38,3 +38,24 @@ 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
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Column names of a table: the keys of the first row, falling back to the filter
|
|
54
|
+
* definitions when there is no data. Shared by `Table` and the formatting editor so the
|
|
55
|
+
* two can't drift apart.
|
|
56
|
+
*/
|
|
57
|
+
export function columnNamesOf(items, paramFilter) {
|
|
58
|
+
if (items[0])
|
|
59
|
+
return Object.keys(items[0]);
|
|
60
|
+
return paramFilter && paramFilter.length > 0 ? paramFilter.map((el) => el.nom) : [];
|
|
61
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CacheDeps, ParamFilterType, ReqTableQueryOptions, TableQueryDeps } from './types.js';
|
|
2
|
-
export type { CacheDeps, ParamFilterType, ReqTableQueryOptions, TableQueryDeps } from './types.js';
|
|
2
|
+
export type { CacheDeps, FormattingRuleInput, ParamFilterType, ReqTableQueryOptions, TableQueryDeps } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Creates the table-query module (router + query helpers) bound to this project's own
|
|
5
5
|
* date formatting / route-wrapping / cache implementations. Nothing here is hardcoded to
|
|
@@ -11,8 +11,8 @@ export type { CacheDeps, ParamFilterType, ReqTableQueryOptions, TableQueryDeps }
|
|
|
11
11
|
export declare function createTableQueryModule(deps: TableQueryDeps): {
|
|
12
12
|
router: import("express-serve-static-core").Router;
|
|
13
13
|
reqTableQuery: (opt: ReqTableQueryOptions) => Promise<{
|
|
14
|
-
error: string;
|
|
15
|
-
status: number;
|
|
14
|
+
error: string | undefined;
|
|
15
|
+
status: number | undefined;
|
|
16
16
|
empty?: undefined;
|
|
17
17
|
data?: undefined;
|
|
18
18
|
} | {
|
package/dist/server/index.js
CHANGED
|
@@ -52,7 +52,7 @@ export function createTableQueryModule(deps) {
|
|
|
52
52
|
sendFiltres();
|
|
53
53
|
}
|
|
54
54
|
async function reqTableQuery(opt) {
|
|
55
|
-
const { query, req, paramFilter, sort, argsQuery, keepCache = 5 * 60 * 1000 } = opt;
|
|
55
|
+
const { query, req, paramFilter, sort, argsQuery, formattingRules, keepCache = 5 * 60 * 1000 } = opt;
|
|
56
56
|
if (!req.body && !req.query) {
|
|
57
57
|
return { error: `req.query or req.body is missing`, status: 400 };
|
|
58
58
|
}
|
|
@@ -69,11 +69,27 @@ export function createTableQueryModule(deps) {
|
|
|
69
69
|
// ── Paginated items ────────────────────────────────────────────────
|
|
70
70
|
const itemsSql = `${query} ${whereClause} ORDER BY ${sorting} LIMIT ${limit < 0 ? 0 : limit} OFFSET ${offset < 0 ? 0 : offset}`;
|
|
71
71
|
let { payload, status, error, empty } = await getDataFromQuery(itemsSql, argsQuery, cache ? keepCache : 0, cache);
|
|
72
|
+
// getDataFromQuery returns { status, error } with no payload when the query fails.
|
|
73
|
+
if (!payload)
|
|
74
|
+
return { error, status };
|
|
72
75
|
payload.paramFilter = paramFilter
|
|
73
76
|
? paramFilter.map((param, i) => {
|
|
74
77
|
return { nom: payload.fieldsType[i].fieldName, type: param };
|
|
75
78
|
})
|
|
76
79
|
: [];
|
|
80
|
+
// Forwarded untouched — formatting is presentation, it never reaches the SQL.
|
|
81
|
+
// Resolved by column NAME against the very same source `paramFilter` is zipped against,
|
|
82
|
+
// so the two can't drift. Sent on every request (a few hundred bytes), not gated behind
|
|
83
|
+
// setFilter, so the client needs no extra round-trip.
|
|
84
|
+
if (formattingRules?.length) {
|
|
85
|
+
const names = new Set((payload.fieldsType ?? []).map((f) => f.fieldName));
|
|
86
|
+
const kept = formattingRules.filter((r) => r && typeof r.column === 'string' && names.has(r.column));
|
|
87
|
+
const dropped = formattingRules.length - kept.length;
|
|
88
|
+
// A bad column name is a presentation mistake: warn, don't take the table down.
|
|
89
|
+
if (dropped)
|
|
90
|
+
console.warn(`[table-query] ${dropped} règle(s) de mise en forme ignorée(s) : colonne inconnue.`);
|
|
91
|
+
payload.formattingRules = kept;
|
|
92
|
+
}
|
|
77
93
|
if (empty)
|
|
78
94
|
return { empty, data: payload };
|
|
79
95
|
if (error && status)
|
|
@@ -124,12 +140,17 @@ export function createTableQueryModule(deps) {
|
|
|
124
140
|
if (tableValues.length === 0)
|
|
125
141
|
return { empty: true, payload: { items: tableValues, count: 0, fieldsType: reqRows.fieldsType } };
|
|
126
142
|
Object.keys(tableValues[0]).forEach((col, i) => {
|
|
127
|
-
if (col === '
|
|
143
|
+
if (col.toLowerCase().trim() === 'documents')
|
|
128
144
|
reqRows.fieldsType[i].fieldType = 'FILE';
|
|
129
145
|
});
|
|
130
146
|
payload = { items: tableValues, count: reqRows.rows[0]?.TotalCount ?? 0, fieldsType: reqRows.fieldsType };
|
|
131
147
|
if (cache && keepCache) {
|
|
132
|
-
await cache.setSQLCache(titleSQLCacheQuery, {
|
|
148
|
+
await cache.setSQLCache(titleSQLCacheQuery, {
|
|
149
|
+
title: titleSQLCacheQuery,
|
|
150
|
+
data: payload,
|
|
151
|
+
expiration: Date.now() + keepCache,
|
|
152
|
+
tables: reqRows.tables,
|
|
153
|
+
});
|
|
133
154
|
}
|
|
134
155
|
}
|
|
135
156
|
return { payload };
|
package/dist/server/types.d.ts
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import type { Request, Response } from 'express';
|
|
2
2
|
export type ParamFilterType = 'HIDE' | 'SLIDER' | 'DATE' | 'DATETIME' | 'UNGROUP_MULTISELECT' | 'MULTISELECT' | null | 'JSON' | 'FILE';
|
|
3
|
+
/**
|
|
4
|
+
* Same shape as the React-side `FormattingRule`, but with no dependency on @types/react:
|
|
5
|
+
* the server only ever forwards these rules, it never evaluates them, and a server-only
|
|
6
|
+
* consumer must not be forced to install React's types to compile.
|
|
7
|
+
*/
|
|
8
|
+
export interface FormattingRuleInput {
|
|
9
|
+
id?: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
/** Name of the tested column, matched against the query's own column names. */
|
|
12
|
+
column: string;
|
|
13
|
+
operator: string;
|
|
14
|
+
value?: unknown;
|
|
15
|
+
valueType?: 'auto' | 'string' | 'number' | 'date' | 'boolean';
|
|
16
|
+
target?: 'row' | 'cell' | string[];
|
|
17
|
+
style?: Record<string, string | number>;
|
|
18
|
+
className?: string;
|
|
19
|
+
stopIfTrue?: boolean;
|
|
20
|
+
enabled?: boolean;
|
|
21
|
+
}
|
|
3
22
|
export interface CacheDeps {
|
|
4
23
|
getSQLCache: (key: string) => Promise<any>;
|
|
5
24
|
setSQLCache: (key: string, value: any) => Promise<any>;
|
|
@@ -20,6 +39,9 @@ export interface ReqTableQueryOptions {
|
|
|
20
39
|
argsQuery?: any[];
|
|
21
40
|
/** How long (ms) a cached result stays valid. Only used when `useCache` resolves to true. */
|
|
22
41
|
keepCache?: number;
|
|
42
|
+
/** Conditional formatting rules forwarded as-is to the client in `payload.formattingRules`.
|
|
43
|
+
* Rules whose `column` matches no column of the query are dropped. */
|
|
44
|
+
formattingRules?: FormattingRuleInput[];
|
|
23
45
|
/** Use the Redis cache for this call. Defaults to true if `deps.cache` was provided at
|
|
24
46
|
* module creation, false otherwise. Pass `false` explicitly to always force a fresh query
|
|
25
47
|
* even when the module has a cache configured (e.g. for a "live" screen). */
|
package/package.json
CHANGED
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@benjosivo/table-query",
|
|
3
|
-
"version": "1.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
|
-
"express": "^5.2.1"
|
|
38
|
-
},
|
|
39
|
-
"devDependencies": {
|
|
40
|
-
"@types/express": "^5.0.6",
|
|
41
|
-
"@types/react": "^18.0.0",
|
|
42
|
-
"typescript": "^5.4.0"
|
|
43
|
-
},
|
|
44
|
-
"peerDependencies": {
|
|
45
|
-
"react": "^18.0.0 || ^19.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
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@benjosivo/table-query",
|
|
3
|
+
"version": "1.2.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
|
+
"express": "^5.2.1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/express": "^5.0.6",
|
|
41
|
+
"@types/react": "^18.0.0",
|
|
42
|
+
"typescript": "^5.4.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"react": "^18.0.0 || ^19.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
|
+
}
|