@benjosivo/table-query 1.0.4 → 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 +142 -0
- package/dist/react/DataTable.d.ts +1 -1
- package/dist/react/DataTable.js +15 -3
- 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 +8 -2
- package/dist/react/Table.js +26 -6
- 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 +59 -0
- package/dist/react/useDataTable.d.ts +2 -1
- package/dist/react/useDataTable.js +5 -0
- package/dist/react/useFormattingRules.d.ts +34 -0
- package/dist/react/useFormattingRules.js +121 -0
- package/dist/react/utils.d.ts +8 -0
- package/dist/react/utils.js +10 -0
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +17 -1
- package/dist/server/types.d.ts +22 -0
- package/package.json +61 -61
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// ==================== CONDITIONAL FORMATTING ====================
|
|
2
|
+
//
|
|
3
|
+
// Pure module: the only React import is a type, so tsc elides it and the compiled
|
|
4
|
+
// `dist/react/formatting.js` runs in plain Node. Keep it that way — it is what makes
|
|
5
|
+
// the evaluator verifiable without a test runner or a DOM.
|
|
6
|
+
/** Operator metadata: the single source of truth for the editor's <select> and its operand inputs. */
|
|
7
|
+
export const FORMATTING_OPERATORS = [
|
|
8
|
+
{ value: '=', label: '= (égal)', arity: 1 },
|
|
9
|
+
{ value: '!=', label: '≠ (différent)', arity: 1 },
|
|
10
|
+
{ value: '>', label: '> (supérieur)', arity: 1 },
|
|
11
|
+
{ value: '>=', label: '≥ (supérieur ou égal)', arity: 1 },
|
|
12
|
+
{ value: '<', label: '< (inférieur)', arity: 1 },
|
|
13
|
+
{ value: '<=', label: '≤ (inférieur ou égal)', arity: 1 },
|
|
14
|
+
{ value: 'between', label: 'Entre (bornes incluses)', arity: 2 },
|
|
15
|
+
{ value: 'in', label: 'Dans la liste', arity: 'n' },
|
|
16
|
+
{ value: 'contains', label: 'Contient', arity: 1 },
|
|
17
|
+
{ value: 'notContains', label: 'Ne contient pas', arity: 1 },
|
|
18
|
+
{ value: 'startsWith', label: 'Commence par', arity: 1 },
|
|
19
|
+
{ value: 'endsWith', label: 'Finit par', arity: 1 },
|
|
20
|
+
{ value: 'isNull', label: 'Est vide (NULL)', arity: 0 },
|
|
21
|
+
{ value: 'isNotNull', label: "N'est pas vide", arity: 0 },
|
|
22
|
+
];
|
|
23
|
+
const OPERATOR_SET = new Set(FORMATTING_OPERATORS.map((o) => o.value));
|
|
24
|
+
export function operatorArity(operator) {
|
|
25
|
+
return FORMATTING_OPERATORS.find((o) => o.value === operator)?.arity ?? 1;
|
|
26
|
+
}
|
|
27
|
+
// ==================== COERCION ====================
|
|
28
|
+
//
|
|
29
|
+
// Values come straight from MySQL: DECIMAL/BIGINT arrive as strings, DATE/DATETIME as
|
|
30
|
+
// Date objects (or ISO strings with dateStrings: true), TINYINT(1) as 0/1.
|
|
31
|
+
const NUMERIC = /^[+-]?\d+(?:[.,]\d+)?$/;
|
|
32
|
+
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
33
|
+
const ISO_LIKE = /^\d{4}-\d{2}-\d{2}([T ]|$)/;
|
|
34
|
+
function isBlank(v) {
|
|
35
|
+
return v === null || v === undefined;
|
|
36
|
+
}
|
|
37
|
+
function toNumber(v) {
|
|
38
|
+
if (typeof v === 'number')
|
|
39
|
+
return Number.isFinite(v) ? v : null;
|
|
40
|
+
if (typeof v === 'boolean')
|
|
41
|
+
return v ? 1 : 0;
|
|
42
|
+
if (typeof v !== 'string')
|
|
43
|
+
return null;
|
|
44
|
+
const s = v.trim();
|
|
45
|
+
// '' and '12 rue de la Paix' are deliberately NOT numbers.
|
|
46
|
+
if (!s || !NUMERIC.test(s))
|
|
47
|
+
return null;
|
|
48
|
+
return Number(s.replace(',', '.'));
|
|
49
|
+
}
|
|
50
|
+
function toDate(v) {
|
|
51
|
+
if (v instanceof Date)
|
|
52
|
+
return Number.isNaN(v.getTime()) ? null : { t: v.getTime(), dayOnly: false };
|
|
53
|
+
if (typeof v !== 'string')
|
|
54
|
+
return null;
|
|
55
|
+
const s = v.trim();
|
|
56
|
+
const m = DATE_ONLY.exec(s);
|
|
57
|
+
// 'YYYY-MM-DD' must mean LOCAL midnight: new Date('2024-01-05') is UTC midnight,
|
|
58
|
+
// which lands on the 4th for anyone west of Greenwich.
|
|
59
|
+
if (m)
|
|
60
|
+
return { t: new Date(+m[1], +m[2] - 1, +m[3]).getTime(), dayOnly: true };
|
|
61
|
+
const t = Date.parse(s);
|
|
62
|
+
return Number.isNaN(t) ? null : { t, dayOnly: false };
|
|
63
|
+
}
|
|
64
|
+
function toText(v) {
|
|
65
|
+
return (v instanceof Date ? v.toISOString() : String(v)).trim().toLocaleLowerCase();
|
|
66
|
+
}
|
|
67
|
+
function toBool(v) {
|
|
68
|
+
if (typeof v === 'boolean')
|
|
69
|
+
return v;
|
|
70
|
+
if (typeof v === 'number')
|
|
71
|
+
return v !== 0;
|
|
72
|
+
if (typeof v !== 'string')
|
|
73
|
+
return null;
|
|
74
|
+
const s = v.trim().toLowerCase();
|
|
75
|
+
if (['1', 'true', 'oui', 'vrai', 'y', 'o'].includes(s))
|
|
76
|
+
return true;
|
|
77
|
+
if (['0', 'false', 'non', 'faux', 'n'].includes(s))
|
|
78
|
+
return false;
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
function startOfDay(t) {
|
|
82
|
+
const d = new Date(t);
|
|
83
|
+
d.setHours(0, 0, 0, 0);
|
|
84
|
+
return d.getTime();
|
|
85
|
+
}
|
|
86
|
+
function sign(a, b) {
|
|
87
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Three-way compare. Returns null when the two operands cannot be compared at all,
|
|
91
|
+
* which callers treat as "does not match" (except '!=', where it means "different").
|
|
92
|
+
*/
|
|
93
|
+
export function compareValues(cell, operand, valueType = 'auto') {
|
|
94
|
+
if (valueType === 'number') {
|
|
95
|
+
const a = toNumber(cell);
|
|
96
|
+
const b = toNumber(operand);
|
|
97
|
+
return a === null || b === null ? null : sign(a, b);
|
|
98
|
+
}
|
|
99
|
+
if (valueType === 'boolean') {
|
|
100
|
+
const a = toBool(cell);
|
|
101
|
+
const b = toBool(operand);
|
|
102
|
+
return a === null || b === null ? null : sign(Number(a), Number(b));
|
|
103
|
+
}
|
|
104
|
+
if (valueType === 'date') {
|
|
105
|
+
const a = toDate(cell);
|
|
106
|
+
const b = toDate(operand);
|
|
107
|
+
if (!a || !b)
|
|
108
|
+
return null;
|
|
109
|
+
return b.dayOnly ? sign(startOfDay(a.t), b.t) : sign(a.t, b.t);
|
|
110
|
+
}
|
|
111
|
+
if (valueType === 'string') {
|
|
112
|
+
return toText(cell).localeCompare(toText(operand));
|
|
113
|
+
}
|
|
114
|
+
// 'auto', in this exact order.
|
|
115
|
+
// 1. A real Date on either side wins — that is unambiguous.
|
|
116
|
+
if (cell instanceof Date || operand instanceof Date) {
|
|
117
|
+
const a = toDate(cell);
|
|
118
|
+
const b = toDate(operand);
|
|
119
|
+
if (a && b)
|
|
120
|
+
return b.dayOnly ? sign(startOfDay(a.t), b.t) : sign(a.t, b.t);
|
|
121
|
+
}
|
|
122
|
+
// 2. Both numeric → numbers. Note this makes '007' equal to '7'; use valueType:'string'
|
|
123
|
+
// for reference codes and postcodes.
|
|
124
|
+
const na = toNumber(cell);
|
|
125
|
+
const nb = toNumber(operand);
|
|
126
|
+
if (na !== null && nb !== null)
|
|
127
|
+
return sign(na, nb);
|
|
128
|
+
// 3. Both date-parseable and at least one ISO-shaped. Runs after the numeric step,
|
|
129
|
+
// so '2024' stays a number rather than becoming a year.
|
|
130
|
+
if ((typeof cell === 'string' && ISO_LIKE.test(cell.trim())) || (typeof operand === 'string' && ISO_LIKE.test(operand.trim()))) {
|
|
131
|
+
const a = toDate(cell);
|
|
132
|
+
const b = toDate(operand);
|
|
133
|
+
if (a && b)
|
|
134
|
+
return b.dayOnly ? sign(startOfDay(a.t), b.t) : sign(a.t, b.t);
|
|
135
|
+
}
|
|
136
|
+
// 4. Text, case-insensitive.
|
|
137
|
+
return toText(cell).localeCompare(toText(operand));
|
|
138
|
+
}
|
|
139
|
+
/** 'between' accepts [a, b] and {min, max} — the same vocabulary buildWhereClause already speaks. */
|
|
140
|
+
function asPair(value) {
|
|
141
|
+
if (Array.isArray(value))
|
|
142
|
+
return [value[0], value[1]];
|
|
143
|
+
if (value !== null && typeof value === 'object' && 'min' in value) {
|
|
144
|
+
return [value.min, value.max];
|
|
145
|
+
}
|
|
146
|
+
return [undefined, undefined];
|
|
147
|
+
}
|
|
148
|
+
/** 'in' accepts an array or a comma-separated string (what the editor's input produces). */
|
|
149
|
+
function asList(value) {
|
|
150
|
+
if (Array.isArray(value))
|
|
151
|
+
return value;
|
|
152
|
+
if (typeof value === 'string') {
|
|
153
|
+
return value
|
|
154
|
+
.split(',')
|
|
155
|
+
.map((s) => s.trim())
|
|
156
|
+
.filter(Boolean);
|
|
157
|
+
}
|
|
158
|
+
return [value];
|
|
159
|
+
}
|
|
160
|
+
// ==================== RULE EVALUATION ====================
|
|
161
|
+
export function evaluateRule(rule, row) {
|
|
162
|
+
// Guard on key presence, NOT on `row[col] === undefined`. A typo'd column must never
|
|
163
|
+
// match anything — otherwise `isNull` would repaint the entire table.
|
|
164
|
+
if (!rule || typeof rule.column !== 'string' || !(rule.column in row))
|
|
165
|
+
return false;
|
|
166
|
+
const cell = row[rule.column];
|
|
167
|
+
const vt = rule.valueType ?? 'auto';
|
|
168
|
+
// Operators with their own null semantics come first.
|
|
169
|
+
switch (rule.operator) {
|
|
170
|
+
case 'isNull':
|
|
171
|
+
return isBlank(cell) || String(cell).trim() === '';
|
|
172
|
+
case 'isNotNull':
|
|
173
|
+
return !isBlank(cell) && String(cell).trim() !== '';
|
|
174
|
+
case 'notContains':
|
|
175
|
+
return isBlank(cell) ? true : !toText(cell).includes(toText(rule.value));
|
|
176
|
+
case '!=': {
|
|
177
|
+
if (isBlank(cell))
|
|
178
|
+
return !isBlank(rule.value);
|
|
179
|
+
const c = compareValues(cell, rule.value, vt);
|
|
180
|
+
return c === null ? true : c !== 0;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Every remaining operator is false on a NULL cell.
|
|
184
|
+
if (isBlank(cell))
|
|
185
|
+
return false;
|
|
186
|
+
switch (rule.operator) {
|
|
187
|
+
case '=': {
|
|
188
|
+
return compareValues(cell, rule.value, vt) === 0;
|
|
189
|
+
}
|
|
190
|
+
case '<': {
|
|
191
|
+
const c = compareValues(cell, rule.value, vt);
|
|
192
|
+
return c !== null && c < 0;
|
|
193
|
+
}
|
|
194
|
+
case '<=': {
|
|
195
|
+
const c = compareValues(cell, rule.value, vt);
|
|
196
|
+
return c !== null && c <= 0;
|
|
197
|
+
}
|
|
198
|
+
case '>': {
|
|
199
|
+
const c = compareValues(cell, rule.value, vt);
|
|
200
|
+
return c !== null && c > 0;
|
|
201
|
+
}
|
|
202
|
+
case '>=': {
|
|
203
|
+
const c = compareValues(cell, rule.value, vt);
|
|
204
|
+
return c !== null && c >= 0;
|
|
205
|
+
}
|
|
206
|
+
case 'between': {
|
|
207
|
+
let [lo, hi] = asPair(rule.value);
|
|
208
|
+
if (lo === undefined || hi === undefined)
|
|
209
|
+
return false;
|
|
210
|
+
// Tolerate swapped bounds rather than silently matching nothing.
|
|
211
|
+
if ((compareValues(lo, hi, vt) ?? 0) > 0)
|
|
212
|
+
[lo, hi] = [hi, lo];
|
|
213
|
+
const a = compareValues(cell, lo, vt);
|
|
214
|
+
const b = compareValues(cell, hi, vt);
|
|
215
|
+
return a !== null && b !== null && a >= 0 && b <= 0;
|
|
216
|
+
}
|
|
217
|
+
case 'in': {
|
|
218
|
+
return asList(rule.value).some((v) => compareValues(cell, v, vt) === 0);
|
|
219
|
+
}
|
|
220
|
+
case 'contains':
|
|
221
|
+
return toText(cell).includes(toText(rule.value));
|
|
222
|
+
case 'startsWith':
|
|
223
|
+
return toText(cell).startsWith(toText(rule.value));
|
|
224
|
+
case 'endsWith':
|
|
225
|
+
return toText(cell).endsWith(toText(rule.value));
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
// ==================== MERGE ====================
|
|
230
|
+
function joinClass(a, b) {
|
|
231
|
+
if (!a)
|
|
232
|
+
return b || undefined;
|
|
233
|
+
if (!b)
|
|
234
|
+
return a;
|
|
235
|
+
const seen = new Set(a.split(/\s+/).filter(Boolean));
|
|
236
|
+
for (const c of b.split(/\s+/).filter(Boolean))
|
|
237
|
+
seen.add(c);
|
|
238
|
+
return Array.from(seen).join(' ');
|
|
239
|
+
}
|
|
240
|
+
/** null = the "row" bucket; otherwise the column names this rule paints. */
|
|
241
|
+
function resolveTargets(rule, columns) {
|
|
242
|
+
const target = rule.target ?? 'row';
|
|
243
|
+
if (target === 'row')
|
|
244
|
+
return null;
|
|
245
|
+
if (target === 'cell')
|
|
246
|
+
return [rule.column];
|
|
247
|
+
if (Array.isArray(target))
|
|
248
|
+
return target.filter((c) => columns.has(c));
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
export function computeRowFormatting(row, rules, columns, callbacks, rowIndex = 0) {
|
|
252
|
+
let rowStyle;
|
|
253
|
+
let rowClassName;
|
|
254
|
+
const cellStyles = {};
|
|
255
|
+
const columnSet = new Set(columns);
|
|
256
|
+
// stopIfTrue freezes exactly the buckets a rule wrote to: a row rule never freezes
|
|
257
|
+
// cell rules and vice-versa.
|
|
258
|
+
let rowStopped = false;
|
|
259
|
+
const stoppedCells = new Set();
|
|
260
|
+
for (const rule of rules) {
|
|
261
|
+
if (!rule || rule.enabled === false)
|
|
262
|
+
continue;
|
|
263
|
+
const targets = resolveTargets(rule, columnSet);
|
|
264
|
+
if (targets === null) {
|
|
265
|
+
if (rowStopped)
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
else if (targets.length === 0 || targets.every((c) => stoppedCells.has(c))) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!evaluateRule(rule, row))
|
|
272
|
+
continue;
|
|
273
|
+
if (targets === null) {
|
|
274
|
+
// Later rule wins per CSS property.
|
|
275
|
+
if (rule.style)
|
|
276
|
+
rowStyle = { ...rowStyle, ...rule.style };
|
|
277
|
+
rowClassName = joinClass(rowClassName, rule.className);
|
|
278
|
+
if (rule.stopIfTrue)
|
|
279
|
+
rowStopped = true;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
for (const col of targets) {
|
|
283
|
+
if (stoppedCells.has(col))
|
|
284
|
+
continue;
|
|
285
|
+
const prev = cellStyles[col];
|
|
286
|
+
cellStyles[col] = {
|
|
287
|
+
style: rule.style ? { ...prev?.style, ...rule.style } : prev?.style,
|
|
288
|
+
className: joinClass(prev?.className, rule.className),
|
|
289
|
+
};
|
|
290
|
+
if (rule.stopIfTrue)
|
|
291
|
+
stoppedCells.add(col);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Callbacks get the last word and are deliberately NOT subject to stopIfTrue —
|
|
296
|
+
// they are the escape hatch, so they must always be able to override.
|
|
297
|
+
const fromRowCb = callbacks?.getRowFormatting?.(row, rowIndex);
|
|
298
|
+
if (fromRowCb) {
|
|
299
|
+
if (fromRowCb.style)
|
|
300
|
+
rowStyle = { ...rowStyle, ...fromRowCb.style };
|
|
301
|
+
rowClassName = joinClass(rowClassName, fromRowCb.className);
|
|
302
|
+
}
|
|
303
|
+
if (callbacks?.getCellFormatting) {
|
|
304
|
+
for (const col of columns) {
|
|
305
|
+
const fromCellCb = callbacks.getCellFormatting(col, row[col], row, rowIndex);
|
|
306
|
+
if (!fromCellCb)
|
|
307
|
+
continue;
|
|
308
|
+
const prev = cellStyles[col];
|
|
309
|
+
cellStyles[col] = {
|
|
310
|
+
style: fromCellCb.style ? { ...prev?.style, ...fromCellCb.style } : prev?.style,
|
|
311
|
+
className: joinClass(prev?.className, fromCellCb.className),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { rowStyle, rowClassName, cellStyles };
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Formatting for a whole page of rows. Returns `null` when there is nothing to do, so the
|
|
319
|
+
* feature costs one boolean check for everyone who does not use it.
|
|
320
|
+
*/
|
|
321
|
+
export function computeTableFormatting(items, rules, columns, callbacks) {
|
|
322
|
+
const hasRules = rules.some((r) => r && r.enabled !== false);
|
|
323
|
+
if (!hasRules && !callbacks?.getRowFormatting && !callbacks?.getCellFormatting)
|
|
324
|
+
return null;
|
|
325
|
+
return items.map((row, i) => computeRowFormatting(row, rules, columns, callbacks, i));
|
|
326
|
+
}
|
|
327
|
+
// ==================== SANITIZING ====================
|
|
328
|
+
let idCounter = 0;
|
|
329
|
+
/** Stable-enough id for a rule that arrived without one. randomUUID needs a secure context. */
|
|
330
|
+
export function newRuleId() {
|
|
331
|
+
const uuid = typeof globalThis.crypto !== 'undefined' ? globalThis.crypto.randomUUID?.() : undefined;
|
|
332
|
+
return uuid ?? `r${Date.now().toString(36)}${(idCounter++).toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
|
333
|
+
}
|
|
334
|
+
function sanitizeStyle(input) {
|
|
335
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
336
|
+
return undefined;
|
|
337
|
+
const out = {};
|
|
338
|
+
for (const [k, v] of Object.entries(input)) {
|
|
339
|
+
if (typeof v === 'string' || typeof v === 'number')
|
|
340
|
+
out[k] = v;
|
|
341
|
+
}
|
|
342
|
+
return Object.keys(out).length ? out : undefined;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Coerce an untrusted rule list (an API payload or localStorage) into valid rules.
|
|
346
|
+
* Anything malformed is dropped, never thrown on — a bad stored rule must not take the
|
|
347
|
+
* table down.
|
|
348
|
+
*/
|
|
349
|
+
export function sanitizeFormattingRules(input) {
|
|
350
|
+
if (!Array.isArray(input))
|
|
351
|
+
return [];
|
|
352
|
+
const out = [];
|
|
353
|
+
for (const raw of input) {
|
|
354
|
+
if (!raw || typeof raw !== 'object')
|
|
355
|
+
continue;
|
|
356
|
+
const r = raw;
|
|
357
|
+
if (typeof r.column !== 'string' || !r.column)
|
|
358
|
+
continue;
|
|
359
|
+
if (typeof r.operator !== 'string' || !OPERATOR_SET.has(r.operator))
|
|
360
|
+
continue;
|
|
361
|
+
let target = 'row';
|
|
362
|
+
if (r.target === 'cell' || r.target === 'row')
|
|
363
|
+
target = r.target;
|
|
364
|
+
else if (Array.isArray(r.target))
|
|
365
|
+
target = r.target.filter((c) => typeof c === 'string');
|
|
366
|
+
out.push({
|
|
367
|
+
id: typeof r.id === 'string' && r.id ? r.id : newRuleId(),
|
|
368
|
+
label: typeof r.label === 'string' ? r.label : undefined,
|
|
369
|
+
column: r.column,
|
|
370
|
+
operator: r.operator,
|
|
371
|
+
value: r.value,
|
|
372
|
+
valueType: r.valueType === 'string' || r.valueType === 'number' || r.valueType === 'date' || r.valueType === 'boolean'
|
|
373
|
+
? r.valueType
|
|
374
|
+
: 'auto',
|
|
375
|
+
target,
|
|
376
|
+
style: sanitizeStyle(r.style),
|
|
377
|
+
className: typeof r.className === 'string' ? r.className : undefined,
|
|
378
|
+
stopIfTrue: r.stopIfTrue === true,
|
|
379
|
+
enabled: r.enabled !== false,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
export function styleToEditorState(style) {
|
|
385
|
+
const { backgroundColor, color, fontWeight, fontStyle, ...rest } = (style ?? {});
|
|
386
|
+
return {
|
|
387
|
+
background: typeof backgroundColor === 'string' ? backgroundColor : undefined,
|
|
388
|
+
color: typeof color === 'string' ? color : undefined,
|
|
389
|
+
bold: fontWeight === 'bold' || fontWeight === 700 || fontWeight === '700',
|
|
390
|
+
italic: fontStyle === 'italic',
|
|
391
|
+
rest: rest,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
export function editorStateToStyle(state) {
|
|
395
|
+
const style = { ...state.rest };
|
|
396
|
+
if (state.background)
|
|
397
|
+
style.backgroundColor = state.background;
|
|
398
|
+
if (state.color)
|
|
399
|
+
style.color = state.color;
|
|
400
|
+
if (state.bold)
|
|
401
|
+
style.fontWeight = 'bold';
|
|
402
|
+
if (state.italic)
|
|
403
|
+
style.fontStyle = 'italic';
|
|
404
|
+
return Object.keys(style).length ? style : undefined;
|
|
405
|
+
}
|
|
406
|
+
/** Human-readable one-liner for a rule, used when it carries no explicit label. */
|
|
407
|
+
export function describeRule(rule) {
|
|
408
|
+
const op = FORMATTING_OPERATORS.find((o) => o.value === rule.operator);
|
|
409
|
+
const arity = op?.arity ?? 1;
|
|
410
|
+
let operand = '';
|
|
411
|
+
if (arity === 2) {
|
|
412
|
+
const [lo, hi] = asPair(rule.value);
|
|
413
|
+
operand = ` ${String(lo ?? '')} et ${String(hi ?? '')}`;
|
|
414
|
+
}
|
|
415
|
+
else if (arity === 'n') {
|
|
416
|
+
operand = ` ${asList(rule.value).join(', ')}`;
|
|
417
|
+
}
|
|
418
|
+
else if (arity === 1) {
|
|
419
|
+
operand = ` ${String(rule.value ?? '')}`;
|
|
420
|
+
}
|
|
421
|
+
const target = rule.target === 'cell' ? 'cellule' : Array.isArray(rule.target) ? rule.target.join(', ') : 'ligne';
|
|
422
|
+
return `${rule.column} ${op?.label.split(' ')[0] ?? rule.operator}${operand} → ${target}`;
|
|
423
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -6,4 +6,11 @@ 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
|
|
9
|
+
export { FormattingModal } from './FormattingModal.js';
|
|
10
|
+
export type { FormattingModalProps } from './FormattingModal.js';
|
|
11
|
+
export { FormattingToolbar } from './FormattingToolbar.js';
|
|
12
|
+
export type { FormattingToolbarProps } from './FormattingToolbar.js';
|
|
13
|
+
export { useFormattingRules } from './useFormattingRules.js';
|
|
14
|
+
export type { UseFormattingRulesOptions, UseFormattingRulesResult } from './useFormattingRules.js';
|
|
15
|
+
export { FORMATTING_OPERATORS, compareValues, computeRowFormatting, computeTableFormatting, describeRule, evaluateRule, operatorArity, sanitizeFormattingRules, } from './formatting.js';
|
|
16
|
+
export type { DataTableProps, FetchParams, FetchResult, FieldTypeInfo, CellFormatting, FilterConfig, FilterFieldConfig, FormattingOperator, FormattingRule, FormattingRuleSource, FormattingStyle, FormattingTarget, FormattingValueType, GetCellFormatting, GetRowFormatting, ParamFilter, RowFormatting, SelectionColumnPosition, SortDirection, } from './types.js';
|
package/dist/react/index.js
CHANGED
|
@@ -4,3 +4,7 @@ export { Pagination } from './Pagination.js';
|
|
|
4
4
|
export { useDataTable } from './useDataTable.js';
|
|
5
5
|
export { FilterModal } from './FilterModal.js';
|
|
6
6
|
export { FilterPanel } from './FilterPanel.js';
|
|
7
|
+
export { FormattingModal } from './FormattingModal.js';
|
|
8
|
+
export { FormattingToolbar } from './FormattingToolbar.js';
|
|
9
|
+
export { useFormattingRules } from './useFormattingRules.js';
|
|
10
|
+
export { FORMATTING_OPERATORS, compareValues, computeRowFormatting, computeTableFormatting, describeRule, evaluateRule, operatorArity, sanitizeFormattingRules, } from './formatting.js';
|
package/dist/react/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
1
2
|
export type SortDirection = 'ASC' | 'DESC';
|
|
2
3
|
/** Matches the "fieldType" info your API already returns per column. */
|
|
3
4
|
export type FieldTypeName = 'DATE' | 'DATETIME' | 'TIMESTAMP' | 'JSON' | 'BLOB' | 'FILE' | string;
|
|
@@ -23,6 +24,8 @@ export interface FetchResult<T> {
|
|
|
23
24
|
filtre?: FilterConfig;
|
|
24
25
|
/** Key to re-fetch filter definitions later via a dedicated endpoint. */
|
|
25
26
|
cleRecupFiltre?: string;
|
|
27
|
+
/** Conditional formatting rules defined by the API (see FormattingRule). */
|
|
28
|
+
formattingRules?: FormattingRule[];
|
|
26
29
|
}
|
|
27
30
|
export type FilterFieldType = 'MULTISELECT' | 'UNGROUP_MULTISELECT' | 'SLIDER' | 'DATE' | 'DATETIME' | 'HIDE';
|
|
28
31
|
export interface FilterFieldConfig {
|
|
@@ -37,6 +40,47 @@ export type ParamFilter = {
|
|
|
37
40
|
export type FilterConfig = Record<string, FilterFieldConfig>;
|
|
38
41
|
/** Where the selection (checkbox) column is inserted among the visible columns. */
|
|
39
42
|
export type SelectionColumnPosition = number | 'start' | 'end';
|
|
43
|
+
export type FormattingOperator = '=' | '!=' | '<' | '<=' | '>' | '>=' | 'between' | 'in' | 'contains' | 'notContains' | 'startsWith' | 'endsWith' | 'isNull' | 'isNotNull';
|
|
44
|
+
/** What a rule paints: the whole row (default), the tested column's cell, or a given list of columns. */
|
|
45
|
+
export type FormattingTarget = 'row' | 'cell' | string[];
|
|
46
|
+
/** How the two operands are compared. 'auto' infers it from the values (see README). */
|
|
47
|
+
export type FormattingValueType = 'auto' | 'string' | 'number' | 'date' | 'boolean';
|
|
48
|
+
export interface FormattingStyle {
|
|
49
|
+
/** Inline style — the package ships no CSS, so this is the path that works with no setup. */
|
|
50
|
+
style?: CSSProperties;
|
|
51
|
+
/** Class added to the <tr> (target 'row') or to the <td> (target 'cell' | string[]). */
|
|
52
|
+
className?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface FormattingRule extends FormattingStyle {
|
|
55
|
+
/** Stable id, needed by the editor to reorder/disable. Generated when missing. */
|
|
56
|
+
id?: string;
|
|
57
|
+
/** Free-form label shown in the editor; a summary is generated when missing. */
|
|
58
|
+
label?: string;
|
|
59
|
+
/** Name of the tested column (a key of the row objects), not its position. */
|
|
60
|
+
column: string;
|
|
61
|
+
operator: FormattingOperator;
|
|
62
|
+
/** Operand(s): a scalar, [min, max] or {min, max} for 'between', an array for 'in', unused for isNull/isNotNull. */
|
|
63
|
+
value?: unknown;
|
|
64
|
+
valueType?: FormattingValueType;
|
|
65
|
+
/** Defaults to 'row'. */
|
|
66
|
+
target?: FormattingTarget;
|
|
67
|
+
/** Stop evaluating later rules for the targets this rule wrote to. */
|
|
68
|
+
stopIfTrue?: boolean;
|
|
69
|
+
/** false keeps the rule in the list without applying it. Defaults to true. */
|
|
70
|
+
enabled?: boolean;
|
|
71
|
+
}
|
|
72
|
+
export interface CellFormatting extends FormattingStyle {
|
|
73
|
+
}
|
|
74
|
+
export interface RowFormatting {
|
|
75
|
+
rowStyle?: CSSProperties;
|
|
76
|
+
rowClassName?: string;
|
|
77
|
+
/** Keyed by column NAME — never by the positional index of fieldsType/paramFilter. */
|
|
78
|
+
cellStyles: Record<string, CellFormatting>;
|
|
79
|
+
}
|
|
80
|
+
/** Which layer a rule came from: the app's props, the API payload, or the end user's editor. */
|
|
81
|
+
export type FormattingRuleSource = 'props' | 'server' | 'user';
|
|
82
|
+
export type GetRowFormatting<T> = (row: T, rowIndex: number) => CellFormatting | null | undefined;
|
|
83
|
+
export type GetCellFormatting<T> = (column: string, value: any, row: T, rowIndex: number) => CellFormatting | null | undefined;
|
|
40
84
|
export interface DataTableProps<T extends Record<string, any>> {
|
|
41
85
|
/** Called every time the table needs data (page change, sort, filter, page size). */
|
|
42
86
|
fetchData: (params: FetchParams) => Promise<FetchResult<T> | null>;
|
|
@@ -64,4 +108,19 @@ export interface DataTableProps<T extends Record<string, any>> {
|
|
|
64
108
|
selectedIds?: any[];
|
|
65
109
|
/** Called on every selection change, with the selected ids and the matching rows (same shape as onRowClick). */
|
|
66
110
|
onSelectionChange?: (ids: any[], rows: T[]) => void;
|
|
111
|
+
/** Conditional formatting rules set by the app — the lowest-priority layer. */
|
|
112
|
+
formattingRules?: FormattingRule[];
|
|
113
|
+
/** Escape hatch for logic spanning several columns. Applied last, after every rule. */
|
|
114
|
+
getRowFormatting?: GetRowFormatting<T>;
|
|
115
|
+
getCellFormatting?: GetCellFormatting<T>;
|
|
116
|
+
/** Show the toolbar and its formatting button (the end-user editor). Default false. */
|
|
117
|
+
formattingEditor?: boolean;
|
|
118
|
+
/** When set, the user's rules are saved to / reloaded from localStorage under this key. */
|
|
119
|
+
formattingStorageKey?: string;
|
|
120
|
+
/** Called on every change to the user's rules, so the host can persist them server-side. */
|
|
121
|
+
onFormattingRulesChange?: (rules: FormattingRule[]) => void;
|
|
122
|
+
/** Rehydrate the user's rules from your own backend — takes precedence over localStorage. */
|
|
123
|
+
initialUserFormattingRules?: FormattingRule[];
|
|
124
|
+
/** Label of the toolbar button. Default 'Mise en forme'. */
|
|
125
|
+
formattingButtonLabel?: string;
|
|
67
126
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DataTableProps, FieldTypeInfo, FilterConfig, SortDirection, ParamFilter } from './types.js';
|
|
1
|
+
import type { DataTableProps, FieldTypeInfo, FilterConfig, FormattingRule, SortDirection, ParamFilter } from './types.js';
|
|
2
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;
|
|
@@ -13,6 +13,7 @@ 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
|
+
serverFormattingRules: FormattingRule[];
|
|
16
17
|
selectedIds: any[];
|
|
17
18
|
selectedRows: T[];
|
|
18
19
|
isRowSelected: (row: T) => boolean;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { sanitizeFormattingRules } from './formatting.js';
|
|
2
3
|
import { getRowId, selectionKey } from './utils.js';
|
|
3
4
|
export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions = [10, 25, 50, 100, 250, 500, 1000], defaultRowsPerPage = 100, fetchFilterConfig, selectedIds: controlledSelectedIds, onSelectionChange, }) {
|
|
4
5
|
const [items, setItems] = useState([]);
|
|
@@ -13,6 +14,7 @@ export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions =
|
|
|
13
14
|
const [filters, setFilters] = useState({});
|
|
14
15
|
const [filterConfig, setFilterConfig] = useState(null);
|
|
15
16
|
const [cleRecupFiltre, setCleRecupFiltre] = useState();
|
|
17
|
+
const [serverFormattingRules, setServerFormattingRules] = useState([]);
|
|
16
18
|
// Guards against a slow, stale request overwriting a newer one.
|
|
17
19
|
const requestId = useRef(0);
|
|
18
20
|
// Selection is uncontrolled unless the parent passes `selectedIds`.
|
|
@@ -59,6 +61,8 @@ export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions =
|
|
|
59
61
|
setFilterConfig(result.filtre);
|
|
60
62
|
if (result.cleRecupFiltre)
|
|
61
63
|
setCleRecupFiltre(result.cleRecupFiltre);
|
|
64
|
+
if (result.formattingRules)
|
|
65
|
+
setServerFormattingRules(sanitizeFormattingRules(result.formattingRules));
|
|
62
66
|
}
|
|
63
67
|
finally {
|
|
64
68
|
if (id === requestId.current)
|
|
@@ -183,6 +187,7 @@ export function useDataTable({ fetchData, advancedFilters, rowsPerPageOptions =
|
|
|
183
187
|
sortDirection,
|
|
184
188
|
filters,
|
|
185
189
|
filterConfig,
|
|
190
|
+
serverFormattingRules,
|
|
186
191
|
selectedIds,
|
|
187
192
|
selectedRows,
|
|
188
193
|
isRowSelected,
|
|
@@ -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;
|