@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
|
@@ -6,4 +6,4 @@ import type { DataTableProps } from './types.js';
|
|
|
6
6
|
* If you want your own filter UI, don't use this component: compose `useDataTable` +
|
|
7
7
|
* `Table` + `Pagination` directly instead (see README "Using the pieces separately").
|
|
8
8
|
*/
|
|
9
|
-
export declare function DataTable<T extends Record<string, any>>({ fetchData, onRowClick, filterEnabled, sortingEnabled, advancedFilters, height, rowsPerPageOptions, defaultRowsPerPage, onImagePreview, fetchFilterConfig, }: DataTableProps<T>): import("react").JSX.Element;
|
|
9
|
+
export declare function DataTable<T extends Record<string, any>>({ fetchData, onRowClick, filterEnabled, sortingEnabled, advancedFilters, height, rowsPerPageOptions, defaultRowsPerPage, onImagePreview, fetchFilterConfig, selectable, selectionColumnPosition, selectedIds, onSelectionChange, formattingRules, getRowFormatting, getCellFormatting, formattingEditor, formattingStorageKey, onFormattingRulesChange, initialUserFormattingRules, formattingButtonLabel, }: DataTableProps<T>): import("react").JSX.Element;
|
package/dist/react/DataTable.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from 'react';
|
|
2
|
+
import { useMemo, useState } from 'react';
|
|
3
3
|
import { useDataTable } from './useDataTable.js';
|
|
4
|
+
import { useFormattingRules } from './useFormattingRules.js';
|
|
5
|
+
import { FormattingToolbar } from './FormattingToolbar.js';
|
|
6
|
+
import { columnNamesOf } from './utils.js';
|
|
4
7
|
import { FilterModal } from './FilterModal.js';
|
|
5
8
|
import { FilterPanel } from './FilterPanel.js';
|
|
6
9
|
import { Table } from './Table.js';
|
|
@@ -12,15 +15,32 @@ import { Pagination } from './Pagination.js';
|
|
|
12
15
|
* If you want your own filter UI, don't use this component: compose `useDataTable` +
|
|
13
16
|
* `Table` + `Pagination` directly instead (see README "Using the pieces separately").
|
|
14
17
|
*/
|
|
15
|
-
export function DataTable({ fetchData, onRowClick, filterEnabled = true, sortingEnabled = true, advancedFilters = false, height = '76vh', rowsPerPageOptions, defaultRowsPerPage, onImagePreview, fetchFilterConfig, }) {
|
|
16
|
-
const table = useDataTable({
|
|
18
|
+
export function DataTable({ fetchData, onRowClick, filterEnabled = true, sortingEnabled = true, advancedFilters = false, height = '76vh', rowsPerPageOptions, defaultRowsPerPage, onImagePreview, fetchFilterConfig, selectable = false, selectionColumnPosition = 'start', selectedIds, onSelectionChange, formattingRules, getRowFormatting, getCellFormatting, formattingEditor = false, formattingStorageKey, onFormattingRulesChange, initialUserFormattingRules, formattingButtonLabel, }) {
|
|
19
|
+
const table = useDataTable({
|
|
20
|
+
fetchData,
|
|
21
|
+
advancedFilters,
|
|
22
|
+
rowsPerPageOptions,
|
|
23
|
+
defaultRowsPerPage,
|
|
24
|
+
fetchFilterConfig,
|
|
25
|
+
selectedIds,
|
|
26
|
+
onSelectionChange,
|
|
27
|
+
});
|
|
17
28
|
const [openFilterCol, setOpenFilterCol] = useState(null);
|
|
18
29
|
const [filterAnchor, setFilterAnchor] = useState(null);
|
|
30
|
+
const formatting = useFormattingRules({
|
|
31
|
+
propsRules: formattingRules,
|
|
32
|
+
serverRules: table.serverFormattingRules,
|
|
33
|
+
storageKey: formattingStorageKey,
|
|
34
|
+
initialUserRules: initialUserFormattingRules,
|
|
35
|
+
onChange: onFormattingRulesChange,
|
|
36
|
+
});
|
|
37
|
+
// Same derivation as Table's, via the shared helper, so the editor's column list can't drift.
|
|
38
|
+
const columnsForEditor = useMemo(() => columnNamesOf(table.items, table.paramFilter), [table.items, table.paramFilter]);
|
|
19
39
|
const uniqueValuesFor = (column) => {
|
|
20
40
|
const values = table.items.map((row) => String(row[column] ?? ''));
|
|
21
41
|
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
|
|
22
42
|
};
|
|
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
|
|
43
|
+
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: [formattingEditor && (_jsx("div", { className: 'flex-row', style: { justifyContent: 'flex-end' }, children: _jsx(FormattingToolbar, { columns: columnsForEditor, fieldsType: table.fieldsType, paramFilter: table.paramFilter, label: formattingButtonLabel, ...formatting }) })), _jsx("div", { style: { maxHeight: '-webkit-fill-available', overflowY: 'auto', overflowX: 'auto' }, children: _jsx(Table, { items: table.items, fieldsType: table.fieldsType, paramFilter: table.paramFilter, sortColumn: table.sortColumn, sortDirection: table.sortDirection, sortingEnabled: sortingEnabled, onSort: (col) => table.toggleSort(col), onRowClick: onRowClick, onImagePreview: onImagePreview, selectable: selectable, selectionColumnPosition: selectionColumnPosition, selectedIds: table.selectedIds, onToggleRow: table.setRowSelected, onToggleAllRows: table.setAllRowsSelected, formattingRules: formatting.rules, getRowFormatting: getRowFormatting, getCellFormatting: getCellFormatting, renderHeaderExtra: filterEnabled && !advancedFilters
|
|
24
44
|
? (col) => (_jsx("button", { style: { padding: 5, marginTop: 0 }, className: 'btnDataFilterTable', onClick: (e) => {
|
|
25
45
|
setOpenFilterCol(col);
|
|
26
46
|
setFilterAnchor(e.currentTarget);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FieldTypeInfo, ParamFilter } from './types.js';
|
|
2
|
+
import type { UseFormattingRulesResult } from './useFormattingRules.js';
|
|
3
|
+
export interface FormattingModalProps extends UseFormattingRulesResult {
|
|
4
|
+
/** Every column of the table, hidden ones included. */
|
|
5
|
+
columns: string[];
|
|
6
|
+
fieldsType?: FieldTypeInfo[];
|
|
7
|
+
paramFilter?: ParamFilter[];
|
|
8
|
+
anchorEl: HTMLElement | null;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The end-user editor for conditional formatting rules: list on top, one rule form below.
|
|
13
|
+
* Shares FilterModal's popover mechanics (anchored positioning, outside-click dismissal)
|
|
14
|
+
* and the same unstyled class hooks, so a host stylesheet themes both at once.
|
|
15
|
+
*/
|
|
16
|
+
export declare function FormattingModal({ columns, fieldsType, paramFilter, anchorEl, onClose, rules, userRules, inherited, disabledIds, addRule, updateRule, removeRule, moveRule, setRuleDisabled, resetUserRules, }: FormattingModalProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { FORMATTING_OPERATORS, describeRule, editorStateToStyle, newRuleId, operatorArity, styleToEditorState, } from './formatting.js';
|
|
4
|
+
/**
|
|
5
|
+
* The end-user editor for conditional formatting rules: list on top, one rule form below.
|
|
6
|
+
* Shares FilterModal's popover mechanics (anchored positioning, outside-click dismissal)
|
|
7
|
+
* and the same unstyled class hooks, so a host stylesheet themes both at once.
|
|
8
|
+
*/
|
|
9
|
+
export function FormattingModal({ columns, fieldsType, paramFilter, anchorEl, onClose, rules, userRules, inherited, disabledIds, addRule, updateRule, removeRule, moveRule, setRuleDisabled, resetUserRules, }) {
|
|
10
|
+
const modalRef = useRef(null);
|
|
11
|
+
const [style, setStyle] = useState({ visibility: 'hidden' });
|
|
12
|
+
const [editingId, setEditingId] = useState(null);
|
|
13
|
+
const hiddenColumns = useMemo(() => {
|
|
14
|
+
const set = new Set();
|
|
15
|
+
columns.forEach((col, i) => {
|
|
16
|
+
if (paramFilter?.[i]?.type?.trim() === 'HIDE')
|
|
17
|
+
set.add(col);
|
|
18
|
+
});
|
|
19
|
+
return set;
|
|
20
|
+
}, [columns, paramFilter]);
|
|
21
|
+
// Same anchoring maths as FilterModal.
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!anchorEl || !modalRef.current)
|
|
24
|
+
return;
|
|
25
|
+
const modalRect = modalRef.current.getBoundingClientRect();
|
|
26
|
+
const triggerRect = anchorEl.getBoundingClientRect();
|
|
27
|
+
const left = Math.max(0, triggerRect.left - modalRect.width + triggerRect.width);
|
|
28
|
+
const top = Math.min(window.scrollY + window.innerHeight - modalRect.height, triggerRect.bottom + 5 + window.scrollY);
|
|
29
|
+
setStyle({ position: 'absolute', top, left, zIndex: 1000 });
|
|
30
|
+
}, [anchorEl, editingId, userRules.length, inherited.length]);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const handler = (e) => {
|
|
33
|
+
if (modalRef.current && !modalRef.current.contains(e.target) && e.target !== anchorEl)
|
|
34
|
+
onClose();
|
|
35
|
+
};
|
|
36
|
+
window.addEventListener('mousedown', handler);
|
|
37
|
+
return () => window.removeEventListener('mousedown', handler);
|
|
38
|
+
}, [anchorEl, onClose]);
|
|
39
|
+
const editing = userRules.find((r) => r.id === editingId) ?? null;
|
|
40
|
+
const noColumns = columns.length === 0;
|
|
41
|
+
const startNewRule = () => {
|
|
42
|
+
const rule = {
|
|
43
|
+
id: newRuleId(),
|
|
44
|
+
column: columns[0] ?? '',
|
|
45
|
+
operator: '=',
|
|
46
|
+
value: '',
|
|
47
|
+
valueType: 'auto',
|
|
48
|
+
target: 'row',
|
|
49
|
+
style: { backgroundColor: '#ffe08a' },
|
|
50
|
+
enabled: true,
|
|
51
|
+
};
|
|
52
|
+
addRule(rule);
|
|
53
|
+
setEditingId(rule.id);
|
|
54
|
+
};
|
|
55
|
+
return (_jsxs("div", { ref: modalRef, className: 'modal flex-column', style: { minWidth: '22em', maxWidth: '34em', ...style }, children: [_jsxs("div", { className: 'frame', children: [_jsx("strong", { children: "Mise en forme conditionnelle" }), _jsxs("span", { style: { opacity: 0.7, fontSize: '0.85em' }, children: [rules.length, " r\u00E8gle", rules.length > 1 ? 's' : '', " active", rules.length > 1 ? 's' : ''] })] }), inherited.length > 0 && (_jsxs("div", { className: 'frame flex-column', style: { maxHeight: '18vh', overflowY: 'auto' }, children: [_jsx("span", { style: { fontWeight: 'bold' }, children: "R\u00E8gles de l'application" }), inherited.map((rule) => (_jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6, opacity: 0.85 }, children: [_jsx("input", { type: 'checkbox', checked: !disabledIds.includes(rule.id), onChange: (e) => setRuleDisabled(rule.id, !e.target.checked), title: 'Activer / d\u00E9sactiver' }), _jsx(StyleSwatch, { rule: rule }), _jsx("span", { style: { width: '100%' }, children: rule.label || describeRule(rule) })] }, rule.id))), _jsx("span", { style: { opacity: 0.6, fontSize: '0.8em' }, children: "Ces r\u00E8gles ne sont pas modifiables ici." })] })), _jsxs("div", { className: 'frame flex-column', style: { maxHeight: '24vh', overflowY: 'auto' }, children: [_jsx("span", { style: { fontWeight: 'bold' }, children: "Mes r\u00E8gles" }), userRules.length === 0 && _jsx("span", { style: { opacity: 0.6 }, children: "Aucune r\u00E8gle pour l'instant." }), userRules.map((rule, i) => (_jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 4 }, children: [_jsx("input", { type: 'checkbox', checked: rule.enabled !== false, onChange: (e) => updateRule(rule.id, { enabled: e.target.checked }), title: 'Activer / d\u00E9sactiver' }), _jsx("button", { onClick: () => moveRule(rule.id, -1), disabled: i === 0, title: 'Monter (priorit\u00E9 plus faible)', children: "\u2191" }), _jsx("button", { onClick: () => moveRule(rule.id, 1), disabled: i === userRules.length - 1, title: 'Descendre (priorit\u00E9 plus forte)', children: "\u2193" }), _jsx(StyleSwatch, { rule: rule }), _jsx("span", { style: { width: '100%', cursor: 'pointer' }, onClick: () => setEditingId(rule.id), children: rule.label || describeRule(rule) }), _jsx("button", { onClick: () => setEditingId(editingId === rule.id ? null : rule.id), children: "\u00C9diter" }), _jsx("button", { onClick: () => {
|
|
56
|
+
if (editingId === rule.id)
|
|
57
|
+
setEditingId(null);
|
|
58
|
+
removeRule(rule.id);
|
|
59
|
+
}, title: 'Supprimer', children: "\u2715" })] }, rule.id)))] }), editing && (_jsx(RuleEditor, { rule: editing, columns: columns, hiddenColumns: hiddenColumns, fieldsType: fieldsType, onChange: (patch) => updateRule(editing.id, patch), onDone: () => setEditingId(null) })), _jsxs("div", { className: 'flex-row', children: [_jsx("button", { className: 'btn-accent', onClick: startNewRule, disabled: noColumns, children: "Ajouter une r\u00E8gle" }), _jsx("button", { onClick: () => {
|
|
60
|
+
setEditingId(null);
|
|
61
|
+
resetUserRules();
|
|
62
|
+
}, disabled: userRules.length === 0 && disabledIds.length === 0, children: "R\u00E9initialiser" }), _jsx("button", { onClick: onClose, children: "Fermer" })] }), noColumns && _jsx("span", { style: { opacity: 0.6 }, children: "Aucune colonne charg\u00E9e." })] }));
|
|
63
|
+
}
|
|
64
|
+
function StyleSwatch({ rule }) {
|
|
65
|
+
const s = rule.style ?? {};
|
|
66
|
+
return (_jsx("span", { "aria-hidden": 'true', style: {
|
|
67
|
+
display: 'inline-block',
|
|
68
|
+
width: '1.1em',
|
|
69
|
+
height: '1.1em',
|
|
70
|
+
flex: '0 0 auto',
|
|
71
|
+
border: '1px solid rgba(0,0,0,0.3)',
|
|
72
|
+
backgroundColor: s.backgroundColor ?? 'transparent',
|
|
73
|
+
color: s.color ?? 'inherit',
|
|
74
|
+
fontWeight: s.fontWeight,
|
|
75
|
+
fontStyle: s.fontStyle,
|
|
76
|
+
textAlign: 'center',
|
|
77
|
+
lineHeight: '1.1em',
|
|
78
|
+
fontSize: '0.8em',
|
|
79
|
+
}, children: "A" }));
|
|
80
|
+
}
|
|
81
|
+
// ==================== RULE EDITOR ====================
|
|
82
|
+
/** The operand input type follows the column's SQL type, so date rules get a date picker. */
|
|
83
|
+
function inputTypeFor(fieldType) {
|
|
84
|
+
const t = (fieldType ?? '').trim().toUpperCase();
|
|
85
|
+
if (t === 'DATE')
|
|
86
|
+
return 'date';
|
|
87
|
+
if (t === 'DATETIME' || t === 'TIMESTAMP')
|
|
88
|
+
return 'datetime-local';
|
|
89
|
+
return 'text';
|
|
90
|
+
}
|
|
91
|
+
function RuleEditor({ rule, columns, hiddenColumns, fieldsType, onChange, onDone, }) {
|
|
92
|
+
const arity = operatorArity(rule.operator);
|
|
93
|
+
const styleState = styleToEditorState(rule.style);
|
|
94
|
+
const columnIndex = columns.indexOf(rule.column);
|
|
95
|
+
const inputType = inputTypeFor(fieldsType?.[columnIndex]?.fieldType);
|
|
96
|
+
const setStyle = (patch) => onChange({ style: editorStateToStyle({ ...styleState, ...patch }) });
|
|
97
|
+
const pair = Array.isArray(rule.value) ? rule.value : [undefined, undefined];
|
|
98
|
+
const targetMode = Array.isArray(rule.target) ? 'columns' : rule.target === 'cell' ? 'cell' : 'row';
|
|
99
|
+
return (_jsxs("div", { className: 'frame flex-column', style: { gap: 6 }, children: [_jsx("span", { style: { fontWeight: 'bold' }, children: "Modifier la r\u00E8gle" }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Colonne" }), _jsxs("select", { value: rule.column, onChange: (e) => onChange({ column: e.target.value }), style: { width: '100%' }, children: [!columns.includes(rule.column) && _jsxs("option", { value: rule.column, children: [rule.column, " (inconnue)"] }), columns.map((col) => (_jsxs("option", { value: col, children: [col, hiddenColumns.has(col) ? ' (masquée)' : ''] }, col)))] })] }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Op\u00E9rateur" }), _jsx("select", { value: rule.operator, onChange: (e) => onChange({ operator: e.target.value, value: '' }), style: { width: '100%' }, children: FORMATTING_OPERATORS.map((op) => (_jsx("option", { value: op.value, children: op.label }, op.value))) })] }), arity === 1 && (_jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Valeur" }), _jsx("input", { type: inputType, value: String(rule.value ?? ''), onChange: (e) => onChange({ value: e.target.value }), style: { width: '100%' } })] })), arity === 2 && (_jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Entre" }), _jsx("input", { type: inputType, value: String(pair[0] ?? ''), placeholder: 'Min', onChange: (e) => onChange({ value: [e.target.value, pair[1] ?? ''] }) }), _jsx("input", { type: inputType, value: String(pair[1] ?? ''), placeholder: 'Max', onChange: (e) => onChange({ value: [pair[0] ?? '', e.target.value] }) })] })), arity === 'n' && (_jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Valeurs" }), _jsx("input", { type: 'text', value: Array.isArray(rule.value) ? rule.value.join(', ') : String(rule.value ?? ''), placeholder: 's\u00E9par\u00E9es par des virgules', onChange: (e) => onChange({ value: e.target.value }), style: { width: '100%' } })] })), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Comparer comme" }), _jsxs("select", { value: rule.valueType ?? 'auto', onChange: (e) => onChange({ valueType: e.target.value }), style: { width: '100%' }, children: [_jsx("option", { value: 'auto', children: "Automatique" }), _jsx("option", { value: 'string', children: "Texte" }), _jsx("option", { value: 'number', children: "Nombre" }), _jsx("option", { value: 'date', children: "Date" }), _jsx("option", { value: 'boolean', children: "Bool\u00E9en" })] })] }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Appliquer \u00E0" }), _jsxs("select", { value: targetMode, onChange: (e) => {
|
|
100
|
+
const mode = e.target.value;
|
|
101
|
+
onChange({ target: (mode === 'columns' ? [rule.column] : mode) });
|
|
102
|
+
}, style: { width: '100%' }, children: [_jsx("option", { value: 'row', children: "Toute la ligne" }), _jsx("option", { value: 'cell', children: "Cette cellule" }), _jsx("option", { value: 'columns', children: "Colonnes choisies\u2026" })] })] }), targetMode === 'columns' && (_jsx("div", { className: 'frame flex-column', style: { maxHeight: '12vh', overflowY: 'auto' }, children: columns.map((col) => {
|
|
103
|
+
const list = rule.target ?? [];
|
|
104
|
+
return (_jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("input", { type: 'checkbox', checked: list.includes(col), onChange: (e) => onChange({ target: e.target.checked ? [...list, col] : list.filter((c) => c !== col) }) }), _jsxs("span", { children: [col, hiddenColumns.has(col) ? ' (masquée)' : ''] })] }, col));
|
|
105
|
+
}) })), _jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Fond" }), _jsx("input", { type: 'checkbox', checked: !!styleState.background, onChange: (e) => setStyle({ background: e.target.checked ? '#ffe08a' : undefined }) }), _jsx("input", { type: 'color', value: styleState.background ?? '#ffe08a', disabled: !styleState.background, onChange: (e) => setStyle({ background: e.target.value }) }), _jsx("span", { style: { minWidth: '4em' }, children: "Texte" }), _jsx("input", { type: 'checkbox', checked: !!styleState.color, onChange: (e) => setStyle({ color: e.target.checked ? '#000000' : undefined }) }), _jsx("input", { type: 'color', value: styleState.color ?? '#000000', disabled: !styleState.color, onChange: (e) => setStyle({ color: e.target.value }) })] }), _jsxs("div", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 10 }, children: [_jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 4 }, children: [_jsx("input", { type: 'checkbox', checked: styleState.bold, onChange: (e) => setStyle({ bold: e.target.checked }) }), _jsx("span", { children: "Gras" })] }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 4 }, children: [_jsx("input", { type: 'checkbox', checked: styleState.italic, onChange: (e) => setStyle({ italic: e.target.checked }) }), _jsx("span", { children: "Italique" })] }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 4 }, children: [_jsx("input", { type: 'checkbox', checked: !!rule.stopIfTrue, onChange: (e) => onChange({ stopIfTrue: e.target.checked }) }), _jsx("span", { title: "Les r\u00E8gles suivantes ne s'appliqueront plus \u00E0 ce que celle-ci a color\u00E9", children: "Arr\u00EAter si vrai" })] })] }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Classe CSS" }), _jsx("input", { type: 'text', value: rule.className ?? '', placeholder: 'optionnel', onChange: (e) => onChange({ className: e.target.value || undefined }), style: { width: '100%' } })] }), _jsxs("label", { className: 'flex-row nowrap', style: { alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: { minWidth: '7em' }, children: "Libell\u00E9" }), _jsx("input", { type: 'text', value: rule.label ?? '', placeholder: describeRule(rule), onChange: (e) => onChange({ label: e.target.value || undefined }), style: { width: '100%' } })] }), _jsx("button", { className: 'btn-accent', onClick: onDone, children: "Termin\u00E9" })] }));
|
|
106
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FieldTypeInfo, ParamFilter } from './types.js';
|
|
2
|
+
import type { UseFormattingRulesResult } from './useFormattingRules.js';
|
|
3
|
+
export interface FormattingToolbarProps extends UseFormattingRulesResult {
|
|
4
|
+
columns: string[];
|
|
5
|
+
fieldsType?: FieldTypeInfo[];
|
|
6
|
+
paramFilter?: ParamFilter[];
|
|
7
|
+
label?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The button that opens the conditional formatting editor. Exported separately so a host
|
|
11
|
+
* composing `useDataTable` + `Table` by hand can drop it wherever it likes.
|
|
12
|
+
*/
|
|
13
|
+
export declare function FormattingToolbar({ columns, fieldsType, paramFilter, label, ...formatting }: FormattingToolbarProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { FormattingModal } from './FormattingModal.js';
|
|
4
|
+
/**
|
|
5
|
+
* The button that opens the conditional formatting editor. Exported separately so a host
|
|
6
|
+
* composing `useDataTable` + `Table` by hand can drop it wherever it likes.
|
|
7
|
+
*/
|
|
8
|
+
export function FormattingToolbar({ columns, fieldsType, paramFilter, label = 'Mise en forme', ...formatting }) {
|
|
9
|
+
const [anchorEl, setAnchorEl] = useState(null);
|
|
10
|
+
const activeCount = formatting.rules.length;
|
|
11
|
+
return (_jsxs(_Fragment, { children: [_jsxs("button", { className: 'btnDataFilterTable', onClick: (e) => setAnchorEl(anchorEl ? null : e.currentTarget), title: 'Colorer des lignes ou des cellules selon leurs valeurs', children: [label, activeCount > 0 ? ` (${activeCount})` : ''] }), anchorEl && (_jsx(FormattingModal, { columns: columns, fieldsType: fieldsType, paramFilter: paramFilter, anchorEl: anchorEl, onClose: () => setAnchorEl(null), ...formatting }))] }));
|
|
12
|
+
}
|
package/dist/react/Table.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type { FieldTypeInfo, ParamFilter, SortDirection } from './types.js';
|
|
2
|
+
import type { FieldTypeInfo, FormattingRule, GetCellFormatting, GetRowFormatting, ParamFilter, SelectionColumnPosition, SortDirection } from './types.js';
|
|
3
3
|
export interface TableProps<T extends Record<string, any>> {
|
|
4
4
|
items: T[];
|
|
5
5
|
fieldsType?: FieldTypeInfo[];
|
|
@@ -13,10 +13,30 @@ export interface TableProps<T extends Record<string, any>> {
|
|
|
13
13
|
onImagePreview?: (src: string) => void;
|
|
14
14
|
/** Slot to render your own control in a column header (e.g. your own filter icon/input). Receives the column name. */
|
|
15
15
|
renderHeaderExtra?: (column: string) => ReactNode;
|
|
16
|
+
/** Add a checkbox column; the header checkbox selects/deselects every displayed row. */
|
|
17
|
+
selectable?: boolean;
|
|
18
|
+
/** Where the checkbox column goes among the visible columns: 'start' (default), 'end', or a 0-based index. */
|
|
19
|
+
selectionColumnPosition?: SelectionColumnPosition;
|
|
20
|
+
/** Ids (value of each row's first column) of the currently selected rows. */
|
|
21
|
+
selectedIds?: any[];
|
|
22
|
+
/** Called when a row checkbox is toggled. */
|
|
23
|
+
onToggleRow?: (row: T, selected: boolean) => void;
|
|
24
|
+
/** Called when the header checkbox is toggled; applies to every displayed row. */
|
|
25
|
+
onToggleAllRows?: (selected: boolean) => void;
|
|
26
|
+
/** Labels for the checkboxes (accessibility). */
|
|
27
|
+
selectAllLabel?: string;
|
|
28
|
+
selectRowLabel?: string;
|
|
29
|
+
/** Conditional formatting rules, already merged and ordered (see useFormattingRules). */
|
|
30
|
+
formattingRules?: FormattingRule[];
|
|
31
|
+
/** Escape hatch for logic spanning several columns. Applied after every rule.
|
|
32
|
+
* Wrap these in useCallback, or the formatting memo recomputes on each render. */
|
|
33
|
+
getRowFormatting?: GetRowFormatting<T>;
|
|
34
|
+
getCellFormatting?: GetCellFormatting<T>;
|
|
16
35
|
}
|
|
17
36
|
/**
|
|
18
|
-
* Just the `<table>` — header, sorting, rows, cell formatting (dates, JSON, lazy images)
|
|
37
|
+
* Just the `<table>` — header, sorting, rows, cell formatting (dates, JSON, lazy images),
|
|
38
|
+
* optional checkbox column.
|
|
19
39
|
* No filter UI, no pagination: bring your own and drive `items`/`fieldsType` yourself
|
|
20
40
|
* (e.g. from `useDataTable`, or from any other data source).
|
|
21
41
|
*/
|
|
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;
|
|
42
|
+
export declare function Table<T extends Record<string, any>>({ items, fieldsType, paramFilter, sortColumn, sortDirection, onSort, sortingEnabled, onRowClick, onImagePreview, renderHeaderExtra, selectable, selectionColumnPosition, selectedIds, onToggleRow, onToggleAllRows, selectAllLabel, selectRowLabel, formattingRules, getRowFormatting, getCellFormatting, }: TableProps<T>): import("react").JSX.Element;
|
package/dist/react/Table.js
CHANGED
|
@@ -1,19 +1,74 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo } from 'react';
|
|
2
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
3
3
|
import { Cell } from './Cell.js';
|
|
4
|
+
import { computeTableFormatting } from './formatting.js';
|
|
5
|
+
import { columnNamesOf, getRowId, selectionKey } from './utils.js';
|
|
6
|
+
/** Stable identity so the formatting memo isn't invalidated on every render. */
|
|
7
|
+
const NO_RULES = [];
|
|
4
8
|
/**
|
|
5
|
-
* Just the `<table>` — header, sorting, rows, cell formatting (dates, JSON, lazy images)
|
|
9
|
+
* Just the `<table>` — header, sorting, rows, cell formatting (dates, JSON, lazy images),
|
|
10
|
+
* optional checkbox column.
|
|
6
11
|
* No filter UI, no pagination: bring your own and drive `items`/`fieldsType` yourself
|
|
7
12
|
* (e.g. from `useDataTable`, or from any other data source).
|
|
8
13
|
*/
|
|
9
|
-
export function Table({ items, fieldsType = [], paramFilter, sortColumn, sortDirection, onSort, sortingEnabled = true, onRowClick, onImagePreview, renderHeaderExtra, }) {
|
|
10
|
-
const columns = useMemo(() => (items
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
export function Table({ items, fieldsType = [], paramFilter, sortColumn, sortDirection, onSort, sortingEnabled = true, onRowClick, onImagePreview, renderHeaderExtra, selectable = false, selectionColumnPosition = 'start', selectedIds, onToggleRow, onToggleAllRows, selectAllLabel = 'Tout sélectionner', selectRowLabel = 'Sélectionner la ligne', formattingRules, getRowFormatting, getCellFormatting, }) {
|
|
15
|
+
const columns = useMemo(() => columnNamesOf(items, paramFilter), [items, paramFilter]);
|
|
16
|
+
/** Columns actually rendered, keeping their original index (fieldsType/paramFilter are indexed on it). */
|
|
17
|
+
const visibleColumns = useMemo(() => columns.map((col, index) => ({ col, index })).filter(({ index }) => paramFilter?.[index]?.type.trim() !== 'HIDE'), [columns, paramFilter]);
|
|
18
|
+
/**
|
|
19
|
+
* Computed for the whole page at once rather than inside the render loop: Table re-renders
|
|
20
|
+
* on every checkbox toggle and every filter popover open, and re-evaluating N rules over up
|
|
21
|
+
* to 1000 rows on each of those is wasteful. `items` gets a fresh identity on every load(),
|
|
22
|
+
* so invalidation is automatic. Returns null when there is nothing to format.
|
|
23
|
+
*/
|
|
24
|
+
const formatting = useMemo(() => computeTableFormatting(items, formattingRules ?? NO_RULES, columns, { getRowFormatting, getCellFormatting }), [items, formattingRules, columns, getRowFormatting, getCellFormatting]);
|
|
25
|
+
const selectedKeys = useMemo(() => new Set((selectedIds ?? []).map(selectionKey)), [selectedIds]);
|
|
26
|
+
const displayedCount = items.length;
|
|
27
|
+
const selectedOnPage = useMemo(() => items.filter((row) => selectedKeys.has(selectionKey(getRowId(row)))).length, [items, selectedKeys]);
|
|
28
|
+
const allDisplayedSelected = displayedCount > 0 && selectedOnPage === displayedCount;
|
|
29
|
+
const someDisplayedSelected = selectedOnPage > 0 && !allDisplayedSelected;
|
|
30
|
+
/** -1 when there is no checkbox column, otherwise its slot among the visible columns. */
|
|
31
|
+
const selectionIndex = useMemo(() => {
|
|
32
|
+
if (!selectable)
|
|
33
|
+
return -1;
|
|
34
|
+
if (selectionColumnPosition === 'end')
|
|
35
|
+
return visibleColumns.length;
|
|
36
|
+
if (typeof selectionColumnPosition === 'number')
|
|
37
|
+
return Math.min(Math.max(0, Math.trunc(selectionColumnPosition)), visibleColumns.length);
|
|
38
|
+
return 0;
|
|
39
|
+
}, [selectable, selectionColumnPosition, visibleColumns.length]);
|
|
40
|
+
const withSelectionCell = (cells, selectionCell) => {
|
|
41
|
+
if (selectionIndex < 0)
|
|
42
|
+
return cells;
|
|
43
|
+
const next = [...cells];
|
|
44
|
+
next.splice(selectionIndex, 0, selectionCell);
|
|
45
|
+
return next;
|
|
46
|
+
};
|
|
47
|
+
const headerCells = visibleColumns.map(({ col }) => (_jsx("th", { "data-sort": col, children: _jsxs("div", { className: 'flex-row nowrap', style: { justifyContent: 'space-between', alignItems: 'center' }, children: [_jsxs("div", { className: 'flex-row nowrap clickOnSort', style: { cursor: sortingEnabled ? 'pointer' : 'default' }, onClick: () => sortingEnabled && onSort?.(col), children: [_jsx("span", { translate: 'yes', children: col }), sortingEnabled && (_jsx("span", { className: 'sort-icon', children: sortColumn === col ? (sortDirection === 'ASC' ? '\u2B06' : '\u2B07') : '\u2B07\u2B06' }))] }), renderHeaderExtra?.(col)] }) }, col)));
|
|
48
|
+
const headerSelectionCell = (_jsx("th", { className: 'selectionColumn', style: { width: '1%' }, children: _jsx(SelectionCheckbox, { checked: allDisplayedSelected, indeterminate: someDisplayedSelected, disabled: displayedCount === 0, label: selectAllLabel, onChange: (checked) => onToggleAllRows?.(checked) }) }, '__selection__'));
|
|
49
|
+
return (_jsxs("table", { children: [_jsx("thead", { className: 'tableHeader', children: _jsx("tr", { children: withSelectionCell(headerCells, headerSelectionCell) }) }), _jsxs("tbody", { style: { maxHeight: 'stretch', maxWidth: 'stretch', overflow: 'auto' }, children: [items.length === 0 && (_jsx("tr", { children: _jsx("td", { colSpan: 100, children: "No data found" }) })), items.map((row, i) => {
|
|
50
|
+
const id = getRowId(row);
|
|
51
|
+
const fmt = formatting?.[i];
|
|
52
|
+
const cells = visibleColumns.map(({ col, index }) => {
|
|
53
|
+
// Keyed by column NAME. `index` stays positional for fieldsType/paramFilter —
|
|
54
|
+
// using it here would mis-colour every table that has a HIDE column.
|
|
55
|
+
const cf = fmt?.cellStyles[col];
|
|
56
|
+
// The row style is re-applied as a base layer on each <td>: host CSS that sets a
|
|
57
|
+
// background on td (zebra striping) paints over the <tr>'s own background otherwise.
|
|
58
|
+
// Spreading the cell style second makes it win per property, with no JS arbitration.
|
|
59
|
+
const style = fmt?.rowStyle || cf?.style ? { ...fmt?.rowStyle, ...cf?.style } : undefined;
|
|
60
|
+
return (_jsx("td", { className: cf?.className, style: style, children: _jsx(Cell, { value: row[col], fieldType: fieldsType[index]?.fieldType, onImagePreview: onImagePreview }) }, col));
|
|
61
|
+
});
|
|
62
|
+
const selectionCell = (_jsx("td", { className: 'selectionColumn', style: fmt?.rowStyle, onClick: (e) => e.stopPropagation(), children: _jsx(SelectionCheckbox, { checked: selectedKeys.has(selectionKey(id)), label: selectRowLabel, onChange: (checked) => onToggleRow?.(row, checked) }) }, '__selection__'));
|
|
63
|
+
return (_jsx("tr", { className: fmt?.rowClassName, style: { cursor: onRowClick ? 'pointer' : 'default', ...fmt?.rowStyle }, onClick: () => id !== undefined && onRowClick?.(id, row), children: withSelectionCell(cells, selectionCell) }, id !== undefined ? String(id) : i));
|
|
64
|
+
})] })] }));
|
|
65
|
+
}
|
|
66
|
+
function SelectionCheckbox({ checked, indeterminate = false, disabled = false, label, onChange, }) {
|
|
67
|
+
const ref = useRef(null);
|
|
68
|
+
// `indeterminate` only exists on the DOM node, not as an attribute.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (ref.current)
|
|
71
|
+
ref.current.indeterminate = indeterminate && !checked;
|
|
72
|
+
}, [indeterminate, checked]);
|
|
73
|
+
return (_jsx("input", { ref: ref, type: 'checkbox', className: 'selectionCheckbox', checked: checked, disabled: disabled, "aria-label": label, title: label, style: { cursor: disabled ? 'default' : 'pointer', margin: 0 }, onClick: (e) => e.stopPropagation(), onChange: (e) => onChange(e.target.checked) }));
|
|
19
74
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import type { FormattingOperator, FormattingRule, FormattingValueType, GetCellFormatting, GetRowFormatting, RowFormatting } from './types.js';
|
|
3
|
+
/** Operator metadata: the single source of truth for the editor's <select> and its operand inputs. */
|
|
4
|
+
export declare const FORMATTING_OPERATORS: {
|
|
5
|
+
value: FormattingOperator;
|
|
6
|
+
label: string;
|
|
7
|
+
arity: 0 | 1 | 2 | 'n';
|
|
8
|
+
}[];
|
|
9
|
+
export declare function operatorArity(operator: FormattingOperator): 0 | 1 | 2 | 'n';
|
|
10
|
+
/**
|
|
11
|
+
* Three-way compare. Returns null when the two operands cannot be compared at all,
|
|
12
|
+
* which callers treat as "does not match" (except '!=', where it means "different").
|
|
13
|
+
*/
|
|
14
|
+
export declare function compareValues(cell: unknown, operand: unknown, valueType?: FormattingValueType): -1 | 0 | 1 | null;
|
|
15
|
+
export declare function evaluateRule(rule: FormattingRule, row: Record<string, any>): boolean;
|
|
16
|
+
export declare function computeRowFormatting<T extends Record<string, any>>(row: T, rules: FormattingRule[], columns: string[], callbacks?: {
|
|
17
|
+
getRowFormatting?: GetRowFormatting<T>;
|
|
18
|
+
getCellFormatting?: GetCellFormatting<T>;
|
|
19
|
+
}, rowIndex?: number): RowFormatting;
|
|
20
|
+
/**
|
|
21
|
+
* Formatting for a whole page of rows. Returns `null` when there is nothing to do, so the
|
|
22
|
+
* feature costs one boolean check for everyone who does not use it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function computeTableFormatting<T extends Record<string, any>>(items: T[], rules: FormattingRule[], columns: string[], callbacks?: {
|
|
25
|
+
getRowFormatting?: GetRowFormatting<T>;
|
|
26
|
+
getCellFormatting?: GetCellFormatting<T>;
|
|
27
|
+
}): RowFormatting[] | null;
|
|
28
|
+
/** Stable-enough id for a rule that arrived without one. randomUUID needs a secure context. */
|
|
29
|
+
export declare function newRuleId(): string;
|
|
30
|
+
/**
|
|
31
|
+
* Coerce an untrusted rule list (an API payload or localStorage) into valid rules.
|
|
32
|
+
* Anything malformed is dropped, never thrown on — a bad stored rule must not take the
|
|
33
|
+
* table down.
|
|
34
|
+
*/
|
|
35
|
+
export declare function sanitizeFormattingRules(input: unknown): FormattingRule[];
|
|
36
|
+
export interface RuleStyleState {
|
|
37
|
+
background?: string;
|
|
38
|
+
color?: string;
|
|
39
|
+
bold: boolean;
|
|
40
|
+
italic: boolean;
|
|
41
|
+
/** Every property the editor has no widget for, preserved across an edit. */
|
|
42
|
+
rest: CSSProperties;
|
|
43
|
+
}
|
|
44
|
+
export declare function styleToEditorState(style?: CSSProperties): RuleStyleState;
|
|
45
|
+
export declare function editorStateToStyle(state: RuleStyleState): CSSProperties | undefined;
|
|
46
|
+
/** Human-readable one-liner for a rule, used when it carries no explicit label. */
|
|
47
|
+
export declare function describeRule(rule: FormattingRule): string;
|