@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 2.0.0-alpha.6
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/package.json +4 -4
- package/src/access.ts +188 -0
- package/src/client/api.ts +261 -0
- package/src/client/bulk.ts +361 -0
- package/src/client/cell.ts +139 -0
- package/src/client/confirm.ts +201 -0
- package/src/client/csv-commit.ts +185 -0
- package/src/client/csv-map.ts +274 -0
- package/src/client/csv-model.ts +420 -0
- package/src/client/csv-pick.ts +54 -0
- package/src/client/csv-preview.ts +91 -0
- package/src/client/csv.ts +104 -0
- package/src/client/dom.ts +144 -0
- package/src/client/edit-session.ts +219 -0
- package/src/client/editors.ts +283 -0
- package/src/client/filter-builder.ts +198 -0
- package/src/client/fk.ts +242 -0
- package/src/client/grid-body.ts +103 -0
- package/src/client/grid-header.ts +65 -0
- package/src/client/grid-rowbar.ts +64 -0
- package/src/client/grid.ts +466 -0
- package/src/client/meta.ts +188 -0
- package/src/client/page.ts +332 -0
- package/src/client/panel.ts +296 -0
- package/src/client/relations.ts +205 -0
- package/src/client/save.ts +209 -0
- package/src/client/sidebar.ts +110 -0
- package/src/client/state.ts +218 -0
- package/src/client/statusbar.ts +130 -0
- package/src/client/structure.ts +231 -0
- package/src/client/tabs.ts +224 -0
- package/src/client/tabstrip.ts +127 -0
- package/src/client.ts +374 -160
- package/src/endpoints/common.ts +122 -0
- package/src/endpoints/graph.ts +0 -0
- package/src/endpoints/import.ts +89 -0
- package/src/endpoints/read.ts +173 -0
- package/src/endpoints/rows.ts +435 -0
- package/src/identity.ts +391 -0
- package/src/index.ts +40 -45
- package/src/policy.ts +45 -0
- package/src/preview.ts +53 -0
- package/src/setup.ts +64 -81
- package/src/shared/coerce.ts +399 -0
- package/src/shared/csv.ts +235 -0
- package/src/shared/filters.ts +200 -0
- package/src/shared/plan.ts +164 -0
- package/src/shell.ts +187 -0
- package/src/validate.ts +295 -0
- package/src/credential.ts +0 -26
- package/src/endpoints.ts +0 -48
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last step: the bad-row policy, and sending.
|
|
3
|
+
*
|
|
4
|
+
* The interesting part is `commit`. It chunks, and the Cancel button stops
|
|
5
|
+
* **before** the next request rather than aborting one in flight — cancelling
|
|
6
|
+
* mid-flight would leave the user unable to say what landed, and this way the
|
|
7
|
+
* answer is exact and is reported: the chunks that completed are the rows that
|
|
8
|
+
* are in.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { type ImportResult, importRows } from './api'
|
|
12
|
+
import { notify } from './confirm'
|
|
13
|
+
import {
|
|
14
|
+
type BadRowPolicy,
|
|
15
|
+
buildRecords,
|
|
16
|
+
chunk,
|
|
17
|
+
type ImportModel,
|
|
18
|
+
issuesOf,
|
|
19
|
+
rejectedCSV,
|
|
20
|
+
setBadRowPolicy,
|
|
21
|
+
} from './csv-model'
|
|
22
|
+
import { append, box, button, downloadText, el } from './dom'
|
|
23
|
+
import type { SchemaColumn, SchemaTable } from './meta'
|
|
24
|
+
|
|
25
|
+
/** One request per chunk. Well under `policy.ts`'s 50,000 row ceiling. */
|
|
26
|
+
const CHUNK = 500
|
|
27
|
+
|
|
28
|
+
export interface CommitContext {
|
|
29
|
+
table: SchemaTable
|
|
30
|
+
columns: SchemaColumn[]
|
|
31
|
+
reload: () => Promise<void>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Failures = ReturnType<typeof buildRecords>['failures']
|
|
35
|
+
|
|
36
|
+
export function paintFooter(
|
|
37
|
+
node: HTMLElement,
|
|
38
|
+
ctx: CommitContext,
|
|
39
|
+
model: ImportModel,
|
|
40
|
+
update: (next: ImportModel) => void,
|
|
41
|
+
onClose: () => void,
|
|
42
|
+
stage: HTMLElement,
|
|
43
|
+
): void {
|
|
44
|
+
node.replaceChildren()
|
|
45
|
+
const issues = issuesOf(model, ctx.columns)
|
|
46
|
+
const built = buildRecords(model, ctx.columns)
|
|
47
|
+
|
|
48
|
+
const policies: { value: BadRowPolicy; label: string }[] = [
|
|
49
|
+
{ value: 'skip', label: 'skip bad rows and report' },
|
|
50
|
+
{ value: 'stop', label: 'stop at the first bad row' },
|
|
51
|
+
{ value: 'all', label: 'all or nothing' },
|
|
52
|
+
]
|
|
53
|
+
const picker = policyPicker(policies, model, update)
|
|
54
|
+
|
|
55
|
+
const count = el('span', {
|
|
56
|
+
class: built.failures.length ? 'row-error' : 'note',
|
|
57
|
+
text: `${built.records.length} rows ready · ${built.failures.length} bad`,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const go = button(
|
|
61
|
+
'Import',
|
|
62
|
+
() =>
|
|
63
|
+
void commit(stage, ctx, model, built.records, built.failures, onClose),
|
|
64
|
+
{ class: 'btn primary' },
|
|
65
|
+
)
|
|
66
|
+
go.disabled = issues.blocking.length > 0 || built.records.length === 0
|
|
67
|
+
|
|
68
|
+
append(node, [picker, count, button('Cancel', onClose, { class: 'btn' }), go])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function policyPicker(
|
|
72
|
+
policies: readonly { value: BadRowPolicy; label: string }[],
|
|
73
|
+
model: ImportModel,
|
|
74
|
+
update: (next: ImportModel) => void,
|
|
75
|
+
): HTMLSelectElement {
|
|
76
|
+
const node = el('select', { class: 'sel' })
|
|
77
|
+
for (const policy of policies) {
|
|
78
|
+
const option = el('option', { text: policy.label })
|
|
79
|
+
option.value = policy.value
|
|
80
|
+
option.selected = policy.value === model.onBadRow
|
|
81
|
+
node.appendChild(option)
|
|
82
|
+
}
|
|
83
|
+
node.addEventListener('change', () =>
|
|
84
|
+
update(setBadRowPolicy(model, node.value as BadRowPolicy)),
|
|
85
|
+
)
|
|
86
|
+
return node
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function commit(
|
|
90
|
+
stage: HTMLElement,
|
|
91
|
+
ctx: CommitContext,
|
|
92
|
+
model: ImportModel,
|
|
93
|
+
records: Record<string, unknown>[],
|
|
94
|
+
failures: Failures,
|
|
95
|
+
onClose: () => void,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
stage.replaceChildren()
|
|
98
|
+
const progress = el('p', { text: `0 / ${records.length}` })
|
|
99
|
+
let cancelled = false
|
|
100
|
+
const stop = button(
|
|
101
|
+
'Cancel',
|
|
102
|
+
() => {
|
|
103
|
+
cancelled = true
|
|
104
|
+
},
|
|
105
|
+
{ class: 'btn' },
|
|
106
|
+
)
|
|
107
|
+
append(stage, [progress, box('row-bar', stop)])
|
|
108
|
+
|
|
109
|
+
// All-or-nothing means one transaction, and one transaction means one
|
|
110
|
+
// request — chunking it would produce N transactions and exactly the partial
|
|
111
|
+
// apply the option exists to rule out.
|
|
112
|
+
const batches = model.onBadRow === 'all' ? [records] : chunk(records, CHUNK)
|
|
113
|
+
const onBadRow = model.onBadRow === 'skip' ? 'skip' : 'stop'
|
|
114
|
+
|
|
115
|
+
let inserted = 0
|
|
116
|
+
for (const batch of batches) {
|
|
117
|
+
if (cancelled) break
|
|
118
|
+
const result = await sendBatch(ctx, batch, onBadRow)
|
|
119
|
+
if (!result) break
|
|
120
|
+
inserted += result.inserted
|
|
121
|
+
progress.textContent = `${inserted} / ${records.length}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
paintDone(stage, ctx, model, failures, {
|
|
125
|
+
cancelled,
|
|
126
|
+
inserted,
|
|
127
|
+
total: records.length,
|
|
128
|
+
onClose,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface DoneFacts {
|
|
133
|
+
cancelled: boolean
|
|
134
|
+
inserted: number
|
|
135
|
+
total: number
|
|
136
|
+
onClose: () => void
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function paintDone(
|
|
140
|
+
stage: HTMLElement,
|
|
141
|
+
ctx: CommitContext,
|
|
142
|
+
model: ImportModel,
|
|
143
|
+
failures: Failures,
|
|
144
|
+
facts: DoneFacts,
|
|
145
|
+
): void {
|
|
146
|
+
const done = button(
|
|
147
|
+
'Done',
|
|
148
|
+
() => {
|
|
149
|
+
void ctx.reload()
|
|
150
|
+
facts.onClose()
|
|
151
|
+
},
|
|
152
|
+
{ class: 'btn primary' },
|
|
153
|
+
)
|
|
154
|
+
stage.replaceChildren()
|
|
155
|
+
append(stage, [
|
|
156
|
+
el('p', {
|
|
157
|
+
text: facts.cancelled
|
|
158
|
+
? `Cancelled — ${facts.inserted} rows landed before it stopped.`
|
|
159
|
+
: `${facts.inserted} rows imported.`,
|
|
160
|
+
}),
|
|
161
|
+
failures.length ? rejectedDownload(model, failures) : null,
|
|
162
|
+
box('row-bar', done),
|
|
163
|
+
])
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function sendBatch(
|
|
167
|
+
ctx: CommitContext,
|
|
168
|
+
rows: Record<string, unknown>[],
|
|
169
|
+
onBadRow: 'stop' | 'skip',
|
|
170
|
+
): Promise<ImportResult | null> {
|
|
171
|
+
try {
|
|
172
|
+
return await importRows({ table: ctx.table.name, rows, onBadRow })
|
|
173
|
+
} catch (error) {
|
|
174
|
+
notify((error as Error)?.message ?? 'import failed', 'error')
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function rejectedDownload(model: ImportModel, failures: Failures): HTMLElement {
|
|
180
|
+
return button(
|
|
181
|
+
`Download ${failures.length} rejected rows`,
|
|
182
|
+
() => downloadText('rejected.csv', rejectedCSV(model, failures)),
|
|
183
|
+
{ class: 'btn' },
|
|
184
|
+
)
|
|
185
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Steps two and three: how the file was parsed, and where each column goes.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here inspects a `<select>` to work out what the mapping is — the
|
|
5
|
+
* mapping *is* the model, and every control writes a whole new one through
|
|
6
|
+
* `update`. `csv-model.ts` holds all of it and is pure.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type Assignment,
|
|
11
|
+
buildModel,
|
|
12
|
+
type ImportModel,
|
|
13
|
+
issuesOf,
|
|
14
|
+
reassign,
|
|
15
|
+
} from './csv-model'
|
|
16
|
+
import { append, box, each, el, on, select } from './dom'
|
|
17
|
+
import type { SchemaColumn } from './meta'
|
|
18
|
+
|
|
19
|
+
export const SKIP_VALUE = ' skip'
|
|
20
|
+
export const CONST_VALUE = ' const'
|
|
21
|
+
|
|
22
|
+
export interface MapContext {
|
|
23
|
+
columns: SchemaColumn[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Reparse {
|
|
27
|
+
source: string
|
|
28
|
+
delimiter: string
|
|
29
|
+
hasHeader: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Delimiter and header, both sniffed and both overridable.
|
|
34
|
+
*
|
|
35
|
+
* Re-parsing needs the original text, which the model does not keep — so the
|
|
36
|
+
* source is rebuilt from the parsed rows. That is lossless for the purpose:
|
|
37
|
+
* changing the delimiter after the fact re-splits fields that the wrong
|
|
38
|
+
* delimiter merged, and the merged text is exactly what was in the file.
|
|
39
|
+
*/
|
|
40
|
+
export function paintHead(
|
|
41
|
+
node: HTMLElement,
|
|
42
|
+
model: ImportModel,
|
|
43
|
+
onReparse: (next: Reparse) => void,
|
|
44
|
+
): void {
|
|
45
|
+
node.replaceChildren()
|
|
46
|
+
const source = () => rebuildSource(model)
|
|
47
|
+
|
|
48
|
+
const delimiters = [
|
|
49
|
+
{ value: ',', label: 'comma ,' },
|
|
50
|
+
{ value: ';', label: 'semicolon ;' },
|
|
51
|
+
{ value: '\t', label: 'tab' },
|
|
52
|
+
{ value: '|', label: 'pipe |' },
|
|
53
|
+
]
|
|
54
|
+
const picker = select(delimiters, model.delimiter, value =>
|
|
55
|
+
onReparse({
|
|
56
|
+
source: source(),
|
|
57
|
+
delimiter: value,
|
|
58
|
+
hasHeader: model.hasHeader,
|
|
59
|
+
}),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const header = el('input')
|
|
63
|
+
header.type = 'checkbox'
|
|
64
|
+
header.checked = model.hasHeader
|
|
65
|
+
on(header, 'change', () =>
|
|
66
|
+
onReparse({
|
|
67
|
+
source: source(),
|
|
68
|
+
delimiter: model.delimiter,
|
|
69
|
+
hasHeader: header.checked,
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
const headerLabel = el('label', {
|
|
73
|
+
class: 'note',
|
|
74
|
+
text: ' first row is a header',
|
|
75
|
+
})
|
|
76
|
+
headerLabel.prepend(header)
|
|
77
|
+
|
|
78
|
+
append(node, [
|
|
79
|
+
el('span', { class: 'note', text: 'delimiter' }),
|
|
80
|
+
picker,
|
|
81
|
+
headerLabel,
|
|
82
|
+
el('span', { class: 'note', text: summaryText(model) }),
|
|
83
|
+
])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function summaryText(model: ImportModel): string {
|
|
87
|
+
const base = `${model.rows.length} rows · ${model.headers.length} columns`
|
|
88
|
+
return model.ragged.length ? `${base} · ${model.ragged.length} ragged` : base
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The original text, near enough to re-split.
|
|
93
|
+
*
|
|
94
|
+
* Fields are re-quoted with the model's current delimiter so a value that
|
|
95
|
+
* itself contains the *new* delimiter survives the round trip. This is why the
|
|
96
|
+
* wizard can offer a delimiter override at all without holding the file.
|
|
97
|
+
*/
|
|
98
|
+
export function rebuildSource(model: ImportModel): string {
|
|
99
|
+
const quote = (field: string) =>
|
|
100
|
+
/["\n\r]/.test(field) || field.includes(model.delimiter)
|
|
101
|
+
? `"${field.replace(/"/g, '""')}"`
|
|
102
|
+
: field
|
|
103
|
+
const lines = model.rows.map(row => row.map(quote).join(model.delimiter))
|
|
104
|
+
const head = model.hasHeader
|
|
105
|
+
? [model.headers.map(quote).join(model.delimiter)]
|
|
106
|
+
: []
|
|
107
|
+
return [...head, ...lines].join('\n')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A `Reparse` applied. Kept here so `csv.ts` holds no parsing knowledge. */
|
|
111
|
+
export function reparse(
|
|
112
|
+
next: Reparse,
|
|
113
|
+
columns: readonly SchemaColumn[],
|
|
114
|
+
): ImportModel {
|
|
115
|
+
return buildModel(next.source, columns, {
|
|
116
|
+
delimiter: next.delimiter,
|
|
117
|
+
hasHeader: next.hasHeader,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// -------------------------------------------------------------- the mapping
|
|
122
|
+
|
|
123
|
+
export function paintMapping(
|
|
124
|
+
node: HTMLElement,
|
|
125
|
+
ctx: MapContext,
|
|
126
|
+
model: ImportModel,
|
|
127
|
+
update: (next: ImportModel) => void,
|
|
128
|
+
): void {
|
|
129
|
+
node.replaceChildren()
|
|
130
|
+
node.appendChild(el('h4', { text: 'Mapping' }))
|
|
131
|
+
each(node, model.headers, header => mappingRow(ctx, model, header, update))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One CSV column: its name, three samples, and where it goes.
|
|
136
|
+
*
|
|
137
|
+
* Re-picking a database column already taken **moves** it — `reassign` clears
|
|
138
|
+
* the previous holder — so a duplicate mapping cannot be expressed here at all,
|
|
139
|
+
* rather than being flagged after the fact.
|
|
140
|
+
*/
|
|
141
|
+
function mappingRow(
|
|
142
|
+
ctx: MapContext,
|
|
143
|
+
model: ImportModel,
|
|
144
|
+
header: string,
|
|
145
|
+
update: (next: ImportModel) => void,
|
|
146
|
+
): HTMLElement {
|
|
147
|
+
const assignment = model.assign[header] ?? { kind: 'skip' as const }
|
|
148
|
+
const row = box('map-row')
|
|
149
|
+
|
|
150
|
+
const options = [
|
|
151
|
+
{ value: SKIP_VALUE, label: '— skip —' },
|
|
152
|
+
{ value: CONST_VALUE, label: '— constant… —' },
|
|
153
|
+
...ctx.columns.map(column => ({
|
|
154
|
+
value: column.name,
|
|
155
|
+
label: `${column.name} · ${column.type}`,
|
|
156
|
+
})),
|
|
157
|
+
]
|
|
158
|
+
const picker = select(options, currentValue(assignment), value =>
|
|
159
|
+
update(reassign(model, header, assignmentFor(value, assignment))),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
append(row, [
|
|
163
|
+
el('div', { class: 'map-name', text: header }),
|
|
164
|
+
el('div', { class: 'map-samples note', text: sampleText(model, header) }),
|
|
165
|
+
picker,
|
|
166
|
+
])
|
|
167
|
+
if (assignment.kind === 'constant') {
|
|
168
|
+
append(row, constantControls(ctx, model, header, assignment, update))
|
|
169
|
+
}
|
|
170
|
+
return row
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function currentValue(assignment: Assignment): string {
|
|
174
|
+
if (assignment.kind === 'column') return assignment.column
|
|
175
|
+
return assignment.kind === 'constant' ? CONST_VALUE : SKIP_VALUE
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function assignmentFor(value: string, previous: Assignment): Assignment {
|
|
179
|
+
if (value === SKIP_VALUE) return { kind: 'skip' }
|
|
180
|
+
if (value === CONST_VALUE) {
|
|
181
|
+
return {
|
|
182
|
+
kind: 'constant',
|
|
183
|
+
column: previous.kind === 'column' ? previous.column : null,
|
|
184
|
+
text: previous.kind === 'constant' ? previous.text : '',
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { kind: 'column', column: value }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A constant needs two things a column mapping does not: which column it feeds,
|
|
192
|
+
* and what the literal is. The CSV column's own values are ignored.
|
|
193
|
+
*/
|
|
194
|
+
function constantControls(
|
|
195
|
+
ctx: MapContext,
|
|
196
|
+
model: ImportModel,
|
|
197
|
+
header: string,
|
|
198
|
+
assignment: Extract<Assignment, { kind: 'constant' }>,
|
|
199
|
+
update: (next: ImportModel) => void,
|
|
200
|
+
): HTMLElement[] {
|
|
201
|
+
const target = select(
|
|
202
|
+
[
|
|
203
|
+
{ value: SKIP_VALUE, label: '— into which column —' },
|
|
204
|
+
...ctx.columns.map(column => ({
|
|
205
|
+
value: column.name,
|
|
206
|
+
label: column.name,
|
|
207
|
+
})),
|
|
208
|
+
],
|
|
209
|
+
assignment.column ?? SKIP_VALUE,
|
|
210
|
+
value =>
|
|
211
|
+
update(
|
|
212
|
+
reassign(model, header, {
|
|
213
|
+
...assignment,
|
|
214
|
+
column: value === SKIP_VALUE ? null : value,
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
const literal = el('input', { class: 'ed' })
|
|
219
|
+
literal.type = 'text'
|
|
220
|
+
literal.value = assignment.text
|
|
221
|
+
literal.placeholder = 'constant value'
|
|
222
|
+
on(literal, 'change', () =>
|
|
223
|
+
update(reassign(model, header, { ...assignment, text: literal.value })),
|
|
224
|
+
)
|
|
225
|
+
return [target, literal]
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sampleText(model: ImportModel, header: string): string {
|
|
229
|
+
const index = model.headers.indexOf(header)
|
|
230
|
+
if (index < 0) return ''
|
|
231
|
+
return model.rows
|
|
232
|
+
.slice(0, 3)
|
|
233
|
+
.map(row => row[index] ?? '')
|
|
234
|
+
.map(value => (value === '' ? '␀' : value))
|
|
235
|
+
.join(' · ')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Database columns nobody feeds, tagged with whether that matters.
|
|
240
|
+
*
|
|
241
|
+
* `blockingIssues` decides `required`; this only renders the answer. A required
|
|
242
|
+
* column names itself here *and* disables the Import button, because a list the
|
|
243
|
+
* user can scroll past is not a block.
|
|
244
|
+
*/
|
|
245
|
+
export function paintUnmapped(
|
|
246
|
+
node: HTMLElement,
|
|
247
|
+
ctx: MapContext,
|
|
248
|
+
model: ImportModel,
|
|
249
|
+
): void {
|
|
250
|
+
node.replaceChildren()
|
|
251
|
+
const issues = issuesOf(model, ctx.columns)
|
|
252
|
+
|
|
253
|
+
node.appendChild(el('h4', { text: 'Database columns' }))
|
|
254
|
+
each(node, issues.unmapped, entry => {
|
|
255
|
+
const row = box('unmapped-row')
|
|
256
|
+
row.appendChild(
|
|
257
|
+
el('span', { class: `tag ${entry.status}`, text: entry.status }),
|
|
258
|
+
)
|
|
259
|
+
row.appendChild(el('span', { text: entry.column }))
|
|
260
|
+
return row
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
if (issues.unmatched.length) {
|
|
264
|
+
node.appendChild(
|
|
265
|
+
el('p', {
|
|
266
|
+
class: 'note',
|
|
267
|
+
text: `${issues.unmatched.length} CSV columns are not imported: ${issues.unmatched.join(', ')}`,
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
each(node, issues.blocking, issue =>
|
|
272
|
+
el('p', { class: 'row-error', text: issue.message }),
|
|
273
|
+
)
|
|
274
|
+
}
|