@xeplr/ui-utils 1.0.9 → 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 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
 
@@ -118,8 +119,9 @@ export default function SelectCompany() {
118
119
  | `useNumberFieldController(props)` | NumberFieldPage props | `type, raw, numeric, error, placeholder, disabled, decimals, formatDisplay, affix {list, selected, position, labelKey, valueKey} \| null, handleNumberChange, handleAffixChange, handleBlur` |
119
120
  | `useTextFieldController(props)` | TextFieldPage props | `type, raw, error, remaining, placeholder, disabled, required, maxLength, rows, label, help, handleChange, handleBlur, handleKeyDown, handleClear` |
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
- | `useDataImportController(options)` | DataImportPage props | `file, inspecting, inspected, mappings, committing, results, error, handleFileChange, updateMapping(i, patch), handleCommit, reset, accept, targetLabel` |
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)` |
@@ -146,7 +148,7 @@ export default function SelectCompany() {
146
148
  | `toggleOpen`, `initialOpen` | `(open, key, multiple?) → keys`, `(items, initial?, multiple?) → keys` | Which expanders are open |
147
149
  | `toggleBranch`, `decorateTree` | `(open, parentKey, key) → open`, `(nodes, open) → nodes` | Which branch of a settings tree is open at each level; `open` is `{ [parentKey]: key }`, the root's parent `''` |
148
150
  | `filterGridRows`, `sortGridRows` | `(rows, query, fields)`, `(rows, field, 'asc'\|'desc')` | Grid search and sort. An array field matches if any entry matches. |
149
- | `sanitizeTargetName`, `buildInitialMappings` | `(name) → string`, `({ parts?, originalName? }) → [{ part, target, include }]` | Data import mapping |
151
+ | `sanitizeTargetName`, `buildInitialMappings`, `checksFor` | `(name) → string`, `({ parts?, originalName?, checks? }) → [{ part, target, include, fixes }]`, `(inspectResult, part) → checks[]` | Data import mapping |
150
152
  | `configureUpload`, `uploadFile` | `({ url, headers, getHeaders })`, `(files, { url, fieldName, extraData, onProgress }) → Promise` | Multipart upload over XHR |
151
153
  | `attachProgressSource`, `progressSourceAttached`, `subscribeToProgress`, `describeProgress`, `describeComplete` | `(subscribe)`, `() → boolean`, `(key, handler) → unsubscribe`, `(data, noun?)`, `(data, noun?)` | The progress event source and its sentences |
152
154
 
@@ -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
 
@@ -294,13 +296,16 @@ configureUpload({ url: '/uploads', getHeaders: () => ({ Authorization: 'Bearer '
294
296
 
295
297
  | prop | type | meaning |
296
298
  |---|---|---|
297
- | `onInspect` | `(filePath, file) => Promise<{ format, parts? }>` | **Required.** Say what an uploaded file contains. `parts` holds, for example, an Excel workbook's sheet names. |
298
- | `onCommit` | `(filePath, [{ part, target }], { originalName }) => Promise<{ results } \| results[]>` | **Required.** Load the included parts into their targets. |
299
+ | `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. |
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
 
307
+ **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
+
304
309
  `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'`.
305
310
 
306
311
  ## ImportHistoryPage
@@ -310,9 +315,23 @@ The file is sent through this package's own `uploadFile()` to the URL set by `co
310
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. |
311
316
  | `onRollback` | `(job) => Promise` | Rollback of a `completed` job. The list reloads afterwards. |
312
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 |
313
319
 
314
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.
315
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
+
316
335
  ## GridDisplayerPage
317
336
 
318
337
  ```jsx
@@ -455,6 +474,7 @@ These create their own DOM nodes with inline styles, so they need no provider, m
455
474
  | `EXPANDERS_RULES` | `.xeplr-expander-head` or `[aria-expanded]` |
456
475
  | `SETTINGS_TREE_RULES` | `.xeplr-tree-head` or `[aria-expanded]` |
457
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 |
458
478
 
459
479
  ## Theming
460
480
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-utils",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
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",
@@ -16,8 +16,26 @@ export function sanitizeTargetName(name) {
16
16
  .replace(/^_+|_+$/g, '') || 'table';
17
17
  }
18
18
 
19
- // inspectResult: { filePath, format, originalName?, parts?: string[] }
20
- // -> [{ part: string|null, target: string, include: boolean }]
19
+ // What the inspect step found wrong with one part, if anything:
20
+ // inspectResult.checks = { [part]: [{ key, message, fixLabel?, fixDefault? }] }
21
+ // (the key '' for a file that is one unit). A check with a fixLabel offers a
22
+ // fix the person switches on or off before importing; its choice travels
23
+ // with the mapping as fixes[key].
24
+ export function checksFor(inspectResult, part) {
25
+ var all = (inspectResult && inspectResult.checks) || {};
26
+ return all[part == null ? '' : part] || [];
27
+ }
28
+
29
+ function initialFixes(inspectResult, part) {
30
+ var fixes = {};
31
+ checksFor(inspectResult, part).forEach(function(c) {
32
+ if (c.fixLabel) fixes[c.key] = c.fixDefault !== false;
33
+ });
34
+ return fixes;
35
+ }
36
+
37
+ // inspectResult: { filePath, format, originalName?, parts?: string[], checks? }
38
+ // -> [{ part: string|null, target: string, include: boolean, fixes: { [key]: boolean } }]
21
39
  //
22
40
  // parts present (e.g. Excel sheets) -> one row per part, all included by
23
41
  // default. parts absent (e.g. CSV/JSON — the whole file is one unit) -> a
@@ -25,8 +43,8 @@ export function sanitizeTargetName(name) {
25
43
  export function buildInitialMappings(inspectResult) {
26
44
  if (inspectResult.parts && inspectResult.parts.length) {
27
45
  return inspectResult.parts.map(function(part) {
28
- return { part: part, target: sanitizeTargetName(part), include: true };
46
+ return { part: part, target: sanitizeTargetName(part), include: true, fixes: initialFixes(inspectResult, part) };
29
47
  });
30
48
  }
31
- return [{ part: null, target: sanitizeTargetName(inspectResult.originalName || 'data'), include: true }];
49
+ return [{ part: null, target: sanitizeTargetName(inspectResult.originalName || 'data'), include: true, fixes: initialFixes(inspectResult, null) }];
32
50
  }
@@ -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, 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">
@@ -37,6 +38,31 @@ export default function DataImportSample({
37
38
  onChange={function(e) { updateMapping(i, { target: e.target.value }); }}
38
39
  placeholder={targetLabel}
39
40
  />
41
+ {/* What the inspect step found, before anything is loaded —
42
+ and the fix, when one is offered, as a choice. */}
43
+ {(checksOf ? checksOf(m.part) : []).map(function(c) {
44
+ return (
45
+ <div key={c.key} className="xeplr-data-import-check" role="alert">
46
+ <p className="xeplr-data-import-check-message">{c.message}</p>
47
+ {c.fixLabel && (
48
+ <label className="xeplr-data-import-check-fix">
49
+ <input
50
+ type="checkbox"
51
+ checked={Boolean(m.fixes && m.fixes[c.key])}
52
+ disabled={!m.include}
53
+ onChange={function(e) { setFix(i, c.key, e.target.checked); }}
54
+ />
55
+ {c.fixLabel}
56
+ </label>
57
+ )}
58
+ {(c.examples || []).length > 0 && (
59
+ <ul className="xeplr-data-import-check-examples">
60
+ {c.examples.map(function(x, xi) { return <li key={xi}>{x}</li>; })}
61
+ </ul>
62
+ )}
63
+ </div>
64
+ );
65
+ })}
40
66
  </div>
41
67
  );
42
68
  })}
@@ -61,6 +87,20 @@ export default function DataImportSample({
61
87
  <span className={r.success ? 'xeplr-data-import-result-ok' : 'xeplr-data-import-result-fail'}>
62
88
  {r.success ? (r.rows != null ? r.rows + ' rows' : 'Done') : (r.error || 'Failed')}
63
89
  </span>
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
+ )}
64
104
  </div>
65
105
  );
66
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',
@@ -96,3 +96,69 @@
96
96
  .xeplr-import-history-status-failed { color: var(--xeplr-danger, #ff6b6b); }
97
97
  .xeplr-import-history-status-rolled-back { color: var(--xeplr-text-muted, #888); }
98
98
  .xeplr-import-history-error { color: var(--xeplr-text-secondary, #999); }
99
+
100
+ /* What the inspect step found in a part — on its own line under the part,
101
+ with the fix (when one is offered) as a checkbox the person decides. */
102
+ .xeplr-data-import-mapping-row { flex-wrap: wrap; }
103
+ .xeplr-data-import-check {
104
+ flex-basis: 100%;
105
+ margin: 2px 0 4px;
106
+ padding: 10px 12px;
107
+ border-radius: 8px;
108
+ border: 1px solid var(--xeplr-warning, #a86a00);
109
+ background: var(--xeplr-warning-bg, rgba(168, 106, 0, 0.08));
110
+ color: var(--xeplr-text-primary, #e0e0e0);
111
+ font-size: 13px;
112
+ }
113
+ .xeplr-data-import-check-message { margin: 0 0 6px; line-height: 1.45; }
114
+ .xeplr-data-import-check-fix { display: flex; align-items: center; gap: 8px; font-weight: 600; cursor: pointer; }
115
+ .xeplr-data-import-check-examples { margin: 6px 0 0; padding-left: 18px; font-size: 12px; color: var(--xeplr-text-muted, #888); }
116
+ .xeplr-data-import-result-note { flex-basis: 100%; font-size: 12px; color: var(--xeplr-text-muted, #888); }
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; }
@@ -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';
@@ -0,0 +1,86 @@
1
+ // ROWS AN IMPORT DROPPED — the ones the database refused — as the dropped-rows
2
+ // view shows them, copies them and counts them. Pure: no React, no fetch.
3
+ //
4
+ // The app's onLoadDropped(job) answers:
5
+ // { columns: ['sku', 'qty'], // the file's own, in order
6
+ // rows: [{ rowNum: 5, reason: '…', values: { sku: 'S5', qty: -1 } }],
7
+ // total: 2, // rows kept to look at
8
+ // dropped: 2 } // rows the import counted
9
+
10
+ /** How many rows a job or an import result dropped (either shape). */
11
+ export function droppedCount(job) {
12
+ if (!job) return 0;
13
+ var n = job.rowsDropped != null ? job.rowsDropped : job.dropped;
14
+ return Number(n) || 0;
15
+ }
16
+
17
+ /**
18
+ * The view's table: a row number, the reason, then the file's columns. Keys
19
+ * are positional (c0, c1, …) because a column name can hold a dot, which a
20
+ * table's accessor reads as a path.
21
+ *
22
+ * @returns {{ columns: [{ key, header }], rows: object[] }}
23
+ */
24
+ export function droppedTable(data) {
25
+ var names = (data && data.columns) || [];
26
+ var columns = [{ key: 'rowNum', header: 'Row' }, { key: 'reason', header: 'Why it was dropped' }]
27
+ .concat(names.map(function(name, i) { return { key: 'c' + i, header: name }; }));
28
+ var rows = ((data && data.rows) || []).map(function(r) {
29
+ var row = { rowNum: r.rowNum == null ? '' : r.rowNum, reason: r.reason || '' };
30
+ names.forEach(function(name, i) { row['c' + i] = displayValue(r.values ? r.values[name] : null); });
31
+ return row;
32
+ });
33
+ return { columns: columns, rows: rows };
34
+ }
35
+
36
+ function displayValue(v) {
37
+ if (v == null) return '';
38
+ return typeof v === 'object' ? JSON.stringify(v) : String(v);
39
+ }
40
+
41
+ /**
42
+ * The rows as tab-separated text with a header — what Excel takes from the
43
+ * clipboard as cells. The file's columns only, like the download: pasted,
44
+ * fixed and saved, it imports again as it is. A value holding a tab, a line
45
+ * break or a quote is quoted, its quotes doubled, as Excel itself copies it.
46
+ */
47
+ export function droppedAsTsv(data) {
48
+ var names = (data && data.columns) || [];
49
+ var lines = [names.map(tsvCell).join('\t')];
50
+ ((data && data.rows) || []).forEach(function(r) {
51
+ lines.push(names.map(function(name) { return tsvCell(displayValue(r.values ? r.values[name] : null)); }).join('\t'));
52
+ });
53
+ return lines.join('\r\n');
54
+ }
55
+
56
+ export function tsvCell(value) {
57
+ var s = value == null ? '' : String(value);
58
+ return /[\t\r\n"]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
59
+ }
60
+
61
+ /**
62
+ * What to say above the rows: the heading, and a note when not every dropped
63
+ * row is here — the view shows the first ones, or the import predates rows
64
+ * being kept and has only a count.
65
+ */
66
+ export function droppedSummary(job, data) {
67
+ var dropped = data && data.dropped != null ? Number(data.dropped) : droppedCount(job);
68
+ var total = data ? Number(data.total) || 0 : 0;
69
+ var shown = data && data.rows ? data.rows.length : 0;
70
+ var heading = plural(dropped, 'row') + ' dropped' +
71
+ (job && job.originalFileName ? ' from ' + job.originalFileName + (job.part ? ' (' + job.part + ')' : '') : '') +
72
+ (job && job.target ? ' → ' + job.target : '');
73
+ var note = null;
74
+ if (data && total === 0 && dropped > 0) {
75
+ note = 'These rows were not kept — this import ran before dropped rows were saved. Import the file again to see them.';
76
+ } else if (data && total < dropped) {
77
+ note = 'Only ' + plural(total, 'row') + ' of ' + dropped + ' were kept; the import stopped recording after too many failures in a row.';
78
+ } else if (data && shown < total) {
79
+ note = 'Showing the first ' + shown.toLocaleString() + ' of ' + total.toLocaleString() + '. Download has all of them.';
80
+ }
81
+ return { heading: heading, note: note };
82
+ }
83
+
84
+ function plural(n, word) {
85
+ return Number(n).toLocaleString() + ' ' + word + (Number(n) === 1 ? '' : 's');
86
+ }
@@ -1,17 +1,40 @@
1
1
  import { useDataImportController } from './useDataImportController.js';
2
2
  import { useImportHistoryController } from './useImportHistoryController.js';
3
+ import { useDroppedRowsController } from './useDroppedRowsController.js';
3
4
  import DataImportSample from './designs/DataImportSample.jsx';
4
5
  import ImportHistorySample from './designs/ImportHistorySample.jsx';
5
- import { useDesignValidator, DATA_IMPORT_RULES, IMPORT_HISTORY_RULES } from './validateDesign.js';
6
+ import DroppedRowsSample from './designs/DroppedRowsSample.jsx';
7
+ import { useDesignValidator, DATA_IMPORT_RULES, IMPORT_HISTORY_RULES, DROPPED_ROWS_RULES } from './validateDesign.js';
6
8
 
9
+ // Props: onInspect, onCommit, … and, to show the rows an import dropped,
10
+ // onLoadDropped(job) and onDownloadDropped(job) (see useDroppedRowsController).
7
11
  export function DataImportPage(props) {
8
12
  var controller = useDataImportController(props);
13
+ var dropped = useDroppedRowsController(props);
9
14
  var ref = useDesignValidator('DataImportPage', DATA_IMPORT_RULES);
10
- return <div ref={ref}><DataImportSample {...controller} /></div>;
15
+ return (
16
+ <div ref={ref}>
17
+ <DataImportSample {...controller} canViewDropped={dropped.canView} viewDropped={dropped.open} />
18
+ {dropped.job && <DroppedRowsPage {...dropped} />}
19
+ </div>
20
+ );
11
21
  }
12
22
 
13
23
  export function ImportHistoryPage(props) {
14
24
  var controller = useImportHistoryController(props);
25
+ var dropped = useDroppedRowsController(props);
15
26
  var ref = useDesignValidator('ImportHistoryPage', IMPORT_HISTORY_RULES);
16
- return <div ref={ref}><ImportHistorySample {...controller} /></div>;
27
+ return (
28
+ <div ref={ref}>
29
+ <ImportHistorySample {...controller} canViewDropped={dropped.canView} viewDropped={dropped.open} />
30
+ {dropped.job && <DroppedRowsPage {...dropped} />}
31
+ </div>
32
+ );
33
+ }
34
+
35
+ // The dropped-rows dialog, validated as it opens — its required buttons only
36
+ // exist while it is on screen.
37
+ export function DroppedRowsPage(props) {
38
+ var ref = useDesignValidator('DroppedRows', DROPPED_ROWS_RULES);
39
+ return <div ref={ref}><DroppedRowsSample {...props} /></div>;
17
40
  }
@@ -1,16 +1,18 @@
1
1
  import { useState } from 'react';
2
2
  import { uploadFile } from '../fileUpload/uploadFile.js';
3
- import { buildInitialMappings } from './dataImport.js';
3
+ import { buildInitialMappings, checksFor } from './dataImport.js';
4
4
 
5
5
  /**
6
6
  * @param {object} options
7
7
  * @param {string} [options.accept] - file input accept attribute, e.g. '.csv,.json,.xlsx,.xls'
8
8
  * @param {string} [options.targetLabel='Target'] - placeholder/label for the target-name field
9
- * @param {(filePath: string, file: File) => Promise<{format: string, parts?: string[]}>} options.onInspect
9
+ * @param {(filePath: string, file: File) => Promise<{format: string, parts?: string[], checks?: object}>} options.onInspect
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}[], meta: {originalName: string}) => Promise<{results: object[]}>} options.onCommit
13
+ * @param {(filePath: string, mappings: {part: string|null, target: string, fixes: object}[], meta: {originalName: string}) => Promise<{results: object[]}>} options.onCommit
14
+ * (each mapping's `fixes` is the person's choice for each fix the inspect
15
+ * step's `checks` offered — see checksFor in dataImport.js)
14
16
  * - required. App-supplied: load the included parts into their targets.
15
17
  * meta.originalName is the picked file's own name (not the server-side
16
18
  * path) — pass it through if the app wants it recorded (e.g. import
@@ -61,6 +63,15 @@ export function useDataImportController(options = {}) {
61
63
  }
62
64
  }
63
65
 
66
+ // A fix the inspect step offered, switched on or off for one part.
67
+ function setFix(index, key, on) {
68
+ setMappings(function(prev) {
69
+ var next = prev.slice();
70
+ next[index] = Object.assign({}, next[index], { fixes: Object.assign({}, next[index].fixes, { [key]: on }) });
71
+ return next;
72
+ });
73
+ }
74
+
64
75
  function updateMapping(index, patch) {
65
76
  setMappings(function(prev) {
66
77
  var next = prev.slice();
@@ -75,7 +86,7 @@ export function useDataImportController(options = {}) {
75
86
  setCommitting(true);
76
87
  setError('');
77
88
  try {
78
- var payload = included.map(function(m) { return { part: m.part, target: m.target }; });
89
+ var payload = included.map(function(m) { return { part: m.part, target: m.target, fixes: m.fixes || {} }; });
79
90
  var res = await options.onCommit(inspected.filePath, payload, { originalName: inspected.originalName });
80
91
  setResults((res && res.results) || res || []);
81
92
  } catch (err) {
@@ -87,7 +98,8 @@ export function useDataImportController(options = {}) {
87
98
 
88
99
  return {
89
100
  file, inspecting, inspected, mappings, committing, results, error,
90
- handleFileChange, updateMapping, handleCommit, reset,
101
+ handleFileChange, updateMapping, setFix, handleCommit, reset,
102
+ checksOf: function(part) { return checksFor(inspected, part); },
91
103
  accept: options.accept,
92
104
  targetLabel: options.targetLabel || 'Target'
93
105
  };
@@ -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,