@svgrid/enterprise 1.2.0 → 2.0.0
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/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- package/src/upgrade-prompt.ts +149 -148
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
<script lang="ts" generics="TFeatures extends TableFeatures, TData extends RowData">
|
|
2
|
+
/**
|
|
3
|
+
* SvImportDialog - a drop-in, production-hardened "bring your data in"
|
|
4
|
+
* dialog for a licensed grid. The mirror image of SvExportMenu: where that
|
|
5
|
+
* sends the grid OUT, this pulls a file IN and lines it up with the grid's
|
|
6
|
+
* own columns.
|
|
7
|
+
*
|
|
8
|
+
* - drag-drop / browse / paste (Excel .xlsx, CSV, TSV, JSON),
|
|
9
|
+
* - grid-aware auto-mapping: source headers matched to your columns,
|
|
10
|
+
* - per-column mapping UI (retarget or skip any source column),
|
|
11
|
+
* - typed coercion driven by each column's `format` (currency/date/...),
|
|
12
|
+
* - a live, NON-BLOCKING preview with per-cell validation errors,
|
|
13
|
+
* - size / row guard-rails, cancel, and a full focus trap.
|
|
14
|
+
*
|
|
15
|
+
* The parse happens ONCE (readImportMatrix); re-mapping runs on a chunked,
|
|
16
|
+
* cancelable worker loop (mapImportMatrixAsync) so even a 100k-row file
|
|
17
|
+
* never freezes the tab. Styling uses the same overridable CSS vars as the
|
|
18
|
+
* rest of the suite.
|
|
19
|
+
*/
|
|
20
|
+
import type { RowData, TableFeatures } from '@svgrid/grid'
|
|
21
|
+
import type { EnterpriseGridApi } from './install'
|
|
22
|
+
import {
|
|
23
|
+
readImportMatrix,
|
|
24
|
+
mapImportMatrixAsync,
|
|
25
|
+
autoMapColumns,
|
|
26
|
+
inferImportColumnTypes,
|
|
27
|
+
type ImportColumnMap,
|
|
28
|
+
type ImportColumnTypes,
|
|
29
|
+
type ImportFieldType,
|
|
30
|
+
type ImportGridColumn,
|
|
31
|
+
type ImportResult,
|
|
32
|
+
type ImportRowError,
|
|
33
|
+
type ImportValidator,
|
|
34
|
+
} from './import'
|
|
35
|
+
|
|
36
|
+
type Props = {
|
|
37
|
+
api: EnterpriseGridApi<TFeatures, TData> | null | undefined
|
|
38
|
+
/** Trigger-button label. Default 'Import'. */
|
|
39
|
+
label?: string
|
|
40
|
+
/** Dialog heading. Default 'Import data'. */
|
|
41
|
+
title?: string
|
|
42
|
+
/** Where committed rows land (append mode). Default 'bottom'. */
|
|
43
|
+
commitAt?: 'top' | 'bottom' | number
|
|
44
|
+
/** Create a grid column for any imported field the grid doesn't already
|
|
45
|
+
* have, so "Import as new field" actually shows up. Default true. */
|
|
46
|
+
createColumns?: boolean
|
|
47
|
+
/** Offer an Append / Replace-all mode toggle. Replace clears the grid's
|
|
48
|
+
* existing rows first (and still creates any missing columns). Default true. */
|
|
49
|
+
allowReplace?: boolean
|
|
50
|
+
/** Initial commit mode. Default 'append'. */
|
|
51
|
+
defaultMode?: 'append' | 'replace'
|
|
52
|
+
/** In Replace mode, offer a "Replace columns" checkbox that also REMOVES
|
|
53
|
+
* grid columns absent from the file, so the grid matches it exactly.
|
|
54
|
+
* Default true (the checkbox itself defaults to off). */
|
|
55
|
+
allowColumnPrune?: boolean
|
|
56
|
+
/** Accept filter for the file picker. */
|
|
57
|
+
accept?: string
|
|
58
|
+
/** Rows to show in the preview table. Default 8. */
|
|
59
|
+
previewRows?: number
|
|
60
|
+
/** Block the import while any cell failed to coerce. Default false. */
|
|
61
|
+
strict?: boolean
|
|
62
|
+
/** Optional per-row validator, surfaced in the preview like type errors. */
|
|
63
|
+
validator?: ImportValidator<TData>
|
|
64
|
+
/** Reject a file larger than this many bytes. Default 25 MB. */
|
|
65
|
+
maxBytes?: number
|
|
66
|
+
/** Cap on data rows. See `overLimit` for what happens past it. */
|
|
67
|
+
maxRows?: number
|
|
68
|
+
/** Over `maxRows`: 'reject' (default) refuses the file; 'truncate' keeps
|
|
69
|
+
* the first `maxRows` and warns. */
|
|
70
|
+
overLimit?: 'reject' | 'truncate'
|
|
71
|
+
/** Cap the errors collected (memory guard). Default 500. */
|
|
72
|
+
maxErrors?: number
|
|
73
|
+
/** Drop duplicate rows by this target field (keeps the last). */
|
|
74
|
+
dedupeBy?: keyof TData | (string & {})
|
|
75
|
+
/** Text encoding for CSV/TSV/JSON files. Default 'utf-8'. */
|
|
76
|
+
encoding?: string
|
|
77
|
+
/** Called after rows are committed into the grid. */
|
|
78
|
+
onImported?: (result: ImportResult<TData>) => void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let {
|
|
82
|
+
api,
|
|
83
|
+
label = 'Import',
|
|
84
|
+
title = 'Import data',
|
|
85
|
+
commitAt = 'bottom',
|
|
86
|
+
createColumns = true,
|
|
87
|
+
allowReplace = true,
|
|
88
|
+
defaultMode = 'append',
|
|
89
|
+
allowColumnPrune = true,
|
|
90
|
+
accept = '.xlsx,.csv,.tsv,.tab,.json,text/csv,application/json',
|
|
91
|
+
previewRows = 8,
|
|
92
|
+
strict = false,
|
|
93
|
+
validator,
|
|
94
|
+
maxBytes = 25 * 1024 * 1024,
|
|
95
|
+
maxRows,
|
|
96
|
+
overLimit = 'reject',
|
|
97
|
+
maxErrors = 500,
|
|
98
|
+
dedupeBy,
|
|
99
|
+
encoding,
|
|
100
|
+
onImported,
|
|
101
|
+
}: Props = $props()
|
|
102
|
+
|
|
103
|
+
const SKIP = ' skip'
|
|
104
|
+
const DEFAULT = ' default'
|
|
105
|
+
const KNOWN_EXT = ['xlsx', 'csv', 'tsv', 'tab', 'json']
|
|
106
|
+
|
|
107
|
+
let open = $state(false)
|
|
108
|
+
// svelte-ignore state_referenced_locally
|
|
109
|
+
let mode = $state<'append' | 'replace'>(defaultMode)
|
|
110
|
+
let pruneColumns = $state(false) // Replace mode: also drop columns absent from the file
|
|
111
|
+
let busy = $state(false) // reading a file OR committing
|
|
112
|
+
let remapping = $state(false) // re-running the map after a mapping change
|
|
113
|
+
let progress = $state(0)
|
|
114
|
+
let error = $state<string | null>(null)
|
|
115
|
+
let dragging = $state(false)
|
|
116
|
+
let fileName = $state('')
|
|
117
|
+
let format = $state('')
|
|
118
|
+
let pasteMode = $state(false)
|
|
119
|
+
let pasteText = $state('')
|
|
120
|
+
let truncated = $state(false)
|
|
121
|
+
let sourceTotal = $state(0)
|
|
122
|
+
|
|
123
|
+
// The parsed source (read once). `mapping` is source header -> target
|
|
124
|
+
// field (or the SKIP / DEFAULT sentinels); the preview derives from it.
|
|
125
|
+
let matrix = $state<string[][] | null>(null)
|
|
126
|
+
let sourceHeaders = $state<string[]>([])
|
|
127
|
+
let mapping = $state<Record<string, string>>({})
|
|
128
|
+
let columnTypes = $state<ImportColumnTypes>({})
|
|
129
|
+
let gridFields = $state<Array<{ field: string; header: string; type?: ImportFieldType }>>([])
|
|
130
|
+
// Per-source-header config for headers mapped to a NEW field: display name,
|
|
131
|
+
// kind (drives coercion + the created column's format/editor), dropdown
|
|
132
|
+
// options, and visibility. Keyed by source header.
|
|
133
|
+
type ColKind =
|
|
134
|
+
| 'string' | 'number' | 'integer' | 'currency' | 'percent'
|
|
135
|
+
| 'boolean' | 'date' | 'datetime' | 'select' | 'json'
|
|
136
|
+
type NewColConfig = { header: string; kind: ColKind; options: string; visible: boolean }
|
|
137
|
+
let newCols = $state<Record<string, NewColConfig>>({})
|
|
138
|
+
|
|
139
|
+
// Mapped output (async). Held in state, not derived, because the map runs
|
|
140
|
+
// on a chunked loop that can be cancelled mid-flight.
|
|
141
|
+
let built = $state<{
|
|
142
|
+
rows: TData[]
|
|
143
|
+
errors: ImportRowError[]
|
|
144
|
+
skipped: number
|
|
145
|
+
total: number
|
|
146
|
+
errorsTruncated: boolean
|
|
147
|
+
deduped: number
|
|
148
|
+
} | null>(null)
|
|
149
|
+
|
|
150
|
+
let loadController: AbortController | null = null
|
|
151
|
+
let remapController: AbortController | null = null
|
|
152
|
+
let remapTimer: ReturnType<typeof setTimeout> | null = null
|
|
153
|
+
|
|
154
|
+
let fileInput: HTMLInputElement | undefined = $state()
|
|
155
|
+
let dialogEl: HTMLDivElement | undefined = $state()
|
|
156
|
+
let triggerEl: HTMLButtonElement | undefined = $state()
|
|
157
|
+
let lastFocused: HTMLElement | null = null
|
|
158
|
+
|
|
159
|
+
function reset() {
|
|
160
|
+
loadController?.abort()
|
|
161
|
+
remapController?.abort()
|
|
162
|
+
if (remapTimer) clearTimeout(remapTimer)
|
|
163
|
+
matrix = null
|
|
164
|
+
sourceHeaders = []
|
|
165
|
+
mapping = {}
|
|
166
|
+
columnTypes = {}
|
|
167
|
+
newCols = {}
|
|
168
|
+
built = null
|
|
169
|
+
fileName = ''
|
|
170
|
+
format = ''
|
|
171
|
+
error = null
|
|
172
|
+
progress = 0
|
|
173
|
+
busy = false
|
|
174
|
+
remapping = false
|
|
175
|
+
pasteMode = false
|
|
176
|
+
pasteText = ''
|
|
177
|
+
truncated = false
|
|
178
|
+
sourceTotal = 0
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function openDialog() {
|
|
182
|
+
reset()
|
|
183
|
+
lastFocused = (document.activeElement as HTMLElement) ?? null
|
|
184
|
+
open = true
|
|
185
|
+
requestAnimationFrame(() => dialogEl?.focus())
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function closeDialog() {
|
|
189
|
+
reset()
|
|
190
|
+
open = false
|
|
191
|
+
// Restore focus to wherever it was before the dialog opened.
|
|
192
|
+
;(lastFocused ?? triggerEl)?.focus()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function gridColumns(): ImportGridColumn[] {
|
|
196
|
+
if (!api) return []
|
|
197
|
+
return api.getColumns().filter((c) => c.field) as ImportGridColumn[]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function extOf(name: string): string {
|
|
201
|
+
return name.toLowerCase().split('.').pop() ?? ''
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Validate a picked/dropped file before we read a single byte. */
|
|
205
|
+
function validateFile(file: File): string | null {
|
|
206
|
+
const ext = extOf(file.name)
|
|
207
|
+
if (file.name.includes('.') && !KNOWN_EXT.includes(ext)) {
|
|
208
|
+
return `Unsupported file type ".${ext}". Use Excel (.xlsx), CSV, TSV, or JSON.`
|
|
209
|
+
}
|
|
210
|
+
if (file.size === 0) return 'That file is empty.'
|
|
211
|
+
if (file.size > maxBytes) {
|
|
212
|
+
return `File is ${fmtBytes(file.size)}, over the ${fmtBytes(maxBytes)} limit.`
|
|
213
|
+
}
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function fmtBytes(n: number): string {
|
|
218
|
+
if (n < 1024) return `${n} B`
|
|
219
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`
|
|
220
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function load(source: File | Blob | string, name: string) {
|
|
224
|
+
if (!api) return
|
|
225
|
+
loadController?.abort()
|
|
226
|
+
loadController = new AbortController()
|
|
227
|
+
const signal = loadController.signal
|
|
228
|
+
busy = true
|
|
229
|
+
error = null
|
|
230
|
+
progress = 0
|
|
231
|
+
try {
|
|
232
|
+
const read = await readImportMatrix(source, 'auto', { maxBytes, encoding, signal })
|
|
233
|
+
const dataCount = read.matrix.length > 0 ? read.matrix.length - 1 : 0
|
|
234
|
+
if (dataCount === 0) throw new Error('No data rows found in that file.')
|
|
235
|
+
// Enforce the row cap: reject outright, or truncate + warn.
|
|
236
|
+
let workMatrix = read.matrix
|
|
237
|
+
if (maxRows != null && dataCount > maxRows) {
|
|
238
|
+
if (overLimit === 'truncate') {
|
|
239
|
+
workMatrix = read.matrix.slice(0, maxRows + 1)
|
|
240
|
+
truncated = true
|
|
241
|
+
sourceTotal = dataCount
|
|
242
|
+
} else {
|
|
243
|
+
throw new Error(`File has ${dataCount.toLocaleString()} rows, over the ${maxRows.toLocaleString()} limit.`)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const cols = gridColumns()
|
|
247
|
+
const headers = (workMatrix[0] ?? []).map((h) => h.trim())
|
|
248
|
+
const auto = autoMapColumns(headers, cols)
|
|
249
|
+
const types = inferImportColumnTypes(cols)
|
|
250
|
+
matrix = workMatrix
|
|
251
|
+
sourceHeaders = headers
|
|
252
|
+
format = read.format
|
|
253
|
+
fileName = name
|
|
254
|
+
columnTypes = types
|
|
255
|
+
gridFields = cols.map((c) => ({ field: c.field!, header: c.header ?? c.field!, type: types[c.field!] }))
|
|
256
|
+
const seed: Record<string, string> = {}
|
|
257
|
+
const cfg: Record<string, NewColConfig> = {}
|
|
258
|
+
for (const h of headers) {
|
|
259
|
+
seed[h] = auto[h] ?? DEFAULT
|
|
260
|
+
// Default a new column: name = source header, kind = Text, visible; the
|
|
261
|
+
// user can rename / retype / hide / add dropdown options in the UI.
|
|
262
|
+
cfg[h] = { header: h || deriveField(h), kind: 'string', options: '', visible: true }
|
|
263
|
+
}
|
|
264
|
+
mapping = seed
|
|
265
|
+
newCols = cfg
|
|
266
|
+
await remap()
|
|
267
|
+
} catch (err) {
|
|
268
|
+
if ((err as { name?: string })?.name !== 'AbortError') {
|
|
269
|
+
matrix = null
|
|
270
|
+
error = err instanceof Error ? err.message : String(err)
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
busy = false
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function onPick(e: Event) {
|
|
278
|
+
const input = e.currentTarget as HTMLInputElement
|
|
279
|
+
const file = input.files?.[0]
|
|
280
|
+
input.value = '' // allow re-picking the same file
|
|
281
|
+
if (!file) return
|
|
282
|
+
const invalid = validateFile(file)
|
|
283
|
+
if (invalid) { error = invalid; return }
|
|
284
|
+
void load(file, file.name)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function onDrop(e: DragEvent) {
|
|
288
|
+
e.preventDefault()
|
|
289
|
+
dragging = false
|
|
290
|
+
const files = e.dataTransfer?.files
|
|
291
|
+
if (!files || files.length === 0) return
|
|
292
|
+
if (files.length > 1) { error = 'Drop one file at a time.'; return }
|
|
293
|
+
const file = files[0]!
|
|
294
|
+
const invalid = validateFile(file)
|
|
295
|
+
if (invalid) { error = invalid; return }
|
|
296
|
+
void load(file, file.name)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function loadPasted() {
|
|
300
|
+
const text = pasteText.trim()
|
|
301
|
+
if (!text) return
|
|
302
|
+
if (text.length > maxBytes) { error = `Pasted text is over the ${fmtBytes(maxBytes)} limit.`; return }
|
|
303
|
+
void load(text, 'pasted text')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// The snake_case field the engine derives for an unmapped ("new field")
|
|
307
|
+
// header - kept in step with the engine's own computeFields() so the keys line up.
|
|
308
|
+
function deriveField(h: string): string {
|
|
309
|
+
return h.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Translate the sentinel-bearing `mapping` into an ImportColumnMap:
|
|
313
|
+
// SKIP -> null (drop), DEFAULT -> omitted (engine's snake_case fallback),
|
|
314
|
+
// anything else -> that field.
|
|
315
|
+
function toColumnMap(): ImportColumnMap {
|
|
316
|
+
const out: ImportColumnMap = {}
|
|
317
|
+
for (const h of sourceHeaders) {
|
|
318
|
+
const v = mapping[h]
|
|
319
|
+
if (v === SKIP) out[h] = null as unknown as string
|
|
320
|
+
else if (v && v !== DEFAULT) out[h] = v
|
|
321
|
+
}
|
|
322
|
+
return out
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// A column KIND (UI) maps to the engine's coercion type: currency/percent are
|
|
326
|
+
// numbers, a dropdown holds a string, the rest map through directly.
|
|
327
|
+
function coerceKind(kind: ColKind): ImportFieldType {
|
|
328
|
+
if (kind === 'currency' || kind === 'percent') return 'number'
|
|
329
|
+
if (kind === 'select') return 'string'
|
|
330
|
+
return kind
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Types the engine coerces with: those inferred from existing grid columns,
|
|
334
|
+
// plus the user's chosen kind for each header mapped to a NEW field.
|
|
335
|
+
function effectiveColumnTypes(): ImportColumnTypes {
|
|
336
|
+
const out: ImportColumnTypes = { ...columnTypes }
|
|
337
|
+
for (const h of sourceHeaders) {
|
|
338
|
+
const v = mapping[h]
|
|
339
|
+
if (!v || v === DEFAULT) {
|
|
340
|
+
const field = deriveField(h)
|
|
341
|
+
const kind = newCols[h]?.kind
|
|
342
|
+
if (field && kind) out[field] = coerceKind(kind)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return out
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Re-run the (chunked, cancelable) map. Called on load + on mapping edits. */
|
|
349
|
+
async function remap() {
|
|
350
|
+
if (!matrix) return
|
|
351
|
+
remapController?.abort()
|
|
352
|
+
remapController = new AbortController()
|
|
353
|
+
const signal = remapController.signal
|
|
354
|
+
remapping = true
|
|
355
|
+
progress = 0
|
|
356
|
+
try {
|
|
357
|
+
const r = await mapImportMatrixAsync<TData>(matrix, {
|
|
358
|
+
columnMap: toColumnMap(),
|
|
359
|
+
columnTypes: effectiveColumnTypes(),
|
|
360
|
+
validator,
|
|
361
|
+
maxErrors,
|
|
362
|
+
dedupeBy,
|
|
363
|
+
signal,
|
|
364
|
+
onProgress: (p) => { if (!signal.aborted) progress = p.ratio },
|
|
365
|
+
})
|
|
366
|
+
if (!signal.aborted) built = r
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if ((err as { name?: string })?.name !== 'AbortError') {
|
|
369
|
+
error = err instanceof Error ? err.message : String(err)
|
|
370
|
+
}
|
|
371
|
+
} finally {
|
|
372
|
+
if (!signal.aborted) remapping = false
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Debounced re-map whenever the mapping changes (reading mapping + matrix
|
|
377
|
+
// registers the dependency).
|
|
378
|
+
$effect(() => {
|
|
379
|
+
void JSON.stringify(mapping)
|
|
380
|
+
void JSON.stringify(newCols) // retype a new column -> re-coerce the preview
|
|
381
|
+
const m = matrix
|
|
382
|
+
if (!m) return
|
|
383
|
+
if (remapTimer) clearTimeout(remapTimer)
|
|
384
|
+
remapTimer = setTimeout(() => void remap(), 140)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
const errorKeys = $derived(
|
|
388
|
+
new Set((built?.errors ?? []).map((e) => `${e.rowIndex}:${e.field}`)),
|
|
389
|
+
)
|
|
390
|
+
const errorCount = $derived(built?.errors.length ?? 0)
|
|
391
|
+
const rowCount = $derived(built?.rows.length ?? 0)
|
|
392
|
+
|
|
393
|
+
// Which target fields end up in the output (mapped or defaulted), in source
|
|
394
|
+
// order - the preview table's columns. Carries whether the field is NEW (to
|
|
395
|
+
// be created) + its type, so the preview header + commit both use it.
|
|
396
|
+
const previewCols = $derived.by(() => {
|
|
397
|
+
const cols: Array<{ field: string; header: string; isNew: boolean; cfg?: NewColConfig }> = []
|
|
398
|
+
const seen = new Set<string>()
|
|
399
|
+
for (const h of sourceHeaders) {
|
|
400
|
+
const v = mapping[h]
|
|
401
|
+
if (v === SKIP) continue
|
|
402
|
+
const isNew = !v || v === DEFAULT
|
|
403
|
+
const field = isNew ? deriveField(h) : v
|
|
404
|
+
if (!field || seen.has(field)) continue
|
|
405
|
+
seen.add(field)
|
|
406
|
+
if (isNew) {
|
|
407
|
+
const c = newCols[h]
|
|
408
|
+
cols.push({ field, header: c?.header || h, isNew: true, cfg: c })
|
|
409
|
+
} else {
|
|
410
|
+
const gf = gridFields.find((g) => g.field === field)
|
|
411
|
+
cols.push({ field, header: gf?.header ?? h, isNew: false })
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return cols
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
// Two source headers pointing at the same grid field silently overwrite.
|
|
418
|
+
// Surface it so the user notices before importing.
|
|
419
|
+
const conflicts = $derived.by(() => {
|
|
420
|
+
const counts = new Map<string, number>()
|
|
421
|
+
for (const h of sourceHeaders) {
|
|
422
|
+
const v = mapping[h]
|
|
423
|
+
if (v && v !== DEFAULT && v !== SKIP) counts.set(v, (counts.get(v) ?? 0) + 1)
|
|
424
|
+
}
|
|
425
|
+
return [...counts.entries()].filter(([, n]) => n > 1).map(([f]) => f)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
function cellText(v: unknown): string {
|
|
429
|
+
if (v == null || v === '') return ''
|
|
430
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false'
|
|
431
|
+
if (typeof v === 'object') return JSON.stringify(v)
|
|
432
|
+
return String(v)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const canImport = $derived(
|
|
436
|
+
!!api && rowCount > 0 && !busy && !remapping && (!strict || errorCount === 0),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
/** When a header is switched to the Dropdown kind, pre-fill its options box
|
|
440
|
+
* from the file's distinct values (visible + editable), so the auto-fill is
|
|
441
|
+
* obvious rather than silent. */
|
|
442
|
+
function onKindChange(h: string) {
|
|
443
|
+
const cfg = newCols[h]
|
|
444
|
+
if (cfg && cfg.kind === 'select' && !cfg.options.trim()) {
|
|
445
|
+
cfg.options = dropdownOptions(deriveField(h), '').join(', ')
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Parse a comma-separated dropdown option string; empty -> derive distinct
|
|
450
|
+
* values from the imported column so a dropdown works out of the box. */
|
|
451
|
+
function dropdownOptions(field: string, raw: string): string[] {
|
|
452
|
+
const listed = raw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
453
|
+
if (listed.length) return listed
|
|
454
|
+
const seen = new Set<string>()
|
|
455
|
+
for (const row of built?.rows ?? []) {
|
|
456
|
+
const val = (row as Record<string, unknown>)[field]
|
|
457
|
+
if (val == null || val === '') continue
|
|
458
|
+
seen.add(String(val))
|
|
459
|
+
if (seen.size >= 100) break
|
|
460
|
+
}
|
|
461
|
+
return [...seen]
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Build a ColumnDef for a new field from its config - so the created column
|
|
465
|
+
* formats, edits, and shows/hides exactly as configured. */
|
|
466
|
+
function columnDefForConfig(field: string, cfg: NewColConfig) {
|
|
467
|
+
const base: Record<string, unknown> = { field, header: cfg.header, visible: cfg.visible }
|
|
468
|
+
switch (cfg.kind) {
|
|
469
|
+
case 'number':
|
|
470
|
+
case 'integer':
|
|
471
|
+
return { ...base, align: 'right', editorType: 'number', format: { type: 'number' } }
|
|
472
|
+
case 'currency':
|
|
473
|
+
return { ...base, align: 'right', editorType: 'number', format: { type: 'currency', currency: 'USD' } }
|
|
474
|
+
case 'percent':
|
|
475
|
+
return { ...base, align: 'right', editorType: 'number', format: { type: 'percent' } }
|
|
476
|
+
case 'boolean':
|
|
477
|
+
return { ...base, editorType: 'checkbox' }
|
|
478
|
+
case 'date':
|
|
479
|
+
return { ...base, editorType: 'date', format: { type: 'date' } }
|
|
480
|
+
case 'datetime':
|
|
481
|
+
return { ...base, editorType: 'date', format: { type: 'datetime' } }
|
|
482
|
+
case 'select':
|
|
483
|
+
return { ...base, editorType: 'select', editorOptions: dropdownOptions(field, cfg.options) }
|
|
484
|
+
default: // string / json
|
|
485
|
+
return { ...base, editorType: 'text' }
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Add a grid column for every imported field the grid doesn't render yet,
|
|
490
|
+
* so a field mapped to "Import as new field" is actually visible - using the
|
|
491
|
+
* user's name / kind / format / dropdown options / visibility. */
|
|
492
|
+
function ensureColumns() {
|
|
493
|
+
if (!api || !createColumns) return
|
|
494
|
+
const present = new Set(gridFields.map((g) => g.field))
|
|
495
|
+
for (const c of previewCols) {
|
|
496
|
+
if (present.has(c.field)) continue
|
|
497
|
+
const def = c.isNew && c.cfg
|
|
498
|
+
? columnDefForConfig(c.field, c.cfg)
|
|
499
|
+
: { field: c.field, header: c.header }
|
|
500
|
+
api.addColumn(def as never)
|
|
501
|
+
present.add(c.field)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function commit() {
|
|
506
|
+
if (!api || !built || !canImport) return
|
|
507
|
+
busy = true
|
|
508
|
+
error = null
|
|
509
|
+
try {
|
|
510
|
+
ensureColumns()
|
|
511
|
+
// Replace mode + "Replace columns": drop grid columns the file lacks, so
|
|
512
|
+
// the grid ends up matching the import exactly. Runs after ensureColumns
|
|
513
|
+
// so freshly-added columns (which ARE in the file) are never pruned.
|
|
514
|
+
if (mode === 'replace' && pruneColumns) {
|
|
515
|
+
const keep = new Set(previewCols.map((c) => c.field))
|
|
516
|
+
for (const col of api.getColumns()) {
|
|
517
|
+
if (col.field && !keep.has(col.field)) api.removeColumn(col.id)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (built.rows.length > 0) {
|
|
521
|
+
if (mode === 'replace') {
|
|
522
|
+
// Clear the grid's current rows + add the imported ones in one update.
|
|
523
|
+
const existing = api.getData()
|
|
524
|
+
api.applyTransaction({
|
|
525
|
+
remove: existing.length ? [...existing] : [],
|
|
526
|
+
add: built.rows,
|
|
527
|
+
})
|
|
528
|
+
} else {
|
|
529
|
+
api.addRows(built.rows, commitAt)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
onImported?.({
|
|
533
|
+
headers: sourceHeaders,
|
|
534
|
+
rows: built.rows,
|
|
535
|
+
errors: built.errors,
|
|
536
|
+
skipped: built.skipped,
|
|
537
|
+
total: truncated ? sourceTotal : built.total,
|
|
538
|
+
format: (format || 'csv') as ImportResult<TData>['format'],
|
|
539
|
+
errorsTruncated: built.errorsTruncated,
|
|
540
|
+
deduped: built.deduped,
|
|
541
|
+
truncated,
|
|
542
|
+
})
|
|
543
|
+
closeDialog()
|
|
544
|
+
} catch (err) {
|
|
545
|
+
error = err instanceof Error ? err.message : String(err)
|
|
546
|
+
busy = false
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function cancel() {
|
|
551
|
+
loadController?.abort()
|
|
552
|
+
remapController?.abort()
|
|
553
|
+
busy = false
|
|
554
|
+
remapping = false
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// --- Focus trap -----------------------------------------------------------
|
|
558
|
+
function focusable(): HTMLElement[] {
|
|
559
|
+
if (!dialogEl) return []
|
|
560
|
+
return Array.from(
|
|
561
|
+
dialogEl.querySelectorAll<HTMLElement>(
|
|
562
|
+
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex="0"]',
|
|
563
|
+
),
|
|
564
|
+
).filter((el) => el.offsetParent !== null || el === document.activeElement)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function onKeydown(e: KeyboardEvent) {
|
|
568
|
+
if (e.key === 'Escape') {
|
|
569
|
+
e.preventDefault()
|
|
570
|
+
closeDialog()
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
if (e.key !== 'Tab') return
|
|
574
|
+
const items = focusable()
|
|
575
|
+
if (items.length === 0) return
|
|
576
|
+
const first = items[0]!
|
|
577
|
+
const last = items[items.length - 1]!
|
|
578
|
+
const active = document.activeElement as HTMLElement
|
|
579
|
+
if (e.shiftKey && (active === first || active === dialogEl)) {
|
|
580
|
+
e.preventDefault()
|
|
581
|
+
last.focus()
|
|
582
|
+
} else if (!e.shiftKey && active === last) {
|
|
583
|
+
e.preventDefault()
|
|
584
|
+
first.focus()
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
</script>
|
|
588
|
+
|
|
589
|
+
<button
|
|
590
|
+
type="button"
|
|
591
|
+
class="sv-import-btn"
|
|
592
|
+
disabled={!api}
|
|
593
|
+
bind:this={triggerEl}
|
|
594
|
+
onclick={openDialog}
|
|
595
|
+
>
|
|
596
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
597
|
+
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
598
|
+
<path d="M12 21V9M7 14l5 5 5-5M5 3h14" />
|
|
599
|
+
</svg>
|
|
600
|
+
{label}
|
|
601
|
+
</button>
|
|
602
|
+
|
|
603
|
+
{#if open}
|
|
604
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
605
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
606
|
+
<div class="sv-import-backdrop" onclick={closeDialog}></div>
|
|
607
|
+
<div
|
|
608
|
+
class="sv-import-dialog"
|
|
609
|
+
role="dialog"
|
|
610
|
+
aria-modal="true"
|
|
611
|
+
aria-label={title}
|
|
612
|
+
tabindex="-1"
|
|
613
|
+
bind:this={dialogEl}
|
|
614
|
+
onkeydown={onKeydown}
|
|
615
|
+
>
|
|
616
|
+
<header class="sv-import-head">
|
|
617
|
+
<h2 class="sv-import-title">{title}</h2>
|
|
618
|
+
<button type="button" class="sv-import-x" aria-label="Close" onclick={closeDialog}>×</button>
|
|
619
|
+
</header>
|
|
620
|
+
|
|
621
|
+
<div class="sv-import-body">
|
|
622
|
+
{#if !matrix}
|
|
623
|
+
<!-- Stage 1: pick a source. -->
|
|
624
|
+
<div class="sv-import-tabs">
|
|
625
|
+
<button type="button" class="sv-import-tab" class:on={!pasteMode} onclick={() => (pasteMode = false)}>File</button>
|
|
626
|
+
<button type="button" class="sv-import-tab" class:on={pasteMode} onclick={() => (pasteMode = true)}>Paste</button>
|
|
627
|
+
</div>
|
|
628
|
+
|
|
629
|
+
{#if pasteMode}
|
|
630
|
+
<textarea
|
|
631
|
+
class="sv-import-paste"
|
|
632
|
+
placeholder="Paste rows from Excel / Google Sheets, or CSV / JSON text…"
|
|
633
|
+
bind:value={pasteText}
|
|
634
|
+
></textarea>
|
|
635
|
+
<div class="sv-import-paste-actions">
|
|
636
|
+
<button type="button" class="sv-import-primary" disabled={!pasteText.trim() || busy} onclick={loadPasted}>
|
|
637
|
+
{busy ? 'Reading…' : 'Load pasted data'}
|
|
638
|
+
</button>
|
|
639
|
+
</div>
|
|
640
|
+
{:else}
|
|
641
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
642
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
643
|
+
<div
|
|
644
|
+
class="sv-import-drop"
|
|
645
|
+
class:drag={dragging}
|
|
646
|
+
onclick={() => fileInput?.click()}
|
|
647
|
+
ondragover={(e) => { e.preventDefault(); dragging = true }}
|
|
648
|
+
ondragleave={() => (dragging = false)}
|
|
649
|
+
ondrop={onDrop}
|
|
650
|
+
>
|
|
651
|
+
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
652
|
+
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
653
|
+
<path d="M12 16V4M8 8l4-4 4 4M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />
|
|
654
|
+
</svg>
|
|
655
|
+
<p class="sv-import-drop-title">{busy ? 'Reading…' : 'Drop a file or click to browse'}</p>
|
|
656
|
+
<p class="sv-import-drop-sub">Excel .xlsx, CSV, TSV, or JSON · up to {fmtBytes(maxBytes)}</p>
|
|
657
|
+
</div>
|
|
658
|
+
<input
|
|
659
|
+
bind:this={fileInput}
|
|
660
|
+
class="sv-import-file"
|
|
661
|
+
type="file"
|
|
662
|
+
{accept}
|
|
663
|
+
onchange={onPick}
|
|
664
|
+
/>
|
|
665
|
+
{/if}
|
|
666
|
+
{:else}
|
|
667
|
+
<!-- Stage 2: map + preview. -->
|
|
668
|
+
<div class="sv-import-summary">
|
|
669
|
+
<span class="sv-import-badge">{format.toUpperCase()}</span>
|
|
670
|
+
<span class="sv-import-fname" title={fileName}>{fileName}</span>
|
|
671
|
+
<span class="sv-import-stat">{rowCount.toLocaleString()} row{rowCount === 1 ? '' : 's'}</span>
|
|
672
|
+
{#if built?.skipped}<span class="sv-import-stat">· {built.skipped} blank skipped</span>{/if}
|
|
673
|
+
{#if built?.deduped}<span class="sv-import-stat">· {built.deduped} duplicate{built.deduped === 1 ? '' : 's'} merged</span>{/if}
|
|
674
|
+
<button type="button" class="sv-import-rechoose" onclick={reset}>Choose another…</button>
|
|
675
|
+
</div>
|
|
676
|
+
|
|
677
|
+
{#if truncated}
|
|
678
|
+
<p class="sv-import-conflict" role="alert">
|
|
679
|
+
File has {sourceTotal.toLocaleString()} rows; importing the first
|
|
680
|
+
{rowCount.toLocaleString()} (over the {maxRows?.toLocaleString()} limit).
|
|
681
|
+
</p>
|
|
682
|
+
{/if}
|
|
683
|
+
|
|
684
|
+
<div class="sv-import-section-label">Column mapping</div>
|
|
685
|
+
<div class="sv-import-map">
|
|
686
|
+
{#each sourceHeaders as h (h)}
|
|
687
|
+
<div class="sv-import-map-row">
|
|
688
|
+
<div class="sv-import-map-main">
|
|
689
|
+
<span class="sv-import-src" title={h}>{h || '(unnamed)'}</span>
|
|
690
|
+
<span class="sv-import-arrow" aria-hidden="true">→</span>
|
|
691
|
+
<select class="sv-import-select" bind:value={mapping[h]}>
|
|
692
|
+
<option value={DEFAULT}>Import as new field</option>
|
|
693
|
+
{#each gridFields as g (g.field)}
|
|
694
|
+
<option value={g.field}>{g.header}{g.type ? ` (${g.type})` : ''}</option>
|
|
695
|
+
{/each}
|
|
696
|
+
<option value={SKIP}>Skip this column</option>
|
|
697
|
+
</select>
|
|
698
|
+
</div>
|
|
699
|
+
{#if (!mapping[h] || mapping[h] === DEFAULT) && newCols[h]}
|
|
700
|
+
<div class="sv-import-newcol">
|
|
701
|
+
<input
|
|
702
|
+
class="sv-import-newname"
|
|
703
|
+
bind:value={newCols[h].header}
|
|
704
|
+
placeholder="Column name"
|
|
705
|
+
aria-label="New column name for {h}"
|
|
706
|
+
/>
|
|
707
|
+
<select class="sv-import-newtype" bind:value={newCols[h].kind} onchange={() => onKindChange(h)} aria-label="Type for {h}">
|
|
708
|
+
<option value="string">Text</option>
|
|
709
|
+
<option value="number">Number</option>
|
|
710
|
+
<option value="integer">Integer</option>
|
|
711
|
+
<option value="currency">Currency</option>
|
|
712
|
+
<option value="percent">Percent</option>
|
|
713
|
+
<option value="boolean">Boolean</option>
|
|
714
|
+
<option value="date">Date</option>
|
|
715
|
+
<option value="datetime">Date & time</option>
|
|
716
|
+
<option value="select">Dropdown</option>
|
|
717
|
+
<option value="json">JSON</option>
|
|
718
|
+
</select>
|
|
719
|
+
<label class="sv-import-vis" title="Show this column in the grid">
|
|
720
|
+
<input type="checkbox" bind:checked={newCols[h].visible} />
|
|
721
|
+
Visible
|
|
722
|
+
</label>
|
|
723
|
+
</div>
|
|
724
|
+
{#if newCols[h].kind === 'select'}
|
|
725
|
+
<div class="sv-import-newopts">
|
|
726
|
+
<input
|
|
727
|
+
class="sv-import-optsinput"
|
|
728
|
+
bind:value={newCols[h].options}
|
|
729
|
+
placeholder="Dropdown options, comma-separated (blank = use the file's values)"
|
|
730
|
+
aria-label="Dropdown options for {h}"
|
|
731
|
+
/>
|
|
732
|
+
</div>
|
|
733
|
+
{/if}
|
|
734
|
+
{/if}
|
|
735
|
+
</div>
|
|
736
|
+
{/each}
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
{#if conflicts.length > 0}
|
|
740
|
+
<p class="sv-import-conflict" role="alert">
|
|
741
|
+
Two columns map to the same field ({conflicts.join(', ')}); the later one wins.
|
|
742
|
+
</p>
|
|
743
|
+
{/if}
|
|
744
|
+
|
|
745
|
+
{#if built && errorCount > 0}
|
|
746
|
+
<div class="sv-import-warn" role="alert">
|
|
747
|
+
<strong>{errorCount}{built.errorsTruncated ? '+' : ''}</strong>
|
|
748
|
+
cell{errorCount === 1 ? '' : 's'} could not be converted.
|
|
749
|
+
{#if strict}They must be fixed in the source before importing.{:else}They keep their raw text - fix in-grid after import, or adjust the mapping.{/if}
|
|
750
|
+
<ul class="sv-import-errlist">
|
|
751
|
+
{#each built.errors.slice(0, 4) as e (e.rowIndex + ':' + e.field + ':' + e.message)}
|
|
752
|
+
<li>Row {e.rowIndex + 1}, <code>{e.field}</code>: {e.message}</li>
|
|
753
|
+
{/each}
|
|
754
|
+
{#if built.errors.length > 4}<li>…and {built.errors.length - 4}{built.errorsTruncated ? '+' : ''} more</li>{/if}
|
|
755
|
+
</ul>
|
|
756
|
+
</div>
|
|
757
|
+
{/if}
|
|
758
|
+
|
|
759
|
+
<div class="sv-import-section-label">
|
|
760
|
+
Preview
|
|
761
|
+
{#if remapping}<span class="sv-import-working">· mapping {Math.round(progress * 100)}%</span>{/if}
|
|
762
|
+
</div>
|
|
763
|
+
<div class="sv-import-preview-wrap">
|
|
764
|
+
<table class="sv-import-preview">
|
|
765
|
+
<thead>
|
|
766
|
+
<tr>
|
|
767
|
+
<th class="sv-import-rownum">#</th>
|
|
768
|
+
{#each previewCols as c (c.field)}<th>{c.header}</th>{/each}
|
|
769
|
+
</tr>
|
|
770
|
+
</thead>
|
|
771
|
+
<tbody>
|
|
772
|
+
{#each (built?.rows ?? []).slice(0, previewRows) as row, i (i)}
|
|
773
|
+
<tr>
|
|
774
|
+
<td class="sv-import-rownum">{i + 1}</td>
|
|
775
|
+
{#each previewCols as c (c.field)}
|
|
776
|
+
<td class:err={errorKeys.has(`${i}:${c.field}`)}>
|
|
777
|
+
{cellText((row as Record<string, unknown>)[c.field])}
|
|
778
|
+
</td>
|
|
779
|
+
{/each}
|
|
780
|
+
</tr>
|
|
781
|
+
{/each}
|
|
782
|
+
</tbody>
|
|
783
|
+
</table>
|
|
784
|
+
{#if rowCount > previewRows}
|
|
785
|
+
<p class="sv-import-more">…and {(rowCount - previewRows).toLocaleString()} more rows</p>
|
|
786
|
+
{/if}
|
|
787
|
+
</div>
|
|
788
|
+
{/if}
|
|
789
|
+
|
|
790
|
+
{#if error}<p class="sv-import-error" role="alert">{error}</p>{/if}
|
|
791
|
+
</div>
|
|
792
|
+
|
|
793
|
+
<footer class="sv-import-foot">
|
|
794
|
+
{#if busy || remapping}
|
|
795
|
+
<div class="sv-import-progress">
|
|
796
|
+
<div class="sv-import-track"><div class="sv-import-bar" style:width={`${Math.round(progress * 100)}%`}></div></div>
|
|
797
|
+
</div>
|
|
798
|
+
<button type="button" class="sv-import-ghost" onclick={cancel}>Cancel</button>
|
|
799
|
+
{:else}
|
|
800
|
+
<button type="button" class="sv-import-ghost" onclick={closeDialog}>Cancel</button>
|
|
801
|
+
{#if matrix && allowReplace}
|
|
802
|
+
<div class="sv-import-mode" role="radiogroup" aria-label="Import mode">
|
|
803
|
+
<button type="button" class="sv-import-mode-opt" class:on={mode === 'append'} role="radio" aria-checked={mode === 'append'} onclick={() => (mode = 'append')}>Append</button>
|
|
804
|
+
<button type="button" class="sv-import-mode-opt" class:on={mode === 'replace'} role="radio" aria-checked={mode === 'replace'} onclick={() => (mode = 'replace')}>Replace all</button>
|
|
805
|
+
</div>
|
|
806
|
+
{/if}
|
|
807
|
+
{#if matrix && allowReplace && allowColumnPrune && mode === 'replace'}
|
|
808
|
+
<label class="sv-import-prune" title="Remove grid columns that aren't in the imported file">
|
|
809
|
+
<input type="checkbox" bind:checked={pruneColumns} />
|
|
810
|
+
Replace columns
|
|
811
|
+
</label>
|
|
812
|
+
{/if}
|
|
813
|
+
{#if matrix}
|
|
814
|
+
<button type="button" class="sv-import-primary" disabled={!canImport} onclick={commit}>
|
|
815
|
+
{mode === 'replace' ? 'Replace with' : 'Import'} {rowCount.toLocaleString()} row{rowCount === 1 ? '' : 's'}
|
|
816
|
+
</button>
|
|
817
|
+
{/if}
|
|
818
|
+
{/if}
|
|
819
|
+
</footer>
|
|
820
|
+
</div>
|
|
821
|
+
{/if}
|
|
822
|
+
|
|
823
|
+
<style>
|
|
824
|
+
.sv-import-btn {
|
|
825
|
+
display: inline-flex;
|
|
826
|
+
align-items: center;
|
|
827
|
+
gap: 6px;
|
|
828
|
+
padding: 6px 12px;
|
|
829
|
+
font: inherit;
|
|
830
|
+
font-size: 13px;
|
|
831
|
+
font-weight: 500;
|
|
832
|
+
border: 1px solid var(--sg-border, #d0d7de);
|
|
833
|
+
border-radius: 8px;
|
|
834
|
+
background: var(--sg-header-bg, #f6f8fa);
|
|
835
|
+
color: var(--sg-fg, #1f2328);
|
|
836
|
+
cursor: pointer;
|
|
837
|
+
}
|
|
838
|
+
.sv-import-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
839
|
+
|
|
840
|
+
.sv-import-backdrop {
|
|
841
|
+
position: fixed;
|
|
842
|
+
inset: 0;
|
|
843
|
+
z-index: 60;
|
|
844
|
+
background: rgba(15, 23, 42, 0.42);
|
|
845
|
+
}
|
|
846
|
+
.sv-import-dialog {
|
|
847
|
+
position: fixed;
|
|
848
|
+
z-index: 61;
|
|
849
|
+
top: 50%;
|
|
850
|
+
left: 50%;
|
|
851
|
+
transform: translate(-50%, -50%);
|
|
852
|
+
display: flex;
|
|
853
|
+
flex-direction: column;
|
|
854
|
+
width: min(680px, calc(100vw - 32px));
|
|
855
|
+
max-height: min(80vh, 760px);
|
|
856
|
+
border: 1px solid var(--sg-border, #d0d7de);
|
|
857
|
+
border-radius: 14px;
|
|
858
|
+
background: var(--sg-bg, #fff);
|
|
859
|
+
color: var(--sg-fg, #1f2328);
|
|
860
|
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
|
|
861
|
+
outline: none;
|
|
862
|
+
}
|
|
863
|
+
.sv-import-head {
|
|
864
|
+
display: flex;
|
|
865
|
+
align-items: center;
|
|
866
|
+
justify-content: space-between;
|
|
867
|
+
padding: 14px 16px;
|
|
868
|
+
border-bottom: 1px solid var(--sg-border, #eaecef);
|
|
869
|
+
}
|
|
870
|
+
.sv-import-title { margin: 0; font-size: 15px; font-weight: 600; }
|
|
871
|
+
.sv-import-x {
|
|
872
|
+
border: none; background: transparent; font-size: 22px; line-height: 1;
|
|
873
|
+
color: var(--sg-muted, #8b949e); cursor: pointer; padding: 0 4px;
|
|
874
|
+
}
|
|
875
|
+
.sv-import-body { padding: 14px 16px; overflow-y: auto; }
|
|
876
|
+
|
|
877
|
+
.sv-import-tabs { display: flex; gap: 6px; margin-bottom: 12px; }
|
|
878
|
+
.sv-import-tab {
|
|
879
|
+
font: inherit; font-size: 12.5px; padding: 5px 14px; border-radius: 999px;
|
|
880
|
+
border: 1px solid var(--sg-border, #d0d7de); background: var(--sg-header-bg, #f6f8fa);
|
|
881
|
+
color: var(--sg-fg, #1f2328); cursor: pointer;
|
|
882
|
+
}
|
|
883
|
+
.sv-import-tab.on { border-color: var(--site-accent, #3b82f6); background: var(--site-accent, #3b82f6); color: #fff; }
|
|
884
|
+
|
|
885
|
+
.sv-import-drop {
|
|
886
|
+
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
|
887
|
+
padding: 34px 16px; text-align: center;
|
|
888
|
+
border: 2px dashed var(--sg-border, #d0d7de); border-radius: 12px;
|
|
889
|
+
color: var(--sg-muted, #8b949e); cursor: pointer;
|
|
890
|
+
}
|
|
891
|
+
.sv-import-drop.drag { border-color: var(--site-accent, #3b82f6); background: var(--sg-header-bg, #f6f8fa); }
|
|
892
|
+
.sv-import-drop-title { margin: 4px 0 0; font-size: 13.5px; font-weight: 500; color: var(--sg-fg, #1f2328); }
|
|
893
|
+
.sv-import-drop-sub { margin: 0; font-size: 12px; }
|
|
894
|
+
.sv-import-file { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
|
895
|
+
|
|
896
|
+
.sv-import-paste {
|
|
897
|
+
width: 100%; min-height: 150px; resize: vertical; box-sizing: border-box;
|
|
898
|
+
padding: 10px; font: inherit; font-size: 12.5px; border-radius: 10px;
|
|
899
|
+
border: 1px solid var(--sg-border, #d0d7de); background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328);
|
|
900
|
+
}
|
|
901
|
+
.sv-import-paste-actions { display: flex; justify-content: flex-end; margin-top: 10px; }
|
|
902
|
+
|
|
903
|
+
.sv-import-summary {
|
|
904
|
+
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
|
905
|
+
padding-bottom: 12px; margin-bottom: 4px; border-bottom: 1px solid var(--sg-border, #eaecef);
|
|
906
|
+
}
|
|
907
|
+
.sv-import-badge {
|
|
908
|
+
font-size: 10px; font-weight: 700; letter-spacing: 0.05em;
|
|
909
|
+
padding: 2px 7px; border-radius: 5px; background: var(--site-accent, #3b82f6); color: #fff;
|
|
910
|
+
}
|
|
911
|
+
.sv-import-fname { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
|
|
912
|
+
.sv-import-stat { font-size: 12px; color: var(--sg-muted, #8b949e); }
|
|
913
|
+
.sv-import-rechoose {
|
|
914
|
+
margin-left: auto; font: inherit; font-size: 12px; border: none; background: transparent;
|
|
915
|
+
color: var(--site-accent-2, #6366f1); cursor: pointer;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
.sv-import-section-label {
|
|
919
|
+
margin: 12px 0 6px; font-size: 10px; font-weight: 700; text-transform: uppercase;
|
|
920
|
+
letter-spacing: 0.07em; color: var(--sg-muted, #8b949e);
|
|
921
|
+
}
|
|
922
|
+
.sv-import-working { font-weight: 600; color: var(--site-accent, #3b82f6); }
|
|
923
|
+
.sv-import-map {
|
|
924
|
+
display: grid; grid-template-columns: 1fr; gap: 6px;
|
|
925
|
+
max-height: 220px; overflow-y: auto;
|
|
926
|
+
}
|
|
927
|
+
.sv-import-map-row { display: flex; flex-direction: column; gap: 4px; }
|
|
928
|
+
.sv-import-map-main { display: flex; align-items: center; gap: 8px; }
|
|
929
|
+
.sv-import-src {
|
|
930
|
+
flex: 0 0 38%; font-size: 12.5px; font-weight: 500;
|
|
931
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
932
|
+
}
|
|
933
|
+
.sv-import-arrow { color: var(--sg-muted, #8b949e); flex-shrink: 0; }
|
|
934
|
+
.sv-import-select {
|
|
935
|
+
flex: 1; min-width: 0; font: inherit; font-size: 12.5px; padding: 4px 6px;
|
|
936
|
+
border-radius: 6px; border: 1px solid var(--sg-border, #d0d7de);
|
|
937
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328);
|
|
938
|
+
}
|
|
939
|
+
/* New-column config controls, indented under the source -> target row. */
|
|
940
|
+
.sv-import-newcol { display: flex; align-items: center; gap: 6px; padding-left: calc(38% + 20px); }
|
|
941
|
+
.sv-import-newopts { display: flex; padding-left: calc(38% + 20px); }
|
|
942
|
+
.sv-import-newname {
|
|
943
|
+
flex: 1; min-width: 0; font: inherit; font-size: 12px; padding: 3px 6px;
|
|
944
|
+
border-radius: 6px; border: 1px solid var(--sg-border, #d0d7de);
|
|
945
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328);
|
|
946
|
+
}
|
|
947
|
+
.sv-import-newtype {
|
|
948
|
+
flex: 0 0 30%; font: inherit; font-size: 12px; padding: 3px 6px;
|
|
949
|
+
border-radius: 6px; border: 1px solid var(--sg-border, #d0d7de);
|
|
950
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328);
|
|
951
|
+
}
|
|
952
|
+
.sv-import-vis {
|
|
953
|
+
display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px;
|
|
954
|
+
color: var(--sg-muted, #8b949e); cursor: pointer; white-space: nowrap; flex-shrink: 0;
|
|
955
|
+
}
|
|
956
|
+
.sv-import-optsinput {
|
|
957
|
+
flex: 1; min-width: 0; font: inherit; font-size: 12px; padding: 3px 6px;
|
|
958
|
+
border-radius: 6px; border: 1px solid var(--sg-border, #d0d7de);
|
|
959
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
.sv-import-conflict { margin: 10px 0 0; font-size: 12px; color: #b45309; }
|
|
963
|
+
.sv-import-warn {
|
|
964
|
+
margin-top: 12px; padding: 8px 10px; border-radius: 8px;
|
|
965
|
+
background: #fffbeb; border: 1px solid #fde68a; color: #92400e; font-size: 12px;
|
|
966
|
+
}
|
|
967
|
+
.sv-import-errlist { margin: 6px 0 0; padding-left: 18px; }
|
|
968
|
+
.sv-import-errlist code { font-size: 11px; }
|
|
969
|
+
|
|
970
|
+
.sv-import-preview-wrap { overflow-x: auto; border: 1px solid var(--sg-border, #eaecef); border-radius: 8px; }
|
|
971
|
+
.sv-import-preview { border-collapse: collapse; width: 100%; font-size: 12px; }
|
|
972
|
+
.sv-import-preview th, .sv-import-preview td {
|
|
973
|
+
padding: 4px 8px; text-align: left; white-space: nowrap;
|
|
974
|
+
border-bottom: 1px solid var(--sg-border, #eaecef);
|
|
975
|
+
}
|
|
976
|
+
.sv-import-preview thead th {
|
|
977
|
+
position: sticky; top: 0; background: var(--sg-header-bg, #f6f8fa);
|
|
978
|
+
font-weight: 600; color: var(--sg-fg, #1f2328);
|
|
979
|
+
}
|
|
980
|
+
.sv-import-rownum { color: var(--sg-muted, #8b949e); text-align: right; font-variant-numeric: tabular-nums; }
|
|
981
|
+
.sv-import-preview td.err { background: #fee2e2; color: #991b1b; }
|
|
982
|
+
.sv-import-more { margin: 6px 2px 0; font-size: 11px; color: var(--sg-muted, #8b949e); }
|
|
983
|
+
|
|
984
|
+
.sv-import-error { margin: 10px 0 0; font-size: 12px; color: #dc2626; }
|
|
985
|
+
|
|
986
|
+
.sv-import-foot {
|
|
987
|
+
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
|
|
988
|
+
padding: 12px 16px; border-top: 1px solid var(--sg-border, #eaecef);
|
|
989
|
+
}
|
|
990
|
+
.sv-import-mode {
|
|
991
|
+
display: inline-flex; border: 1px solid var(--sg-border, #d0d7de);
|
|
992
|
+
border-radius: 8px; overflow: hidden;
|
|
993
|
+
}
|
|
994
|
+
.sv-import-mode-opt {
|
|
995
|
+
font: inherit; font-size: 12.5px; padding: 6px 12px; border: none; cursor: pointer;
|
|
996
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328);
|
|
997
|
+
}
|
|
998
|
+
.sv-import-mode-opt + .sv-import-mode-opt { border-left: 1px solid var(--sg-border, #d0d7de); }
|
|
999
|
+
.sv-import-mode-opt.on { background: var(--site-accent, #3b82f6); color: #fff; }
|
|
1000
|
+
.sv-import-prune {
|
|
1001
|
+
display: inline-flex; align-items: center; gap: 6px; font-size: 12px;
|
|
1002
|
+
color: var(--sg-fg, #1f2328); cursor: pointer; white-space: nowrap;
|
|
1003
|
+
}
|
|
1004
|
+
.sv-import-progress { flex: 1; min-width: 0; }
|
|
1005
|
+
.sv-import-track {
|
|
1006
|
+
height: 6px; border-radius: 999px; background: var(--sg-border, #eaecef); overflow: hidden;
|
|
1007
|
+
}
|
|
1008
|
+
.sv-import-bar {
|
|
1009
|
+
height: 100%; border-radius: 999px; background: var(--site-accent, #3b82f6); transition: width 120ms ease;
|
|
1010
|
+
}
|
|
1011
|
+
.sv-import-primary {
|
|
1012
|
+
font: inherit; font-size: 13px; font-weight: 500; padding: 7px 16px; border-radius: 8px;
|
|
1013
|
+
border: 1px solid var(--site-accent, #3b82f6); background: var(--site-accent, #3b82f6); color: #fff; cursor: pointer;
|
|
1014
|
+
}
|
|
1015
|
+
.sv-import-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
1016
|
+
.sv-import-ghost {
|
|
1017
|
+
font: inherit; font-size: 13px; padding: 7px 14px; border-radius: 8px;
|
|
1018
|
+
border: 1px solid var(--sg-border, #d0d7de); background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328); cursor: pointer;
|
|
1019
|
+
}
|
|
1020
|
+
</style>
|