@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.
@@ -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
+ }