@xeplr/ui-utils 1.0.11 → 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 CHANGED
@@ -74,7 +74,7 @@ export default function SelectCompany() {
74
74
  }
75
75
  ```
76
76
 
77
- 3. **Model only.** The model files import neither React nor the DOM: `dateRange.js`, `rangeField.js`, `textField.js`, `numberField.js`, `dateField.js`, `dropdown.js`, `gridDisplayer.js`, `dataImport.js`, `droppedRows.js`, and `theme/index.js`. They are exported from the root (for a bundler). In plain Node, import the file directly, because the root index pulls in JSX, CSS and JSON. The tests do this:
77
+ 3. **Model only.** The model files import neither React nor the DOM: `dateRange.js`, `rangeField.js`, `textField.js`, `numberField.js`, `dateField.js`, `dropdown.js`, `gridDisplayer.js`, `dataImport.js` (with `IMPORT_MODES`, `DEFAULT_MODE`, `targetProblems`, `blocksImport`, `canRollback`, `modeLabel`), `droppedRows.js`, `imports/importsApi.js` (fetch only), and `theme/index.js`. They are exported from the root (for a bundler). In plain Node, import the file directly, because the root index pulls in JSX, CSS and JSON. The tests do this:
78
78
 
79
79
  ```js
80
80
  import { resolveDateValue } from '@xeplr/ui-utils/src/dateRange/dateRange.js'
@@ -97,6 +97,7 @@ export default function SelectCompany() {
97
97
  | `NumberFieldPage` | [see below](#numberfieldpage) | Float, currency or units input with an affix picker |
98
98
  | `TextFieldPage` | [see below](#textfieldpage) | Text, multiline or search input, themed by `--xeplr-input-text-*` |
99
99
  | `FileUploadPage` | [see below](#fileuploadpage) | Drag-and-drop upload with type and size checks and a progress bar |
100
+ | `ImportsPage` | [see below](#importspage) | The whole import screen against `@xeplr/imports`: database picker, import, progress, history |
100
101
  | `DataImportPage` | [see below](#dataimportpage) | Upload a file, map its parts to targets, commit |
101
102
  | `ImportHistoryPage` | [see below](#importhistorypage) | Past imports in a `XeplrTable`, with Rollback and Delete |
102
103
  | `DroppedRowsPage` | [see below](#dropped-rows) | The rows an import's database refused, in a dialog: row, reason, Copy, Download. Built into the two pages above |
@@ -106,7 +107,7 @@ export default function SelectCompany() {
106
107
  | `SettingsTreePage` | [see below](#panel-panelsection-panelfield-expanderspage) | Nested groups of settings: cards, branches on a rail, one open per level |
107
108
  | `PanelSection`, `PanelField` | [see below](#panel-panelsection-panelfield-expanderspage) | A section card with an icon; a row with the label left and help under the control |
108
109
  | `ProgressNotifier` | [see below](#progressnotifier) | Indeterminate bar and a line of text for one event key |
109
- | `FileUploadSample`, `DataImportSample`, `ImportHistorySample`, `DroppedRowsSample`, `NumberFieldSample`, `DateFieldSample`, `DateRangeSample`, `RangeFieldSample`, `DropdownSample`, `GridDisplayerSample`, `TextFieldSample`, `ColorPickerSample`, `ExpandersSample`, `SettingsTreeSample` | the matching controller's return value | The default designs, usable on their own |
110
+ | `FileUploadSample`, `ImportsSample`, `DataImportSample`, `ImportHistorySample`, `DroppedRowsSample`, `NumberFieldSample`, `DateFieldSample`, `DateRangeSample`, `RangeFieldSample`, `DropdownSample`, `GridDisplayerSample`, `TextFieldSample`, `ColorPickerSample`, `ExpandersSample`, `SettingsTreeSample` | the matching controller's return value | The default designs, usable on their own |
110
111
 
111
112
  ### Controllers (hooks)
112
113
 
@@ -119,7 +120,8 @@ export default function SelectCompany() {
119
120
  | `useNumberFieldController(props)` | NumberFieldPage props | `type, raw, numeric, error, placeholder, disabled, decimals, formatDisplay, affix {list, selected, position, labelKey, valueKey} \| null, handleNumberChange, handleAffixChange, handleBlur` |
120
121
  | `useTextFieldController(props)` | TextFieldPage props | `type, raw, error, remaining, placeholder, disabled, required, maxLength, rows, label, help, handleChange, handleBlur, handleKeyDown, handleClear` |
121
122
  | `useFileUploadController(options)` | FileUploadPage props | `files, uploading, progress, error, result, dragOver, inputRef, handleChange, handleDragOver, handleDragLeave, handleDrop, handleUpload, openFilePicker, removeFile(i), clear, allowedTypes, maxSize, maxFiles` |
122
- | `useDataImportController(options)` | DataImportPage props | `file, inspecting, inspected, mappings, committing, results, error, handleFileChange, updateMapping(i, patch), setFix(i, key, on), checksOf(part), handleCommit, reset, accept, targetLabel` |
123
+ | `useImportsController(props)` | ImportsPage props | `targets, targetsError, connectionId, dbInfoId, databases, chooseConnection, chooseDatabase, importing, progressText, stop, historyVersion, importProps, historyProps` |
124
+ | `useDataImportController(options)` | DataImportPage props | `file, inspecting, uploadPercent, inspected, mappings, committing, results, error, handleFileChange, updateMapping(i, patch), setFix(i, key, on), checksOf(part), modes, setMode(i, mode), problems (per mapping), checking, blocked, handleCommit, reset, accept, targetLabel` |
123
125
  | `useImportHistoryController(options)` | ImportHistoryPage props | `jobs, loading, error, rollingBackId, deletingId, reload, handleRollback(job), handleDelete(job)` |
124
126
  | `useDroppedRowsController(options)` | `onLoadDropped`, `onDownloadDropped` | `job, data, table {columns, rows}, summary {heading, note}, loading, error, copied, downloading, canCopy, canDownload, canView(job), open(job), close, copy, download` |
125
127
  | `useGridDisplayerController(props)` | GridDisplayerPage props | `query, setQuery, sortFields, sortValue, setSortValue, displayRows, tileFormatComponent, onSelect, onAddNew` |
@@ -167,7 +169,7 @@ export default function SelectCompany() {
167
169
  | `deepMerge` | `(base, override)` | Immutable merge. Arrays are replaced, and `undefined` never overwrites a value. |
168
170
  | `COUNTRIES` | `[{ code, name }]` | 196 countries |
169
171
  | `STATES`, `STATES_IN` | `{ IN: [{ code, name }] }`, `[{ code, name }]` | 36 Indian states and union territories |
170
- | `FILE_UPLOAD_RULES`, `DATA_IMPORT_RULES`, `IMPORT_HISTORY_RULES`, `DROPPED_ROWS_RULES`, `NUMBER_FIELD_RULES`, `DATE_FIELD_RULES`, `DATE_RANGE_RULES`, `RANGE_FIELD_RULES`, `DROPDOWN_RULES`, `GRID_DISPLAYER_RULES`, `TEXT_FIELD_RULES`, `COLOR_PICKER_RULES`, `EXPANDERS_RULES`, `SETTINGS_TREE_RULES` | rule arrays | What each page requires of a design |
172
+ | `FILE_UPLOAD_RULES`, `IMPORTS_RULES`, `DATA_IMPORT_RULES`, `IMPORT_HISTORY_RULES`, `DROPPED_ROWS_RULES`, `NUMBER_FIELD_RULES`, `DATE_FIELD_RULES`, `DATE_RANGE_RULES`, `RANGE_FIELD_RULES`, `DROPDOWN_RULES`, `GRID_DISPLAYER_RULES`, `TEXT_FIELD_RULES`, `COLOR_PICKER_RULES`, `EXPANDERS_RULES`, `SETTINGS_TREE_RULES` | rule arrays | What each page requires of a design |
171
173
 
172
174
  ## DropdownPage
173
175
 
@@ -292,20 +294,63 @@ configureUpload({ url: '/uploads', getHeaders: () => ({ Authorization: 'Bearer '
292
294
 
293
295
  `uploadFile` POSTs `multipart/form-data` with `XMLHttpRequest`, so it can report upload progress. The default URL is `/internal/upload`. `getHeaders()`, if set, is called on **every** upload, so headers such as an auth token are always current. Otherwise the static `headers` are used. The response must be JSON. A non-2xx response rejects with the response's `error` or "Upload failed". If any picked file breaks a rule, the whole selection is rejected with a message naming that file.
294
296
 
297
+ ## ImportsPage
298
+
299
+ The whole import feature, against [`@xeplr/imports`](https://www.npmjs.com/package/@xeplr/imports)' routes. An app that mounts that router adds one route here and nothing else:
300
+
301
+ ```jsx
302
+ configureUpload({ url: API_BASE + '/imports/files', getHeaders }) // once, at startup — the headers every call carries
303
+ <Route path="/upload" element={<ImportsPage basePath={API_BASE + '/imports'} />} />
304
+ ```
305
+
306
+ | prop | type | meaning |
307
+ |---|---|---|
308
+ | `basePath` | `string` | Where the app mounted the router, as the browser sees it (default `'/imports'`) |
309
+ | `pollMs` | `number` | How often a running import is asked how far it has got (default 1000) |
310
+
311
+ It shows three things:
312
+
313
+ 1. **The database picker**, filled from `GET /targets`.
314
+ 2. **The import itself.** This is `DataImportPage`: its checks, the New table / Append / Replace modes with the file checked against the typed table, the upload percentage, and the dropped rows. While an import runs, its progress shows with its running time and ✕.
315
+ 3. **The history.** This is `ImportHistoryPage`, with dropped rows, rollback (none for a Replace) and delete.
316
+
317
+ Requests carry the headers `configureUpload` was given (`uploadHeaders()`), so sign-in and tenancy need no second setup. The model is `importsApi.js`:
318
+
319
+ - `createImportsApi(basePath)` makes every route call.
320
+ - Pure helpers: `importProgressText`, `runResults`, `historyJob`, `databasesOf`, `fileNameOf`.
321
+
322
+ A custom design receives the controller's values plus `importSection` and `historySection` as elements.
323
+
295
324
  ## DataImportPage
296
325
 
297
326
  | prop | type | meaning |
298
327
  |---|---|---|
299
328
  | `onInspect` | `(filePath, file) => Promise<{ format, parts?, checks? }>` | **Required.** Say what an uploaded file contains. `parts` holds, for example, an Excel workbook's sheet names. `checks` says what is wrong with a part before it is loaded — see below. |
300
- | `onCommit` | `(filePath, [{ part, target, fixes }], { originalName }) => Promise<{ results } \| results[]>` | **Required.** Load the included parts into their targets. `fixes` is the person's choice for each fix the checks offered. |
329
+ | `onCommit` | `(filePath, [{ part, target, mode, fixes }], { originalName }) => Promise<{ results } \| results[]>` | **Required.** Load the included parts into their targets. `mode` is `'new'`, `'append'` or `'replace'`; `fixes` is the person's choice for each fix the checks offered. |
330
+ | `onCheckTarget` | `(filePath, { part, target }) => Promise<{ exists, matched, extra, missing: [{ name, required }] } \| null>` | Optional. The part's columns against the table it names, asked as the person types (after a 400 ms pause). Without it nothing is checked and nothing blocked. A new function (another database chosen) asks again. |
301
331
  | `accept` | `string` | The file input's `accept` attribute, for example `'.csv,.json,.xlsx,.xls'` |
302
332
  | `targetLabel` | `string` | Placeholder for the target name (default `'Target'`) |
333
+ | `onUpload` | `(file, onProgress(percent)) => Promise<{ filePath }>` | Optional: where the file goes (default `uploadFile()` to `configureUpload`'s url). While it uploads, the page shows "Uploading the file… N%" |
303
334
  | `onLoadDropped`, `onDownloadDropped` | see [Dropped rows](#dropped-rows) | A result with `dropped > 0` and a `jobId` then says "N dropped — View" |
304
335
 
305
336
  The file is sent through this package's own `uploadFile()` to the URL set by `configureUpload`, not through a prop. That keeps the component free of app-specific upload wiring. The apps point it at `@xeplr/base-apis`' generic upload route. The upload response must include `filePath`. With `parts`, each part becomes a row (included by default) whose target is `sanitizeTargetName(part)`. Without `parts`, there is one row named after the file. Result rows show `target (part)` and then `rows` + " rows", "Done", or `error`, depending on `success`.
306
337
 
307
338
  **Checks, before anything is loaded.** `onInspect` may return `checks: { [part]: [{ key, message, fixLabel?, fixDefault?, examples? }] }` (the key `''` for a one-unit file). Each shows under its part as a warning with its message and examples. One with a `fixLabel` offers the fix as a checkbox, on unless `fixDefault: false`; the choice reaches `onCommit` as `fixes[key]`. A result row's `note` is shown under it — what a fix did. The component knows no particular check: the app decides what to look for and what its fix means (BI: rows pasted into one cell, recovered).
308
339
 
340
+ **After an import** its mapping clears, because the app has removed the file, and its results stay on screen. The file input is cleared on each click, so choosing the same file again (fixed and saved under its old name) is a new pick.
341
+
342
+ **New table, Append, Replace.** Each part has a mode, **New table** by default — adding to or emptying a table someone typed the name of is chosen, not assumed. With `onCheckTarget`, what the import will do is said under the part before anything loads (`targetProblems(target, mode, check)`, levels `error` / `warning` / `info` / `ok`):
343
+
344
+ | mode | the table | said |
345
+ |---|---|---|
346
+ | New table | not there | "A new table … will be created" |
347
+ | New table | there | **error**: "already exists. Choose Append … or Replace …, or type a new name" |
348
+ | Append, Replace | not there | **error**: "There is no table …. Check the name, or choose New table" |
349
+ | Append, Replace | there | file columns the table lacks: **error** (refused); table columns the file lacks: **error** when required (no empty value, no default — every row would be refused), else "left empty"; all matching: "All N columns match" |
350
+ | Replace | there | also a **warning**: it removes every row now in the table, and cannot be rolled back |
351
+
352
+ An `error` disables Import (`blocked`), as does an empty target or a check still running ("Checking the table…"). The person types the table's name; there is deliberately no list of the database's tables to pick from.
353
+
309
354
  `sanitizeTargetName('Sales 2024.xlsx')` returns `'sales_2024'`. It lowercases, drops the extension, turns runs of non-`[a-z0-9_]` characters into `_`, trims `_` from both ends, and falls back to `'table'`.
310
355
 
311
356
  ## ImportHistoryPage
@@ -317,7 +362,7 @@ The file is sent through this package's own `uploadFile()` to the URL set by `co
317
362
  | `onDelete` | `(job) => Promise` | Removes a history record. The Delete button is on every row, so without this prop it fails with an error. |
318
363
  | `onLoadDropped`, `onDownloadDropped` | see [Dropped rows](#dropped-rows) | A job with `rowsDropped > 0` gets a **View dropped** action |
319
364
 
320
- Job shape: `{ id, originalFileName, part, target, rows, status: 'completed' | 'failed' | 'rolled-back', startedAt, error, destination?, rowsLoaded?, rowsDropped?, createdByName? }`. Missing optional fields show "—". Both actions ask first with `raiseConfirm` (danger style). The Delete prompt warns that deleting a completed job's record does **not** undo its data and makes it impossible to roll back. The table shows 20 rows per page.
365
+ Job shape: `{ id, originalFileName, part, target, rows, status: 'completed' | 'failed' | 'rolled-back', startedAt, error, destination?, rowsLoaded?, rowsDropped?, mode?, createdByName? }`. `mode` shows beside the target (Replace highlighted), and **a Replace has no Rollback** (`canRollback(job)`): its old rows are gone. Missing optional fields show "—". Both actions ask first with `raiseConfirm` (danger style). The Delete prompt warns that deleting a completed job's record does **not** undo its data and makes it impossible to roll back. The table shows 20 rows per page.
321
366
 
322
367
  ## Dropped rows
323
368
 
@@ -468,6 +513,7 @@ These create their own DOM nodes with inline styles, so they need no provider, m
468
513
  | `NUMBER_FIELD_RULES` | `input[inputmode="decimal"]` or `input.xeplr-numberfield-input` |
469
514
  | `TEXT_FIELD_RULES` | a text or search input, a `textarea`, or `.xeplr-textfield-input` |
470
515
  | `FILE_UPLOAD_RULES` | `input[type="file"]`; `.xeplr-upload-dropzone` or `[role="dropzone"]`; a `button` |
516
+ | `IMPORTS_RULES` | `#xeplr-imports-connection`, `#xeplr-imports-database` |
471
517
  | `DATA_IMPORT_RULES` | `input[type="file"]` |
472
518
  | `GRID_DISPLAYER_RULES` | `.xeplr-grid-search`; `.xeplr-grid` |
473
519
  | `COLOR_PICKER_RULES` | `.xeplr-colorpicker-swatch` or `[role="option"]` |
@@ -547,6 +593,8 @@ npm test
547
593
 
548
594
  This runs each `test/*.test.js` with plain `node` and stops at the first failing file. The tests cover the date range model, the range field model, the text field model with `inputStyleVars`, the colour, expander and settings-tree models, and the design rules of the date range, colour picker, expanders and settings tree. CI runs the same files with `node --test` and coverage.
549
595
 
596
+ `test/importsApi.test.js` runs the imports model against the real `@xeplr/imports` router: a real Express app, real Postgres and a throwaway `xeplr_configs`. It runs when `PG_HOST`, `PG_PORT`, `PG_USER`, `PG_PASSWORD` and `PG_DATABASE` are set and `XEPLR_IMPORTS_DIR` names the package's folder; otherwise it is skipped. The upload itself uses XMLHttpRequest, so it only runs in a browser, where BI's screen is driven.
597
+
550
598
  ## License
551
599
 
552
600
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-utils",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
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",
@@ -35,7 +35,7 @@ function initialFixes(inspectResult, part) {
35
35
  }
36
36
 
37
37
  // inspectResult: { filePath, format, originalName?, parts?: string[], checks? }
38
- // -> [{ part: string|null, target: string, include: boolean, fixes: { [key]: boolean } }]
38
+ // -> [{ part: string|null, target: string, include: boolean, mode, fixes: { [key]: boolean } }]
39
39
  //
40
40
  // parts present (e.g. Excel sheets) -> one row per part, all included by
41
41
  // default. parts absent (e.g. CSV/JSON — the whole file is one unit) -> a
@@ -43,8 +43,88 @@ function initialFixes(inspectResult, part) {
43
43
  export function buildInitialMappings(inspectResult) {
44
44
  if (inspectResult.parts && inspectResult.parts.length) {
45
45
  return inspectResult.parts.map(function(part) {
46
- return { part: part, target: sanitizeTargetName(part), include: true, fixes: initialFixes(inspectResult, part) };
46
+ return { part: part, target: sanitizeTargetName(part), include: true, mode: DEFAULT_MODE, fixes: initialFixes(inspectResult, part) };
47
47
  });
48
48
  }
49
- return [{ part: null, target: sanitizeTargetName(inspectResult.originalName || 'data'), include: true, fixes: initialFixes(inspectResult, null) }];
49
+ return [{ part: null, target: sanitizeTargetName(inspectResult.originalName || 'data'), include: true, mode: DEFAULT_MODE, fixes: initialFixes(inspectResult, null) }];
50
+ }
51
+
52
+ // ── What the import does to its target ──────────────────────────────────
53
+ //
54
+ // New table: it must not exist yet. Append: add to one that exists. Replace:
55
+ // empty one that exists and load the file — the app makes that safe (BI
56
+ // stages it, so a failed import leaves the table as it was) and it cannot be
57
+ // rolled back. New is the default: adding to or emptying a table someone
58
+ // typed the name of is something they choose, not something that happens.
59
+ export var IMPORT_MODES = [
60
+ { key: 'new', label: 'New table' },
61
+ { key: 'append', label: 'Append' },
62
+ { key: 'replace', label: 'Replace' }
63
+ ];
64
+ export var DEFAULT_MODE = 'new';
65
+
66
+ /**
67
+ * What will happen to the target, said before anything is loaded, from the
68
+ * app's comparison of the file with the table (onCheckTarget):
69
+ * check = { exists, matched: string[], extra: string[], missing: [{ name, required }] }
70
+ *
71
+ * @returns {[{ level: 'error'|'warning'|'info'|'ok', message }]} — an 'error'
72
+ * blocks the import.
73
+ */
74
+ export function targetProblems(target, mode, check) {
75
+ if (!check) return [];
76
+ var name = '"' + target + '"';
77
+ var out = [];
78
+ if (!check.exists) {
79
+ if (mode === 'new') return [{ level: 'info', message: 'A new table ' + name + ' will be created from the file.' }];
80
+ return [{ level: 'error', message: 'There is no table ' + name + '. Check the name, or choose New table.' }];
81
+ }
82
+ if (mode === 'new') {
83
+ return [{ level: 'error', message: 'Table ' + name + ' already exists. Choose Append to add to it or Replace to replace its rows, or type a new name.' }];
84
+ }
85
+ var extra = check.extra || [];
86
+ var missing = check.missing || [];
87
+ var required = missing.filter(function(m) { return m.required; });
88
+ var optional = missing.filter(function(m) { return !m.required; });
89
+ if (extra.length) {
90
+ out.push({ level: 'error', message: 'The file has ' + countOf(extra.length, 'column') + ' the table does not: ' + listOf(extra) +
91
+ '. The import would be refused — rename or remove ' + (extra.length === 1 ? 'it' : 'them') + ' in the file.' });
92
+ }
93
+ if (required.length) {
94
+ out.push({ level: 'error', message: 'The table needs ' + listOf(required.map(nameOf)) + ', which the file does not have — every row would be refused.' });
95
+ }
96
+ if (optional.length) {
97
+ out.push({ level: 'info', message: 'The table has ' + listOf(optional.map(nameOf)) + ', which the file does not — ' +
98
+ (optional.length === 1 ? 'it' : 'they') + ' will be left empty.' });
99
+ }
100
+ if (!extra.length && !missing.length) {
101
+ out.push({ level: 'ok', message: 'All ' + countOf((check.matched || []).length, 'column') + ' match the table.' });
102
+ }
103
+ if (mode === 'replace') {
104
+ out.push({ level: 'warning', message: 'Replace removes every row now in ' + name + ' and puts the file\'s rows in their place. It cannot be rolled back.' });
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** Whether these problems stop the import. */
110
+ export function blocksImport(problems) {
111
+ return (problems || []).some(function(p) { return p.level === 'error'; });
112
+ }
113
+
114
+ /** Whether a past import can be undone: a Replace cannot — its old rows are gone. */
115
+ export function canRollback(job) {
116
+ return !!job && job.status === 'completed' && job.mode !== 'replace';
117
+ }
118
+
119
+ /** The words for a mode, for a history row: 'Replace', 'Append', 'New table'. */
120
+ export function modeLabel(mode) {
121
+ var m = IMPORT_MODES.filter(function(x) { return x.key === mode; })[0];
122
+ return m ? m.label : '';
123
+ }
124
+
125
+ function nameOf(m) { return m.name; }
126
+ function countOf(n, word) { return n + ' ' + word + (n === 1 ? '' : 's'); }
127
+ function listOf(names) {
128
+ var shown = names.slice(0, 6).map(function(n) { return '"' + n + '"'; }).join(', ');
129
+ return names.length > 6 ? shown + ' and ' + (names.length - 6) + ' more' : shown;
50
130
  }
@@ -1,18 +1,25 @@
1
1
  import './dataImport.css';
2
2
 
3
3
  export default function DataImportSample({
4
- file, inspecting, inspected, mappings, committing, results, error,
4
+ file, inspecting, uploadPercent, inspected, mappings, committing, results, error,
5
5
  handleFileChange, updateMapping, setFix, checksOf, handleCommit, reset, accept, targetLabel,
6
- canViewDropped, viewDropped
6
+ canViewDropped, viewDropped, modes, setMode, problems, checking, blocked
7
7
  }) {
8
8
  return (
9
9
  <div className="xeplr-data-import">
10
10
  <label className="xeplr-data-import-dropzone">
11
- <input type="file" accept={accept} onChange={handleFileChange} />
11
+ {/* Cleared on each pick, so choosing the same file again — fixed and
12
+ saved under its old name — is a new pick, not nothing. */}
13
+ <input type="file" accept={accept} onChange={handleFileChange}
14
+ onClick={function(e) { e.currentTarget.value = ''; }} />
12
15
  {file ? file.name : 'Choose a file to import'}
13
16
  </label>
14
17
 
15
- {inspecting && <p className="xeplr-data-import-hint">Inspecting file…</p>}
18
+ {inspecting && (
19
+ <p className="xeplr-data-import-hint">
20
+ {uploadPercent != null && uploadPercent < 100 ? 'Uploading the file… ' + uploadPercent + '%' : 'Inspecting file…'}
21
+ </p>
22
+ )}
16
23
  {error && <div className="xeplr-data-import-alert xeplr-data-import-alert-error">{error}</div>}
17
24
 
18
25
  {inspected && mappings.length > 0 && (
@@ -38,6 +45,30 @@ export default function DataImportSample({
38
45
  onChange={function(e) { updateMapping(i, { target: e.target.value }); }}
39
46
  placeholder={targetLabel}
40
47
  />
48
+ {/* What the import does to the table: make it, add to it, or
49
+ replace its rows. */}
50
+ {modes && (
51
+ <div className="xeplr-data-import-mode" role="radiogroup" aria-label="What to do with the table">
52
+ {modes.map(function(mode) {
53
+ var on = m.mode === mode.key;
54
+ return (
55
+ <button key={mode.key} type="button" role="radio" aria-checked={on} disabled={!m.include}
56
+ className={'xeplr-data-import-mode-option' + (on ? ' xeplr-data-import-mode-option-on' : '')}
57
+ onClick={function() { setMode(i, mode.key); }}>
58
+ {mode.label}
59
+ </button>
60
+ );
61
+ })}
62
+ </div>
63
+ )}
64
+ {/* What will happen to that table, said before anything loads. */}
65
+ {problems && (problems[i] || []).length > 0 && (
66
+ <ul className="xeplr-data-import-target">
67
+ {problems[i].map(function(p, pi) {
68
+ return <li key={pi} className={'xeplr-data-import-target-' + p.level}>{p.message}</li>;
69
+ })}
70
+ </ul>
71
+ )}
41
72
  {/* What the inspect step found, before anything is loaded —
42
73
  and the fix, when one is offered, as a choice. */}
43
74
  {(checksOf ? checksOf(m.part) : []).map(function(c) {
@@ -71,8 +102,9 @@ export default function DataImportSample({
71
102
 
72
103
  {inspected && mappings.length > 0 && (
73
104
  <div className="xeplr-data-import-actions">
74
- <button type="button" onClick={handleCommit} disabled={committing || !mappings.some(function(m) { return m.include; })}>
75
- {committing ? 'Importing…' : 'Import'}
105
+ <button type="button" onClick={handleCommit}
106
+ disabled={committing || checking || blocked || !mappings.some(function(m) { return m.include; })}>
107
+ {committing ? 'Importing…' : checking ? 'Checking the table…' : 'Import'}
76
108
  </button>
77
109
  <button type="button" onClick={reset} disabled={committing}>Cancel</button>
78
110
  </div>
@@ -1,6 +1,7 @@
1
1
  import './dataImport.css';
2
2
  import { XeplrTable } from '@xeplr/ui-table';
3
3
  import { raiseConfirm } from '../../confirm/confirm.js';
4
+ import { canRollback, modeLabel } from '../dataImport.js';
4
5
 
5
6
  var STATUS_LABEL = {
6
7
  completed: 'Completed',
@@ -31,7 +32,21 @@ var SCHEMA = {
31
32
  header: 'Destination',
32
33
  cell: function(info) { return info.getValue() || '—'; }
33
34
  },
34
- { accessor: 'target', header: 'Target' },
35
+ {
36
+ accessor: 'target',
37
+ header: 'Target',
38
+ // How it went in: a Replace is worth seeing at a glance — it is the
39
+ // one import that cannot be rolled back.
40
+ cell: function(info) {
41
+ var job = info.row.original;
42
+ return (
43
+ <span>
44
+ {info.getValue()}
45
+ {job.mode && <span className={'xeplr-import-history-mode xeplr-import-history-mode-' + job.mode}>{modeLabel(job.mode)}</span>}
46
+ </span>
47
+ );
48
+ }
49
+ },
35
50
  {
36
51
  accessor: 'rows',
37
52
  header: 'Rows',
@@ -107,7 +122,8 @@ export default function ImportHistorySample({ jobs, loading, error, rollingBackI
107
122
  key: 'rollback',
108
123
  label: 'Rollback',
109
124
  variant: 'danger',
110
- visible: function(job) { return job.status === 'completed'; },
125
+ // A Replace cannot be undone: its old rows are gone.
126
+ visible: function(job) { return canRollback(job); },
111
127
  disabled: function(job) { return rollingBackId === job.id; },
112
128
  onClick: onRollbackClick
113
129
  },
@@ -162,3 +162,48 @@
162
162
  .xeplr-data-import-dropped-actions { display: flex; align-items: center; gap: 8px; }
163
163
  .xeplr-data-import-dropped-spacer { flex: 1; }
164
164
  .xeplr-data-import-result-dropped { color: var(--xeplr-text-secondary, #999); font-size: 13px; }
165
+
166
+ /* ── What the import does to its table, and what that will mean ── */
167
+ .xeplr-data-import-mapping-row { flex-wrap: wrap; }
168
+ .xeplr-data-import-mode {
169
+ display: inline-flex;
170
+ border: 1px solid var(--xeplr-border-primary, #444);
171
+ border-radius: 6px;
172
+ overflow: hidden;
173
+ }
174
+ .xeplr-data-import-mode-option {
175
+ padding: 6px 12px;
176
+ border: none;
177
+ border-right: 1px solid var(--xeplr-border-primary, #444);
178
+ background: var(--xeplr-bg-secondary, #232323);
179
+ color: var(--xeplr-text-secondary, #999);
180
+ font-size: 13px;
181
+ cursor: pointer;
182
+ }
183
+ .xeplr-data-import-mode-option:last-child { border-right: none; }
184
+ .xeplr-data-import-mode-option-on { background: var(--xeplr-accent, #646cff); color: var(--xeplr-accent-text, #fff); }
185
+ .xeplr-data-import-mode-option:disabled { opacity: 0.5; cursor: default; }
186
+ .xeplr-data-import-target {
187
+ flex-basis: 100%;
188
+ margin: 2px 0 0;
189
+ padding: 0;
190
+ list-style: none;
191
+ display: flex;
192
+ flex-direction: column;
193
+ gap: 4px;
194
+ font-size: 13px;
195
+ }
196
+ .xeplr-data-import-target li { padding: 6px 10px; border-radius: 6px; border: 1px solid transparent; }
197
+ .xeplr-data-import-target-error { color: var(--xeplr-danger, #ff6b6b); background: var(--xeplr-danger-bg, #3d1111); border-color: var(--xeplr-danger-border, #5a1a1a); }
198
+ .xeplr-data-import-target-warning { color: var(--xeplr-warning, #a86a00); background: var(--xeplr-warning-bg, rgba(168, 106, 0, 0.08)); border-color: var(--xeplr-warning, #a86a00); }
199
+ .xeplr-data-import-target-info { color: var(--xeplr-text-secondary, #999); background: var(--xeplr-bg-secondary, #222); }
200
+ .xeplr-data-import-target-ok { color: var(--xeplr-success, #6bcc6b); }
201
+ .xeplr-import-history-mode {
202
+ margin-left: 6px;
203
+ padding: 1px 6px;
204
+ border-radius: 4px;
205
+ font-size: 11px;
206
+ background: var(--xeplr-bg-secondary, #222);
207
+ color: var(--xeplr-text-secondary, #999);
208
+ }
209
+ .xeplr-import-history-mode-replace { color: var(--xeplr-warning, #a86a00); background: var(--xeplr-warning-bg, rgba(168, 106, 0, 0.08)); }
@@ -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(); }
@@ -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
+ }
@@ -0,0 +1,125 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { createImportsApi, importProgressText, runResults, historyJob, databasesOf } from './importsApi.js';
3
+
4
+ /**
5
+ * The whole import screen against @xeplr/imports' routes: which database,
6
+ * the import itself (DataImportPage), its progress and ✕, and the history
7
+ * (ImportHistoryPage) with dropped rows, rollback and delete. No React-free
8
+ * logic lives here — see importsApi.js.
9
+ *
10
+ * @param {object} props
11
+ * @param {string} [props.basePath='/imports'] - where the app mounted the router, as the browser sees it
12
+ * @param {number} [props.pollMs=1000] - how often a running import is asked how far it has got
13
+ */
14
+ export function useImportsController(props = {}) {
15
+ var api = useMemo(function() { return createImportsApi(props.basePath); }, [props.basePath]);
16
+ var pollMs = props.pollMs || 1000;
17
+
18
+ var [targets, setTargets] = useState([]);
19
+ var [targetsError, setTargetsError] = useState('');
20
+ var [connectionId, setConnectionId] = useState('');
21
+ var [dbInfoId, setDbInfoId] = useState('');
22
+ // The import running now: { runId, startedAt, progress }.
23
+ var [importing, setImporting] = useState(null);
24
+ // Bumped to make the history list read itself again.
25
+ var [historyVersion, setHistoryVersion] = useState(0);
26
+ var mounted = useRef(true);
27
+ useEffect(function() { mounted.current = true; return function() { mounted.current = false; }; }, []);
28
+
29
+ useEffect(function() {
30
+ api.targets().then(function(list) {
31
+ if (!mounted.current) return;
32
+ setTargets(list);
33
+ if (list.length) {
34
+ setConnectionId(list[0].connectionId);
35
+ setDbInfoId(((list[0].databases || [])[0] || {}).id || '');
36
+ }
37
+ }, function(err) { if (mounted.current) setTargetsError(err.message); });
38
+ }, [api]);
39
+
40
+ function chooseConnection(id) {
41
+ setConnectionId(id);
42
+ setDbInfoId((databasesOf(targets, id)[0] || {}).id || '');
43
+ }
44
+
45
+ var onUpload = useCallback(function(file, onProgress) { return api.upload(file, onProgress); }, [api]);
46
+
47
+ var onInspect = useCallback(async function(filePath) {
48
+ var found = await api.inspect(filePath);
49
+ return { format: found.format, parts: found.sheets, checks: found.checks };
50
+ }, [api]);
51
+
52
+ // Bound to the chosen database, so choosing another asks again.
53
+ var onCheckTarget = useCallback(function(filePath, part) {
54
+ if (!connectionId || !dbInfoId) return Promise.resolve(null);
55
+ return api.checkTarget({ filePath: filePath, sheet: part.part, connectionId: connectionId, dbInfoId: dbInfoId, targetTable: part.target });
56
+ }, [api, connectionId, dbInfoId]);
57
+
58
+ // STARTS the import, then asks how far it has got until it ends. The
59
+ // request answers at once with a run id, so no gateway timeout applies.
60
+ var onCommit = useCallback(async function(filePath, mappings, meta) {
61
+ if (!connectionId || !dbInfoId) throw new Error('Choose a database to import into first');
62
+ var started = await api.commit({
63
+ filePath: filePath, connectionId: connectionId, dbInfoId: dbInfoId, originalFileName: meta && meta.originalName,
64
+ mappings: mappings.map(function(m) {
65
+ return { sheet: m.part, targetTable: m.target, mode: m.mode, repairPastedRows: Boolean(m.fixes && m.fixes.pastedRows) };
66
+ })
67
+ });
68
+ setImporting({ runId: started.runId, startedAt: Date.now(), progress: null });
69
+ setHistoryVersion(function(v) { return v + 1; }); // in the history from its first row
70
+ try {
71
+ for (;;) {
72
+ await new Promise(function(r) { setTimeout(r, pollMs); });
73
+ var run = await api.run(started.runId);
74
+ if (run.status !== 'running') return { results: runResults(run) };
75
+ if (mounted.current) setImporting(function(cur) { return cur && cur.runId === run.id ? Object.assign({}, cur, { progress: run.progress }) : cur; });
76
+ }
77
+ } finally {
78
+ if (mounted.current) {
79
+ setImporting(null);
80
+ setHistoryVersion(function(v) { return v + 1; });
81
+ }
82
+ }
83
+ }, [api, connectionId, dbInfoId, pollMs]);
84
+
85
+ function stop() {
86
+ if (importing) api.stop(importing.runId).catch(function() {});
87
+ }
88
+
89
+ var onList = useCallback(async function() {
90
+ return { jobs: (await api.history()).map(historyJob) };
91
+ }, [api]);
92
+ var onRollback = useCallback(function(job) { return api.rollback(job.id); }, [api]);
93
+ var onDelete = useCallback(function(job) { return api.remove(job.id); }, [api]);
94
+ var onLoadDropped = useCallback(function(job) { return api.dropped(job.id); }, [api]);
95
+ var onDownloadDropped = useCallback(async function(job) {
96
+ var file = await api.droppedFile(job.id);
97
+ var url = URL.createObjectURL(file.blob);
98
+ var a = document.createElement('a');
99
+ a.href = url;
100
+ a.download = file.name;
101
+ document.body.appendChild(a);
102
+ a.click();
103
+ a.remove();
104
+ setTimeout(function() { URL.revokeObjectURL(url); }, 0);
105
+ }, [api]);
106
+
107
+ return {
108
+ targets: targets,
109
+ targetsError: targetsError,
110
+ connectionId: connectionId,
111
+ dbInfoId: dbInfoId,
112
+ databases: databasesOf(targets, connectionId),
113
+ chooseConnection: chooseConnection,
114
+ chooseDatabase: setDbInfoId,
115
+ importing: importing,
116
+ progressText: importing ? importProgressText(importing.progress) : '',
117
+ stop: stop,
118
+ historyVersion: historyVersion,
119
+ // What the two built-in pages take.
120
+ importProps: { onUpload: onUpload, onInspect: onInspect, onCheckTarget: onCheckTarget, onCommit: onCommit,
121
+ onLoadDropped: onLoadDropped, onDownloadDropped: onDownloadDropped },
122
+ historyProps: { onList: onList, onRollback: onRollback, onDelete: onDelete,
123
+ onLoadDropped: onLoadDropped, onDownloadDropped: onDownloadDropped }
124
+ };
125
+ }
@@ -0,0 +1,6 @@
1
+ // What an ImportsPage design must have: the two pickers that say where the
2
+ // file goes. (The import and history sections validate themselves.)
3
+ export var IMPORTS_RULES = [
4
+ { id: 'xeplr-imports-connection', label: 'Connection picker' },
5
+ { id: 'xeplr-imports-database', label: 'Database picker' }
6
+ ];
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ export { FileUploadPage } from './fileUpload/pages.jsx';
17
17
  // the whole file, for CSV/JSON) to app-defined named targets, commit. The
18
18
  // app supplies onInspect/onCommit (its own upload + load endpoints); this
19
19
  // module only owns the file-pick -> mapping -> commit flow and UI.
20
- export { sanitizeTargetName, buildInitialMappings } from './dataImport/dataImport.js';
20
+ export { sanitizeTargetName, buildInitialMappings, IMPORT_MODES, DEFAULT_MODE, targetProblems, blocksImport, canRollback, modeLabel } from './dataImport/dataImport.js';
21
21
 
22
22
  // DataImport — controller
23
23
  export { useDataImportController } from './dataImport/useDataImportController.js';
@@ -50,6 +50,16 @@ export { DROPPED_ROWS_RULES } from './dataImport/validateDesign.js';
50
50
  export { DroppedRowsSample } from './dataImport/designs/index.js';
51
51
  export { DroppedRowsPage } from './dataImport/pages.jsx';
52
52
 
53
+ // Imports — the whole import screen against @xeplr/imports' routes: database
54
+ // picker, import (DataImportPage), progress with ✕, history (ImportHistoryPage).
55
+ // <ImportsPage basePath="/api/imports" /> is all an app adds.
56
+ export { createImportsApi, importProgressText, runResults, historyJob, databasesOf, fileNameOf } from './imports/importsApi.js';
57
+ export { useImportsController } from './imports/useImportsController.js';
58
+ export { IMPORTS_RULES } from './imports/validateDesign.js';
59
+ export { ImportsSample } from './imports/designs/index.js';
60
+ export { ImportsPage } from './imports/pages.jsx';
61
+ export { uploadHeaders } from './fileUpload/uploadFile.js';
62
+
53
63
  // NumberField — model
54
64
  export {
55
65
  NUMBER_TYPES,