@xeplr/ui-utils 1.0.0 → 1.0.1
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/package.json +22 -6
- package/src/confirm/confirm.js +106 -0
- package/src/dataImport/dataImport.js +32 -0
- package/src/dataImport/designs/DataImportSample.jsx +71 -0
- package/src/dataImport/designs/ImportHistorySample.jsx +130 -0
- package/src/dataImport/designs/dataImport.css +98 -0
- package/src/dataImport/designs/index.js +2 -0
- package/src/dataImport/pages.jsx +17 -0
- package/src/dataImport/useDataImportController.js +94 -0
- package/src/dataImport/useImportHistoryController.js +67 -0
- package/src/dataImport/validateDesign.js +49 -0
- package/src/dateField/dateField.js +71 -0
- package/src/dateField/designs/DateFieldSample.jsx +30 -0
- package/src/dateField/designs/dateField.css +60 -0
- package/src/dateField/designs/index.js +1 -0
- package/src/dateField/pages.jsx +10 -0
- package/src/dateField/useDateFieldController.js +77 -0
- package/src/dateField/validateDesign.js +3 -0
- package/src/dropdown/designs/DropdownSample.jsx +151 -0
- package/src/dropdown/designs/dropdown.css +226 -0
- package/src/dropdown/designs/index.js +1 -0
- package/src/dropdown/dropdown.js +75 -0
- package/src/dropdown/pages.jsx +10 -0
- package/src/dropdown/useDropdownController.js +254 -0
- package/src/dropdown/validateDesign.js +4 -0
- package/src/gridDisplayer/designs/GridDisplayerSample.jsx +58 -0
- package/src/gridDisplayer/designs/gridDisplayer.css +89 -0
- package/src/gridDisplayer/designs/index.js +1 -0
- package/src/gridDisplayer/gridDisplayer.js +44 -0
- package/src/gridDisplayer/pages.jsx +10 -0
- package/src/gridDisplayer/useGridDisplayerController.js +59 -0
- package/src/gridDisplayer/validateDesign.js +4 -0
- package/src/index.js +115 -1
- package/src/numberField/designs/NumberFieldSample.jsx +52 -0
- package/src/numberField/designs/index.js +1 -0
- package/src/numberField/designs/numberField.css +66 -0
- package/src/numberField/numberField.js +105 -0
- package/src/numberField/pages.jsx +10 -0
- package/src/numberField/useNumberFieldController.js +116 -0
- package/src/numberField/validateDesign.js +3 -0
- package/src/snackbar/snackbar.js +6 -3
- package/src/theme/default.theme.json +178 -0
- package/src/theme/index.js +75 -0
package/package.json
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "UI utility components: file upload with progress, snackbar, static data (countries/states)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
|
-
"files": [
|
|
8
|
-
|
|
7
|
+
"files": [
|
|
8
|
+
"src/"
|
|
9
|
+
],
|
|
10
|
+
"keywords": [
|
|
11
|
+
"react",
|
|
12
|
+
"hooks",
|
|
13
|
+
"file-upload",
|
|
14
|
+
"ui",
|
|
15
|
+
"utilities",
|
|
16
|
+
"countries",
|
|
17
|
+
"states"
|
|
18
|
+
],
|
|
9
19
|
"author": "xeplr",
|
|
10
20
|
"license": "MIT",
|
|
11
|
-
"repository": {
|
|
12
|
-
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/Xeplr/xeplr-ui-utils"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
13
28
|
"peerDependencies": {
|
|
14
|
-
"react": "^18.0.0 || ^19.0.0"
|
|
29
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
30
|
+
"@xeplr/ui-table": "^1.0.0"
|
|
15
31
|
}
|
|
16
32
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Imperative, self-mounting confirm dialog — same shape as ../snackbar/snackbar.js
|
|
2
|
+
// (own DOM node, inline styles, no provider/mount point needed, no CSS import).
|
|
3
|
+
// Unlike the snackbar it's modal and returns a Promise, since a caller needs
|
|
4
|
+
// the answer before proceeding (e.g. "enter this workspace?").
|
|
5
|
+
|
|
6
|
+
var _root = null;
|
|
7
|
+
// A second raiseConfirm() before the first resolves would otherwise stack a
|
|
8
|
+
// second overlay into the same _root and add a second document keydown
|
|
9
|
+
// listener — cancel whatever's still open before mounting a new one.
|
|
10
|
+
var _cancelPending = null;
|
|
11
|
+
|
|
12
|
+
function ensureRoot() {
|
|
13
|
+
if (_root) return _root;
|
|
14
|
+
_root = document.createElement('div');
|
|
15
|
+
_root.id = 'xeplr-confirm-root';
|
|
16
|
+
document.body.appendChild(_root);
|
|
17
|
+
return _root;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} message
|
|
22
|
+
* @param {object} [options]
|
|
23
|
+
* @param {string} [options.title='Are you sure?']
|
|
24
|
+
* @param {string} [options.confirmLabel='Confirm']
|
|
25
|
+
* @param {string} [options.cancelLabel='Cancel']
|
|
26
|
+
* @param {boolean} [options.danger=false] - style the confirm button as destructive
|
|
27
|
+
* @returns {Promise<boolean>} resolves true (confirmed) or false (cancelled/dismissed)
|
|
28
|
+
*/
|
|
29
|
+
export function raiseConfirm(message, options) {
|
|
30
|
+
options = options || {};
|
|
31
|
+
var title = options.title || 'Are you sure?';
|
|
32
|
+
var confirmLabel = options.confirmLabel || 'Confirm';
|
|
33
|
+
var cancelLabel = options.cancelLabel || 'Cancel';
|
|
34
|
+
|
|
35
|
+
if (_cancelPending) _cancelPending();
|
|
36
|
+
|
|
37
|
+
return new Promise(function(resolve) {
|
|
38
|
+
var root = ensureRoot();
|
|
39
|
+
|
|
40
|
+
function cleanup(result) {
|
|
41
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
42
|
+
root.innerHTML = '';
|
|
43
|
+
if (_cancelPending === cleanupAsCancel) _cancelPending = null;
|
|
44
|
+
resolve(result);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cleanupAsCancel() { cleanup(false); }
|
|
48
|
+
_cancelPending = cleanupAsCancel;
|
|
49
|
+
|
|
50
|
+
function onKeyDown(e) {
|
|
51
|
+
if (e.key === 'Escape') cleanup(false);
|
|
52
|
+
if (e.key === 'Enter') cleanup(true);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// var(--xeplr-*, fallback) — picks up @xeplr/ui-account's theme tokens
|
|
56
|
+
// (light/dark, whichever .xeplr-theme-* an ancestor applies) when that
|
|
57
|
+
// stylesheet is loaded, falls back to the original dark palette otherwise.
|
|
58
|
+
var overlay = document.createElement('div');
|
|
59
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:var(--xeplr-bg-overlay,rgba(0,0,0,0.55));z-index:100000;'
|
|
60
|
+
+ 'display:flex;align-items:center;justify-content:center;';
|
|
61
|
+
overlay.onclick = function(e) { if (e.target === overlay) cleanup(false); };
|
|
62
|
+
|
|
63
|
+
var box = document.createElement('div');
|
|
64
|
+
box.style.cssText = 'background:var(--xeplr-bg-tertiary,#1a1a1a);color:var(--xeplr-text-primary,#e0e0e0);'
|
|
65
|
+
+ 'border:1px solid var(--xeplr-border-primary,#333);border-radius:10px;'
|
|
66
|
+
+ 'padding:20px;max-width:380px;width:calc(100% - 40px);font:14px/1.5 system-ui,sans-serif;'
|
|
67
|
+
+ 'box-shadow:var(--xeplr-shadow-lg,0 12px 32px rgba(0,0,0,0.4));';
|
|
68
|
+
|
|
69
|
+
var titleEl = document.createElement('div');
|
|
70
|
+
titleEl.style.cssText = 'font-size:16px;font-weight:600;margin-bottom:8px;';
|
|
71
|
+
titleEl.textContent = title;
|
|
72
|
+
|
|
73
|
+
var messageEl = document.createElement('div');
|
|
74
|
+
messageEl.style.cssText = 'color:var(--xeplr-text-secondary,#bbb);margin-bottom:18px;';
|
|
75
|
+
messageEl.textContent = message;
|
|
76
|
+
|
|
77
|
+
var actions = document.createElement('div');
|
|
78
|
+
actions.style.cssText = 'display:flex;justify-content:flex-end;gap:8px;';
|
|
79
|
+
|
|
80
|
+
var cancelBtn = document.createElement('button');
|
|
81
|
+
cancelBtn.type = 'button';
|
|
82
|
+
cancelBtn.textContent = cancelLabel;
|
|
83
|
+
cancelBtn.style.cssText = 'padding:8px 14px;border:1px solid var(--xeplr-border-primary,#444);border-radius:6px;'
|
|
84
|
+
+ 'background:var(--xeplr-bg-secondary,#232323);color:var(--xeplr-text-primary,#e0e0e0);font-size:13px;cursor:pointer;';
|
|
85
|
+
cancelBtn.onclick = function() { cleanup(false); };
|
|
86
|
+
|
|
87
|
+
var confirmBtn = document.createElement('button');
|
|
88
|
+
confirmBtn.type = 'button';
|
|
89
|
+
confirmBtn.textContent = confirmLabel;
|
|
90
|
+
var confirmBg = options.danger ? 'var(--xeplr-danger,#dc2626)' : 'var(--xeplr-accent,#646cff)';
|
|
91
|
+
confirmBtn.style.cssText = 'padding:8px 14px;border:none;border-radius:6px;'
|
|
92
|
+
+ 'background:' + confirmBg + ';color:var(--xeplr-accent-text,#fff);font-size:13px;font-weight:600;cursor:pointer;';
|
|
93
|
+
confirmBtn.onclick = function() { cleanup(true); };
|
|
94
|
+
|
|
95
|
+
actions.appendChild(cancelBtn);
|
|
96
|
+
actions.appendChild(confirmBtn);
|
|
97
|
+
box.appendChild(titleEl);
|
|
98
|
+
box.appendChild(messageEl);
|
|
99
|
+
box.appendChild(actions);
|
|
100
|
+
overlay.appendChild(box);
|
|
101
|
+
root.appendChild(overlay);
|
|
102
|
+
|
|
103
|
+
document.addEventListener('keydown', onKeyDown);
|
|
104
|
+
confirmBtn.focus();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// DataImport — model. Pure functions, no React/DOM — see
|
|
2
|
+
// useDataImportController.js for the stateful wiring.
|
|
3
|
+
//
|
|
4
|
+
// The shape this whole module works with: a file is inspected (app-supplied
|
|
5
|
+
// onInspect) and comes back as either a single unit or a list of named
|
|
6
|
+
// PARTS (e.g. an Excel workbook's sheet names) — each part maps to a named
|
|
7
|
+
// TARGET the app will load it into (e.g. a DB table). Deliberately generic
|
|
8
|
+
// vocabulary (part/target, not sheet/table) — the app decides what a "part"
|
|
9
|
+
// and "target" mean; this module only handles the mapping UI + flow.
|
|
10
|
+
|
|
11
|
+
export function sanitizeTargetName(name) {
|
|
12
|
+
return String(name || '')
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/\.[^/.]+$/, '') // strip a file extension, if any
|
|
15
|
+
.replace(/[^a-z0-9_]+/g, '_')
|
|
16
|
+
.replace(/^_+|_+$/g, '') || 'table';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// inspectResult: { filePath, format, originalName?, parts?: string[] }
|
|
20
|
+
// -> [{ part: string|null, target: string, include: boolean }]
|
|
21
|
+
//
|
|
22
|
+
// parts present (e.g. Excel sheets) -> one row per part, all included by
|
|
23
|
+
// default. parts absent (e.g. CSV/JSON — the whole file is one unit) -> a
|
|
24
|
+
// single row with part:null, named from the file itself.
|
|
25
|
+
export function buildInitialMappings(inspectResult) {
|
|
26
|
+
if (inspectResult.parts && inspectResult.parts.length) {
|
|
27
|
+
return inspectResult.parts.map(function(part) {
|
|
28
|
+
return { part: part, target: sanitizeTargetName(part), include: true };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return [{ part: null, target: sanitizeTargetName(inspectResult.originalName || 'data'), include: true }];
|
|
32
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import './dataImport.css';
|
|
2
|
+
|
|
3
|
+
export default function DataImportSample({
|
|
4
|
+
file, inspecting, inspected, mappings, committing, results, error,
|
|
5
|
+
handleFileChange, updateMapping, handleCommit, reset, accept, targetLabel
|
|
6
|
+
}) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="xeplr-data-import">
|
|
9
|
+
<label className="xeplr-data-import-dropzone">
|
|
10
|
+
<input type="file" accept={accept} onChange={handleFileChange} />
|
|
11
|
+
{file ? file.name : 'Choose a file to import'}
|
|
12
|
+
</label>
|
|
13
|
+
|
|
14
|
+
{inspecting && <p className="xeplr-data-import-hint">Inspecting file…</p>}
|
|
15
|
+
{error && <div className="xeplr-data-import-alert xeplr-data-import-alert-error">{error}</div>}
|
|
16
|
+
|
|
17
|
+
{inspected && mappings.length > 0 && (
|
|
18
|
+
<div className="xeplr-data-import-mappings">
|
|
19
|
+
{mappings.map(function(m, i) {
|
|
20
|
+
return (
|
|
21
|
+
<div key={m.part || 'single'} className="xeplr-data-import-mapping-row">
|
|
22
|
+
{m.part && (
|
|
23
|
+
<label className="xeplr-data-import-mapping-check">
|
|
24
|
+
<input
|
|
25
|
+
type="checkbox"
|
|
26
|
+
checked={m.include}
|
|
27
|
+
onChange={function(e) { updateMapping(i, { include: e.target.checked }); }}
|
|
28
|
+
/>
|
|
29
|
+
{m.part}
|
|
30
|
+
</label>
|
|
31
|
+
)}
|
|
32
|
+
<span className="xeplr-data-import-mapping-arrow">→</span>
|
|
33
|
+
<input
|
|
34
|
+
type="text"
|
|
35
|
+
value={m.target}
|
|
36
|
+
disabled={!m.include}
|
|
37
|
+
onChange={function(e) { updateMapping(i, { target: e.target.value }); }}
|
|
38
|
+
placeholder={targetLabel}
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
{inspected && mappings.length > 0 && (
|
|
47
|
+
<div className="xeplr-data-import-actions">
|
|
48
|
+
<button type="button" onClick={handleCommit} disabled={committing || !mappings.some(function(m) { return m.include; })}>
|
|
49
|
+
{committing ? 'Importing…' : 'Import'}
|
|
50
|
+
</button>
|
|
51
|
+
<button type="button" onClick={reset} disabled={committing}>Cancel</button>
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
|
|
55
|
+
{results && results.length > 0 && (
|
|
56
|
+
<div className="xeplr-data-import-results">
|
|
57
|
+
{results.map(function(r, i) {
|
|
58
|
+
return (
|
|
59
|
+
<div key={i} className="xeplr-data-import-result-row">
|
|
60
|
+
<span>{r.target}{r.part ? ' (' + r.part + ')' : ''}</span>
|
|
61
|
+
<span className={r.success ? 'xeplr-data-import-result-ok' : 'xeplr-data-import-result-fail'}>
|
|
62
|
+
{r.success ? (r.rows != null ? r.rows + ' rows' : 'Done') : (r.error || 'Failed')}
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
})}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import './dataImport.css';
|
|
2
|
+
import { XeplrTable } from '@xeplr/ui-table';
|
|
3
|
+
import { raiseConfirm } from '../../confirm/confirm.js';
|
|
4
|
+
|
|
5
|
+
var STATUS_LABEL = {
|
|
6
|
+
completed: 'Completed',
|
|
7
|
+
failed: 'Failed',
|
|
8
|
+
'rolled-back': 'Rolled back'
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function formatWhen(value) {
|
|
12
|
+
if (!value) return '';
|
|
13
|
+
var d = new Date(value);
|
|
14
|
+
if (isNaN(d.getTime())) return String(value);
|
|
15
|
+
return d.toLocaleString();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
var SCHEMA = {
|
|
19
|
+
0: {
|
|
20
|
+
columns: [
|
|
21
|
+
{
|
|
22
|
+
accessor: 'originalFileName',
|
|
23
|
+
header: 'File',
|
|
24
|
+
cell: function(info) {
|
|
25
|
+
var job = info.row.original;
|
|
26
|
+
return job.originalFileName + (job.part ? ' (' + job.part + ')' : '');
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
accessor: 'destination',
|
|
31
|
+
header: 'Destination',
|
|
32
|
+
cell: function(info) { return info.getValue() || '—'; }
|
|
33
|
+
},
|
|
34
|
+
{ accessor: 'target', header: 'Target' },
|
|
35
|
+
{
|
|
36
|
+
accessor: 'rows',
|
|
37
|
+
header: 'Rows',
|
|
38
|
+
cell: function(info) {
|
|
39
|
+
var job = info.row.original;
|
|
40
|
+
var total = info.getValue();
|
|
41
|
+
if (total == null) return '—';
|
|
42
|
+
if (job.rowsLoaded != null || job.rowsDropped != null) {
|
|
43
|
+
var loaded = job.rowsLoaded != null ? job.rowsLoaded : total;
|
|
44
|
+
var dropped = job.rowsDropped || 0;
|
|
45
|
+
return total + (dropped > 0 ? ' (' + loaded + ' loaded, ' + dropped + ' dropped)' : '');
|
|
46
|
+
}
|
|
47
|
+
return total;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
accessor: 'status',
|
|
52
|
+
header: 'Status',
|
|
53
|
+
cell: function(info) {
|
|
54
|
+
var job = info.row.original;
|
|
55
|
+
return (
|
|
56
|
+
<span>
|
|
57
|
+
<span className={'xeplr-import-history-status xeplr-import-history-status-' + job.status}>
|
|
58
|
+
{STATUS_LABEL[job.status] || job.status}
|
|
59
|
+
</span>
|
|
60
|
+
{job.status === 'failed' && job.error && (
|
|
61
|
+
<span className="xeplr-import-history-error"> — {job.error}</span>
|
|
62
|
+
)}
|
|
63
|
+
</span>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
accessor: 'startedAt',
|
|
69
|
+
header: 'When',
|
|
70
|
+
cell: function(info) { return formatWhen(info.getValue()); }
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
accessor: 'createdByName',
|
|
74
|
+
header: 'Created By',
|
|
75
|
+
cell: function(info) { return info.getValue() || '—'; }
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export default function ImportHistorySample({ jobs, loading, error, rollingBackId, deletingId, handleRollback, handleDelete }) {
|
|
82
|
+
async function onRollbackClick(job) {
|
|
83
|
+
var ok = await raiseConfirm(
|
|
84
|
+
'This deletes every row loaded into "' + job.target + '" by this import. This cannot be undone.',
|
|
85
|
+
{ title: 'Rollback this import?', confirmLabel: 'Rollback', danger: true }
|
|
86
|
+
);
|
|
87
|
+
if (ok) handleRollback(job);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function onDeleteClick(job) {
|
|
91
|
+
var message = job.status === 'completed'
|
|
92
|
+
? 'This removes the history record only — it does NOT undo the data loaded into "' + job.target + '". Roll back first if you need to remove that data; once this record is deleted, you won\'t be able to roll it back. This cannot be undone.'
|
|
93
|
+
: 'This removes the history record for this import. This cannot be undone.';
|
|
94
|
+
var ok = await raiseConfirm(message, { title: 'Delete this import record?', confirmLabel: 'Delete', danger: true });
|
|
95
|
+
if (ok) handleDelete(job);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
var rowActions = [
|
|
99
|
+
{
|
|
100
|
+
key: 'rollback',
|
|
101
|
+
label: 'Rollback',
|
|
102
|
+
variant: 'danger',
|
|
103
|
+
visible: function(job) { return job.status === 'completed'; },
|
|
104
|
+
disabled: function(job) { return rollingBackId === job.id; },
|
|
105
|
+
onClick: onRollbackClick
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: 'delete',
|
|
109
|
+
label: 'Delete',
|
|
110
|
+
variant: 'danger',
|
|
111
|
+
disabled: function(job) { return deletingId === job.id; },
|
|
112
|
+
onClick: onDeleteClick
|
|
113
|
+
}
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className="xeplr-import-history">
|
|
118
|
+
{loading && <p className="xeplr-data-import-hint">Loading import history…</p>}
|
|
119
|
+
{error && <div className="xeplr-data-import-alert xeplr-data-import-alert-error">{error}</div>}
|
|
120
|
+
|
|
121
|
+
{!loading && jobs.length === 0 && !error && (
|
|
122
|
+
<p className="xeplr-data-import-hint">No imports yet.</p>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{jobs.length > 0 && (
|
|
126
|
+
<XeplrTable data={jobs} schema={SCHEMA} rowActions={rowActions} pageSize={20} />
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* Uses @xeplr/ui-account's --xeplr-* theme tokens when available (apply
|
|
2
|
+
.xeplr-theme-light/-dark on an ancestor); falls back to the original dark
|
|
3
|
+
hex values when that stylesheet isn't loaded — same convention as
|
|
4
|
+
gridDisplayer.css. */
|
|
5
|
+
|
|
6
|
+
.xeplr-data-import { display: flex; flex-direction: column; gap: 14px; color: var(--xeplr-text-primary, #e0e0e0); }
|
|
7
|
+
|
|
8
|
+
.xeplr-data-import-dropzone {
|
|
9
|
+
display: block;
|
|
10
|
+
padding: 20px;
|
|
11
|
+
border: 1px dashed var(--xeplr-border-primary, #444);
|
|
12
|
+
border-radius: 8px;
|
|
13
|
+
background: var(--xeplr-bg-secondary, #222);
|
|
14
|
+
color: var(--xeplr-text-secondary, #999);
|
|
15
|
+
font-size: 13px;
|
|
16
|
+
text-align: center;
|
|
17
|
+
cursor: pointer;
|
|
18
|
+
}
|
|
19
|
+
.xeplr-data-import-dropzone input[type="file"] { display: none; }
|
|
20
|
+
|
|
21
|
+
.xeplr-data-import-hint { font-size: 13px; color: var(--xeplr-text-secondary, #999); margin: 0; }
|
|
22
|
+
|
|
23
|
+
.xeplr-data-import-alert { padding: 10px 12px; border-radius: 6px; font-size: 13px; }
|
|
24
|
+
.xeplr-data-import-alert-error {
|
|
25
|
+
background: var(--xeplr-danger-bg, #3d1111);
|
|
26
|
+
color: var(--xeplr-danger, #ff6b6b);
|
|
27
|
+
border: 1px solid var(--xeplr-danger-border, #5a1a1a);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.xeplr-data-import-mappings { display: flex; flex-direction: column; }
|
|
31
|
+
.xeplr-data-import-mapping-row {
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
gap: 10px;
|
|
35
|
+
padding: 8px 0;
|
|
36
|
+
border-bottom: 1px solid var(--xeplr-border-secondary, #2a2a2a);
|
|
37
|
+
}
|
|
38
|
+
.xeplr-data-import-mapping-row:last-child { border-bottom: none; }
|
|
39
|
+
.xeplr-data-import-mapping-check {
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
gap: 6px;
|
|
43
|
+
flex: 0 0 160px;
|
|
44
|
+
font-size: 13px;
|
|
45
|
+
font-weight: 600;
|
|
46
|
+
overflow: hidden;
|
|
47
|
+
text-overflow: ellipsis;
|
|
48
|
+
white-space: nowrap;
|
|
49
|
+
cursor: pointer;
|
|
50
|
+
}
|
|
51
|
+
.xeplr-data-import-mapping-arrow { color: var(--xeplr-text-muted, #888); flex-shrink: 0; }
|
|
52
|
+
.xeplr-data-import-mapping-row input[type="text"] {
|
|
53
|
+
flex: 1;
|
|
54
|
+
min-width: 0;
|
|
55
|
+
padding: 8px 10px;
|
|
56
|
+
border: 1px solid var(--xeplr-input-border, #3a3a3a);
|
|
57
|
+
border-radius: 6px;
|
|
58
|
+
background: var(--xeplr-input-bg, #141414);
|
|
59
|
+
color: var(--xeplr-input-text, #e0e0e0);
|
|
60
|
+
font-size: 13px;
|
|
61
|
+
font-family: inherit;
|
|
62
|
+
}
|
|
63
|
+
.xeplr-data-import-mapping-row input[type="text"]:focus { outline: none; border-color: var(--xeplr-accent, #646cff); }
|
|
64
|
+
.xeplr-data-import-mapping-row input[type="text"]:disabled { opacity: 0.5; }
|
|
65
|
+
|
|
66
|
+
.xeplr-data-import-actions { display: flex; gap: 8px; }
|
|
67
|
+
.xeplr-data-import-actions button {
|
|
68
|
+
padding: 9px 16px;
|
|
69
|
+
border-radius: 6px;
|
|
70
|
+
font-size: 13px;
|
|
71
|
+
font-weight: 600;
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
}
|
|
74
|
+
.xeplr-data-import-actions button:first-child { border: none; background: var(--xeplr-accent, #646cff); color: var(--xeplr-accent-text, #fff); }
|
|
75
|
+
.xeplr-data-import-actions button:last-child { border: 1px solid var(--xeplr-border-primary, #444); background: var(--xeplr-bg-secondary, #232323); color: var(--xeplr-text-primary, #e0e0e0); }
|
|
76
|
+
.xeplr-data-import-actions button:disabled { opacity: 0.6; cursor: default; }
|
|
77
|
+
|
|
78
|
+
.xeplr-data-import-results { display: flex; flex-direction: column; }
|
|
79
|
+
.xeplr-data-import-result-row {
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
justify-content: space-between;
|
|
83
|
+
gap: 10px;
|
|
84
|
+
padding: 8px 0;
|
|
85
|
+
font-size: 13px;
|
|
86
|
+
border-bottom: 1px solid var(--xeplr-border-secondary, #2a2a2a);
|
|
87
|
+
}
|
|
88
|
+
.xeplr-data-import-result-row:last-child { border-bottom: none; }
|
|
89
|
+
.xeplr-data-import-result-ok { color: var(--xeplr-success, #6bcc6b); }
|
|
90
|
+
.xeplr-data-import-result-fail { color: var(--xeplr-danger, #ff6b6b); }
|
|
91
|
+
|
|
92
|
+
.xeplr-import-history { display: flex; flex-direction: column; gap: 10px; color: var(--xeplr-text-primary, #e0e0e0); }
|
|
93
|
+
|
|
94
|
+
.xeplr-import-history-status { font-weight: 600; font-size: 12px; }
|
|
95
|
+
.xeplr-import-history-status-completed { color: var(--xeplr-success, #6bcc6b); }
|
|
96
|
+
.xeplr-import-history-status-failed { color: var(--xeplr-danger, #ff6b6b); }
|
|
97
|
+
.xeplr-import-history-status-rolled-back { color: var(--xeplr-text-muted, #888); }
|
|
98
|
+
.xeplr-import-history-error { color: var(--xeplr-text-secondary, #999); }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useDataImportController } from './useDataImportController.js';
|
|
2
|
+
import { useImportHistoryController } from './useImportHistoryController.js';
|
|
3
|
+
import DataImportSample from './designs/DataImportSample.jsx';
|
|
4
|
+
import ImportHistorySample from './designs/ImportHistorySample.jsx';
|
|
5
|
+
import { useDesignValidator, DATA_IMPORT_RULES, IMPORT_HISTORY_RULES } from './validateDesign.js';
|
|
6
|
+
|
|
7
|
+
export function DataImportPage(props) {
|
|
8
|
+
var controller = useDataImportController(props);
|
|
9
|
+
var ref = useDesignValidator('DataImportPage', DATA_IMPORT_RULES);
|
|
10
|
+
return <div ref={ref}><DataImportSample {...controller} /></div>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ImportHistoryPage(props) {
|
|
14
|
+
var controller = useImportHistoryController(props);
|
|
15
|
+
var ref = useDesignValidator('ImportHistoryPage', IMPORT_HISTORY_RULES);
|
|
16
|
+
return <div ref={ref}><ImportHistorySample {...controller} /></div>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { uploadFile } from '../fileUpload/uploadFile.js';
|
|
3
|
+
import { buildInitialMappings } from './dataImport.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {object} options
|
|
7
|
+
* @param {string} [options.accept] - file input accept attribute, e.g. '.csv,.json,.xlsx,.xls'
|
|
8
|
+
* @param {string} [options.targetLabel='Target'] - placeholder/label for the target-name field
|
|
9
|
+
* @param {(filePath: string, file: File) => Promise<{format: string, parts?: string[]}>} options.onInspect
|
|
10
|
+
* - required. App-supplied: given a file ALREADY uploaded (see below), what
|
|
11
|
+
* does it contain? (format, and for a multi-part file like an Excel
|
|
12
|
+
* workbook, its part/sheet names).
|
|
13
|
+
* @param {(filePath: string, mappings: {part: string|null, target: string}[], meta: {originalName: string}) => Promise<{results: object[]}>} options.onCommit
|
|
14
|
+
* - required. App-supplied: load the included parts into their targets.
|
|
15
|
+
* meta.originalName is the picked file's own name (not the server-side
|
|
16
|
+
* path) — pass it through if the app wants it recorded (e.g. import
|
|
17
|
+
* history), it's otherwise unused by this controller.
|
|
18
|
+
*
|
|
19
|
+
* The actual file transfer is NOT app-supplied — handleFileChange sends the
|
|
20
|
+
* picked file through @xeplr/ui-utils' own uploadFile() (see
|
|
21
|
+
* fileUpload/uploadFile.js), which every app configures ONCE via
|
|
22
|
+
* configureUpload({url, getHeaders}) to point at @xeplr/base-apis' generic
|
|
23
|
+
* uploadRoute. That's what keeps this component product/app-independent: it
|
|
24
|
+
* never needs app-specific upload wiring, only the (unavoidably app-specific)
|
|
25
|
+
* "what's in this file" and "where does it go" steps after the file already
|
|
26
|
+
* has a server-side path.
|
|
27
|
+
*/
|
|
28
|
+
export function useDataImportController(options = {}) {
|
|
29
|
+
var [file, setFile] = useState(null);
|
|
30
|
+
var [inspecting, setInspecting] = useState(false);
|
|
31
|
+
var [inspected, setInspected] = useState(null);
|
|
32
|
+
var [mappings, setMappings] = useState([]);
|
|
33
|
+
var [committing, setCommitting] = useState(false);
|
|
34
|
+
var [results, setResults] = useState(null);
|
|
35
|
+
var [error, setError] = useState('');
|
|
36
|
+
|
|
37
|
+
function reset() {
|
|
38
|
+
setFile(null);
|
|
39
|
+
setInspected(null);
|
|
40
|
+
setMappings([]);
|
|
41
|
+
setResults(null);
|
|
42
|
+
setError('');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function handleFileChange(e) {
|
|
46
|
+
var picked = e.target.files && e.target.files[0];
|
|
47
|
+
if (!picked) return;
|
|
48
|
+
reset();
|
|
49
|
+
setFile(picked);
|
|
50
|
+
setInspecting(true);
|
|
51
|
+
try {
|
|
52
|
+
var uploadRes = await uploadFile(picked);
|
|
53
|
+
var inspectRes = await options.onInspect(uploadRes.filePath, picked);
|
|
54
|
+
var merged = Object.assign({ filePath: uploadRes.filePath, originalName: picked.name }, inspectRes);
|
|
55
|
+
setInspected(merged);
|
|
56
|
+
setMappings(buildInitialMappings(Object.assign({ originalName: picked.name }, merged)));
|
|
57
|
+
} catch (err) {
|
|
58
|
+
setError(err.message || 'Failed to upload/inspect file');
|
|
59
|
+
} finally {
|
|
60
|
+
setInspecting(false);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function updateMapping(index, patch) {
|
|
65
|
+
setMappings(function(prev) {
|
|
66
|
+
var next = prev.slice();
|
|
67
|
+
next[index] = Object.assign({}, next[index], patch);
|
|
68
|
+
return next;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function handleCommit() {
|
|
73
|
+
var included = mappings.filter(function(m) { return m.include; });
|
|
74
|
+
if (!inspected || included.length === 0) return;
|
|
75
|
+
setCommitting(true);
|
|
76
|
+
setError('');
|
|
77
|
+
try {
|
|
78
|
+
var payload = included.map(function(m) { return { part: m.part, target: m.target }; });
|
|
79
|
+
var res = await options.onCommit(inspected.filePath, payload, { originalName: inspected.originalName });
|
|
80
|
+
setResults((res && res.results) || res || []);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
setError(err.message || 'Import failed');
|
|
83
|
+
} finally {
|
|
84
|
+
setCommitting(false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
file, inspecting, inspected, mappings, committing, results, error,
|
|
90
|
+
handleFileChange, updateMapping, handleCommit, reset,
|
|
91
|
+
accept: options.accept,
|
|
92
|
+
targetLabel: options.targetLabel || 'Target'
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {object} options
|
|
5
|
+
* @param {() => Promise<{jobs: object[]}|object[]>} options.onList
|
|
6
|
+
* @param {(job: object) => Promise<any>} options.onRollback
|
|
7
|
+
* @param {(job: object) => Promise<any>} [options.onDelete]
|
|
8
|
+
*
|
|
9
|
+
* Job shape (generic — same part/target vocabulary as useDataImportController):
|
|
10
|
+
* { id, originalFileName, part, target, rows, status, startedAt, error,
|
|
11
|
+
* destination?, rowsLoaded?, rowsDropped?, createdByName? }
|
|
12
|
+
* status: 'completed' | 'failed' | 'rolled-back' — only 'completed' rows are
|
|
13
|
+
* rollback-able; the design decides how to render the others.
|
|
14
|
+
* The `?` fields are optional context (where it went beyond the target table,
|
|
15
|
+
* a loaded/dropped breakdown, who ran it) — apps that don't track them can
|
|
16
|
+
* omit them and the design falls back to '—'.
|
|
17
|
+
*/
|
|
18
|
+
export function useImportHistoryController(options = {}) {
|
|
19
|
+
var [jobs, setJobs] = useState([]);
|
|
20
|
+
var [loading, setLoading] = useState(false);
|
|
21
|
+
var [error, setError] = useState('');
|
|
22
|
+
var [rollingBackId, setRollingBackId] = useState(null);
|
|
23
|
+
var [deletingId, setDeletingId] = useState(null);
|
|
24
|
+
|
|
25
|
+
var load = useCallback(async function() {
|
|
26
|
+
setLoading(true);
|
|
27
|
+
setError('');
|
|
28
|
+
try {
|
|
29
|
+
var res = await options.onList();
|
|
30
|
+
setJobs((res && res.jobs) || res || []);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
setError(err.message || 'Failed to load import history');
|
|
33
|
+
} finally {
|
|
34
|
+
setLoading(false);
|
|
35
|
+
}
|
|
36
|
+
}, [options.onList]);
|
|
37
|
+
|
|
38
|
+
useEffect(function() { load(); }, [load]);
|
|
39
|
+
|
|
40
|
+
async function handleRollback(job) {
|
|
41
|
+
setRollingBackId(job.id);
|
|
42
|
+
setError('');
|
|
43
|
+
try {
|
|
44
|
+
await options.onRollback(job);
|
|
45
|
+
await load();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
setError(err.message || 'Rollback failed');
|
|
48
|
+
} finally {
|
|
49
|
+
setRollingBackId(null);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function handleDelete(job) {
|
|
54
|
+
setDeletingId(job.id);
|
|
55
|
+
setError('');
|
|
56
|
+
try {
|
|
57
|
+
await options.onDelete(job);
|
|
58
|
+
await load();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
setError(err.message || 'Delete failed');
|
|
61
|
+
} finally {
|
|
62
|
+
setDeletingId(null);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { jobs, loading, error, rollingBackId, deletingId, reload: load, handleRollback, handleDelete };
|
|
67
|
+
}
|