@xeplr/ui-utils 1.0.10 → 1.0.12
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/README.md +72 -6
- package/package.json +1 -1
- package/src/dataImport/dataImport.js +83 -3
- package/src/dataImport/designs/DataImportSample.jsx +52 -6
- package/src/dataImport/designs/DroppedRowsSample.jsx +65 -0
- package/src/dataImport/designs/ImportHistorySample.jsx +26 -3
- package/src/dataImport/designs/dataImport.css +92 -0
- package/src/dataImport/designs/index.js +1 -0
- package/src/dataImport/droppedRows.js +86 -0
- package/src/dataImport/pages.jsx +26 -3
- package/src/dataImport/useDataImportController.js +84 -7
- package/src/dataImport/useDroppedRowsController.js +95 -0
- package/src/dataImport/validateDesign.js +7 -0
- package/src/fileUpload/uploadFile.js +10 -0
- package/src/imports/designs/ImportsSample.jsx +72 -0
- package/src/imports/designs/imports.css +29 -0
- package/src/imports/designs/index.js +1 -0
- package/src/imports/importsApi.js +96 -0
- package/src/imports/pages.jsx +24 -0
- package/src/imports/useImportsController.js +125 -0
- package/src/imports/validateDesign.js +6 -0
- package/src/index.js +21 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ROWS AN IMPORT DROPPED — the ones the database refused — as the dropped-rows
|
|
2
|
+
// view shows them, copies them and counts them. Pure: no React, no fetch.
|
|
3
|
+
//
|
|
4
|
+
// The app's onLoadDropped(job) answers:
|
|
5
|
+
// { columns: ['sku', 'qty'], // the file's own, in order
|
|
6
|
+
// rows: [{ rowNum: 5, reason: '…', values: { sku: 'S5', qty: -1 } }],
|
|
7
|
+
// total: 2, // rows kept to look at
|
|
8
|
+
// dropped: 2 } // rows the import counted
|
|
9
|
+
|
|
10
|
+
/** How many rows a job or an import result dropped (either shape). */
|
|
11
|
+
export function droppedCount(job) {
|
|
12
|
+
if (!job) return 0;
|
|
13
|
+
var n = job.rowsDropped != null ? job.rowsDropped : job.dropped;
|
|
14
|
+
return Number(n) || 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The view's table: a row number, the reason, then the file's columns. Keys
|
|
19
|
+
* are positional (c0, c1, …) because a column name can hold a dot, which a
|
|
20
|
+
* table's accessor reads as a path.
|
|
21
|
+
*
|
|
22
|
+
* @returns {{ columns: [{ key, header }], rows: object[] }}
|
|
23
|
+
*/
|
|
24
|
+
export function droppedTable(data) {
|
|
25
|
+
var names = (data && data.columns) || [];
|
|
26
|
+
var columns = [{ key: 'rowNum', header: 'Row' }, { key: 'reason', header: 'Why it was dropped' }]
|
|
27
|
+
.concat(names.map(function(name, i) { return { key: 'c' + i, header: name }; }));
|
|
28
|
+
var rows = ((data && data.rows) || []).map(function(r) {
|
|
29
|
+
var row = { rowNum: r.rowNum == null ? '' : r.rowNum, reason: r.reason || '' };
|
|
30
|
+
names.forEach(function(name, i) { row['c' + i] = displayValue(r.values ? r.values[name] : null); });
|
|
31
|
+
return row;
|
|
32
|
+
});
|
|
33
|
+
return { columns: columns, rows: rows };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function displayValue(v) {
|
|
37
|
+
if (v == null) return '';
|
|
38
|
+
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The rows as tab-separated text with a header — what Excel takes from the
|
|
43
|
+
* clipboard as cells. The file's columns only, like the download: pasted,
|
|
44
|
+
* fixed and saved, it imports again as it is. A value holding a tab, a line
|
|
45
|
+
* break or a quote is quoted, its quotes doubled, as Excel itself copies it.
|
|
46
|
+
*/
|
|
47
|
+
export function droppedAsTsv(data) {
|
|
48
|
+
var names = (data && data.columns) || [];
|
|
49
|
+
var lines = [names.map(tsvCell).join('\t')];
|
|
50
|
+
((data && data.rows) || []).forEach(function(r) {
|
|
51
|
+
lines.push(names.map(function(name) { return tsvCell(displayValue(r.values ? r.values[name] : null)); }).join('\t'));
|
|
52
|
+
});
|
|
53
|
+
return lines.join('\r\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function tsvCell(value) {
|
|
57
|
+
var s = value == null ? '' : String(value);
|
|
58
|
+
return /[\t\r\n"]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What to say above the rows: the heading, and a note when not every dropped
|
|
63
|
+
* row is here — the view shows the first ones, or the import predates rows
|
|
64
|
+
* being kept and has only a count.
|
|
65
|
+
*/
|
|
66
|
+
export function droppedSummary(job, data) {
|
|
67
|
+
var dropped = data && data.dropped != null ? Number(data.dropped) : droppedCount(job);
|
|
68
|
+
var total = data ? Number(data.total) || 0 : 0;
|
|
69
|
+
var shown = data && data.rows ? data.rows.length : 0;
|
|
70
|
+
var heading = plural(dropped, 'row') + ' dropped' +
|
|
71
|
+
(job && job.originalFileName ? ' from ' + job.originalFileName + (job.part ? ' (' + job.part + ')' : '') : '') +
|
|
72
|
+
(job && job.target ? ' → ' + job.target : '');
|
|
73
|
+
var note = null;
|
|
74
|
+
if (data && total === 0 && dropped > 0) {
|
|
75
|
+
note = 'These rows were not kept — this import ran before dropped rows were saved. Import the file again to see them.';
|
|
76
|
+
} else if (data && total < dropped) {
|
|
77
|
+
note = 'Only ' + plural(total, 'row') + ' of ' + dropped + ' were kept; the import stopped recording after too many failures in a row.';
|
|
78
|
+
} else if (data && shown < total) {
|
|
79
|
+
note = 'Showing the first ' + shown.toLocaleString() + ' of ' + total.toLocaleString() + '. Download has all of them.';
|
|
80
|
+
}
|
|
81
|
+
return { heading: heading, note: note };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function plural(n, word) {
|
|
85
|
+
return Number(n).toLocaleString() + ' ' + word + (Number(n) === 1 ? '' : 's');
|
|
86
|
+
}
|
package/src/dataImport/pages.jsx
CHANGED
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
import { useDataImportController } from './useDataImportController.js';
|
|
2
2
|
import { useImportHistoryController } from './useImportHistoryController.js';
|
|
3
|
+
import { useDroppedRowsController } from './useDroppedRowsController.js';
|
|
3
4
|
import DataImportSample from './designs/DataImportSample.jsx';
|
|
4
5
|
import ImportHistorySample from './designs/ImportHistorySample.jsx';
|
|
5
|
-
import
|
|
6
|
+
import DroppedRowsSample from './designs/DroppedRowsSample.jsx';
|
|
7
|
+
import { useDesignValidator, DATA_IMPORT_RULES, IMPORT_HISTORY_RULES, DROPPED_ROWS_RULES } from './validateDesign.js';
|
|
6
8
|
|
|
9
|
+
// Props: onInspect, onCommit, … and, to show the rows an import dropped,
|
|
10
|
+
// onLoadDropped(job) and onDownloadDropped(job) (see useDroppedRowsController).
|
|
7
11
|
export function DataImportPage(props) {
|
|
8
12
|
var controller = useDataImportController(props);
|
|
13
|
+
var dropped = useDroppedRowsController(props);
|
|
9
14
|
var ref = useDesignValidator('DataImportPage', DATA_IMPORT_RULES);
|
|
10
|
-
return
|
|
15
|
+
return (
|
|
16
|
+
<div ref={ref}>
|
|
17
|
+
<DataImportSample {...controller} canViewDropped={dropped.canView} viewDropped={dropped.open} />
|
|
18
|
+
{dropped.job && <DroppedRowsPage {...dropped} />}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
11
21
|
}
|
|
12
22
|
|
|
13
23
|
export function ImportHistoryPage(props) {
|
|
14
24
|
var controller = useImportHistoryController(props);
|
|
25
|
+
var dropped = useDroppedRowsController(props);
|
|
15
26
|
var ref = useDesignValidator('ImportHistoryPage', IMPORT_HISTORY_RULES);
|
|
16
|
-
return
|
|
27
|
+
return (
|
|
28
|
+
<div ref={ref}>
|
|
29
|
+
<ImportHistorySample {...controller} canViewDropped={dropped.canView} viewDropped={dropped.open} />
|
|
30
|
+
{dropped.job && <DroppedRowsPage {...dropped} />}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The dropped-rows dialog, validated as it opens — its required buttons only
|
|
36
|
+
// exist while it is on screen.
|
|
37
|
+
export function DroppedRowsPage(props) {
|
|
38
|
+
var ref = useDesignValidator('DroppedRows', DROPPED_ROWS_RULES);
|
|
39
|
+
return <div ref={ref}><DroppedRowsSample {...props} /></div>;
|
|
17
40
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
2
|
import { uploadFile } from '../fileUpload/uploadFile.js';
|
|
3
|
-
import { buildInitialMappings, checksFor } from './dataImport.js';
|
|
3
|
+
import { buildInitialMappings, checksFor, targetProblems, blocksImport, IMPORT_MODES } from './dataImport.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @param {object} options
|
|
@@ -10,7 +10,7 @@ import { buildInitialMappings, checksFor } from './dataImport.js';
|
|
|
10
10
|
* - required. App-supplied: given a file ALREADY uploaded (see below), what
|
|
11
11
|
* does it contain? (format, and for a multi-part file like an Excel
|
|
12
12
|
* workbook, its part/sheet names).
|
|
13
|
-
* @param {(filePath: string, mappings: {part: string|null, target: string, fixes: object}[], meta: {originalName: string}) => Promise<{results: object[]}>} options.onCommit
|
|
13
|
+
* @param {(filePath: string, mappings: {part: string|null, target: string, mode: string, fixes: object}[], meta: {originalName: string}) => Promise<{results: object[]}>} options.onCommit
|
|
14
14
|
* (each mapping's `fixes` is the person's choice for each fix the inspect
|
|
15
15
|
* step's `checks` offered — see checksFor in dataImport.js)
|
|
16
16
|
* - required. App-supplied: load the included parts into their targets.
|
|
@@ -18,6 +18,16 @@ import { buildInitialMappings, checksFor } from './dataImport.js';
|
|
|
18
18
|
* path) — pass it through if the app wants it recorded (e.g. import
|
|
19
19
|
* history), it's otherwise unused by this controller.
|
|
20
20
|
*
|
|
21
|
+
* @param {(filePath: string, target: {part: string|null, target: string}) => Promise<{exists, matched, extra, missing}>} [options.onCheckTarget]
|
|
22
|
+
* - optional. App-supplied: the part's columns against the table it names —
|
|
23
|
+
* asked as the person types, so what will happen (refused, columns left
|
|
24
|
+
* empty, a table that already exists) is said before anything is loaded.
|
|
25
|
+
* Without it no check is made and nothing is blocked.
|
|
26
|
+
*
|
|
27
|
+
* @param {(file: File, onProgress: (percent) => void) => Promise<{filePath}>} [options.onUpload]
|
|
28
|
+
* - optional: send the file somewhere other than configureUpload's url
|
|
29
|
+
* (ImportsPage sends it to @xeplr/imports' /files). Default uploadFile().
|
|
30
|
+
*
|
|
21
31
|
* The actual file transfer is NOT app-supplied — handleFileChange sends the
|
|
22
32
|
* picked file through @xeplr/ui-utils' own uploadFile() (see
|
|
23
33
|
* fileUpload/uploadFile.js), which every app configures ONCE via
|
|
@@ -30,13 +40,30 @@ import { buildInitialMappings, checksFor } from './dataImport.js';
|
|
|
30
40
|
export function useDataImportController(options = {}) {
|
|
31
41
|
var [file, setFile] = useState(null);
|
|
32
42
|
var [inspecting, setInspecting] = useState(false);
|
|
43
|
+
// How much of the file has gone up, 0–100 — a 300 MB file takes a while.
|
|
44
|
+
var [uploadPercent, setUploadPercent] = useState(null);
|
|
33
45
|
var [inspected, setInspected] = useState(null);
|
|
34
46
|
var [mappings, setMappings] = useState([]);
|
|
35
47
|
var [committing, setCommitting] = useState(false);
|
|
36
48
|
var [results, setResults] = useState(null);
|
|
37
49
|
var [error, setError] = useState('');
|
|
50
|
+
// The app's comparison of each part with the table it names, by
|
|
51
|
+
// part + target: { loading, result, error }. The mode is not part of the
|
|
52
|
+
// key — switching it changes what is said, not what the table is.
|
|
53
|
+
// Kept with the onCheckTarget that answered: a new one (another database,
|
|
54
|
+
// say) means a new question, and the old answers no longer apply.
|
|
55
|
+
var [checkStore, setCheckStore] = useState({ by: null, map: {} });
|
|
56
|
+
var onCheckTarget = options.onCheckTarget;
|
|
57
|
+
var targetChecks = checkStore.by === onCheckTarget ? checkStore.map : {};
|
|
58
|
+
function setTargetChecks(update) {
|
|
59
|
+
setCheckStore(function(prev) {
|
|
60
|
+
var map = prev.by === onCheckTarget ? prev.map : {};
|
|
61
|
+
return { by: onCheckTarget, map: update(map) };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
38
64
|
|
|
39
65
|
function reset() {
|
|
66
|
+
setCheckStore({ by: null, map: {} });
|
|
40
67
|
setFile(null);
|
|
41
68
|
setInspected(null);
|
|
42
69
|
setMappings([]);
|
|
@@ -51,7 +78,11 @@ export function useDataImportController(options = {}) {
|
|
|
51
78
|
setFile(picked);
|
|
52
79
|
setInspecting(true);
|
|
53
80
|
try {
|
|
54
|
-
|
|
81
|
+
setUploadPercent(0);
|
|
82
|
+
var uploadRes = options.onUpload
|
|
83
|
+
? await options.onUpload(picked, setUploadPercent)
|
|
84
|
+
: await uploadFile(picked, { onProgress: setUploadPercent });
|
|
85
|
+
setUploadPercent(null);
|
|
55
86
|
var inspectRes = await options.onInspect(uploadRes.filePath, picked);
|
|
56
87
|
var merged = Object.assign({ filePath: uploadRes.filePath, originalName: picked.name }, inspectRes);
|
|
57
88
|
setInspected(merged);
|
|
@@ -59,6 +90,7 @@ export function useDataImportController(options = {}) {
|
|
|
59
90
|
} catch (err) {
|
|
60
91
|
setError(err.message || 'Failed to upload/inspect file');
|
|
61
92
|
} finally {
|
|
93
|
+
setUploadPercent(null);
|
|
62
94
|
setInspecting(false);
|
|
63
95
|
}
|
|
64
96
|
}
|
|
@@ -80,15 +112,52 @@ export function useDataImportController(options = {}) {
|
|
|
80
112
|
});
|
|
81
113
|
}
|
|
82
114
|
|
|
115
|
+
// ASKED AS THE PERSON TYPES, once they pause: every included part whose
|
|
116
|
+
// part + target has not been checked yet. Each answer is kept under its own
|
|
117
|
+
// part + target, so one that arrives late lands where it belongs.
|
|
118
|
+
var checkKey = mappings.map(function(m) { return m.include ? keyOf(m) : ''; }).join('\u0001');
|
|
119
|
+
useEffect(function() {
|
|
120
|
+
if (!onCheckTarget || !inspected) return undefined;
|
|
121
|
+
var wanted = mappings.filter(function(m) { return m.include && String(m.target || '').trim() && !targetChecks[keyOf(m)]; });
|
|
122
|
+
if (!wanted.length) return undefined;
|
|
123
|
+
var timer = setTimeout(function() {
|
|
124
|
+
wanted.forEach(function(m) {
|
|
125
|
+
var key = keyOf(m);
|
|
126
|
+
setTargetChecks(function(prev) { return Object.assign({}, prev, { [key]: { loading: true } }); });
|
|
127
|
+
Promise.resolve(onCheckTarget(inspected.filePath, { part: m.part, target: String(m.target).trim() })).then(function(result) {
|
|
128
|
+
setTargetChecks(function(prev) { return Object.assign({}, prev, { [key]: { result: result || null } }); });
|
|
129
|
+
}, function(err) {
|
|
130
|
+
setTargetChecks(function(prev) { return Object.assign({}, prev, { [key]: { error: err.message || 'Could not check the table' } }); });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}, 400);
|
|
134
|
+
return function() { clearTimeout(timer); };
|
|
135
|
+
}, [checkKey, inspected, onCheckTarget]);
|
|
136
|
+
|
|
137
|
+
// What each part's target will do, said under it: [{ level, message }].
|
|
138
|
+
var problems = mappings.map(function(m) {
|
|
139
|
+
var c = targetChecks[keyOf(m)];
|
|
140
|
+
if (!m.include || !c) return [];
|
|
141
|
+
if (c.error) return [{ level: 'warning', message: 'Could not check the table: ' + c.error }];
|
|
142
|
+
return c.result ? targetProblems(String(m.target).trim(), m.mode, c.result) : [];
|
|
143
|
+
});
|
|
144
|
+
var checking = mappings.some(function(m) { var c = targetChecks[keyOf(m)]; return m.include && c && c.loading; });
|
|
145
|
+
var blocked = mappings.some(function(m, i) { return m.include && blocksImport(problems[i]); }) ||
|
|
146
|
+
mappings.some(function(m) { return m.include && !String(m.target || '').trim(); });
|
|
147
|
+
|
|
83
148
|
async function handleCommit() {
|
|
84
149
|
var included = mappings.filter(function(m) { return m.include; });
|
|
85
|
-
if (!inspected || included.length === 0) return;
|
|
150
|
+
if (!inspected || included.length === 0 || blocked) return;
|
|
86
151
|
setCommitting(true);
|
|
87
152
|
setError('');
|
|
88
153
|
try {
|
|
89
|
-
var payload = included.map(function(m) { return { part: m.part, target: m.target, fixes: m.fixes || {} }; });
|
|
154
|
+
var payload = included.map(function(m) { return { part: m.part, target: String(m.target).trim(), mode: m.mode, fixes: m.fixes || {} }; });
|
|
90
155
|
var res = await options.onCommit(inspected.filePath, payload, { originalName: inspected.originalName });
|
|
91
156
|
setResults((res && res.results) || res || []);
|
|
157
|
+
// The file is gone once imported (the app removes it), so its mapping
|
|
158
|
+
// goes too: the results stay, and the next import starts from a file.
|
|
159
|
+
setInspected(null);
|
|
160
|
+
setMappings([]);
|
|
92
161
|
} catch (err) {
|
|
93
162
|
setError(err.message || 'Import failed');
|
|
94
163
|
} finally {
|
|
@@ -97,10 +166,18 @@ export function useDataImportController(options = {}) {
|
|
|
97
166
|
}
|
|
98
167
|
|
|
99
168
|
return {
|
|
100
|
-
file, inspecting, inspected, mappings, committing, results, error,
|
|
169
|
+
file, inspecting, uploadPercent, inspected, mappings, committing, results, error,
|
|
101
170
|
handleFileChange, updateMapping, setFix, handleCommit, reset,
|
|
171
|
+
// The import's mode per part, what its target will do, and whether that stops it.
|
|
172
|
+
modes: IMPORT_MODES,
|
|
173
|
+
setMode: function(index, mode) { updateMapping(index, { mode: mode }); },
|
|
174
|
+
problems: problems,
|
|
175
|
+
checking: checking,
|
|
176
|
+
blocked: blocked,
|
|
102
177
|
checksOf: function(part) { return checksFor(inspected, part); },
|
|
103
178
|
accept: options.accept,
|
|
104
179
|
targetLabel: options.targetLabel || 'Target'
|
|
105
180
|
};
|
|
106
181
|
}
|
|
182
|
+
|
|
183
|
+
function keyOf(m) { return (m.part == null ? '' : m.part) + '\u0000' + String(m.target || '').trim(); }
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { droppedAsTsv, droppedCount, droppedSummary, droppedTable } from './droppedRows.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The rows an import dropped: open them, copy them, download them.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} options
|
|
8
|
+
* @param {(job) => Promise<{columns, rows, total, dropped}>} [options.onLoadDropped]
|
|
9
|
+
* the app's read of a job's dropped rows. Without it nothing offers to show them.
|
|
10
|
+
* @param {(job) => Promise<any>} [options.onDownloadDropped]
|
|
11
|
+
* the app saves them as a file (an .xlsx that imports again as it is)
|
|
12
|
+
*
|
|
13
|
+
* job: { id, rowsDropped|dropped, originalFileName?, part?, target? } — an
|
|
14
|
+
* import-history row, or an import result with its jobId as id.
|
|
15
|
+
*/
|
|
16
|
+
export function useDroppedRowsController(options = {}) {
|
|
17
|
+
var [job, setJob] = useState(null);
|
|
18
|
+
var [data, setData] = useState(null);
|
|
19
|
+
var [loading, setLoading] = useState(false);
|
|
20
|
+
var [error, setError] = useState('');
|
|
21
|
+
var [copied, setCopied] = useState(false);
|
|
22
|
+
var [downloading, setDownloading] = useState(false);
|
|
23
|
+
var copiedTimer = useRef(null);
|
|
24
|
+
var onLoad = options.onLoadDropped;
|
|
25
|
+
|
|
26
|
+
useEffect(function() { return function() { clearTimeout(copiedTimer.current); }; }, []);
|
|
27
|
+
|
|
28
|
+
var canView = useCallback(function(j) {
|
|
29
|
+
return !!onLoad && !!j && j.id != null && droppedCount(j) > 0;
|
|
30
|
+
}, [onLoad]);
|
|
31
|
+
|
|
32
|
+
var open = useCallback(async function(j) {
|
|
33
|
+
setJob(j);
|
|
34
|
+
setData(null);
|
|
35
|
+
setError('');
|
|
36
|
+
setCopied(false);
|
|
37
|
+
setLoading(true);
|
|
38
|
+
try {
|
|
39
|
+
setData(await onLoad(j));
|
|
40
|
+
} catch (err) {
|
|
41
|
+
setError(err.message || 'Could not load the dropped rows');
|
|
42
|
+
} finally {
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
}, [onLoad]);
|
|
46
|
+
|
|
47
|
+
function close() {
|
|
48
|
+
setJob(null);
|
|
49
|
+
setData(null);
|
|
50
|
+
setError('');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function copy() {
|
|
54
|
+
if (!data) return;
|
|
55
|
+
try {
|
|
56
|
+
await navigator.clipboard.writeText(droppedAsTsv(data));
|
|
57
|
+
setCopied(true);
|
|
58
|
+
clearTimeout(copiedTimer.current);
|
|
59
|
+
copiedTimer.current = setTimeout(function() { setCopied(false); }, 2000);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
setError('Could not copy — the browser refused the clipboard. Use Download instead.');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function download() {
|
|
66
|
+
if (!options.onDownloadDropped || !job) return;
|
|
67
|
+
setDownloading(true);
|
|
68
|
+
setError('');
|
|
69
|
+
try {
|
|
70
|
+
await options.onDownloadDropped(job);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
setError(err.message || 'Download failed');
|
|
73
|
+
} finally {
|
|
74
|
+
setDownloading(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
job: job,
|
|
80
|
+
data: data,
|
|
81
|
+
table: data ? droppedTable(data) : null,
|
|
82
|
+
summary: job ? droppedSummary(job, data) : null,
|
|
83
|
+
loading: loading,
|
|
84
|
+
error: error,
|
|
85
|
+
copied: copied,
|
|
86
|
+
downloading: downloading,
|
|
87
|
+
canDownload: !!options.onDownloadDropped && !!data && data.total > 0,
|
|
88
|
+
canCopy: !!data && data.rows && data.rows.length > 0,
|
|
89
|
+
canView: canView,
|
|
90
|
+
open: open,
|
|
91
|
+
close: close,
|
|
92
|
+
copy: copy,
|
|
93
|
+
download: download
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -66,3 +66,10 @@ export var DATA_IMPORT_RULES = [
|
|
|
66
66
|
];
|
|
67
67
|
|
|
68
68
|
export var IMPORT_HISTORY_RULES = [];
|
|
69
|
+
|
|
70
|
+
// Checked when the dialog opens — it is not on the page before.
|
|
71
|
+
export var DROPPED_ROWS_RULES = [
|
|
72
|
+
{ id: 'xeplr-dropped-rows-copy', label: 'Copy button' },
|
|
73
|
+
{ id: 'xeplr-dropped-rows-download', label: 'Download button' },
|
|
74
|
+
{ id: 'xeplr-dropped-rows-close', label: 'Close button' }
|
|
75
|
+
];
|
|
@@ -9,6 +9,16 @@ export function configureUpload(config = {}) {
|
|
|
9
9
|
if (config.getHeaders) _config.getHeaders = config.getHeaders;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The headers every request to the app's API carries — what configureUpload
|
|
14
|
+
* was given (getHeaders, called each time so a token is current, or static
|
|
15
|
+
* headers). Exported so a page calling the app's JSON routes signs them the
|
|
16
|
+
* same way as its uploads, without a second configuration.
|
|
17
|
+
*/
|
|
18
|
+
export function uploadHeaders() {
|
|
19
|
+
return Object.assign({}, _config.getHeaders ? _config.getHeaders() : _config.headers);
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
/**
|
|
13
23
|
* Upload files to the server.
|
|
14
24
|
* @param {File[]} files - Files to upload
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import './imports.css';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The import screen: which database, the import, and the history. Presentation
|
|
6
|
+
* only — everything comes from useImportsController; the import and history
|
|
7
|
+
* sections arrive as elements (ImportsPage passes DataImportPage and
|
|
8
|
+
* ImportHistoryPage), so a custom design can place them anywhere.
|
|
9
|
+
*
|
|
10
|
+
* Required (IMPORTS_RULES): #xeplr-imports-connection, #xeplr-imports-database.
|
|
11
|
+
*/
|
|
12
|
+
export default function ImportsSample({
|
|
13
|
+
targets, targetsError, connectionId, dbInfoId, databases, chooseConnection, chooseDatabase,
|
|
14
|
+
importing, progressText, stop, importSection, historySection
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="xeplr-imports">
|
|
18
|
+
<section className="xeplr-imports-card">
|
|
19
|
+
<h2 className="xeplr-imports-title">1. Choose a database</h2>
|
|
20
|
+
<p className="xeplr-imports-hint">The file is loaded into a table in this database.</p>
|
|
21
|
+
{targetsError && <div className="xeplr-imports-error">{targetsError}</div>}
|
|
22
|
+
{!targetsError && targets.length === 0 && <p className="xeplr-imports-hint">There is no database to import into yet.</p>}
|
|
23
|
+
<div className="xeplr-imports-pickers">
|
|
24
|
+
<label className="xeplr-imports-field">
|
|
25
|
+
<span>Connection</span>
|
|
26
|
+
<select id="xeplr-imports-connection" value={connectionId} disabled={!!importing}
|
|
27
|
+
onChange={function(e) { chooseConnection(e.target.value); }}>
|
|
28
|
+
{targets.map(function(t) { return <option key={t.connectionId} value={t.connectionId}>{t.label}</option>; })}
|
|
29
|
+
</select>
|
|
30
|
+
</label>
|
|
31
|
+
<label className="xeplr-imports-field">
|
|
32
|
+
<span>Database</span>
|
|
33
|
+
<select id="xeplr-imports-database" value={dbInfoId} disabled={!!importing}
|
|
34
|
+
onChange={function(e) { chooseDatabase(e.target.value); }}>
|
|
35
|
+
{databases.map(function(d) { return <option key={d.id} value={d.id}>{d.label}</option>; })}
|
|
36
|
+
</select>
|
|
37
|
+
</label>
|
|
38
|
+
</div>
|
|
39
|
+
</section>
|
|
40
|
+
|
|
41
|
+
<section className="xeplr-imports-card">
|
|
42
|
+
<h2 className="xeplr-imports-title">2. Upload & map</h2>
|
|
43
|
+
<p className="xeplr-imports-hint">Excel files let you load each sheet into its own table.</p>
|
|
44
|
+
{importing && (
|
|
45
|
+
<p className="xeplr-imports-progress">
|
|
46
|
+
{progressText}
|
|
47
|
+
<RunClock startedAt={importing.startedAt} />
|
|
48
|
+
<button type="button" className="xeplr-imports-stop" title="Stop this import" onClick={stop}>✕</button>
|
|
49
|
+
</p>
|
|
50
|
+
)}
|
|
51
|
+
{importSection}
|
|
52
|
+
</section>
|
|
53
|
+
|
|
54
|
+
<section className="xeplr-imports-card">
|
|
55
|
+
<h2 className="xeplr-imports-title">Import history</h2>
|
|
56
|
+
<p className="xeplr-imports-hint">Past imports — roll one back if it loaded the wrong data, see the rows it dropped, or delete the record.</p>
|
|
57
|
+
{historySection}
|
|
58
|
+
</section>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** "12 s", "3 min 4 s" — how long the import has been running. */
|
|
64
|
+
function RunClock({ startedAt }) {
|
|
65
|
+
var [now, setNow] = useState(Date.now());
|
|
66
|
+
useEffect(function() {
|
|
67
|
+
var t = setInterval(function() { setNow(Date.now()); }, 1000);
|
|
68
|
+
return function() { clearInterval(t); };
|
|
69
|
+
}, []);
|
|
70
|
+
var s = Math.max(0, Math.round((now - startedAt) / 1000));
|
|
71
|
+
return <span className="xeplr-imports-clock">{s < 60 ? s + ' s' : Math.floor(s / 60) + ' min ' + (s % 60) + ' s'}</span>;
|
|
72
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* Uses @xeplr/ui-account's --xeplr-* theme tokens when available, with the
|
|
2
|
+
same dark fallbacks as dataImport.css. */
|
|
3
|
+
.xeplr-imports { display: flex; flex-direction: column; gap: 20px; color: var(--xeplr-text-primary, #e0e0e0); }
|
|
4
|
+
.xeplr-imports-card {
|
|
5
|
+
padding: 20px;
|
|
6
|
+
border: 1px solid var(--xeplr-border-secondary, #2a2a2a);
|
|
7
|
+
border-radius: 12px;
|
|
8
|
+
background: var(--xeplr-bg-primary, #1a1a1a);
|
|
9
|
+
}
|
|
10
|
+
.xeplr-imports-title { margin: 0 0 4px; font-size: 18px; font-weight: 600; }
|
|
11
|
+
.xeplr-imports-hint { margin: 0 0 14px; font-size: 13px; color: var(--xeplr-text-secondary, #999); }
|
|
12
|
+
.xeplr-imports-error {
|
|
13
|
+
margin-bottom: 12px; padding: 10px 12px; border-radius: 6px; font-size: 13px;
|
|
14
|
+
background: var(--xeplr-danger-bg, #3d1111); color: var(--xeplr-danger, #ff6b6b); border: 1px solid var(--xeplr-danger-border, #5a1a1a);
|
|
15
|
+
}
|
|
16
|
+
.xeplr-imports-pickers { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
|
17
|
+
.xeplr-imports-field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--xeplr-text-secondary, #999); }
|
|
18
|
+
.xeplr-imports-field select {
|
|
19
|
+
padding: 9px 10px; border-radius: 6px; font-size: 14px; font-family: inherit;
|
|
20
|
+
border: 1px solid var(--xeplr-border-primary, #444);
|
|
21
|
+
background: var(--xeplr-bg-secondary, #232323);
|
|
22
|
+
color: var(--xeplr-text-primary, #e0e0e0);
|
|
23
|
+
}
|
|
24
|
+
.xeplr-imports-progress { display: flex; align-items: center; gap: 10px; margin: 0 0 12px; font-size: 13px; }
|
|
25
|
+
.xeplr-imports-clock { color: var(--xeplr-text-secondary, #999); font-variant-numeric: tabular-nums; }
|
|
26
|
+
.xeplr-imports-stop {
|
|
27
|
+
padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;
|
|
28
|
+
border: 1px solid var(--xeplr-border-primary, #444); background: none; color: var(--xeplr-text-secondary, #999);
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ImportsSample } from './ImportsSample.jsx';
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// IMPORTS — model. The calls @xeplr/imports' routes answer, and the pure
|
|
2
|
+
// helpers the page needs. No React; fetch and XMLHttpRequest only.
|
|
3
|
+
//
|
|
4
|
+
// basePath is where the app mounted the router as the browser sees it —
|
|
5
|
+
// '/imports', or '/api/imports' behind a proxy prefix. Requests carry the
|
|
6
|
+
// headers configureUpload was given (uploadHeaders): sign-in and tenancy.
|
|
7
|
+
|
|
8
|
+
import { uploadFile, uploadHeaders } from '../fileUpload/uploadFile.js';
|
|
9
|
+
|
|
10
|
+
/** The import routes under one base path. Every call rejects with the server's own `error`. */
|
|
11
|
+
export function createImportsApi(basePath) {
|
|
12
|
+
var base = String(basePath || '/imports').replace(/\/+$/, '');
|
|
13
|
+
|
|
14
|
+
async function call(method, path, body) {
|
|
15
|
+
var headers = uploadHeaders();
|
|
16
|
+
if (body) headers['content-type'] = 'application/json';
|
|
17
|
+
var res = await fetch(base + path, { method: method, headers: headers, body: body ? JSON.stringify(body) : undefined });
|
|
18
|
+
var data = await res.json().catch(function() { return {}; });
|
|
19
|
+
if (!res.ok) throw new Error(data.error || ('The request failed (' + res.status + ')'));
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
upload: function(file, onProgress) { return uploadFile(file, { url: base + '/files', onProgress: onProgress }); },
|
|
25
|
+
inspect: function(filePath) { return call('POST', '/inspect', { filePath: filePath }); },
|
|
26
|
+
checkTarget: function(body) { return call('POST', '/check-target', body); },
|
|
27
|
+
commit: function(body) { return call('POST', '/commit', body); },
|
|
28
|
+
run: function(runId) { return call('GET', '/runs/' + encodeURIComponent(runId)); },
|
|
29
|
+
stop: function(runId) { return call('POST', '/runs/' + encodeURIComponent(runId) + '/stop'); },
|
|
30
|
+
targets: async function() { return (await call('GET', '/targets')).dataArray || []; },
|
|
31
|
+
history: async function() { return (await call('GET', '/history')).dataArray || []; },
|
|
32
|
+
dropped: function(id) { return call('GET', '/history/' + encodeURIComponent(id) + '/dropped'); },
|
|
33
|
+
rollback: function(id) { return call('POST', '/history/' + encodeURIComponent(id) + '/rollback'); },
|
|
34
|
+
remove: function(id) { return call('POST', '/history/' + encodeURIComponent(id) + '/delete'); },
|
|
35
|
+
/** The dropped rows as a file: { blob, name } — saving it is the page's job. */
|
|
36
|
+
droppedFile: async function(id) {
|
|
37
|
+
var res = await fetch(base + '/history/' + encodeURIComponent(id) + '/dropped/file', { headers: uploadHeaders() });
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
var data = await res.json().catch(function() { return {}; });
|
|
40
|
+
throw new Error(data.error || ('The download failed (' + res.status + ')'));
|
|
41
|
+
}
|
|
42
|
+
return { blob: await res.blob(), name: fileNameOf(res.headers.get('content-disposition'), 'dropped rows.xlsx') };
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The file name a Content-Disposition gives — filename* (UTF-8) first. */
|
|
48
|
+
export function fileNameOf(disposition, fallback) {
|
|
49
|
+
var cd = String(disposition || '');
|
|
50
|
+
var star = /filename\*=UTF-8''([^;]+)/i.exec(cd);
|
|
51
|
+
if (star) { try { return decodeURIComponent(star[1]); } catch (e) { /* the plain one */ } }
|
|
52
|
+
var plain = /filename="([^"]+)"/i.exec(cd);
|
|
53
|
+
return plain ? plain[1] : fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** "Sheet 2 of 3 (Orders) · 412,000 rows read · 400,000 written" — how far an import has got. */
|
|
57
|
+
export function importProgressText(p) {
|
|
58
|
+
if (!p) return 'Importing — reading the file…';
|
|
59
|
+
var parts = [];
|
|
60
|
+
if (p.mappings > 1) parts.push('Sheet ' + p.mapping + ' of ' + p.mappings + (p.sheet ? ' (' + p.sheet + ')' : ''));
|
|
61
|
+
if (p.rowsRead != null) parts.push(Number(p.rowsRead).toLocaleString() + ' rows read');
|
|
62
|
+
if (p.rowsWritten != null) parts.push(Number(p.rowsWritten).toLocaleString() + ' written');
|
|
63
|
+
if (p.phase === 'merging') parts.push('replacing the table');
|
|
64
|
+
return 'Importing · ' + (parts.join(' · ') || 'starting');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A finished run in DataImportPage's result shape, or the error it ended
|
|
69
|
+
* with. `run` is GET /runs/:id once its status is no longer 'running'.
|
|
70
|
+
*/
|
|
71
|
+
export function runResults(run) {
|
|
72
|
+
if (run.status === 'error') throw new Error(run.error || 'The import failed');
|
|
73
|
+
if (run.status === 'stopped') throw new Error(run.error === 'Stopped' || !run.error ? 'The import was stopped.' : run.error);
|
|
74
|
+
return ((run.result && run.result.results) || []).map(function(r) {
|
|
75
|
+
return {
|
|
76
|
+
part: r.sheet, target: r.targetTable, success: r.success, rows: r.rows, error: r.error,
|
|
77
|
+
dropped: r.dropped || 0, jobId: r.jobId || null,
|
|
78
|
+
note: r.recovered ? Number(r.recovered).toLocaleString() + ' of them recovered from cells they had been pasted into' : null
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A history row in ImportHistoryPage's shape. */
|
|
84
|
+
export function historyJob(row) {
|
|
85
|
+
return {
|
|
86
|
+
id: row.id, originalFileName: row.originalFileName, part: row.part, target: row.targetTable,
|
|
87
|
+
destination: row.destination, rows: row.totalRows, rowsLoaded: row.rowsLoaded, rowsDropped: row.rowsDropped,
|
|
88
|
+
mode: row.mode, status: row.status, startedAt: row.startedAt, error: row.errorMessage, createdByName: row.createdByName
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The databases of the chosen connection, for the second picker. */
|
|
93
|
+
export function databasesOf(targets, connectionId) {
|
|
94
|
+
var t = (targets || []).filter(function(x) { return x.connectionId === connectionId; })[0];
|
|
95
|
+
return (t && t.databases) || [];
|
|
96
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useImportsController } from './useImportsController.js';
|
|
2
|
+
import ImportsSample from './designs/ImportsSample.jsx';
|
|
3
|
+
import { IMPORTS_RULES } from './validateDesign.js';
|
|
4
|
+
import { useDesignValidator } from '../dataImport/validateDesign.js';
|
|
5
|
+
import { DataImportPage, ImportHistoryPage } from '../dataImport/pages.jsx';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The whole import feature against @xeplr/imports, ready to route to:
|
|
9
|
+
*
|
|
10
|
+
* <Route path="/upload" element={<ImportsPage basePath="/api/imports" />} />
|
|
11
|
+
*
|
|
12
|
+
* Requests carry the headers given to configureUpload (sign-in, tenancy).
|
|
13
|
+
*/
|
|
14
|
+
export function ImportsPage(props) {
|
|
15
|
+
var c = useImportsController(props);
|
|
16
|
+
var ref = useDesignValidator('ImportsPage', IMPORTS_RULES);
|
|
17
|
+
return (
|
|
18
|
+
<div ref={ref}>
|
|
19
|
+
<ImportsSample {...c}
|
|
20
|
+
importSection={<DataImportPage accept=".csv,.json,.xlsx,.xls" targetLabel="Table name" {...c.importProps} />}
|
|
21
|
+
historySection={<ImportHistoryPage key={c.historyVersion} {...c.historyProps} />} />
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|