@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.
@@ -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
+ }
@@ -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 { useDesignValidator, DATA_IMPORT_RULES, IMPORT_HISTORY_RULES } from './validateDesign.js';
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 <div ref={ref}><DataImportSample {...controller} /></div>;
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 <div ref={ref}><ImportHistorySample {...controller} /></div>;
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
- var uploadRes = await uploadFile(picked);
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 &amp; 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
+ }