@xeplr/ui-table 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +17 -0
- package/src/XeplrTable.jsx +332 -0
- package/src/actions/ActionsCell.jsx +75 -0
- package/src/actions/ChildTable.jsx +159 -0
- package/src/actions/RecordDetail.jsx +213 -0
- package/src/actions/RecordModal.jsx +159 -0
- package/src/buildChangeSet.js +126 -0
- package/src/detectTypes.js +102 -0
- package/src/filters/BooleanFilter.jsx +66 -0
- package/src/filters/DateFilter.jsx +75 -0
- package/src/filters/FilterWrapper.jsx +66 -0
- package/src/filters/NumberFilter.jsx +105 -0
- package/src/filters/StringFilter.jsx +142 -0
- package/src/index.js +32 -0
- package/src/operators.js +113 -0
- package/src/resolveCellStyle.js +252 -0
- package/src/useActionsController.js +453 -0
- package/src/useTableController.js +131 -0
- package/src/xeplr-table.css +913 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve conditional formatting for a cell using NLP condition strings.
|
|
3
|
+
*
|
|
4
|
+
* Each rule is { when: "<condition>", ...styleProps }
|
|
5
|
+
* The condition uses xeplr-nlp-parser syntax:
|
|
6
|
+
* "$.field is active" "$.field is not active"
|
|
7
|
+
* "$.field is null" "$.field is not null"
|
|
8
|
+
* "$.field > 100" "$.field >= 90"
|
|
9
|
+
* "$.field between 50 and 100" "$.field starts with 'Jo'"
|
|
10
|
+
* "$.field ends with '.js'" "$.field contains 'urgent'"
|
|
11
|
+
* "$.field does not contain 'x'" "$.field in [a, b, c]"
|
|
12
|
+
*
|
|
13
|
+
* The condition is parsed once and cached for performance.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ── Inline condition parser (ESM-compatible, same syntax as xeplr-nlp-parser) ──
|
|
17
|
+
|
|
18
|
+
var KEYWORDS = new Set(['if', 'typeof', 'is', 'not', 'in', 'then', 'return', 'and', 'or', 'null', 'starts', 'ends', 'with', 'contains', 'contain', 'does', 'between']);
|
|
19
|
+
|
|
20
|
+
function tokenize(input) {
|
|
21
|
+
var tokens = [];
|
|
22
|
+
var i = 0;
|
|
23
|
+
while (i < input.length) {
|
|
24
|
+
if (/\s/.test(input[i])) { i++; continue; }
|
|
25
|
+
if (input[i] === '$') {
|
|
26
|
+
var path = '';
|
|
27
|
+
while (i < input.length && /[a-zA-Z0-9_.$]/.test(input[i])) { path += input[i++]; }
|
|
28
|
+
tokens.push({ type: 'PATH', value: path });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (input[i] === '>' && input[i + 1] === '=') { tokens.push({ type: 'OP', value: '>=' }); i += 2; continue; }
|
|
32
|
+
if (input[i] === '>') { tokens.push({ type: 'OP', value: '>' }); i++; continue; }
|
|
33
|
+
if (input[i] === '<' && input[i + 1] === '=') { tokens.push({ type: 'OP', value: '<=' }); i += 2; continue; }
|
|
34
|
+
if (input[i] === '<') { tokens.push({ type: 'OP', value: '<' }); i++; continue; }
|
|
35
|
+
if (input[i] === '[') {
|
|
36
|
+
i++;
|
|
37
|
+
var content = '';
|
|
38
|
+
while (i < input.length && input[i] !== ']') { content += input[i++]; }
|
|
39
|
+
if (i < input.length) i++;
|
|
40
|
+
tokens.push({ type: 'ARRAY', value: content.split(',').map(function(s) { return s.trim(); }).filter(Boolean) });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (input[i] === "'" || input[i] === '"') {
|
|
44
|
+
var quote = input[i++];
|
|
45
|
+
var str = '';
|
|
46
|
+
while (i < input.length && input[i] !== quote) { str += input[i++]; }
|
|
47
|
+
if (i < input.length) i++;
|
|
48
|
+
tokens.push({ type: 'STRING', value: str });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (/[0-9]/.test(input[i]) || (input[i] === '-' && i + 1 < input.length && /[0-9]/.test(input[i + 1]))) {
|
|
52
|
+
var num = '';
|
|
53
|
+
if (input[i] === '-') { num += input[i++]; }
|
|
54
|
+
while (i < input.length && /[0-9.]/.test(input[i])) { num += input[i++]; }
|
|
55
|
+
tokens.push({ type: 'NUMBER', value: Number(num) });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (/[a-zA-Z_]/.test(input[i])) {
|
|
59
|
+
var word = '';
|
|
60
|
+
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) { word += input[i++]; }
|
|
61
|
+
var lower = word.toLowerCase();
|
|
62
|
+
tokens.push({ type: KEYWORDS.has(lower) ? 'KEYWORD' : 'IDENTIFIER', value: KEYWORDS.has(lower) ? lower : word });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
i++;
|
|
66
|
+
}
|
|
67
|
+
return tokens;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseCondition(conditionStr) {
|
|
71
|
+
var tokens = tokenize(conditionStr);
|
|
72
|
+
var pos = 0;
|
|
73
|
+
function peek() { return tokens[pos] || null; }
|
|
74
|
+
function advance() { return tokens[pos++]; }
|
|
75
|
+
|
|
76
|
+
var path = peek();
|
|
77
|
+
if (!path || path.type !== 'PATH') throw new Error('Condition must start with a $-path, e.g. "$.field is active"');
|
|
78
|
+
advance();
|
|
79
|
+
|
|
80
|
+
var op;
|
|
81
|
+
var opToken = peek();
|
|
82
|
+
|
|
83
|
+
if (opToken && opToken.type === 'OP') {
|
|
84
|
+
op = advance().value;
|
|
85
|
+
} else if (opToken && opToken.type === 'KEYWORD' && opToken.value === 'is') {
|
|
86
|
+
advance();
|
|
87
|
+
var negated = false;
|
|
88
|
+
if (peek() && peek().type === 'KEYWORD' && peek().value === 'not') { advance(); negated = true; }
|
|
89
|
+
if (peek() && peek().type === 'KEYWORD' && peek().value === 'null') {
|
|
90
|
+
advance();
|
|
91
|
+
op = negated ? 'is_not_null' : 'is_null';
|
|
92
|
+
} else {
|
|
93
|
+
op = 'is';
|
|
94
|
+
if (negated) op = 'is_not';
|
|
95
|
+
}
|
|
96
|
+
} else if (opToken && opToken.type === 'KEYWORD' && opToken.value === 'not') {
|
|
97
|
+
advance();
|
|
98
|
+
if (peek() && peek().type === 'KEYWORD' && peek().value === 'in') { advance(); op = 'not_in'; }
|
|
99
|
+
else if (peek() && peek().type === 'KEYWORD' && (peek().value === 'contains' || peek().value === 'contain')) { advance(); op = 'not_contains'; }
|
|
100
|
+
else { throw new Error('Expected "in" or "contains" after "not"'); }
|
|
101
|
+
} else if (opToken && opToken.type === 'KEYWORD' && opToken.value === 'in') {
|
|
102
|
+
advance(); op = 'in';
|
|
103
|
+
} else if (opToken && opToken.type === 'KEYWORD' && opToken.value === 'starts') {
|
|
104
|
+
advance(); if (peek() && peek().value === 'with') advance(); op = 'starts_with';
|
|
105
|
+
} else if (opToken && opToken.type === 'KEYWORD' && opToken.value === 'ends') {
|
|
106
|
+
advance(); if (peek() && peek().value === 'with') advance(); op = 'ends_with';
|
|
107
|
+
} else if (opToken && opToken.type === 'KEYWORD' && opToken.value === 'contains') {
|
|
108
|
+
advance(); op = 'contains';
|
|
109
|
+
} else if (opToken && opToken.type === 'KEYWORD' && opToken.value === 'does') {
|
|
110
|
+
advance();
|
|
111
|
+
if (peek() && peek().value === 'not') advance();
|
|
112
|
+
if (peek() && (peek().value === 'contains' || peek().value === 'contain')) advance();
|
|
113
|
+
op = 'not_contains';
|
|
114
|
+
} else if (opToken && opToken.type === 'KEYWORD' && opToken.value === 'between') {
|
|
115
|
+
advance(); op = 'between';
|
|
116
|
+
} else {
|
|
117
|
+
throw new Error('Expected operator after path, got: ' + (opToken ? opToken.value : 'end of input'));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
var rhs = null;
|
|
121
|
+
var rhsTo = null;
|
|
122
|
+
if (op !== 'is_null' && op !== 'is_not_null') {
|
|
123
|
+
var rhsToken = peek();
|
|
124
|
+
if (rhsToken && (rhsToken.type === 'STRING' || rhsToken.type === 'NUMBER' || rhsToken.type === 'IDENTIFIER' || rhsToken.type === 'ARRAY')) {
|
|
125
|
+
rhs = advance().value;
|
|
126
|
+
}
|
|
127
|
+
if (op === 'between' && peek() && peek().type === 'KEYWORD' && peek().value === 'and') {
|
|
128
|
+
advance();
|
|
129
|
+
var toToken = peek();
|
|
130
|
+
if (toToken && (toToken.type === 'NUMBER' || toToken.type === 'STRING' || toToken.type === 'IDENTIFIER')) {
|
|
131
|
+
rhsTo = advance().value;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { lhs: path.value, op: op, rhs: rhs, rhsTo: rhsTo };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Condition evaluator ──
|
|
140
|
+
|
|
141
|
+
function toComparable(val) {
|
|
142
|
+
if (typeof val === 'number') return val;
|
|
143
|
+
if (typeof val === 'string' && !isNaN(val) && val !== '') return Number(val);
|
|
144
|
+
return val;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolvePath(path, data) {
|
|
148
|
+
var parts = path.replace(/^\$\.?/, '').split('.');
|
|
149
|
+
var current = data;
|
|
150
|
+
for (var i = 0; i < parts.length; i++) {
|
|
151
|
+
if (!parts[i]) continue;
|
|
152
|
+
if (current == null) return undefined;
|
|
153
|
+
current = current[parts[i]];
|
|
154
|
+
}
|
|
155
|
+
return current;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function evaluateCondition(when, data) {
|
|
159
|
+
var raw = resolvePath(when.lhs, data);
|
|
160
|
+
var value = toComparable(raw);
|
|
161
|
+
var rhs = toComparable(when.rhs);
|
|
162
|
+
|
|
163
|
+
switch (when.op) {
|
|
164
|
+
case 'is': return value === rhs;
|
|
165
|
+
case 'is_not': return value !== rhs;
|
|
166
|
+
case 'is_null': return raw == null || raw === '';
|
|
167
|
+
case 'is_not_null': return raw != null && raw !== '';
|
|
168
|
+
case '>': return value > rhs;
|
|
169
|
+
case '<': return value < rhs;
|
|
170
|
+
case '>=': return value >= rhs;
|
|
171
|
+
case '<=': return value <= rhs;
|
|
172
|
+
case 'between': {
|
|
173
|
+
var rhsTo = toComparable(when.rhsTo);
|
|
174
|
+
if (rhsTo == null) return value >= rhs;
|
|
175
|
+
return value >= rhs && value <= rhsTo;
|
|
176
|
+
}
|
|
177
|
+
case 'in': {
|
|
178
|
+
var list = Array.isArray(when.rhs) ? when.rhs : [when.rhs];
|
|
179
|
+
return list.some(function(item) { return toComparable(item) === value; });
|
|
180
|
+
}
|
|
181
|
+
case 'not_in': {
|
|
182
|
+
var list2 = Array.isArray(when.rhs) ? when.rhs : [when.rhs];
|
|
183
|
+
return !list2.some(function(item) { return toComparable(item) === value; });
|
|
184
|
+
}
|
|
185
|
+
case 'starts_with': return raw != null && String(raw).toLowerCase().startsWith(String(when.rhs).toLowerCase());
|
|
186
|
+
case 'ends_with': return raw != null && String(raw).toLowerCase().endsWith(String(when.rhs).toLowerCase());
|
|
187
|
+
case 'contains': return raw != null && String(raw).toLowerCase().includes(String(when.rhs).toLowerCase());
|
|
188
|
+
case 'not_contains': return raw != null && !String(raw).toLowerCase().includes(String(when.rhs).toLowerCase());
|
|
189
|
+
default: return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Parse cache (avoids re-parsing the same condition string) ──
|
|
194
|
+
var parseCache = new Map();
|
|
195
|
+
|
|
196
|
+
function getCachedCondition(when) {
|
|
197
|
+
var cached = parseCache.get(when);
|
|
198
|
+
if (cached) return cached;
|
|
199
|
+
var parsed = parseCondition(when);
|
|
200
|
+
parseCache.set(when, parsed);
|
|
201
|
+
return parsed;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Public API ──
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolve conditional formatting for a cell value.
|
|
208
|
+
*
|
|
209
|
+
* @param {*} value - The cell value
|
|
210
|
+
* @param {Array|Function} cellStyle - Array of { when, ...style } rules, or function(value, row) => style|null
|
|
211
|
+
* @param {string} accessor - The column accessor (used to build data context for $)
|
|
212
|
+
* @param {object} [row] - The full row object
|
|
213
|
+
* @returns {object|null} Inline style object or null
|
|
214
|
+
*/
|
|
215
|
+
export function resolveCellStyle(value, cellStyle, accessor, row) {
|
|
216
|
+
if (!cellStyle) return null;
|
|
217
|
+
|
|
218
|
+
// Function form — user handles everything
|
|
219
|
+
if (typeof cellStyle === 'function') {
|
|
220
|
+
return cellStyle(value, row) || null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!Array.isArray(cellStyle) || cellStyle.length === 0) return null;
|
|
224
|
+
|
|
225
|
+
// Build data context: row fields + accessor mapped to current value
|
|
226
|
+
var data = row ? Object.assign({}, row) : {};
|
|
227
|
+
data[accessor] = value;
|
|
228
|
+
|
|
229
|
+
for (var i = 0; i < cellStyle.length; i++) {
|
|
230
|
+
var rule = cellStyle[i];
|
|
231
|
+
if (!rule.when) continue;
|
|
232
|
+
|
|
233
|
+
var condition = getCachedCondition(rule.when);
|
|
234
|
+
if (evaluateCondition(condition, data)) {
|
|
235
|
+
return extractStyle(rule);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Extract style properties from a rule (everything except 'when'). */
|
|
243
|
+
function extractStyle(rule) {
|
|
244
|
+
var style = {};
|
|
245
|
+
var keys = Object.keys(rule);
|
|
246
|
+
for (var i = 0; i < keys.length; i++) {
|
|
247
|
+
if (keys[i] !== 'when') {
|
|
248
|
+
style[keys[i]] = rule[keys[i]];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return Object.keys(style).length > 0 ? style : null;
|
|
252
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo, useRef } from 'react';
|
|
2
|
+
import { buildChangeSet } from './buildChangeSet.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Controller for table CRUD actions with transactional queue.
|
|
6
|
+
* Supports N-level nesting via schema.
|
|
7
|
+
*
|
|
8
|
+
* Queue entries use a `path` to target any nesting level:
|
|
9
|
+
* path: ['employees'] → level 1
|
|
10
|
+
* path: ['employees', 'e6', 'salaries'] → level 2
|
|
11
|
+
*
|
|
12
|
+
* @param {object} options
|
|
13
|
+
* @param {Function} [options.onCommit] - async (changeSet[]) => void
|
|
14
|
+
* @param {object} options.schema - { 0: { key, columns }, 1: { key, columns }, ... }
|
|
15
|
+
* @param {Array} options.data - Original data
|
|
16
|
+
*/
|
|
17
|
+
export default function useActionsController(options) {
|
|
18
|
+
var onCommit = options.onCommit || null;
|
|
19
|
+
var schema = options.schema || {};
|
|
20
|
+
var data = options.data || [];
|
|
21
|
+
|
|
22
|
+
var hasActions = !!onCommit;
|
|
23
|
+
var hasSave = hasActions;
|
|
24
|
+
var hasDelete = hasActions;
|
|
25
|
+
|
|
26
|
+
// Derive child key from schema (level 1's key)
|
|
27
|
+
var childKey = schema[1] ? schema[1].key : null;
|
|
28
|
+
|
|
29
|
+
// ── Temp ID counter ──
|
|
30
|
+
var tempIdCounter = useRef(0);
|
|
31
|
+
function nextTempId() {
|
|
32
|
+
tempIdCounter.current++;
|
|
33
|
+
return '__new_' + tempIdCounter.current;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Queue ──
|
|
37
|
+
// Parent: { type: 'save', record } | { type: 'delete', ids }
|
|
38
|
+
// Nested: { type: 'nested-save', rootId, path, record }
|
|
39
|
+
// { type: 'nested-delete', rootId, path, ids }
|
|
40
|
+
var [queue, setQueue] = useState([]);
|
|
41
|
+
|
|
42
|
+
var pendingCount = queue.length;
|
|
43
|
+
var hasPending = pendingCount > 0;
|
|
44
|
+
|
|
45
|
+
// ── Staged data ──
|
|
46
|
+
var stagedData = useMemo(function() {
|
|
47
|
+
if (queue.length === 0) return data;
|
|
48
|
+
|
|
49
|
+
// Deep clone data
|
|
50
|
+
var map = new Map();
|
|
51
|
+
var order = [];
|
|
52
|
+
for (var i = 0; i < data.length; i++) {
|
|
53
|
+
map.set(data[i].id, deepClone(data[i]));
|
|
54
|
+
order.push(data[i].id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (var q = 0; q < queue.length; q++) {
|
|
58
|
+
var entry = queue[q];
|
|
59
|
+
|
|
60
|
+
if (entry.type === 'save') {
|
|
61
|
+
var rec = entry.record;
|
|
62
|
+
var id = rec._tempId || rec.id;
|
|
63
|
+
if (map.has(id)) {
|
|
64
|
+
// Preserve child arrays when updating parent fields
|
|
65
|
+
var existing = map.get(id);
|
|
66
|
+
var updated = Object.assign({}, rec);
|
|
67
|
+
copyChildArrays(existing, updated);
|
|
68
|
+
map.set(id, updated);
|
|
69
|
+
} else {
|
|
70
|
+
map.set(id, deepClone(rec));
|
|
71
|
+
order.push(id);
|
|
72
|
+
}
|
|
73
|
+
} else if (entry.type === 'delete') {
|
|
74
|
+
for (var d = 0; d < entry.ids.length; d++) {
|
|
75
|
+
map.delete(entry.ids[d]);
|
|
76
|
+
var idx = order.indexOf(entry.ids[d]);
|
|
77
|
+
if (idx !== -1) order.splice(idx, 1);
|
|
78
|
+
}
|
|
79
|
+
} else if (entry.type === 'nested-save') {
|
|
80
|
+
var root = map.get(entry.rootId);
|
|
81
|
+
if (!root) continue;
|
|
82
|
+
root = deepClone(root);
|
|
83
|
+
applyNestedSave(root, entry.path, entry.record);
|
|
84
|
+
map.set(entry.rootId, root);
|
|
85
|
+
} else if (entry.type === 'nested-delete') {
|
|
86
|
+
var root2 = map.get(entry.rootId);
|
|
87
|
+
if (!root2) continue;
|
|
88
|
+
root2 = deepClone(root2);
|
|
89
|
+
applyNestedDelete(root2, entry.path, entry.ids);
|
|
90
|
+
map.set(entry.rootId, root2);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
var result = [];
|
|
95
|
+
for (var o = 0; o < order.length; o++) {
|
|
96
|
+
var item = map.get(order[o]);
|
|
97
|
+
if (item) result.push(item);
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}, [data, queue]);
|
|
101
|
+
|
|
102
|
+
// ── Selection ──
|
|
103
|
+
var [selectedIds, setSelectedIds] = useState(new Set());
|
|
104
|
+
|
|
105
|
+
var toggleSelect = useCallback(function(id) {
|
|
106
|
+
setSelectedIds(function(prev) {
|
|
107
|
+
var next = new Set(prev);
|
|
108
|
+
if (next.has(id)) { next.delete(id); } else { next.add(id); }
|
|
109
|
+
return next;
|
|
110
|
+
});
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
var toggleSelectAll = useCallback(function(visibleIds) {
|
|
114
|
+
setSelectedIds(function(prev) {
|
|
115
|
+
var allSelected = visibleIds.length > 0 && visibleIds.every(function(id) { return prev.has(id); });
|
|
116
|
+
if (allSelected) return new Set();
|
|
117
|
+
return new Set(visibleIds);
|
|
118
|
+
});
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
var clearSelection = useCallback(function() {
|
|
122
|
+
setSelectedIds(new Set());
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
// ── Expanded rows (per level, keyed by row id) ──
|
|
126
|
+
var [expandedIds, setExpandedIds] = useState(new Set());
|
|
127
|
+
|
|
128
|
+
var toggleExpand = useCallback(function(id) {
|
|
129
|
+
setExpandedIds(function(prev) {
|
|
130
|
+
var next = new Set(prev);
|
|
131
|
+
if (next.has(id)) { next.delete(id); } else { next.add(id); }
|
|
132
|
+
return next;
|
|
133
|
+
});
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
// ── Detail popup (for childDisplay === 'popup') ──
|
|
137
|
+
var [detailRow, setDetailRow] = useState(null);
|
|
138
|
+
|
|
139
|
+
var openDetail = useCallback(function(row) {
|
|
140
|
+
setDetailRow(row);
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
var closeDetail = useCallback(function() {
|
|
144
|
+
setDetailRow(null);
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
// ── Modal ──
|
|
148
|
+
// context: { level, schemaLevel, rootId?, path? }
|
|
149
|
+
var [modal, setModal] = useState({ mode: null, record: null, context: null });
|
|
150
|
+
|
|
151
|
+
var openView = useCallback(function(row) {
|
|
152
|
+
setModal({ mode: 'view', record: Object.assign({}, row), context: { level: 'parent' } });
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
var openEdit = useCallback(function(row) {
|
|
156
|
+
setModal({ mode: 'edit', record: Object.assign({}, row), context: { level: 'parent' } });
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
var openCopy = useCallback(function(row) {
|
|
160
|
+
var copy = Object.assign({}, row);
|
|
161
|
+
copy.id = '';
|
|
162
|
+
delete copy._tempId;
|
|
163
|
+
setModal({ mode: 'add', record: copy, context: { level: 'parent' } });
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
var openAdd = useCallback(function() {
|
|
167
|
+
var empty = buildEmptyRecord(schema, 0, data);
|
|
168
|
+
setModal({ mode: 'add', record: empty, context: { level: 'parent' } });
|
|
169
|
+
}, [schema, data]);
|
|
170
|
+
|
|
171
|
+
// Nested modal openers
|
|
172
|
+
var openNestedView = useCallback(function(rootId, path, row) {
|
|
173
|
+
setModal({ mode: 'view', record: Object.assign({}, row), context: { level: 'nested', rootId: rootId, path: path } });
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
var openNestedEdit = useCallback(function(rootId, path, row) {
|
|
177
|
+
setModal({ mode: 'edit', record: Object.assign({}, row), context: { level: 'nested', rootId: rootId, path: path } });
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
var openNestedCopy = useCallback(function(rootId, path, row) {
|
|
181
|
+
var copy = Object.assign({}, row);
|
|
182
|
+
copy.id = '';
|
|
183
|
+
delete copy._tempId;
|
|
184
|
+
setModal({ mode: 'add', record: copy, context: { level: 'nested', rootId: rootId, path: path } });
|
|
185
|
+
}, []);
|
|
186
|
+
|
|
187
|
+
var openNestedAdd = useCallback(function(rootId, path, schemaLevel) {
|
|
188
|
+
var empty = buildEmptyRecord(schema, schemaLevel, findNestedArray(stagedData, rootId, path));
|
|
189
|
+
setModal({ mode: 'add', record: empty, context: { level: 'nested', rootId: rootId, path: path } });
|
|
190
|
+
}, [schema, stagedData]);
|
|
191
|
+
|
|
192
|
+
var closeModal = useCallback(function() {
|
|
193
|
+
setModal({ mode: null, record: null, context: null });
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
var updateField = useCallback(function(field, value) {
|
|
197
|
+
setModal(function(prev) {
|
|
198
|
+
if (!prev.record) return prev;
|
|
199
|
+
var updated = Object.assign({}, prev.record);
|
|
200
|
+
updated[field] = value;
|
|
201
|
+
return { mode: prev.mode, record: updated, context: prev.context };
|
|
202
|
+
});
|
|
203
|
+
}, []);
|
|
204
|
+
|
|
205
|
+
// ── Queue: save ──
|
|
206
|
+
var handleSave = useCallback(function() {
|
|
207
|
+
if (!modal.record || !modal.context) return;
|
|
208
|
+
var record = Object.assign({}, modal.record);
|
|
209
|
+
var ctx = modal.context;
|
|
210
|
+
|
|
211
|
+
if (!record.id && !record._tempId) {
|
|
212
|
+
record._tempId = nextTempId();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (ctx.level === 'parent') {
|
|
216
|
+
setQueue(function(prev) { return prev.concat({ type: 'save', record: record }); });
|
|
217
|
+
} else if (ctx.level === 'nested') {
|
|
218
|
+
setQueue(function(prev) {
|
|
219
|
+
return prev.concat({
|
|
220
|
+
type: 'nested-save',
|
|
221
|
+
rootId: ctx.rootId,
|
|
222
|
+
path: ctx.path,
|
|
223
|
+
record: record
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
closeModal();
|
|
228
|
+
}, [modal.record, modal.context, closeModal]);
|
|
229
|
+
|
|
230
|
+
// ── Queue: parent delete ──
|
|
231
|
+
var handleDeleteRow = useCallback(function(id) {
|
|
232
|
+
setQueue(function(prev) { return prev.concat({ type: 'delete', ids: [id] }); });
|
|
233
|
+
setSelectedIds(function(prev) {
|
|
234
|
+
if (!prev.has(id)) return prev;
|
|
235
|
+
var next = new Set(prev);
|
|
236
|
+
next.delete(id);
|
|
237
|
+
return next;
|
|
238
|
+
});
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
241
|
+
var handleDeleteSelected = useCallback(function() {
|
|
242
|
+
if (selectedIds.size === 0) return;
|
|
243
|
+
var ids = Array.from(selectedIds);
|
|
244
|
+
setQueue(function(prev) { return prev.concat({ type: 'delete', ids: ids }); });
|
|
245
|
+
clearSelection();
|
|
246
|
+
}, [selectedIds, clearSelection]);
|
|
247
|
+
|
|
248
|
+
// ── Queue: nested delete ──
|
|
249
|
+
var handleNestedDelete = useCallback(function(rootId, path, childId) {
|
|
250
|
+
setQueue(function(prev) {
|
|
251
|
+
return prev.concat({
|
|
252
|
+
type: 'nested-delete',
|
|
253
|
+
rootId: rootId,
|
|
254
|
+
path: path,
|
|
255
|
+
ids: [childId]
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}, []);
|
|
259
|
+
|
|
260
|
+
// ── Commit ──
|
|
261
|
+
var [committing, setCommitting] = useState(false);
|
|
262
|
+
|
|
263
|
+
var handleCommit = useCallback(function() {
|
|
264
|
+
if (queue.length === 0 || !onCommit) return;
|
|
265
|
+
|
|
266
|
+
var changeSet = buildChangeSet(data, stagedData, schema, 0);
|
|
267
|
+
if (changeSet.length === 0) {
|
|
268
|
+
setQueue([]);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
setCommitting(true);
|
|
273
|
+
Promise.resolve(onCommit(changeSet)).then(function() {
|
|
274
|
+
setCommitting(false);
|
|
275
|
+
setQueue([]);
|
|
276
|
+
clearSelection();
|
|
277
|
+
}).catch(function() {
|
|
278
|
+
setCommitting(false);
|
|
279
|
+
});
|
|
280
|
+
}, [queue, data, stagedData, schema, onCommit, clearSelection]);
|
|
281
|
+
|
|
282
|
+
// ── Discard ──
|
|
283
|
+
var handleDiscard = useCallback(function() {
|
|
284
|
+
setQueue([]);
|
|
285
|
+
clearSelection();
|
|
286
|
+
setExpandedIds(new Set());
|
|
287
|
+
}, [clearSelection]);
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
hasActions: hasActions, hasSave: hasSave, hasDelete: hasDelete,
|
|
291
|
+
queue: queue, pendingCount: pendingCount, hasPending: hasPending,
|
|
292
|
+
stagedData: stagedData,
|
|
293
|
+
selectedIds: selectedIds, toggleSelect: toggleSelect, toggleSelectAll: toggleSelectAll, clearSelection: clearSelection,
|
|
294
|
+
expandedIds: expandedIds, toggleExpand: toggleExpand,
|
|
295
|
+
detailRow: detailRow, openDetail: openDetail, closeDetail: closeDetail,
|
|
296
|
+
modal: modal,
|
|
297
|
+
openView: openView, openEdit: openEdit, openCopy: openCopy, openAdd: openAdd,
|
|
298
|
+
openNestedView: openNestedView, openNestedEdit: openNestedEdit,
|
|
299
|
+
openNestedCopy: openNestedCopy, openNestedAdd: openNestedAdd,
|
|
300
|
+
closeModal: closeModal, updateField: updateField,
|
|
301
|
+
handleSave: handleSave,
|
|
302
|
+
handleDeleteRow: handleDeleteRow, handleDeleteSelected: handleDeleteSelected,
|
|
303
|
+
handleNestedDelete: handleNestedDelete,
|
|
304
|
+
handleCommit: handleCommit, handleDiscard: handleDiscard, committing: committing,
|
|
305
|
+
schema: schema
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Helpers ──
|
|
310
|
+
|
|
311
|
+
function deepClone(obj) {
|
|
312
|
+
if (obj === null || typeof obj !== 'object') return obj;
|
|
313
|
+
if (Array.isArray(obj)) return obj.map(deepClone);
|
|
314
|
+
var clone = {};
|
|
315
|
+
var keys = Object.keys(obj);
|
|
316
|
+
for (var i = 0; i < keys.length; i++) {
|
|
317
|
+
clone[keys[i]] = deepClone(obj[keys[i]]);
|
|
318
|
+
}
|
|
319
|
+
return clone;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function copyChildArrays(from, to) {
|
|
323
|
+
var keys = Object.keys(from);
|
|
324
|
+
for (var i = 0; i < keys.length; i++) {
|
|
325
|
+
if (Array.isArray(from[keys[i]]) && !to[keys[i]]) {
|
|
326
|
+
to[keys[i]] = from[keys[i]];
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Navigate path and apply a save to the target array.
|
|
333
|
+
* path: ['employees'] → obj.employees
|
|
334
|
+
* path: ['employees', 'e6', 'salaries'] → obj.employees[e6].salaries
|
|
335
|
+
*/
|
|
336
|
+
function applyNestedSave(obj, path, record) {
|
|
337
|
+
var target = navigatePath(obj, path);
|
|
338
|
+
if (!target) return;
|
|
339
|
+
|
|
340
|
+
var childId = record._tempId || record.id;
|
|
341
|
+
var found = false;
|
|
342
|
+
for (var i = 0; i < target.length; i++) {
|
|
343
|
+
var existingId = target[i]._tempId || target[i].id;
|
|
344
|
+
if (existingId === childId) {
|
|
345
|
+
// Preserve deeper child arrays
|
|
346
|
+
var updated = Object.assign({}, record);
|
|
347
|
+
copyChildArrays(target[i], updated);
|
|
348
|
+
target[i] = updated;
|
|
349
|
+
found = true;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (!found) {
|
|
354
|
+
target.push(Object.assign({}, record));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function applyNestedDelete(obj, path, ids) {
|
|
359
|
+
var arr = navigatePath(obj, path);
|
|
360
|
+
if (!arr) return;
|
|
361
|
+
|
|
362
|
+
// Find the parent object that holds this array, and the key
|
|
363
|
+
var parentRef = navigateToParent(obj, path);
|
|
364
|
+
if (!parentRef) return;
|
|
365
|
+
|
|
366
|
+
parentRef.obj[parentRef.key] = arr.filter(function(child) {
|
|
367
|
+
var cid = child._tempId || child.id;
|
|
368
|
+
return ids.indexOf(cid) === -1;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Navigate a path to get the target array.
|
|
374
|
+
* path: ['employees'] → obj.employees
|
|
375
|
+
* path: ['employees', 'e6', 'salaries'] → find e6 in obj.employees, return e6.salaries
|
|
376
|
+
*/
|
|
377
|
+
function navigatePath(obj, path) {
|
|
378
|
+
var current = obj;
|
|
379
|
+
for (var i = 0; i < path.length; i++) {
|
|
380
|
+
if (i % 2 === 0) {
|
|
381
|
+
// Even index = field name
|
|
382
|
+
current = current[path[i]];
|
|
383
|
+
if (!current) return null;
|
|
384
|
+
} else {
|
|
385
|
+
// Odd index = record id within the array
|
|
386
|
+
var id = path[i];
|
|
387
|
+
var found = null;
|
|
388
|
+
for (var j = 0; j < current.length; j++) {
|
|
389
|
+
if ((current[j]._tempId || current[j].id) === id) {
|
|
390
|
+
found = current[j];
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (!found) return null;
|
|
395
|
+
current = found;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return current;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function navigateToParent(obj, path) {
|
|
402
|
+
if (path.length === 1) {
|
|
403
|
+
return { obj: obj, key: path[0] };
|
|
404
|
+
}
|
|
405
|
+
// Navigate to the parent of the final array
|
|
406
|
+
var parentPath = path.slice(0, path.length - 1);
|
|
407
|
+
var parent = navigatePath(obj, parentPath);
|
|
408
|
+
if (!parent) return null;
|
|
409
|
+
return { obj: parent, key: path[path.length - 1] };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Build an empty record for add form.
|
|
414
|
+
* Derives fields from existing data (all keys from first record) or schema columns.
|
|
415
|
+
*/
|
|
416
|
+
function buildEmptyRecord(schema, schemaLevel, existingRecords) {
|
|
417
|
+
var empty = { id: '' };
|
|
418
|
+
var schemaDef = schema[schemaLevel];
|
|
419
|
+
|
|
420
|
+
// Try to get keys from existing records
|
|
421
|
+
if (existingRecords && existingRecords.length > 0) {
|
|
422
|
+
var keys = Object.keys(existingRecords[0]);
|
|
423
|
+
for (var i = 0; i < keys.length; i++) {
|
|
424
|
+
var k = keys[i];
|
|
425
|
+
if (k === '_tempId') continue;
|
|
426
|
+
if (Array.isArray(existingRecords[0][k])) {
|
|
427
|
+
empty[k] = [];
|
|
428
|
+
} else {
|
|
429
|
+
empty[k] = '';
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} else if (schemaDef && schemaDef.columns) {
|
|
433
|
+
for (var i = 0; i < schemaDef.columns.length; i++) {
|
|
434
|
+
empty[schemaDef.columns[i].accessor] = '';
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
empty.id = '';
|
|
439
|
+
return empty;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Find the nested array for a given rootId and path in stagedData.
|
|
444
|
+
*/
|
|
445
|
+
function findNestedArray(stagedData, rootId, path) {
|
|
446
|
+
for (var i = 0; i < stagedData.length; i++) {
|
|
447
|
+
var rid = stagedData[i]._tempId || stagedData[i].id;
|
|
448
|
+
if (rid === rootId) {
|
|
449
|
+
return navigatePath(stagedData[i], path) || [];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return [];
|
|
453
|
+
}
|