@xeplr/ui-utils 1.0.0 → 1.0.2
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 +25 -6
- package/src/confirm/confirm.js +175 -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 +68 -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/dateRange/dateRange.js +273 -0
- package/src/dateRange/designs/DateRangeSample.jsx +138 -0
- package/src/dateRange/designs/dateRange.css +69 -0
- package/src/dateRange/designs/index.js +1 -0
- package/src/dateRange/pages.jsx +10 -0
- package/src/dateRange/useDateRangeController.js +118 -0
- package/src/dateRange/validateDesign.js +20 -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/fileUpload/validateDesign.js +27 -8
- 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 +177 -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/progress/ProgressNotifier.jsx +118 -0
- package/src/progress/designs/progressNotifier.css +73 -0
- package/src/progress/progressSource.js +168 -0
- package/src/rangeField/designs/RangeFieldSample.jsx +25 -0
- package/src/rangeField/designs/index.js +1 -0
- package/src/rangeField/designs/rangeField.css +26 -0
- package/src/rangeField/pages.jsx +10 -0
- package/src/rangeField/rangeField.js +29 -0
- package/src/rangeField/useRangeFieldController.js +52 -0
- package/src/rangeField/validateDesign.js +3 -0
- package/src/snackbar/snackbar.js +6 -3
- package/src/textField/designs/TextFieldSample.jsx +75 -0
- package/src/textField/designs/index.js +1 -0
- package/src/textField/designs/textField.css +92 -0
- package/src/textField/pages.jsx +10 -0
- package/src/textField/textField.js +65 -0
- package/src/textField/useTextFieldController.js +101 -0
- package/src/textField/validateDesign.js +3 -0
- package/src/theme/default.theme.json +934 -0
- package/src/theme/index.js +155 -0
package/package.json
CHANGED
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
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"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "for f in test/*.test.js; do node \"$f\" || exit 1; done"
|
|
15
34
|
}
|
|
16
35
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Imperative, self-mounting modal dialogs — same shape as ../snackbar/snackbar.js
|
|
2
|
+
// (own DOM node, inline styles, no provider/mount point needed, no CSS import).
|
|
3
|
+
// Unlike the snackbar these are modal and return a Promise, since a caller needs
|
|
4
|
+
// the answer before proceeding (e.g. "enter this workspace?").
|
|
5
|
+
//
|
|
6
|
+
// Two shapes, ONE dialog surface:
|
|
7
|
+
// raiseConfirm — yes/no. The common case, and unchanged.
|
|
8
|
+
// raiseChoice — any number of answers, because some questions genuinely have
|
|
9
|
+
// three. "You have unsaved changes and you are leaving" is the
|
|
10
|
+
// standing example: save, discard, or come back — and folding
|
|
11
|
+
// that into a boolean forces the caller to either drop one of
|
|
12
|
+
// the three or ask twice.
|
|
13
|
+
//
|
|
14
|
+
// They share _root and _cancelPending deliberately: two dialog surfaces could
|
|
15
|
+
// each hold one open at once, and the second would silently cover the first.
|
|
16
|
+
|
|
17
|
+
var _root = null;
|
|
18
|
+
// A second dialog before the first resolves would otherwise stack a second
|
|
19
|
+
// overlay into the same _root and add a second document keydown listener —
|
|
20
|
+
// cancel whatever's still open before mounting a new one.
|
|
21
|
+
var _cancelPending = null;
|
|
22
|
+
|
|
23
|
+
function ensureRoot() {
|
|
24
|
+
if (_root) return _root;
|
|
25
|
+
_root = document.createElement('div');
|
|
26
|
+
_root.id = 'xeplr-confirm-root';
|
|
27
|
+
document.body.appendChild(_root);
|
|
28
|
+
return _root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The shell both shapes are built from.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} spec
|
|
35
|
+
* @param {string} spec.title
|
|
36
|
+
* @param {string} spec.message
|
|
37
|
+
* @param {Array} spec.buttons - [{ label, value, style: 'primary'|'danger'|'plain', isDefault }]
|
|
38
|
+
* rendered left to right; the LAST one is focused.
|
|
39
|
+
* @param {*} spec.dismissValue - what Escape, the overlay and a superseding
|
|
40
|
+
* dialog all resolve to.
|
|
41
|
+
* @returns {Promise<*>} the chosen button's `value`
|
|
42
|
+
*/
|
|
43
|
+
function openDialog(spec) {
|
|
44
|
+
if (_cancelPending) _cancelPending();
|
|
45
|
+
|
|
46
|
+
return new Promise(function(resolve) {
|
|
47
|
+
var root = ensureRoot();
|
|
48
|
+
|
|
49
|
+
function cleanup(result) {
|
|
50
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
51
|
+
root.innerHTML = '';
|
|
52
|
+
if (_cancelPending === cleanupAsDismiss) _cancelPending = null;
|
|
53
|
+
resolve(result);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cleanupAsDismiss() { cleanup(spec.dismissValue); }
|
|
57
|
+
_cancelPending = cleanupAsDismiss;
|
|
58
|
+
|
|
59
|
+
function onKeyDown(e) {
|
|
60
|
+
if (e.key === 'Escape') cleanup(spec.dismissValue);
|
|
61
|
+
if (e.key === 'Enter') {
|
|
62
|
+
// Enter takes the DEFAULT, and only if one was named. With three
|
|
63
|
+
// answers there is no obvious one to guess at, and guessing would
|
|
64
|
+
// commit somebody's work — or throw it away — on a stray keypress.
|
|
65
|
+
for (var i = 0; i < spec.buttons.length; i++) {
|
|
66
|
+
if (spec.buttons[i].isDefault) return cleanup(spec.buttons[i].value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// var(--xeplr-*, fallback) — picks up @xeplr/ui-account's theme tokens
|
|
72
|
+
// (light/dark, whichever .xeplr-theme-* an ancestor applies) when that
|
|
73
|
+
// stylesheet is loaded, falls back to the original dark palette otherwise.
|
|
74
|
+
var overlay = document.createElement('div');
|
|
75
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:var(--xeplr-bg-overlay,rgba(0,0,0,0.55));z-index:100000;'
|
|
76
|
+
+ 'display:flex;align-items:center;justify-content:center;';
|
|
77
|
+
overlay.onclick = function(e) { if (e.target === overlay) cleanup(spec.dismissValue); };
|
|
78
|
+
|
|
79
|
+
var box = document.createElement('div');
|
|
80
|
+
box.style.cssText = 'background:var(--xeplr-bg-tertiary,#1a1a1a);color:var(--xeplr-text-primary,#e0e0e0);'
|
|
81
|
+
+ 'border:1px solid var(--xeplr-border-primary,#333);border-radius:10px;'
|
|
82
|
+
+ 'padding:20px;max-width:420px;width:calc(100% - 40px);font:14px/1.5 system-ui,sans-serif;'
|
|
83
|
+
+ 'box-shadow:var(--xeplr-shadow-lg,0 12px 32px rgba(0,0,0,0.4));';
|
|
84
|
+
|
|
85
|
+
var titleEl = document.createElement('div');
|
|
86
|
+
titleEl.style.cssText = 'font-size:16px;font-weight:600;margin-bottom:8px;';
|
|
87
|
+
titleEl.textContent = spec.title;
|
|
88
|
+
|
|
89
|
+
var messageEl = document.createElement('div');
|
|
90
|
+
messageEl.style.cssText = 'color:var(--xeplr-text-secondary,#bbb);margin-bottom:18px;';
|
|
91
|
+
messageEl.textContent = spec.message;
|
|
92
|
+
|
|
93
|
+
var actions = document.createElement('div');
|
|
94
|
+
// Wraps, because three labels that each say what they DO ("Leave without
|
|
95
|
+
// saving") are longer than "OK" and must not run off a narrow dialog.
|
|
96
|
+
actions.style.cssText = 'display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;';
|
|
97
|
+
|
|
98
|
+
var lastBtn = null;
|
|
99
|
+
spec.buttons.forEach(function(def) {
|
|
100
|
+
var btn = document.createElement('button');
|
|
101
|
+
btn.type = 'button';
|
|
102
|
+
btn.textContent = def.label;
|
|
103
|
+
if (def.style === 'primary' || def.style === 'danger') {
|
|
104
|
+
var bg = def.style === 'danger' ? 'var(--xeplr-danger,#dc2626)' : 'var(--xeplr-accent,#646cff)';
|
|
105
|
+
btn.style.cssText = 'padding:8px 14px;border:none;border-radius:6px;'
|
|
106
|
+
+ 'background:' + bg + ';color:var(--xeplr-accent-text,#fff);font-size:13px;font-weight:600;cursor:pointer;';
|
|
107
|
+
} else {
|
|
108
|
+
btn.style.cssText = 'padding:8px 14px;border:1px solid var(--xeplr-border-primary,#444);border-radius:6px;'
|
|
109
|
+
+ 'background:var(--xeplr-bg-secondary,#232323);color:var(--xeplr-text-primary,#e0e0e0);font-size:13px;cursor:pointer;';
|
|
110
|
+
}
|
|
111
|
+
btn.onclick = function() { cleanup(def.value); };
|
|
112
|
+
actions.appendChild(btn);
|
|
113
|
+
lastBtn = btn;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
box.appendChild(titleEl);
|
|
117
|
+
box.appendChild(messageEl);
|
|
118
|
+
box.appendChild(actions);
|
|
119
|
+
overlay.appendChild(box);
|
|
120
|
+
root.appendChild(overlay);
|
|
121
|
+
|
|
122
|
+
document.addEventListener('keydown', onKeyDown);
|
|
123
|
+
if (lastBtn) lastBtn.focus();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} message
|
|
129
|
+
* @param {object} [options]
|
|
130
|
+
* @param {string} [options.title='Are you sure?']
|
|
131
|
+
* @param {string} [options.confirmLabel='Confirm']
|
|
132
|
+
* @param {string} [options.cancelLabel='Cancel']
|
|
133
|
+
* @param {boolean} [options.danger=false] - style the confirm button as destructive
|
|
134
|
+
* @returns {Promise<boolean>} resolves true (confirmed) or false (cancelled/dismissed)
|
|
135
|
+
*/
|
|
136
|
+
export function raiseConfirm(message, options) {
|
|
137
|
+
options = options || {};
|
|
138
|
+
return openDialog({
|
|
139
|
+
title: options.title || 'Are you sure?',
|
|
140
|
+
message: message,
|
|
141
|
+
dismissValue: false,
|
|
142
|
+
buttons: [
|
|
143
|
+
{ label: options.cancelLabel || 'Cancel', value: false, style: 'plain' },
|
|
144
|
+
{ label: options.confirmLabel || 'Confirm', value: true, isDefault: true,
|
|
145
|
+
style: options.danger ? 'danger' : 'primary' }
|
|
146
|
+
]
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A question with more than two answers.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} message
|
|
154
|
+
* @param {object} options
|
|
155
|
+
* @param {string} [options.title='Are you sure?']
|
|
156
|
+
* @param {Array} options.choices - [{ value, label, style?, isDefault? }], left to
|
|
157
|
+
* right; the last is focused, and `style` is 'primary' | 'danger' |
|
|
158
|
+
* omitted for plain. Put the answer somebody most likely wants last.
|
|
159
|
+
* @param {*} [options.dismissValue=null] - Escape / clicking away / being
|
|
160
|
+
* superseded. Default null, so "they closed the dialog" is
|
|
161
|
+
* distinguishable from every deliberate answer.
|
|
162
|
+
* @returns {Promise<*>} the chosen `value`
|
|
163
|
+
*/
|
|
164
|
+
export function raiseChoice(message, options) {
|
|
165
|
+
options = options || {};
|
|
166
|
+
var choices = options.choices || [];
|
|
167
|
+
return openDialog({
|
|
168
|
+
title: options.title || 'Are you sure?',
|
|
169
|
+
message: message,
|
|
170
|
+
dismissValue: options.dismissValue === undefined ? null : options.dismissValue,
|
|
171
|
+
buttons: choices.map(function(c) {
|
|
172
|
+
return { label: c.label, value: c.value, style: c.style || 'plain', isDefault: c.isDefault };
|
|
173
|
+
})
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -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
|
+
}
|