@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.
Files changed (68) hide show
  1. package/package.json +25 -6
  2. package/src/confirm/confirm.js +175 -0
  3. package/src/dataImport/dataImport.js +32 -0
  4. package/src/dataImport/designs/DataImportSample.jsx +71 -0
  5. package/src/dataImport/designs/ImportHistorySample.jsx +130 -0
  6. package/src/dataImport/designs/dataImport.css +98 -0
  7. package/src/dataImport/designs/index.js +2 -0
  8. package/src/dataImport/pages.jsx +17 -0
  9. package/src/dataImport/useDataImportController.js +94 -0
  10. package/src/dataImport/useImportHistoryController.js +67 -0
  11. package/src/dataImport/validateDesign.js +68 -0
  12. package/src/dateField/dateField.js +71 -0
  13. package/src/dateField/designs/DateFieldSample.jsx +30 -0
  14. package/src/dateField/designs/dateField.css +60 -0
  15. package/src/dateField/designs/index.js +1 -0
  16. package/src/dateField/pages.jsx +10 -0
  17. package/src/dateField/useDateFieldController.js +77 -0
  18. package/src/dateField/validateDesign.js +3 -0
  19. package/src/dateRange/dateRange.js +273 -0
  20. package/src/dateRange/designs/DateRangeSample.jsx +138 -0
  21. package/src/dateRange/designs/dateRange.css +69 -0
  22. package/src/dateRange/designs/index.js +1 -0
  23. package/src/dateRange/pages.jsx +10 -0
  24. package/src/dateRange/useDateRangeController.js +118 -0
  25. package/src/dateRange/validateDesign.js +20 -0
  26. package/src/dropdown/designs/DropdownSample.jsx +151 -0
  27. package/src/dropdown/designs/dropdown.css +226 -0
  28. package/src/dropdown/designs/index.js +1 -0
  29. package/src/dropdown/dropdown.js +75 -0
  30. package/src/dropdown/pages.jsx +10 -0
  31. package/src/dropdown/useDropdownController.js +254 -0
  32. package/src/dropdown/validateDesign.js +4 -0
  33. package/src/fileUpload/validateDesign.js +27 -8
  34. package/src/gridDisplayer/designs/GridDisplayerSample.jsx +58 -0
  35. package/src/gridDisplayer/designs/gridDisplayer.css +89 -0
  36. package/src/gridDisplayer/designs/index.js +1 -0
  37. package/src/gridDisplayer/gridDisplayer.js +44 -0
  38. package/src/gridDisplayer/pages.jsx +10 -0
  39. package/src/gridDisplayer/useGridDisplayerController.js +59 -0
  40. package/src/gridDisplayer/validateDesign.js +4 -0
  41. package/src/index.js +177 -1
  42. package/src/numberField/designs/NumberFieldSample.jsx +52 -0
  43. package/src/numberField/designs/index.js +1 -0
  44. package/src/numberField/designs/numberField.css +66 -0
  45. package/src/numberField/numberField.js +105 -0
  46. package/src/numberField/pages.jsx +10 -0
  47. package/src/numberField/useNumberFieldController.js +116 -0
  48. package/src/numberField/validateDesign.js +3 -0
  49. package/src/progress/ProgressNotifier.jsx +118 -0
  50. package/src/progress/designs/progressNotifier.css +73 -0
  51. package/src/progress/progressSource.js +168 -0
  52. package/src/rangeField/designs/RangeFieldSample.jsx +25 -0
  53. package/src/rangeField/designs/index.js +1 -0
  54. package/src/rangeField/designs/rangeField.css +26 -0
  55. package/src/rangeField/pages.jsx +10 -0
  56. package/src/rangeField/rangeField.js +29 -0
  57. package/src/rangeField/useRangeFieldController.js +52 -0
  58. package/src/rangeField/validateDesign.js +3 -0
  59. package/src/snackbar/snackbar.js +6 -3
  60. package/src/textField/designs/TextFieldSample.jsx +75 -0
  61. package/src/textField/designs/index.js +1 -0
  62. package/src/textField/designs/textField.css +92 -0
  63. package/src/textField/pages.jsx +10 -0
  64. package/src/textField/textField.js +65 -0
  65. package/src/textField/useTextFieldController.js +101 -0
  66. package/src/textField/validateDesign.js +3 -0
  67. package/src/theme/default.theme.json +934 -0
  68. 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.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": ["src/"],
8
- "keywords": ["react", "hooks", "file-upload", "ui", "utilities", "countries", "states"],
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": { "type": "git", "url": "https://github.com/Xeplr/xeplr-ui-utils" },
12
- "publishConfig": { "access": "public" },
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">&rarr;</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,2 @@
1
+ export { default as DataImportSample } from './DataImportSample.jsx';
2
+ export { default as ImportHistorySample } from './ImportHistorySample.jsx';
@@ -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
+ }