@xeplr/ui-utils 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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`, 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,15 +97,17 @@ 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 |
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 |
102
104
  | `GridDisplayerPage` | [see below](#griddisplayerpage) | Searchable, sortable grid of tiles you render |
103
105
  | `ColorPickerPage` | [see below](#colorpickerpage) | Swatches, no colour, a custom colour, opacity and a live preview, as one CSS colour |
104
106
  | `ExpandersPage` | [see below](#panel-panelsection-panelfield-expanderspage) | Titled parts of a panel, one open at a time |
105
107
  | `SettingsTreePage` | [see below](#panel-panelsection-panelfield-expanderspage) | Nested groups of settings: cards, branches on a rail, one open per level |
106
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 |
107
109
  | `ProgressNotifier` | [see below](#progressnotifier) | Indeterminate bar and a line of text for one event key |
108
- | `FileUploadSample`, `DataImportSample`, `ImportHistorySample`, `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 |
109
111
 
110
112
  ### Controllers (hooks)
111
113
 
@@ -118,8 +120,10 @@ export default function SelectCompany() {
118
120
  | `useNumberFieldController(props)` | NumberFieldPage props | `type, raw, numeric, error, placeholder, disabled, decimals, formatDisplay, affix {list, selected, position, labelKey, valueKey} \| null, handleNumberChange, handleAffixChange, handleBlur` |
119
121
  | `useTextFieldController(props)` | TextFieldPage props | `type, raw, error, remaining, placeholder, disabled, required, maxLength, rows, label, help, handleChange, handleBlur, handleKeyDown, handleClear` |
120
122
  | `useFileUploadController(options)` | FileUploadPage props | `files, uploading, progress, error, result, dragOver, inputRef, handleChange, handleDragOver, handleDragLeave, handleDrop, handleUpload, openFilePicker, removeFile(i), clear, allowedTypes, maxSize, maxFiles` |
121
- | `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` |
122
125
  | `useImportHistoryController(options)` | ImportHistoryPage props | `jobs, loading, error, rollingBackId, deletingId, reload, handleRollback(job), handleDelete(job)` |
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` |
123
127
  | `useGridDisplayerController(props)` | GridDisplayerPage props | `query, setQuery, sortFields, sortValue, setSortValue, displayRows, tileFormatComponent, onSelect, onAddNew` |
124
128
  | `useColorPickerController(props)` | ColorPickerPage props | `label, value, hex, opacity, swatches [{color, selected}], custom, allowNone, allowOpacity, previewText, pick(hex), setOpacity(0–100), clear` |
125
129
  | `useExpandersController(props)` | ExpandersPage props | `items` (each with `open`), `toggle(key)` |
@@ -165,7 +169,7 @@ export default function SelectCompany() {
165
169
  | `deepMerge` | `(base, override)` | Immutable merge. Arrays are replaced, and `undefined` never overwrites a value. |
166
170
  | `COUNTRIES` | `[{ code, name }]` | 196 countries |
167
171
  | `STATES`, `STATES_IN` | `{ IN: [{ code, name }] }`, `[{ code, name }]` | 36 Indian states and union territories |
168
- | `FILE_UPLOAD_RULES`, `DATA_IMPORT_RULES`, `IMPORT_HISTORY_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 |
169
173
 
170
174
  ## DropdownPage
171
175
 
@@ -290,19 +294,63 @@ configureUpload({ url: '/uploads', getHeaders: () => ({ Authorization: 'Bearer '
290
294
 
291
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.
292
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
+
293
324
  ## DataImportPage
294
325
 
295
326
  | prop | type | meaning |
296
327
  |---|---|---|
297
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. |
298
- | `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. |
299
331
  | `accept` | `string` | The file input's `accept` attribute, for example `'.csv,.json,.xlsx,.xls'` |
300
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%" |
334
+ | `onLoadDropped`, `onDownloadDropped` | see [Dropped rows](#dropped-rows) | A result with `dropped > 0` and a `jobId` then says "N dropped — View" |
301
335
 
302
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`.
303
337
 
304
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).
305
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
+
306
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'`.
307
355
 
308
356
  ## ImportHistoryPage
@@ -312,8 +360,22 @@ The file is sent through this package's own `uploadFile()` to the URL set by `co
312
360
  | `onList` | `() => Promise<{ jobs } \| jobs[]>` | **Required, and must be a stable function**: module-level, or wrapped in `useCallback`. The list reloads whenever its identity changes. |
313
361
  | `onRollback` | `(job) => Promise` | Rollback of a `completed` job. The list reloads afterwards. |
314
362
  | `onDelete` | `(job) => Promise` | Removes a history record. The Delete button is on every row, so without this prop it fails with an error. |
363
+ | `onLoadDropped`, `onDownloadDropped` | see [Dropped rows](#dropped-rows) | A job with `rowsDropped > 0` gets a **View dropped** action |
364
+
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.
366
+
367
+ ## Dropped rows
315
368
 
316
- 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.
369
+ Rows an import's database refused (a constraint, a value its column will not take) are not in the table. Without a way to see them, "63 dropped" is a number nobody can act on. `DataImportPage` and `ImportHistoryPage` show them when the app passes:
370
+
371
+ | prop | type | meaning |
372
+ |---|---|---|
373
+ | `onLoadDropped` | `(job) => Promise<{ columns, rows: [{ rowNum, reason, values }], total, dropped }>` | The dropped rows of one import. `columns` are the file's, in order; `values` is keyed by them; `total` is how many were kept, `dropped` how many the import counted. Without this prop nothing offers to show them. |
374
+ | `onDownloadDropped` | `(job) => Promise` | Saves them as a file. Make it one that imports again as it is: the file's columns only, header in row 1 (BI: an .xlsx). |
375
+
376
+ `job` is a history row (`rowsDropped`), or an import result (`{ id: result.jobId, dropped }`). The dialog shows the heading ("2 rows dropped from stock.csv → stock"), a table of row number, **Why it was dropped** and the file's columns, and **Copy**, **Download Excel**, **Close** (Escape and a click outside close it too). **Copy** puts the file's columns and the rows on the clipboard as tab-separated text, quoted as Excel quotes it, so it pastes into Excel as cells; like the download, it leaves out the row number and reason, so pasted, fixed and saved, it imports again. A note says when not every row is there: an import from before rows were kept (only a count), fewer kept than dropped, or only the first ones shown.
377
+
378
+ The model (`droppedRows.js`) is pure: `droppedCount(job)`, `droppedTable(data)` (keys `c0`, `c1`, … because a column name may hold a dot), `droppedAsTsv(data)`, `tsvCell(value)`, `droppedSummary(job, data)`.
317
379
 
318
380
  ## GridDisplayerPage
319
381
 
@@ -451,12 +513,14 @@ These create their own DOM nodes with inline styles, so they need no provider, m
451
513
  | `NUMBER_FIELD_RULES` | `input[inputmode="decimal"]` or `input.xeplr-numberfield-input` |
452
514
  | `TEXT_FIELD_RULES` | a text or search input, a `textarea`, or `.xeplr-textfield-input` |
453
515
  | `FILE_UPLOAD_RULES` | `input[type="file"]`; `.xeplr-upload-dropzone` or `[role="dropzone"]`; a `button` |
516
+ | `IMPORTS_RULES` | `#xeplr-imports-connection`, `#xeplr-imports-database` |
454
517
  | `DATA_IMPORT_RULES` | `input[type="file"]` |
455
518
  | `GRID_DISPLAYER_RULES` | `.xeplr-grid-search`; `.xeplr-grid` |
456
519
  | `COLOR_PICKER_RULES` | `.xeplr-colorpicker-swatch` or `[role="option"]` |
457
520
  | `EXPANDERS_RULES` | `.xeplr-expander-head` or `[aria-expanded]` |
458
521
  | `SETTINGS_TREE_RULES` | `.xeplr-tree-head` or `[aria-expanded]` |
459
522
  | `IMPORT_HISTORY_RULES` | nothing |
523
+ | `DROPPED_ROWS_RULES` | `#xeplr-dropped-rows-copy`, `#xeplr-dropped-rows-download`, `#xeplr-dropped-rows-close` — checked when the dialog opens, since it is not on the page before |
460
524
 
461
525
  ## Theming
462
526
 
@@ -529,6 +593,8 @@ npm test
529
593
 
530
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.
531
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
+
532
598
  ## License
533
599
 
534
600
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-utils",
3
- "version": "1.0.10",
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,17 +1,25 @@
1
1
  import './dataImport.css';
2
2
 
3
3
  export default function DataImportSample({
4
- file, inspecting, inspected, mappings, committing, results, error,
5
- handleFileChange, updateMapping, setFix, checksOf, handleCommit, reset, accept, targetLabel
4
+ file, inspecting, uploadPercent, inspected, mappings, committing, results, error,
5
+ handleFileChange, updateMapping, setFix, checksOf, handleCommit, reset, accept, targetLabel,
6
+ canViewDropped, viewDropped, modes, setMode, problems, checking, blocked
6
7
  }) {
7
8
  return (
8
9
  <div className="xeplr-data-import">
9
10
  <label className="xeplr-data-import-dropzone">
10
- <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 = ''; }} />
11
15
  {file ? file.name : 'Choose a file to import'}
12
16
  </label>
13
17
 
14
- {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
+ )}
15
23
  {error && <div className="xeplr-data-import-alert xeplr-data-import-alert-error">{error}</div>}
16
24
 
17
25
  {inspected && mappings.length > 0 && (
@@ -37,6 +45,30 @@ export default function DataImportSample({
37
45
  onChange={function(e) { updateMapping(i, { target: e.target.value }); }}
38
46
  placeholder={targetLabel}
39
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
+ )}
40
72
  {/* What the inspect step found, before anything is loaded —
41
73
  and the fix, when one is offered, as a choice. */}
42
74
  {(checksOf ? checksOf(m.part) : []).map(function(c) {
@@ -70,8 +102,9 @@ export default function DataImportSample({
70
102
 
71
103
  {inspected && mappings.length > 0 && (
72
104
  <div className="xeplr-data-import-actions">
73
- <button type="button" onClick={handleCommit} disabled={committing || !mappings.some(function(m) { return m.include; })}>
74
- {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'}
75
108
  </button>
76
109
  <button type="button" onClick={reset} disabled={committing}>Cancel</button>
77
110
  </div>
@@ -87,6 +120,19 @@ export default function DataImportSample({
87
120
  {r.success ? (r.rows != null ? r.rows + ' rows' : 'Done') : (r.error || 'Failed')}
88
121
  </span>
89
122
  {r.note && <span className="xeplr-data-import-result-note">{r.note}</span>}
123
+ {r.success && r.dropped > 0 && (
124
+ <span className="xeplr-data-import-result-dropped">
125
+ {r.dropped.toLocaleString()} dropped
126
+ {canViewDropped && canViewDropped({ id: r.jobId, dropped: r.dropped }) && (
127
+ <>
128
+ {' — '}
129
+ <button type="button" className="xeplr-data-import-link" onClick={function() {
130
+ viewDropped({ id: r.jobId, dropped: r.dropped, originalFileName: file && file.name, part: r.part, target: r.target });
131
+ }}>View</button>
132
+ </>
133
+ )}
134
+ </span>
135
+ )}
90
136
  </div>
91
137
  );
92
138
  })}
@@ -0,0 +1,65 @@
1
+ import { useEffect } from 'react';
2
+ import './dataImport.css';
3
+ import { XeplrTable } from '@xeplr/ui-table';
4
+
5
+ /**
6
+ * The rows an import dropped, in a dialog: each with its place in the file and
7
+ * why the database refused it, and Copy / Download to fix and import again.
8
+ * Presentation only — everything comes from useDroppedRowsController.
9
+ *
10
+ * Required (DROPPED_ROWS_RULES): #xeplr-dropped-rows-copy, #xeplr-dropped-rows-download,
11
+ * #xeplr-dropped-rows-close.
12
+ */
13
+ export default function DroppedRowsSample({
14
+ job, table, summary, loading, error, copied, downloading, canCopy, canDownload, copy, download, close
15
+ }) {
16
+ useEffect(function() {
17
+ function onKey(e) { if (e.key === 'Escape') close(); }
18
+ window.addEventListener('keydown', onKey);
19
+ return function() { window.removeEventListener('keydown', onKey); };
20
+ }, [close]);
21
+
22
+ if (!job) return null;
23
+ var schema = table ? { 0: { columns: table.columns.map(function(c) {
24
+ return { accessor: c.key, header: c.header, cell: c.key === 'reason'
25
+ ? function(info) { return <span className="xeplr-data-import-dropped-reason">{info.getValue()}</span>; }
26
+ : undefined };
27
+ }) } } : null;
28
+
29
+ return (
30
+ <div className="xeplr-data-import-dropped-backdrop" onMouseDown={function(e) { if (e.target === e.currentTarget) close(); }}>
31
+ <div className="xeplr-data-import-dropped" role="dialog" aria-modal="true" aria-label={summary ? summary.heading : 'Dropped rows'}>
32
+ <div className="xeplr-data-import-dropped-head">
33
+ <h3 className="xeplr-data-import-dropped-title">{summary && summary.heading}</h3>
34
+ <p className="xeplr-data-import-hint">
35
+ The database refused these rows, so they are not in the table. Copy or download them, fix them, and import them again — the
36
+ file has the same columns as yours.
37
+ </p>
38
+ </div>
39
+
40
+ {summary && summary.note && <div className="xeplr-data-import-dropped-note">{summary.note}</div>}
41
+ {error && <div className="xeplr-data-import-alert xeplr-data-import-alert-error">{error}</div>}
42
+ {loading && <p className="xeplr-data-import-hint">Loading the dropped rows…</p>}
43
+
44
+ {table && table.rows.length > 0 && (
45
+ <div className="xeplr-data-import-dropped-table">
46
+ <XeplrTable data={table.rows} schema={schema} pageSize={50} />
47
+ </div>
48
+ )}
49
+
50
+ <div className="xeplr-data-import-dropped-actions">
51
+ <button id="xeplr-dropped-rows-copy" type="button" className="xeplr-data-import-button"
52
+ disabled={!canCopy} onClick={copy}>
53
+ {copied ? 'Copied' : 'Copy'}
54
+ </button>
55
+ <button id="xeplr-dropped-rows-download" type="button" className="xeplr-data-import-button xeplr-data-import-button-primary"
56
+ disabled={!canDownload || downloading} onClick={download}>
57
+ {downloading ? 'Downloading…' : 'Download Excel'}
58
+ </button>
59
+ <span className="xeplr-data-import-dropped-spacer" />
60
+ <button id="xeplr-dropped-rows-close" type="button" className="xeplr-data-import-button" onClick={close}>Close</button>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ );
65
+ }
@@ -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',
@@ -78,7 +93,7 @@ var SCHEMA = {
78
93
  }
79
94
  };
80
95
 
81
- export default function ImportHistorySample({ jobs, loading, error, rollingBackId, deletingId, handleRollback, handleDelete }) {
96
+ export default function ImportHistorySample({ jobs, loading, error, rollingBackId, deletingId, handleRollback, handleDelete, canViewDropped, viewDropped }) {
82
97
  async function onRollbackClick(job) {
83
98
  var ok = await raiseConfirm(
84
99
  'This deletes every row loaded into "' + job.target + '" by this import. This cannot be undone.',
@@ -96,11 +111,19 @@ export default function ImportHistorySample({ jobs, loading, error, rollingBackI
96
111
  }
97
112
 
98
113
  var rowActions = [
114
+ {
115
+ // The rows the database refused: see them, copy or download them.
116
+ key: 'dropped',
117
+ label: 'View dropped',
118
+ visible: function(job) { return !!canViewDropped && canViewDropped(job); },
119
+ onClick: function(job) { viewDropped(job); }
120
+ },
99
121
  {
100
122
  key: 'rollback',
101
123
  label: 'Rollback',
102
124
  variant: 'danger',
103
- 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); },
104
127
  disabled: function(job) { return rollingBackId === job.id; },
105
128
  onClick: onRollbackClick
106
129
  },
@@ -115,3 +115,95 @@
115
115
  .xeplr-data-import-check-examples { margin: 6px 0 0; padding-left: 18px; font-size: 12px; color: var(--xeplr-text-muted, #888); }
116
116
  .xeplr-data-import-result-note { flex-basis: 100%; font-size: 12px; color: var(--xeplr-text-muted, #888); }
117
117
  .xeplr-data-import-result-row { flex-wrap: wrap; }
118
+
119
+ /* ── Dropped rows: the rows an import's database refused, in a dialog ── */
120
+ .xeplr-data-import-button {
121
+ padding: 7px 14px;
122
+ border-radius: 6px;
123
+ border: 1px solid var(--xeplr-border-primary, #444);
124
+ background: var(--xeplr-bg-secondary, #232323);
125
+ color: var(--xeplr-text-primary, #e0e0e0);
126
+ font-size: 13px;
127
+ cursor: pointer;
128
+ }
129
+ .xeplr-data-import-button-primary { border-color: transparent; background: var(--xeplr-accent, #646cff); color: var(--xeplr-accent-text, #fff); }
130
+ .xeplr-data-import-button:disabled { opacity: 0.6; cursor: default; }
131
+ .xeplr-data-import-link {
132
+ padding: 0; border: none; background: none; cursor: pointer;
133
+ color: var(--xeplr-accent, #646cff); font-size: inherit; text-decoration: underline;
134
+ }
135
+
136
+ .xeplr-data-import-dropped-backdrop {
137
+ position: fixed; inset: 0; z-index: 1000;
138
+ display: flex; align-items: center; justify-content: center;
139
+ padding: 24px;
140
+ background: rgba(0, 0, 0, 0.55);
141
+ }
142
+ .xeplr-data-import-dropped {
143
+ display: flex; flex-direction: column; gap: 12px;
144
+ width: min(1100px, 100%); max-height: calc(100vh - 48px);
145
+ padding: 20px;
146
+ border: 1px solid var(--xeplr-border-primary, #444);
147
+ border-radius: 10px;
148
+ background: var(--xeplr-bg-primary, #1a1a1a);
149
+ color: var(--xeplr-text-primary, #e0e0e0);
150
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
151
+ }
152
+ .xeplr-data-import-dropped-head { display: flex; flex-direction: column; gap: 4px; }
153
+ .xeplr-data-import-dropped-title { margin: 0; font-size: 16px; font-weight: 600; }
154
+ .xeplr-data-import-dropped-note {
155
+ padding: 8px 12px; border-radius: 6px; font-size: 13px;
156
+ background: var(--xeplr-bg-secondary, #222);
157
+ border: 1px solid var(--xeplr-border-secondary, #2a2a2a);
158
+ color: var(--xeplr-text-secondary, #999);
159
+ }
160
+ .xeplr-data-import-dropped-table { flex: 1 1 auto; min-height: 0; overflow: auto; }
161
+ .xeplr-data-import-dropped-reason { color: var(--xeplr-danger, #ff6b6b); white-space: normal; }
162
+ .xeplr-data-import-dropped-actions { display: flex; align-items: center; gap: 8px; }
163
+ .xeplr-data-import-dropped-spacer { flex: 1; }
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,2 +1,3 @@
1
1
  export { default as DataImportSample } from './DataImportSample.jsx';
2
2
  export { default as ImportHistorySample } from './ImportHistorySample.jsx';
3
+ export { default as DroppedRowsSample } from './DroppedRowsSample.jsx';