@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,213 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import ChildTable from './ChildTable.jsx';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detail popup — shows parent record fields at top, child tables below.
|
|
6
|
+
* Opened on double-click (or view icon) when childDisplay === 'popup'.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} props
|
|
9
|
+
* @param {object} props.record - The parent row (with child arrays)
|
|
10
|
+
* @param {string} props.rootId - Row id (or _tempId)
|
|
11
|
+
* @param {object} props.schema - Full schema
|
|
12
|
+
* @param {Array} props.schemaColumns - Level 0 columns for display hints
|
|
13
|
+
* @param {boolean} props.hasSave - Whether save is available
|
|
14
|
+
* @param {object} props.actions - Actions controller
|
|
15
|
+
* @param {Function} props.onClose - Close handler
|
|
16
|
+
*/
|
|
17
|
+
export default function RecordDetail(props) {
|
|
18
|
+
var record = props.record;
|
|
19
|
+
var rootId = props.rootId;
|
|
20
|
+
var schema = props.schema;
|
|
21
|
+
var schemaColumns = props.schemaColumns || [];
|
|
22
|
+
var hasSave = props.hasSave;
|
|
23
|
+
var actions = props.actions;
|
|
24
|
+
var onClose = props.onClose;
|
|
25
|
+
|
|
26
|
+
var [editing, setEditing] = useState(false);
|
|
27
|
+
var [editRecord, setEditRecord] = useState(null);
|
|
28
|
+
|
|
29
|
+
// Build column lookup for display hints
|
|
30
|
+
var columnMap = {};
|
|
31
|
+
for (var i = 0; i < schemaColumns.length; i++) {
|
|
32
|
+
columnMap[schemaColumns[i].accessor] = schemaColumns[i];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Collect child keys from schema
|
|
36
|
+
var childKeys = [];
|
|
37
|
+
var level = 1;
|
|
38
|
+
while (schema[level]) {
|
|
39
|
+
if (level === 1) childKeys.push(schema[level].key);
|
|
40
|
+
level++;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Derive display fields from record (exclude arrays and internal fields)
|
|
44
|
+
var fields = [];
|
|
45
|
+
if (record) {
|
|
46
|
+
var keys = Object.keys(record);
|
|
47
|
+
for (var i = 0; i < keys.length; i++) {
|
|
48
|
+
var k = keys[i];
|
|
49
|
+
if (k === '_tempId') continue;
|
|
50
|
+
if (Array.isArray(record[k])) continue;
|
|
51
|
+
var col = columnMap[k];
|
|
52
|
+
fields.push({
|
|
53
|
+
accessor: k,
|
|
54
|
+
header: col ? (col.header || k) : k,
|
|
55
|
+
dataType: col ? (col.dataType || 'string') : guessType(record[k])
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function guessType(value) {
|
|
61
|
+
if (typeof value === 'number') return 'number';
|
|
62
|
+
if (typeof value === 'boolean') return 'boolean';
|
|
63
|
+
return 'string';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getInputType(dataType) {
|
|
67
|
+
switch (dataType) {
|
|
68
|
+
case 'number': return 'number';
|
|
69
|
+
case 'date': return 'date';
|
|
70
|
+
case 'boolean': return 'checkbox';
|
|
71
|
+
default: return 'text';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function handleStartEdit() {
|
|
76
|
+
setEditing(true);
|
|
77
|
+
setEditRecord(Object.assign({}, record));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function handleFieldChange(field, value) {
|
|
81
|
+
setEditRecord(function(prev) {
|
|
82
|
+
var updated = Object.assign({}, prev);
|
|
83
|
+
updated[field] = value;
|
|
84
|
+
return updated;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function handleSaveParent() {
|
|
89
|
+
if (!editRecord) return;
|
|
90
|
+
// Queue the parent edit
|
|
91
|
+
var clean = Object.assign({}, editRecord);
|
|
92
|
+
// Remove child arrays — parent save is own fields only
|
|
93
|
+
for (var i = 0; i < childKeys.length; i++) {
|
|
94
|
+
delete clean[childKeys[i]];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
actions.modal = { mode: 'edit', record: clean, context: { level: 'parent' } };
|
|
98
|
+
// Directly queue via handleSave logic
|
|
99
|
+
if (!clean.id && !clean._tempId) {
|
|
100
|
+
clean._tempId = '__inline_';
|
|
101
|
+
}
|
|
102
|
+
// Push to queue manually
|
|
103
|
+
actions.handleSave.__queueDirect
|
|
104
|
+
? actions.handleSave.__queueDirect(clean)
|
|
105
|
+
: null;
|
|
106
|
+
|
|
107
|
+
// Simpler: open the edit modal which handles queueing
|
|
108
|
+
setEditing(false);
|
|
109
|
+
setEditRecord(null);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Actually, simpler approach: use the existing modal flow for parent edits
|
|
113
|
+
function handleEditViaModal() {
|
|
114
|
+
actions.openEdit(record);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handleCancelEdit() {
|
|
118
|
+
setEditing(false);
|
|
119
|
+
setEditRecord(null);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
var displayRecord = editing ? editRecord : record;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="xeplr-table-modal-overlay" onClick={onClose}>
|
|
126
|
+
<div className="xeplr-table-detail" onClick={function(e) { e.stopPropagation(); }}>
|
|
127
|
+
<div className="xeplr-table-modal-header">
|
|
128
|
+
<span className="xeplr-table-modal-title">Record Detail</span>
|
|
129
|
+
<button type="button" className="xeplr-table-modal-close" onClick={onClose}>
|
|
130
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
131
|
+
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
132
|
+
</svg>
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="xeplr-table-detail-body">
|
|
137
|
+
{/* ── Parent fields ── */}
|
|
138
|
+
<div className="xeplr-table-detail-fields">
|
|
139
|
+
{fields.map(function(field) {
|
|
140
|
+
var value = displayRecord[field.accessor];
|
|
141
|
+
var inputType = getInputType(field.dataType);
|
|
142
|
+
var isId = field.accessor === 'id';
|
|
143
|
+
var disabled = !editing || isId;
|
|
144
|
+
|
|
145
|
+
if (inputType === 'checkbox') {
|
|
146
|
+
return (
|
|
147
|
+
<div key={field.accessor} className="xeplr-table-detail-field">
|
|
148
|
+
<span className="xeplr-table-detail-label">{field.header}</span>
|
|
149
|
+
<input
|
|
150
|
+
type="checkbox"
|
|
151
|
+
className="xeplr-table-modal-checkbox"
|
|
152
|
+
checked={!!value}
|
|
153
|
+
disabled={disabled}
|
|
154
|
+
onChange={function(e) { handleFieldChange(field.accessor, e.target.checked); }}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div key={field.accessor} className="xeplr-table-detail-field">
|
|
162
|
+
<span className="xeplr-table-detail-label">
|
|
163
|
+
{field.header}
|
|
164
|
+
{isId && <span className="xeplr-table-modal-id-badge">ID</span>}
|
|
165
|
+
</span>
|
|
166
|
+
{editing ? (
|
|
167
|
+
<input
|
|
168
|
+
type={inputType}
|
|
169
|
+
className="xeplr-table-modal-input"
|
|
170
|
+
value={value != null ? value : ''}
|
|
171
|
+
disabled={disabled}
|
|
172
|
+
onChange={function(e) { handleFieldChange(field.accessor, e.target.value); }}
|
|
173
|
+
/>
|
|
174
|
+
) : (
|
|
175
|
+
<span className="xeplr-table-detail-value">{value != null ? String(value) : '—'}</span>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
})}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{/* ── Parent actions ── */}
|
|
183
|
+
<div className="xeplr-table-detail-actions">
|
|
184
|
+
{!editing && hasSave && (
|
|
185
|
+
<button type="button" className="xeplr-table-modal-btn xeplr-table-modal-btn-primary" onClick={handleEditViaModal}>
|
|
186
|
+
Edit
|
|
187
|
+
</button>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{/* ── Child tables ── */}
|
|
192
|
+
{childKeys.map(function(ck) {
|
|
193
|
+
var nextLevel = schema[1];
|
|
194
|
+
if (!nextLevel || nextLevel.key !== ck) return null;
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div key={ck} className="xeplr-table-detail-children">
|
|
198
|
+
<ChildTable
|
|
199
|
+
rootId={rootId}
|
|
200
|
+
path={[ck]}
|
|
201
|
+
schemaLevel={1}
|
|
202
|
+
schema={schema}
|
|
203
|
+
rows={record[ck] || []}
|
|
204
|
+
actions={actions}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Modal overlay for viewing, editing, or adding a record.
|
|
5
|
+
*
|
|
6
|
+
* Form fields are auto-generated from ALL keys in the record.
|
|
7
|
+
* Schema columns provide display hints (header, dataType) where available.
|
|
8
|
+
* Fields not in schema columns still appear — with the key name as header.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} props
|
|
11
|
+
* @param {string} props.mode - 'view' | 'edit' | 'add'
|
|
12
|
+
* @param {object} props.record - Current form data
|
|
13
|
+
* @param {Array} [props.schemaColumns] - Schema column configs for display hints
|
|
14
|
+
* @param {boolean} props.hasSave - Whether save action is available
|
|
15
|
+
* @param {Function} props.onFieldChange - (accessor, value) => void
|
|
16
|
+
* @param {Function} props.onSave - Save handler
|
|
17
|
+
* @param {Function} props.onEdit - Switch to edit mode
|
|
18
|
+
* @param {Function} props.onClose - Close modal
|
|
19
|
+
*/
|
|
20
|
+
export default function RecordModal(props) {
|
|
21
|
+
var mode = props.mode;
|
|
22
|
+
var record = props.record;
|
|
23
|
+
var schemaColumns = props.schemaColumns || [];
|
|
24
|
+
var hasSave = props.hasSave;
|
|
25
|
+
var onFieldChange = props.onFieldChange;
|
|
26
|
+
var onSave = props.onSave;
|
|
27
|
+
var onEdit = props.onEdit;
|
|
28
|
+
var onClose = props.onClose;
|
|
29
|
+
|
|
30
|
+
var isReadOnly = mode === 'view';
|
|
31
|
+
var title = mode === 'view' ? 'View Record' : mode === 'edit' ? 'Edit Record' : 'Add Record';
|
|
32
|
+
|
|
33
|
+
// Build column lookup for display hints
|
|
34
|
+
var columnMap = {};
|
|
35
|
+
for (var i = 0; i < schemaColumns.length; i++) {
|
|
36
|
+
columnMap[schemaColumns[i].accessor] = schemaColumns[i];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Derive form fields from ALL keys in the record
|
|
40
|
+
var fields = [];
|
|
41
|
+
if (record) {
|
|
42
|
+
var keys = Object.keys(record);
|
|
43
|
+
for (var i = 0; i < keys.length; i++) {
|
|
44
|
+
var k = keys[i];
|
|
45
|
+
if (k === '_tempId') continue;
|
|
46
|
+
// Skip array fields (those are child tables, not form fields)
|
|
47
|
+
if (Array.isArray(record[k])) continue;
|
|
48
|
+
|
|
49
|
+
var col = columnMap[k];
|
|
50
|
+
fields.push({
|
|
51
|
+
accessor: k,
|
|
52
|
+
header: col ? (col.header || k) : k,
|
|
53
|
+
dataType: col ? (col.dataType || 'string') : guessType(record[k])
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function guessType(value) {
|
|
59
|
+
if (typeof value === 'number') return 'number';
|
|
60
|
+
if (typeof value === 'boolean') return 'boolean';
|
|
61
|
+
return 'string';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getInputType(dataType) {
|
|
65
|
+
switch (dataType) {
|
|
66
|
+
case 'number': return 'number';
|
|
67
|
+
case 'date': return 'date';
|
|
68
|
+
case 'boolean': return 'checkbox';
|
|
69
|
+
default: return 'text';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function handleSubmit(e) {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
onSave();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="xeplr-table-modal-overlay" onClick={onClose}>
|
|
80
|
+
<div className="xeplr-table-modal" onClick={function(e) { e.stopPropagation(); }}>
|
|
81
|
+
<div className="xeplr-table-modal-header">
|
|
82
|
+
<span className="xeplr-table-modal-title">{title}</span>
|
|
83
|
+
<button type="button" className="xeplr-table-modal-close" onClick={onClose}>
|
|
84
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
85
|
+
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
86
|
+
</svg>
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<form className="xeplr-table-modal-body" onSubmit={handleSubmit}>
|
|
91
|
+
{fields.map(function(field) {
|
|
92
|
+
var value = record[field.accessor];
|
|
93
|
+
var inputType = getInputType(field.dataType);
|
|
94
|
+
var isId = field.accessor === 'id';
|
|
95
|
+
var disabled = isReadOnly || isId;
|
|
96
|
+
|
|
97
|
+
if (inputType === 'checkbox') {
|
|
98
|
+
return (
|
|
99
|
+
<div key={field.accessor} className="xeplr-table-modal-field">
|
|
100
|
+
<label className="xeplr-table-modal-label">{field.header}</label>
|
|
101
|
+
<input
|
|
102
|
+
type="checkbox"
|
|
103
|
+
className="xeplr-table-modal-checkbox"
|
|
104
|
+
checked={!!value}
|
|
105
|
+
disabled={disabled}
|
|
106
|
+
onChange={function(e) { onFieldChange(field.accessor, e.target.checked); }}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div key={field.accessor} className="xeplr-table-modal-field">
|
|
114
|
+
<label className="xeplr-table-modal-label">
|
|
115
|
+
{field.header}
|
|
116
|
+
{isId && <span className="xeplr-table-modal-id-badge">ID</span>}
|
|
117
|
+
</label>
|
|
118
|
+
<input
|
|
119
|
+
type={inputType}
|
|
120
|
+
className="xeplr-table-modal-input"
|
|
121
|
+
value={value != null ? value : ''}
|
|
122
|
+
disabled={disabled}
|
|
123
|
+
onChange={function(e) { onFieldChange(field.accessor, e.target.value); }}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
128
|
+
|
|
129
|
+
<div className="xeplr-table-modal-footer">
|
|
130
|
+
{isReadOnly && hasSave && (
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
className="xeplr-table-modal-btn xeplr-table-modal-btn-primary"
|
|
134
|
+
onClick={function() { onEdit(record); }}
|
|
135
|
+
>
|
|
136
|
+
Edit
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
{!isReadOnly && (
|
|
140
|
+
<button
|
|
141
|
+
type="submit"
|
|
142
|
+
className="xeplr-table-modal-btn xeplr-table-modal-btn-primary"
|
|
143
|
+
>
|
|
144
|
+
Save
|
|
145
|
+
</button>
|
|
146
|
+
)}
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
className="xeplr-table-modal-btn"
|
|
150
|
+
onClick={onClose}
|
|
151
|
+
>
|
|
152
|
+
{isReadOnly ? 'Close' : 'Cancel'}
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
</form>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a sparse deep diff between original and staged data.
|
|
3
|
+
* Recursive — supports N levels of nesting via schema.
|
|
4
|
+
*
|
|
5
|
+
* Rules:
|
|
6
|
+
* - No id → new record (include all fields)
|
|
7
|
+
* - Has id, changed → include id + only changed fields
|
|
8
|
+
* - Has id, deleted → { id, deleted: true }
|
|
9
|
+
* - Unchanged → omitted entirely
|
|
10
|
+
*
|
|
11
|
+
* @param {Array} originalData - Original data
|
|
12
|
+
* @param {Array} stagedData - Staged data after queue applied
|
|
13
|
+
* @param {object} schema - { 0: { key, columns }, 1: { key, columns }, ... }
|
|
14
|
+
* @param {number} [level] - Current nesting level (default: 0)
|
|
15
|
+
* @returns {Array} Sparse changeset
|
|
16
|
+
*/
|
|
17
|
+
export function buildChangeSet(originalData, stagedData, schema, level) {
|
|
18
|
+
level = level || 0;
|
|
19
|
+
|
|
20
|
+
// Determine child key from next level in schema
|
|
21
|
+
var nextLevel = schema[level + 1];
|
|
22
|
+
var childKey = nextLevel ? nextLevel.key : null;
|
|
23
|
+
|
|
24
|
+
// Collect all child keys at deeper levels rooted under this level
|
|
25
|
+
var childKeys = [];
|
|
26
|
+
var l = level + 1;
|
|
27
|
+
while (schema[l]) {
|
|
28
|
+
if (l === level + 1) childKeys.push(schema[l].key);
|
|
29
|
+
l++;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
var originalMap = new Map();
|
|
33
|
+
for (var i = 0; i < originalData.length; i++) {
|
|
34
|
+
originalMap.set(originalData[i].id, originalData[i]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
var stagedMap = new Map();
|
|
38
|
+
for (var i = 0; i < stagedData.length; i++) {
|
|
39
|
+
var row = stagedData[i];
|
|
40
|
+
var key = row._tempId || row.id;
|
|
41
|
+
stagedMap.set(key, row);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var changes = [];
|
|
45
|
+
|
|
46
|
+
// ── New and modified records ──
|
|
47
|
+
stagedMap.forEach(function(staged) {
|
|
48
|
+
var isNew = !!staged._tempId || !staged.id;
|
|
49
|
+
|
|
50
|
+
if (isNew) {
|
|
51
|
+
changes.push(buildNewRecord(staged, childKey));
|
|
52
|
+
} else {
|
|
53
|
+
var original = originalMap.get(staged.id);
|
|
54
|
+
if (!original) return;
|
|
55
|
+
|
|
56
|
+
var fieldDiff = diffFields(original, staged, childKeys);
|
|
57
|
+
var childDiffResult = null;
|
|
58
|
+
|
|
59
|
+
if (childKey) {
|
|
60
|
+
var origChildren = original[childKey] || [];
|
|
61
|
+
var stagedChildren = staged[childKey] || [];
|
|
62
|
+
var childChanges = buildChangeSet(origChildren, stagedChildren, schema, level + 1);
|
|
63
|
+
if (childChanges.length > 0) {
|
|
64
|
+
childDiffResult = childChanges;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (fieldDiff || childDiffResult) {
|
|
69
|
+
var entry = fieldDiff ? Object.assign({ id: staged.id }, fieldDiff) : { id: staged.id };
|
|
70
|
+
if (childDiffResult) {
|
|
71
|
+
entry[childKey] = childDiffResult;
|
|
72
|
+
}
|
|
73
|
+
changes.push(entry);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── Deleted records ──
|
|
79
|
+
originalMap.forEach(function(original, id) {
|
|
80
|
+
if (!stagedMap.has(id)) {
|
|
81
|
+
changes.push({ id: id, deleted: true });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return changes;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build a new record for the changeset (strip internal fields).
|
|
90
|
+
* Recursively cleans child arrays.
|
|
91
|
+
*/
|
|
92
|
+
function buildNewRecord(staged, childKey) {
|
|
93
|
+
var rec = {};
|
|
94
|
+
var keys = Object.keys(staged);
|
|
95
|
+
for (var i = 0; i < keys.length; i++) {
|
|
96
|
+
var k = keys[i];
|
|
97
|
+
if (k === '_tempId') continue;
|
|
98
|
+
|
|
99
|
+
if (k === childKey && Array.isArray(staged[k])) {
|
|
100
|
+
rec[k] = staged[k].map(function(child) {
|
|
101
|
+
return buildNewRecord(child, null);
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
rec[k] = staged[k];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return rec;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Diff own fields (excluding id, _tempId, and child array keys).
|
|
112
|
+
*/
|
|
113
|
+
function diffFields(original, staged, childKeys) {
|
|
114
|
+
var diff = null;
|
|
115
|
+
var keys = Object.keys(staged);
|
|
116
|
+
for (var i = 0; i < keys.length; i++) {
|
|
117
|
+
var k = keys[i];
|
|
118
|
+
if (k === 'id' || k === '_tempId') continue;
|
|
119
|
+
if (childKeys.indexOf(k) !== -1) continue;
|
|
120
|
+
if (staged[k] !== original[k]) {
|
|
121
|
+
if (!diff) diff = {};
|
|
122
|
+
diff[k] = staged[k];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return diff;
|
|
126
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const TYPES = {
|
|
2
|
+
STRING: 'string',
|
|
3
|
+
NUMBER: 'number',
|
|
4
|
+
DATE: 'date',
|
|
5
|
+
BOOLEAN: 'boolean'
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const BOOLEAN_VALUES = new Set([true, false, 0, 1, '0', '1', 'true', 'false', 'True', 'False', 'TRUE', 'FALSE']);
|
|
9
|
+
|
|
10
|
+
const MAX_SAMPLE_ROWS = 100;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Detect column data types from row data.
|
|
14
|
+
*
|
|
15
|
+
* @param {Array<object>} data - Row array
|
|
16
|
+
* @param {Array<object>} columns - Column defs: [{ accessor, dataType? }]
|
|
17
|
+
* @param {number} [minRows] - Minimum non-null samples needed for a confident detection
|
|
18
|
+
* @returns {{ types: Map<string, string>, confident: boolean }}
|
|
19
|
+
* - types: Map of accessor → 'string' | 'number' | 'date' | 'boolean'
|
|
20
|
+
* - confident: true if every column had enough samples (≥ minRows)
|
|
21
|
+
*/
|
|
22
|
+
export function detectTypes(data, columns, minRows = 10) {
|
|
23
|
+
var types = new Map();
|
|
24
|
+
var confident = true;
|
|
25
|
+
var sampleRows = data.slice(0, MAX_SAMPLE_ROWS);
|
|
26
|
+
|
|
27
|
+
for (var i = 0; i < columns.length; i++) {
|
|
28
|
+
var col = columns[i];
|
|
29
|
+
var accessor = col.accessor;
|
|
30
|
+
|
|
31
|
+
// Explicit dataType — always trust it
|
|
32
|
+
if (col.dataType) {
|
|
33
|
+
types.set(accessor, col.dataType);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Collect non-null, non-undefined, non-empty-string values
|
|
38
|
+
var values = [];
|
|
39
|
+
for (var r = 0; r < sampleRows.length; r++) {
|
|
40
|
+
var val = sampleRows[r][accessor];
|
|
41
|
+
if (val !== null && val !== undefined && val !== '') {
|
|
42
|
+
values.push(val);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Not enough data — mark as inconclusive, fallback to string
|
|
47
|
+
if (values.length < minRows) {
|
|
48
|
+
confident = false;
|
|
49
|
+
types.set(accessor, values.length === 0 ? TYPES.STRING : detectSingle(values));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
types.set(accessor, detectSingle(values));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { types, confident };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Detect the type of a single column from its sampled values.
|
|
61
|
+
* Order matters: boolean → number → date → string
|
|
62
|
+
*/
|
|
63
|
+
function detectSingle(values) {
|
|
64
|
+
// Boolean check — all values must be boolean-ish
|
|
65
|
+
var allBoolean = true;
|
|
66
|
+
for (var i = 0; i < values.length; i++) {
|
|
67
|
+
if (!BOOLEAN_VALUES.has(values[i])) {
|
|
68
|
+
allBoolean = false;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (allBoolean) return TYPES.BOOLEAN;
|
|
73
|
+
|
|
74
|
+
// Number check — all values must be numeric
|
|
75
|
+
var allNumber = true;
|
|
76
|
+
for (var i = 0; i < values.length; i++) {
|
|
77
|
+
if (typeof values[i] === 'boolean') { allNumber = false; break; }
|
|
78
|
+
var num = Number(values[i]);
|
|
79
|
+
if (isNaN(num)) { allNumber = false; break; }
|
|
80
|
+
}
|
|
81
|
+
if (allNumber) return TYPES.NUMBER;
|
|
82
|
+
|
|
83
|
+
// Date check — all values must parse as dates AND not be plain numbers
|
|
84
|
+
var allDate = true;
|
|
85
|
+
for (var i = 0; i < values.length; i++) {
|
|
86
|
+
var v = values[i];
|
|
87
|
+
// Skip plain numbers — they parse as dates but aren't
|
|
88
|
+
if (typeof v === 'number' || (typeof v === 'string' && v.trim() !== '' && !isNaN(Number(v)))) {
|
|
89
|
+
allDate = false;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (isNaN(Date.parse(v))) {
|
|
93
|
+
allDate = false;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (allDate) return TYPES.DATE;
|
|
98
|
+
|
|
99
|
+
return TYPES.STRING;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { TYPES };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import FilterWrapper from './FilterWrapper.jsx';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Boolean filter: True / False / All toggle.
|
|
6
|
+
*
|
|
7
|
+
* Filter value shape: { value: true | false | null }
|
|
8
|
+
*/
|
|
9
|
+
export default function BooleanFilter({ column }) {
|
|
10
|
+
var currentFilter = column.getFilterValue() || { value: null };
|
|
11
|
+
var isActive = currentFilter.value !== null;
|
|
12
|
+
|
|
13
|
+
function setValue(val) {
|
|
14
|
+
column.setFilterValue(currentFilter.value === val ? { value: null } : { value: val });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function handleClear() {
|
|
18
|
+
column.setFilterValue(undefined);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<FilterWrapper isActive={isActive} onClear={handleClear}>
|
|
23
|
+
<div className="xeplr-table-boolean-filter">
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
className={'xeplr-table-bool-btn' + (currentFilter.value === null ? ' active' : '')}
|
|
27
|
+
onClick={function() { column.setFilterValue(undefined); }}
|
|
28
|
+
>
|
|
29
|
+
All
|
|
30
|
+
</button>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
className={'xeplr-table-bool-btn' + (currentFilter.value === true ? ' active' : '')}
|
|
34
|
+
onClick={function() { setValue(true); }}
|
|
35
|
+
>
|
|
36
|
+
True
|
|
37
|
+
</button>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
className={'xeplr-table-bool-btn' + (currentFilter.value === false ? ' active' : '')}
|
|
41
|
+
onClick={function() { setValue(false); }}
|
|
42
|
+
>
|
|
43
|
+
False
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
</FilterWrapper>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toBool(val) {
|
|
51
|
+
if (val === true || val === 1 || val === '1' || val === 'true' || val === 'True' || val === 'TRUE') return true;
|
|
52
|
+
if (val === false || val === 0 || val === '0' || val === 'false' || val === 'False' || val === 'FALSE') return false;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Custom filter function for boolean columns.
|
|
58
|
+
*/
|
|
59
|
+
export function booleanFilterFn(row, columnId, filterValue) {
|
|
60
|
+
if (!filterValue || filterValue.value === null || filterValue.value === undefined) return true;
|
|
61
|
+
|
|
62
|
+
var raw = row.getValue(columnId);
|
|
63
|
+
var bool = toBool(raw);
|
|
64
|
+
|
|
65
|
+
return bool === filterValue.value;
|
|
66
|
+
}
|