@xeplr/ui-utils 1.0.10 → 1.0.11
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 +21 -3
- package/package.json +1 -1
- package/src/dataImport/designs/DataImportSample.jsx +15 -1
- package/src/dataImport/designs/DroppedRowsSample.jsx +65 -0
- package/src/dataImport/designs/ImportHistorySample.jsx +8 -1
- package/src/dataImport/designs/dataImport.css +47 -0
- package/src/dataImport/designs/index.js +1 -0
- package/src/dataImport/droppedRows.js +86 -0
- package/src/dataImport/pages.jsx +26 -3
- package/src/dataImport/useDroppedRowsController.js +95 -0
- package/src/dataImport/validateDesign.js +7 -0
- package/src/index.js +10 -0
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`, `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:
|
|
78
78
|
|
|
79
79
|
```js
|
|
80
80
|
import { resolveDateValue } from '@xeplr/ui-utils/src/dateRange/dateRange.js'
|
|
@@ -99,13 +99,14 @@ export default function SelectCompany() {
|
|
|
99
99
|
| `FileUploadPage` | [see below](#fileuploadpage) | Drag-and-drop upload with type and size checks and a progress bar |
|
|
100
100
|
| `DataImportPage` | [see below](#dataimportpage) | Upload a file, map its parts to targets, commit |
|
|
101
101
|
| `ImportHistoryPage` | [see below](#importhistorypage) | Past imports in a `XeplrTable`, with Rollback and Delete |
|
|
102
|
+
| `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
103
|
| `GridDisplayerPage` | [see below](#griddisplayerpage) | Searchable, sortable grid of tiles you render |
|
|
103
104
|
| `ColorPickerPage` | [see below](#colorpickerpage) | Swatches, no colour, a custom colour, opacity and a live preview, as one CSS colour |
|
|
104
105
|
| `ExpandersPage` | [see below](#panel-panelsection-panelfield-expanderspage) | Titled parts of a panel, one open at a time |
|
|
105
106
|
| `SettingsTreePage` | [see below](#panel-panelsection-panelfield-expanderspage) | Nested groups of settings: cards, branches on a rail, one open per level |
|
|
106
107
|
| `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
108
|
| `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 |
|
|
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 |
|
|
109
110
|
|
|
110
111
|
### Controllers (hooks)
|
|
111
112
|
|
|
@@ -120,6 +121,7 @@ export default function SelectCompany() {
|
|
|
120
121
|
| `useFileUploadController(options)` | FileUploadPage props | `files, uploading, progress, error, result, dragOver, inputRef, handleChange, handleDragOver, handleDragLeave, handleDrop, handleUpload, openFilePicker, removeFile(i), clear, allowedTypes, maxSize, maxFiles` |
|
|
121
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` |
|
|
122
123
|
| `useImportHistoryController(options)` | ImportHistoryPage props | `jobs, loading, error, rollingBackId, deletingId, reload, handleRollback(job), handleDelete(job)` |
|
|
124
|
+
| `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
125
|
| `useGridDisplayerController(props)` | GridDisplayerPage props | `query, setQuery, sortFields, sortValue, setSortValue, displayRows, tileFormatComponent, onSelect, onAddNew` |
|
|
124
126
|
| `useColorPickerController(props)` | ColorPickerPage props | `label, value, hex, opacity, swatches [{color, selected}], custom, allowNone, allowOpacity, previewText, pick(hex), setOpacity(0–100), clear` |
|
|
125
127
|
| `useExpandersController(props)` | ExpandersPage props | `items` (each with `open`), `toggle(key)` |
|
|
@@ -165,7 +167,7 @@ export default function SelectCompany() {
|
|
|
165
167
|
| `deepMerge` | `(base, override)` | Immutable merge. Arrays are replaced, and `undefined` never overwrites a value. |
|
|
166
168
|
| `COUNTRIES` | `[{ code, name }]` | 196 countries |
|
|
167
169
|
| `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 |
|
|
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 |
|
|
169
171
|
|
|
170
172
|
## DropdownPage
|
|
171
173
|
|
|
@@ -298,6 +300,7 @@ configureUpload({ url: '/uploads', getHeaders: () => ({ Authorization: 'Bearer '
|
|
|
298
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. |
|
|
299
301
|
| `accept` | `string` | The file input's `accept` attribute, for example `'.csv,.json,.xlsx,.xls'` |
|
|
300
302
|
| `targetLabel` | `string` | Placeholder for the target name (default `'Target'`) |
|
|
303
|
+
| `onLoadDropped`, `onDownloadDropped` | see [Dropped rows](#dropped-rows) | A result with `dropped > 0` and a `jobId` then says "N dropped — View" |
|
|
301
304
|
|
|
302
305
|
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
306
|
|
|
@@ -312,9 +315,23 @@ The file is sent through this package's own `uploadFile()` to the URL set by `co
|
|
|
312
315
|
| `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
316
|
| `onRollback` | `(job) => Promise` | Rollback of a `completed` job. The list reloads afterwards. |
|
|
314
317
|
| `onDelete` | `(job) => Promise` | Removes a history record. The Delete button is on every row, so without this prop it fails with an error. |
|
|
318
|
+
| `onLoadDropped`, `onDownloadDropped` | see [Dropped rows](#dropped-rows) | A job with `rowsDropped > 0` gets a **View dropped** action |
|
|
315
319
|
|
|
316
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.
|
|
317
321
|
|
|
322
|
+
## Dropped rows
|
|
323
|
+
|
|
324
|
+
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:
|
|
325
|
+
|
|
326
|
+
| prop | type | meaning |
|
|
327
|
+
|---|---|---|
|
|
328
|
+
| `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. |
|
|
329
|
+
| `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). |
|
|
330
|
+
|
|
331
|
+
`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.
|
|
332
|
+
|
|
333
|
+
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)`.
|
|
334
|
+
|
|
318
335
|
## GridDisplayerPage
|
|
319
336
|
|
|
320
337
|
```jsx
|
|
@@ -457,6 +474,7 @@ These create their own DOM nodes with inline styles, so they need no provider, m
|
|
|
457
474
|
| `EXPANDERS_RULES` | `.xeplr-expander-head` or `[aria-expanded]` |
|
|
458
475
|
| `SETTINGS_TREE_RULES` | `.xeplr-tree-head` or `[aria-expanded]` |
|
|
459
476
|
| `IMPORT_HISTORY_RULES` | nothing |
|
|
477
|
+
| `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
478
|
|
|
461
479
|
## Theming
|
|
462
480
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,8 @@ import './dataImport.css';
|
|
|
2
2
|
|
|
3
3
|
export default function DataImportSample({
|
|
4
4
|
file, inspecting, inspected, mappings, committing, results, error,
|
|
5
|
-
handleFileChange, updateMapping, setFix, checksOf, handleCommit, reset, accept, targetLabel
|
|
5
|
+
handleFileChange, updateMapping, setFix, checksOf, handleCommit, reset, accept, targetLabel,
|
|
6
|
+
canViewDropped, viewDropped
|
|
6
7
|
}) {
|
|
7
8
|
return (
|
|
8
9
|
<div className="xeplr-data-import">
|
|
@@ -87,6 +88,19 @@ export default function DataImportSample({
|
|
|
87
88
|
{r.success ? (r.rows != null ? r.rows + ' rows' : 'Done') : (r.error || 'Failed')}
|
|
88
89
|
</span>
|
|
89
90
|
{r.note && <span className="xeplr-data-import-result-note">{r.note}</span>}
|
|
91
|
+
{r.success && r.dropped > 0 && (
|
|
92
|
+
<span className="xeplr-data-import-result-dropped">
|
|
93
|
+
{r.dropped.toLocaleString()} dropped
|
|
94
|
+
{canViewDropped && canViewDropped({ id: r.jobId, dropped: r.dropped }) && (
|
|
95
|
+
<>
|
|
96
|
+
{' — '}
|
|
97
|
+
<button type="button" className="xeplr-data-import-link" onClick={function() {
|
|
98
|
+
viewDropped({ id: r.jobId, dropped: r.dropped, originalFileName: file && file.name, part: r.part, target: r.target });
|
|
99
|
+
}}>View</button>
|
|
100
|
+
</>
|
|
101
|
+
)}
|
|
102
|
+
</span>
|
|
103
|
+
)}
|
|
90
104
|
</div>
|
|
91
105
|
);
|
|
92
106
|
})}
|
|
@@ -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
|
+
}
|
|
@@ -78,7 +78,7 @@ var SCHEMA = {
|
|
|
78
78
|
}
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
-
export default function ImportHistorySample({ jobs, loading, error, rollingBackId, deletingId, handleRollback, handleDelete }) {
|
|
81
|
+
export default function ImportHistorySample({ jobs, loading, error, rollingBackId, deletingId, handleRollback, handleDelete, canViewDropped, viewDropped }) {
|
|
82
82
|
async function onRollbackClick(job) {
|
|
83
83
|
var ok = await raiseConfirm(
|
|
84
84
|
'This deletes every row loaded into "' + job.target + '" by this import. This cannot be undone.',
|
|
@@ -96,6 +96,13 @@ export default function ImportHistorySample({ jobs, loading, error, rollingBackI
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
var rowActions = [
|
|
99
|
+
{
|
|
100
|
+
// The rows the database refused: see them, copy or download them.
|
|
101
|
+
key: 'dropped',
|
|
102
|
+
label: 'View dropped',
|
|
103
|
+
visible: function(job) { return !!canViewDropped && canViewDropped(job); },
|
|
104
|
+
onClick: function(job) { viewDropped(job); }
|
|
105
|
+
},
|
|
99
106
|
{
|
|
100
107
|
key: 'rollback',
|
|
101
108
|
label: 'Rollback',
|
|
@@ -115,3 +115,50 @@
|
|
|
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; }
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ROWS AN IMPORT DROPPED — the ones the database refused — as the dropped-rows
|
|
2
|
+
// view shows them, copies them and counts them. Pure: no React, no fetch.
|
|
3
|
+
//
|
|
4
|
+
// The app's onLoadDropped(job) answers:
|
|
5
|
+
// { columns: ['sku', 'qty'], // the file's own, in order
|
|
6
|
+
// rows: [{ rowNum: 5, reason: '…', values: { sku: 'S5', qty: -1 } }],
|
|
7
|
+
// total: 2, // rows kept to look at
|
|
8
|
+
// dropped: 2 } // rows the import counted
|
|
9
|
+
|
|
10
|
+
/** How many rows a job or an import result dropped (either shape). */
|
|
11
|
+
export function droppedCount(job) {
|
|
12
|
+
if (!job) return 0;
|
|
13
|
+
var n = job.rowsDropped != null ? job.rowsDropped : job.dropped;
|
|
14
|
+
return Number(n) || 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The view's table: a row number, the reason, then the file's columns. Keys
|
|
19
|
+
* are positional (c0, c1, …) because a column name can hold a dot, which a
|
|
20
|
+
* table's accessor reads as a path.
|
|
21
|
+
*
|
|
22
|
+
* @returns {{ columns: [{ key, header }], rows: object[] }}
|
|
23
|
+
*/
|
|
24
|
+
export function droppedTable(data) {
|
|
25
|
+
var names = (data && data.columns) || [];
|
|
26
|
+
var columns = [{ key: 'rowNum', header: 'Row' }, { key: 'reason', header: 'Why it was dropped' }]
|
|
27
|
+
.concat(names.map(function(name, i) { return { key: 'c' + i, header: name }; }));
|
|
28
|
+
var rows = ((data && data.rows) || []).map(function(r) {
|
|
29
|
+
var row = { rowNum: r.rowNum == null ? '' : r.rowNum, reason: r.reason || '' };
|
|
30
|
+
names.forEach(function(name, i) { row['c' + i] = displayValue(r.values ? r.values[name] : null); });
|
|
31
|
+
return row;
|
|
32
|
+
});
|
|
33
|
+
return { columns: columns, rows: rows };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function displayValue(v) {
|
|
37
|
+
if (v == null) return '';
|
|
38
|
+
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The rows as tab-separated text with a header — what Excel takes from the
|
|
43
|
+
* clipboard as cells. The file's columns only, like the download: pasted,
|
|
44
|
+
* fixed and saved, it imports again as it is. A value holding a tab, a line
|
|
45
|
+
* break or a quote is quoted, its quotes doubled, as Excel itself copies it.
|
|
46
|
+
*/
|
|
47
|
+
export function droppedAsTsv(data) {
|
|
48
|
+
var names = (data && data.columns) || [];
|
|
49
|
+
var lines = [names.map(tsvCell).join('\t')];
|
|
50
|
+
((data && data.rows) || []).forEach(function(r) {
|
|
51
|
+
lines.push(names.map(function(name) { return tsvCell(displayValue(r.values ? r.values[name] : null)); }).join('\t'));
|
|
52
|
+
});
|
|
53
|
+
return lines.join('\r\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function tsvCell(value) {
|
|
57
|
+
var s = value == null ? '' : String(value);
|
|
58
|
+
return /[\t\r\n"]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What to say above the rows: the heading, and a note when not every dropped
|
|
63
|
+
* row is here — the view shows the first ones, or the import predates rows
|
|
64
|
+
* being kept and has only a count.
|
|
65
|
+
*/
|
|
66
|
+
export function droppedSummary(job, data) {
|
|
67
|
+
var dropped = data && data.dropped != null ? Number(data.dropped) : droppedCount(job);
|
|
68
|
+
var total = data ? Number(data.total) || 0 : 0;
|
|
69
|
+
var shown = data && data.rows ? data.rows.length : 0;
|
|
70
|
+
var heading = plural(dropped, 'row') + ' dropped' +
|
|
71
|
+
(job && job.originalFileName ? ' from ' + job.originalFileName + (job.part ? ' (' + job.part + ')' : '') : '') +
|
|
72
|
+
(job && job.target ? ' → ' + job.target : '');
|
|
73
|
+
var note = null;
|
|
74
|
+
if (data && total === 0 && dropped > 0) {
|
|
75
|
+
note = 'These rows were not kept — this import ran before dropped rows were saved. Import the file again to see them.';
|
|
76
|
+
} else if (data && total < dropped) {
|
|
77
|
+
note = 'Only ' + plural(total, 'row') + ' of ' + dropped + ' were kept; the import stopped recording after too many failures in a row.';
|
|
78
|
+
} else if (data && shown < total) {
|
|
79
|
+
note = 'Showing the first ' + shown.toLocaleString() + ' of ' + total.toLocaleString() + '. Download has all of them.';
|
|
80
|
+
}
|
|
81
|
+
return { heading: heading, note: note };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function plural(n, word) {
|
|
85
|
+
return Number(n).toLocaleString() + ' ' + word + (Number(n) === 1 ? '' : 's');
|
|
86
|
+
}
|
package/src/dataImport/pages.jsx
CHANGED
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
import { useDataImportController } from './useDataImportController.js';
|
|
2
2
|
import { useImportHistoryController } from './useImportHistoryController.js';
|
|
3
|
+
import { useDroppedRowsController } from './useDroppedRowsController.js';
|
|
3
4
|
import DataImportSample from './designs/DataImportSample.jsx';
|
|
4
5
|
import ImportHistorySample from './designs/ImportHistorySample.jsx';
|
|
5
|
-
import
|
|
6
|
+
import DroppedRowsSample from './designs/DroppedRowsSample.jsx';
|
|
7
|
+
import { useDesignValidator, DATA_IMPORT_RULES, IMPORT_HISTORY_RULES, DROPPED_ROWS_RULES } from './validateDesign.js';
|
|
6
8
|
|
|
9
|
+
// Props: onInspect, onCommit, … and, to show the rows an import dropped,
|
|
10
|
+
// onLoadDropped(job) and onDownloadDropped(job) (see useDroppedRowsController).
|
|
7
11
|
export function DataImportPage(props) {
|
|
8
12
|
var controller = useDataImportController(props);
|
|
13
|
+
var dropped = useDroppedRowsController(props);
|
|
9
14
|
var ref = useDesignValidator('DataImportPage', DATA_IMPORT_RULES);
|
|
10
|
-
return
|
|
15
|
+
return (
|
|
16
|
+
<div ref={ref}>
|
|
17
|
+
<DataImportSample {...controller} canViewDropped={dropped.canView} viewDropped={dropped.open} />
|
|
18
|
+
{dropped.job && <DroppedRowsPage {...dropped} />}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
11
21
|
}
|
|
12
22
|
|
|
13
23
|
export function ImportHistoryPage(props) {
|
|
14
24
|
var controller = useImportHistoryController(props);
|
|
25
|
+
var dropped = useDroppedRowsController(props);
|
|
15
26
|
var ref = useDesignValidator('ImportHistoryPage', IMPORT_HISTORY_RULES);
|
|
16
|
-
return
|
|
27
|
+
return (
|
|
28
|
+
<div ref={ref}>
|
|
29
|
+
<ImportHistorySample {...controller} canViewDropped={dropped.canView} viewDropped={dropped.open} />
|
|
30
|
+
{dropped.job && <DroppedRowsPage {...dropped} />}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The dropped-rows dialog, validated as it opens — its required buttons only
|
|
36
|
+
// exist while it is on screen.
|
|
37
|
+
export function DroppedRowsPage(props) {
|
|
38
|
+
var ref = useDesignValidator('DroppedRows', DROPPED_ROWS_RULES);
|
|
39
|
+
return <div ref={ref}><DroppedRowsSample {...props} /></div>;
|
|
17
40
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { droppedAsTsv, droppedCount, droppedSummary, droppedTable } from './droppedRows.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The rows an import dropped: open them, copy them, download them.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} options
|
|
8
|
+
* @param {(job) => Promise<{columns, rows, total, dropped}>} [options.onLoadDropped]
|
|
9
|
+
* the app's read of a job's dropped rows. Without it nothing offers to show them.
|
|
10
|
+
* @param {(job) => Promise<any>} [options.onDownloadDropped]
|
|
11
|
+
* the app saves them as a file (an .xlsx that imports again as it is)
|
|
12
|
+
*
|
|
13
|
+
* job: { id, rowsDropped|dropped, originalFileName?, part?, target? } — an
|
|
14
|
+
* import-history row, or an import result with its jobId as id.
|
|
15
|
+
*/
|
|
16
|
+
export function useDroppedRowsController(options = {}) {
|
|
17
|
+
var [job, setJob] = useState(null);
|
|
18
|
+
var [data, setData] = useState(null);
|
|
19
|
+
var [loading, setLoading] = useState(false);
|
|
20
|
+
var [error, setError] = useState('');
|
|
21
|
+
var [copied, setCopied] = useState(false);
|
|
22
|
+
var [downloading, setDownloading] = useState(false);
|
|
23
|
+
var copiedTimer = useRef(null);
|
|
24
|
+
var onLoad = options.onLoadDropped;
|
|
25
|
+
|
|
26
|
+
useEffect(function() { return function() { clearTimeout(copiedTimer.current); }; }, []);
|
|
27
|
+
|
|
28
|
+
var canView = useCallback(function(j) {
|
|
29
|
+
return !!onLoad && !!j && j.id != null && droppedCount(j) > 0;
|
|
30
|
+
}, [onLoad]);
|
|
31
|
+
|
|
32
|
+
var open = useCallback(async function(j) {
|
|
33
|
+
setJob(j);
|
|
34
|
+
setData(null);
|
|
35
|
+
setError('');
|
|
36
|
+
setCopied(false);
|
|
37
|
+
setLoading(true);
|
|
38
|
+
try {
|
|
39
|
+
setData(await onLoad(j));
|
|
40
|
+
} catch (err) {
|
|
41
|
+
setError(err.message || 'Could not load the dropped rows');
|
|
42
|
+
} finally {
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
}, [onLoad]);
|
|
46
|
+
|
|
47
|
+
function close() {
|
|
48
|
+
setJob(null);
|
|
49
|
+
setData(null);
|
|
50
|
+
setError('');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function copy() {
|
|
54
|
+
if (!data) return;
|
|
55
|
+
try {
|
|
56
|
+
await navigator.clipboard.writeText(droppedAsTsv(data));
|
|
57
|
+
setCopied(true);
|
|
58
|
+
clearTimeout(copiedTimer.current);
|
|
59
|
+
copiedTimer.current = setTimeout(function() { setCopied(false); }, 2000);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
setError('Could not copy — the browser refused the clipboard. Use Download instead.');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function download() {
|
|
66
|
+
if (!options.onDownloadDropped || !job) return;
|
|
67
|
+
setDownloading(true);
|
|
68
|
+
setError('');
|
|
69
|
+
try {
|
|
70
|
+
await options.onDownloadDropped(job);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
setError(err.message || 'Download failed');
|
|
73
|
+
} finally {
|
|
74
|
+
setDownloading(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
job: job,
|
|
80
|
+
data: data,
|
|
81
|
+
table: data ? droppedTable(data) : null,
|
|
82
|
+
summary: job ? droppedSummary(job, data) : null,
|
|
83
|
+
loading: loading,
|
|
84
|
+
error: error,
|
|
85
|
+
copied: copied,
|
|
86
|
+
downloading: downloading,
|
|
87
|
+
canDownload: !!options.onDownloadDropped && !!data && data.total > 0,
|
|
88
|
+
canCopy: !!data && data.rows && data.rows.length > 0,
|
|
89
|
+
canView: canView,
|
|
90
|
+
open: open,
|
|
91
|
+
close: close,
|
|
92
|
+
copy: copy,
|
|
93
|
+
download: download
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -66,3 +66,10 @@ export var DATA_IMPORT_RULES = [
|
|
|
66
66
|
];
|
|
67
67
|
|
|
68
68
|
export var IMPORT_HISTORY_RULES = [];
|
|
69
|
+
|
|
70
|
+
// Checked when the dialog opens — it is not on the page before.
|
|
71
|
+
export var DROPPED_ROWS_RULES = [
|
|
72
|
+
{ id: 'xeplr-dropped-rows-copy', label: 'Copy button' },
|
|
73
|
+
{ id: 'xeplr-dropped-rows-download', label: 'Download button' },
|
|
74
|
+
{ id: 'xeplr-dropped-rows-close', label: 'Close button' }
|
|
75
|
+
];
|
package/src/index.js
CHANGED
|
@@ -40,6 +40,16 @@ export { IMPORT_HISTORY_RULES } from './dataImport/validateDesign.js';
|
|
|
40
40
|
export { ImportHistorySample } from './dataImport/designs/index.js';
|
|
41
41
|
export { ImportHistoryPage } from './dataImport/pages.jsx';
|
|
42
42
|
|
|
43
|
+
// DroppedRows — the rows an import's database refused, with each one's place
|
|
44
|
+
// in the file and the reason; Copy (tab-separated, pastes into Excel) and
|
|
45
|
+
// Download (app-supplied onDownloadDropped). Built into DataImportPage and
|
|
46
|
+
// ImportHistoryPage when the app passes onLoadDropped.
|
|
47
|
+
export { droppedCount, droppedTable, droppedAsTsv, droppedSummary, tsvCell } from './dataImport/droppedRows.js';
|
|
48
|
+
export { useDroppedRowsController } from './dataImport/useDroppedRowsController.js';
|
|
49
|
+
export { DROPPED_ROWS_RULES } from './dataImport/validateDesign.js';
|
|
50
|
+
export { DroppedRowsSample } from './dataImport/designs/index.js';
|
|
51
|
+
export { DroppedRowsPage } from './dataImport/pages.jsx';
|
|
52
|
+
|
|
43
53
|
// NumberField — model
|
|
44
54
|
export {
|
|
45
55
|
NUMBER_TYPES,
|