@lobb-js/studio 0.37.1 → 0.38.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/components/dataTable/fieldPicker.svelte +61 -0
- package/dist/components/dataTable/fieldPicker.svelte.d.ts +9 -0
- package/dist/components/dataTable/filterButton.svelte +3 -2
- package/dist/components/dataTable/sort.svelte +169 -104
- package/dist/components/dataTable/sortButton.svelte +33 -7
- package/dist/components/dataTable/table.svelte +2 -1
- package/dist/components/dataTable/table.svelte.d.ts +1 -0
- package/dist/components/importButton.svelte +154 -31
- package/package.json +4 -3
- package/src/lib/components/dataTable/fieldPicker.svelte +61 -0
- package/src/lib/components/dataTable/filterButton.svelte +3 -2
- package/src/lib/components/dataTable/sort.svelte +169 -104
- package/src/lib/components/dataTable/sortButton.svelte +33 -7
- package/src/lib/components/dataTable/table.svelte +2 -1
- package/src/lib/components/importButton.svelte +154 -31
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { toast } from "svelte-sonner";
|
|
5
5
|
import { getStudioContext } from "../context";
|
|
6
6
|
import * as Dialog from "./ui/dialog";
|
|
7
|
+
import * as Tooltip from "./ui/tooltip";
|
|
7
8
|
import { getCollectionColumns } from "./dataTable/utils";
|
|
8
9
|
import Table from "./dataTable/table.svelte";
|
|
9
10
|
import Fuse from "fuse.js";
|
|
@@ -28,11 +29,24 @@
|
|
|
28
29
|
let parseError = $state("");
|
|
29
30
|
let transformedRows = $state<any[]>([]);
|
|
30
31
|
let importResults = $state<{ row: any; error: string | null }[]>([]);
|
|
32
|
+
// Which results tab is visible. Defaults to "failed" when there are
|
|
33
|
+
// any failures (most users want to fix those first); falls back to
|
|
34
|
+
// "imported" when everything went through.
|
|
35
|
+
let resultsTab = $state<"failed" | "imported">("failed");
|
|
36
|
+
// CSV columns that the fuzzy mapper couldn't match to any schema
|
|
37
|
+
// field. Shown as chips above the preview so the user can spot
|
|
38
|
+
// silently-dropped data before clicking Import.
|
|
39
|
+
let unmappedColumns = $state<Array<{ column: string; sample: string }>>([]);
|
|
31
40
|
|
|
32
41
|
const collectionColumns = $derived(getCollectionColumns(ctx, collectionName) ?? []);
|
|
33
42
|
|
|
34
|
-
function applyColumnMapping(rows: any[]):
|
|
35
|
-
|
|
43
|
+
function applyColumnMapping(rows: any[]): {
|
|
44
|
+
rows: any[];
|
|
45
|
+
unmapped: Array<{ column: string; sample: string }>;
|
|
46
|
+
} {
|
|
47
|
+
if (!rows.length || !collectionColumns.length) {
|
|
48
|
+
return { rows, unmapped: [] };
|
|
49
|
+
}
|
|
36
50
|
const csvColumns = Object.keys(rows[0]);
|
|
37
51
|
const fieldNames = collectionColumns.map((c) => c.id);
|
|
38
52
|
const fuse = new Fuse(fieldNames, { includeScore: true, threshold: 0.4 });
|
|
@@ -41,7 +55,7 @@
|
|
|
41
55
|
const results = fuse.search(csvCol);
|
|
42
56
|
mapping[csvCol] = results[0]?.item ?? null;
|
|
43
57
|
}
|
|
44
|
-
|
|
58
|
+
const mappedRows = rows.map((row) =>
|
|
45
59
|
Object.fromEntries(
|
|
46
60
|
collectionColumns.map((c) => {
|
|
47
61
|
const csvCol = Object.keys(mapping).find((k) => mapping[k] === c.id);
|
|
@@ -49,6 +63,15 @@
|
|
|
49
63
|
})
|
|
50
64
|
)
|
|
51
65
|
);
|
|
66
|
+
// Find the first row that has a non-empty value for each unmapped
|
|
67
|
+
// column so the chip tooltip can show a real example.
|
|
68
|
+
const unmapped = csvColumns
|
|
69
|
+
.filter((col) => mapping[col] === null)
|
|
70
|
+
.map((column) => {
|
|
71
|
+
const sample = rows.find((r) => r[column] != null && String(r[column]).trim() !== "")?.[column] ?? "";
|
|
72
|
+
return { column, sample: String(sample) };
|
|
73
|
+
});
|
|
74
|
+
return { rows: mappedRows, unmapped };
|
|
52
75
|
}
|
|
53
76
|
let fileInput: HTMLInputElement;
|
|
54
77
|
|
|
@@ -116,7 +139,9 @@
|
|
|
116
139
|
try {
|
|
117
140
|
const rows = await detectAndParse(content);
|
|
118
141
|
if (rows.length === 0) throw new Error("No data rows found");
|
|
119
|
-
|
|
142
|
+
const mapped = applyColumnMapping(rows);
|
|
143
|
+
transformedRows = mapped.rows;
|
|
144
|
+
unmappedColumns = mapped.unmapped;
|
|
120
145
|
step = "preview";
|
|
121
146
|
} catch (e: any) {
|
|
122
147
|
step = "input";
|
|
@@ -162,6 +187,10 @@
|
|
|
162
187
|
}
|
|
163
188
|
if (hasSuccess && onSuccessfullSave) await onSuccessfullSave();
|
|
164
189
|
|
|
190
|
+
// Default to the failed tab when there are any failures; if
|
|
191
|
+
// everything went through, land on imported so the user can
|
|
192
|
+
// still verify what was written.
|
|
193
|
+
resultsTab = importResults.some((r) => r.error !== null) ? "failed" : "imported";
|
|
165
194
|
step = "results";
|
|
166
195
|
} catch (e: any) {
|
|
167
196
|
step = "preview";
|
|
@@ -181,6 +210,7 @@
|
|
|
181
210
|
pasteContent = "";
|
|
182
211
|
transformedRows = [];
|
|
183
212
|
importResults = [];
|
|
213
|
+
unmappedColumns = [];
|
|
184
214
|
parseError = "";
|
|
185
215
|
}, 200);
|
|
186
216
|
}
|
|
@@ -295,6 +325,39 @@
|
|
|
295
325
|
<div class="shrink-0 px-4 py-2 text-sm text-muted-foreground">
|
|
296
326
|
{transformedRows.length} rows ready to import
|
|
297
327
|
</div>
|
|
328
|
+
{#if unmappedColumns.length}
|
|
329
|
+
<!-- Unmapped CSV columns: the fuzzy matcher couldn't pair
|
|
330
|
+
these with any schema field, so their values won't be
|
|
331
|
+
imported. Surfaced here so the user can spot silently
|
|
332
|
+
dropped data; each chip's tooltip shows a sample
|
|
333
|
+
value from the first row that had one. -->
|
|
334
|
+
<div class="shrink-0 flex flex-wrap items-center gap-1.5 border-t px-4 py-2">
|
|
335
|
+
<AlertCircle size="13" class="text-muted-foreground" />
|
|
336
|
+
<span class="text-xs text-muted-foreground">
|
|
337
|
+
{unmappedColumns.length}
|
|
338
|
+
{unmappedColumns.length === 1 ? "column" : "columns"} not mapped:
|
|
339
|
+
</span>
|
|
340
|
+
<Tooltip.Provider delayDuration={150}>
|
|
341
|
+
{#each unmappedColumns as { column, sample }}
|
|
342
|
+
<Tooltip.Root>
|
|
343
|
+
<Tooltip.Trigger
|
|
344
|
+
class="rounded-md border border-dashed border-muted-foreground/40 bg-muted/40 px-2 py-0.5 text-[0.7rem] text-muted-foreground hover:border-muted-foreground/70"
|
|
345
|
+
>
|
|
346
|
+
{column}
|
|
347
|
+
</Tooltip.Trigger>
|
|
348
|
+
<Tooltip.Content side="bottom" align="start" class="max-w-md">
|
|
349
|
+
{#if sample}
|
|
350
|
+
<span class="text-muted-foreground">Sample:</span>
|
|
351
|
+
<span class="ml-1 font-mono">{sample}</span>
|
|
352
|
+
{:else}
|
|
353
|
+
<span class="text-muted-foreground">No sample values</span>
|
|
354
|
+
{/if}
|
|
355
|
+
</Tooltip.Content>
|
|
356
|
+
</Tooltip.Root>
|
|
357
|
+
{/each}
|
|
358
|
+
</Tooltip.Provider>
|
|
359
|
+
</div>
|
|
360
|
+
{/if}
|
|
298
361
|
<div class="relative flex-1 overflow-auto w-full">
|
|
299
362
|
<Table
|
|
300
363
|
data={transformedRows}
|
|
@@ -318,47 +381,107 @@
|
|
|
318
381
|
{@const succeeded = importResults.filter((r) => r.error === null)}
|
|
319
382
|
{@const failedData = failed.map((r) => ({ __error: r.error, ...r.row }))}
|
|
320
383
|
{#if failed.length === 0}
|
|
384
|
+
<!-- All rows imported successfully: nothing to audit here —
|
|
385
|
+
the user can see the imported rows directly in the
|
|
386
|
+
collection's list view after clicking Done. Show a
|
|
387
|
+
compact success message instead of the tabs/table. -->
|
|
321
388
|
<div class="flex flex-1 flex-col items-center justify-center gap-6 px-8 py-12">
|
|
322
389
|
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
|
323
390
|
<Check size="22" class="text-foreground" />
|
|
324
391
|
</div>
|
|
325
392
|
<div class="text-center">
|
|
326
393
|
<p class="text-sm font-medium text-foreground">Import complete</p>
|
|
327
|
-
<p class="mt-1 text-xs text-muted-foreground">
|
|
394
|
+
<p class="mt-1 text-xs text-muted-foreground">
|
|
395
|
+
{succeeded.length} {succeeded.length === 1 ? "record" : "records"} imported successfully
|
|
396
|
+
</p>
|
|
328
397
|
</div>
|
|
329
398
|
</div>
|
|
330
399
|
{:else}
|
|
331
|
-
<!--
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
400
|
+
<!-- Tab strip: surfaces failed rows (so the user can fix
|
|
401
|
+
them) and lets them peek at what *did* land. Only
|
|
402
|
+
rendered when there were failures — otherwise the plain
|
|
403
|
+
success message above is enough. -->
|
|
404
|
+
<div class="shrink-0 flex items-center gap-1 border-b px-4 py-2">
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
onclick={() => (resultsTab = "failed")}
|
|
408
|
+
class="
|
|
409
|
+
flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs
|
|
410
|
+
{resultsTab === 'failed' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground'}
|
|
411
|
+
"
|
|
412
|
+
>
|
|
340
413
|
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-destructive/10">
|
|
341
414
|
<X size="11" class="text-destructive" />
|
|
342
415
|
</span>
|
|
343
|
-
|
|
344
|
-
</
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
416
|
+
{failed.length} failed
|
|
417
|
+
</button>
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
onclick={() => (resultsTab = "imported")}
|
|
421
|
+
class="
|
|
422
|
+
flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs
|
|
423
|
+
{resultsTab === 'imported' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground'}
|
|
424
|
+
"
|
|
352
425
|
>
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
{/if}
|
|
359
|
-
{/snippet}
|
|
360
|
-
</Table>
|
|
426
|
+
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted">
|
|
427
|
+
<Check size="11" class="text-foreground" />
|
|
428
|
+
</span>
|
|
429
|
+
{succeeded.length} imported
|
|
430
|
+
</button>
|
|
361
431
|
</div>
|
|
432
|
+
{#if resultsTab === "failed"}
|
|
433
|
+
<div class="relative flex-1 overflow-auto w-full">
|
|
434
|
+
<Table
|
|
435
|
+
data={failedData}
|
|
436
|
+
columns={[{ id: "__error", label: "Error", icon: AlertCircle }, ...collectionColumns.filter((c) => c.id !== "id")]}
|
|
437
|
+
showCheckboxes={false}
|
|
438
|
+
headerBorderTop={true}
|
|
439
|
+
>
|
|
440
|
+
{#snippet overrideCell(value, column)}
|
|
441
|
+
{#if column.id === "__error"}
|
|
442
|
+
<!-- Error cell: truncated single-line preview in
|
|
443
|
+
the table. Hovering reveals the full message
|
|
444
|
+
in a tooltip with monospace + wrap so
|
|
445
|
+
multi-line errors / JSON dumps render
|
|
446
|
+
legibly. -->
|
|
447
|
+
<Tooltip.Provider delayDuration={150}>
|
|
448
|
+
<Tooltip.Root>
|
|
449
|
+
<Tooltip.Trigger
|
|
450
|
+
class="block w-full truncate text-left text-destructive"
|
|
451
|
+
>
|
|
452
|
+
{value}
|
|
453
|
+
</Tooltip.Trigger>
|
|
454
|
+
<Tooltip.Content
|
|
455
|
+
class="max-w-md p-0"
|
|
456
|
+
side="bottom"
|
|
457
|
+
align="start"
|
|
458
|
+
>
|
|
459
|
+
<div class="max-h-64 overflow-auto p-3">
|
|
460
|
+
<pre class="whitespace-pre-wrap break-words font-mono text-xs text-destructive">{value}</pre>
|
|
461
|
+
</div>
|
|
462
|
+
</Tooltip.Content>
|
|
463
|
+
</Tooltip.Root>
|
|
464
|
+
</Tooltip.Provider>
|
|
465
|
+
{:else}
|
|
466
|
+
{value}
|
|
467
|
+
{/if}
|
|
468
|
+
{/snippet}
|
|
469
|
+
</Table>
|
|
470
|
+
</div>
|
|
471
|
+
{:else if succeeded.length === 0}
|
|
472
|
+
<div class="flex flex-1 flex-col items-center justify-center gap-3 px-8 py-12">
|
|
473
|
+
<p class="text-xs text-muted-foreground">No rows were imported</p>
|
|
474
|
+
</div>
|
|
475
|
+
{:else}
|
|
476
|
+
<div class="relative flex-1 overflow-auto w-full">
|
|
477
|
+
<Table
|
|
478
|
+
data={succeeded.map((r) => r.row)}
|
|
479
|
+
columns={collectionColumns.filter((c) => c.id !== "id")}
|
|
480
|
+
showCheckboxes={false}
|
|
481
|
+
headerBorderTop={true}
|
|
482
|
+
/>
|
|
483
|
+
</div>
|
|
484
|
+
{/if}
|
|
362
485
|
{/if}
|
|
363
486
|
<div class="flex h-12 shrink-0 items-center justify-end gap-2 border-t px-4">
|
|
364
487
|
<Button onclick={hideDrawer} size="sm" Icon={Check}>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobb-js/studio",
|
|
3
3
|
"license": "UNLICENSED",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.38.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -108,14 +108,15 @@
|
|
|
108
108
|
"imask": "^7.6.1",
|
|
109
109
|
"javascript-time-ago": "^2.6.4",
|
|
110
110
|
"json-stable-stringify": "^1.3.0",
|
|
111
|
+
"lodash-es": "^4.17.21",
|
|
111
112
|
"lucide-svelte": "^0.488.0",
|
|
112
113
|
"mode-watcher": "^0.5.1",
|
|
113
114
|
"mustache": "^4.2.0",
|
|
114
115
|
"qs": "^6.14.1",
|
|
116
|
+
"svelte-dnd-action": "^0.9.69",
|
|
115
117
|
"svelte-portal": "^2.2.1",
|
|
116
118
|
"svelte-sonner": "^0.3.28",
|
|
117
119
|
"tailwind-merge": "^3.4.0",
|
|
118
|
-
"tailwind-variants": "^3.2.2"
|
|
119
|
-
"lodash-es": "^4.17.21"
|
|
120
|
+
"tailwind-variants": "^3.2.2"
|
|
120
121
|
}
|
|
121
122
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Typeahead field picker. Renders inside a parent-owned <Popover.Content>
|
|
3
|
+
// so the parent controls open/close. Used by SortButton's fast-path
|
|
4
|
+
// picker and by Sort's "add a rule" popover.
|
|
5
|
+
import { getStudioContext } from "../../context";
|
|
6
|
+
import { getFieldIcon } from "./utils";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
collectionName: string;
|
|
10
|
+
excludeFields?: string[];
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
onPick: (fieldName: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
collectionName,
|
|
17
|
+
excludeFields = [],
|
|
18
|
+
placeholder = "Pick a field…",
|
|
19
|
+
onPick,
|
|
20
|
+
}: Props = $props();
|
|
21
|
+
|
|
22
|
+
const { ctx } = getStudioContext();
|
|
23
|
+
|
|
24
|
+
let search = $state("");
|
|
25
|
+
|
|
26
|
+
const allFields = $derived(
|
|
27
|
+
Object.keys(ctx.meta.collections[collectionName].fields).filter(
|
|
28
|
+
(f) => !excludeFields.includes(f),
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
const filteredFields = $derived(
|
|
32
|
+
search
|
|
33
|
+
? allFields.filter((f) =>
|
|
34
|
+
f.toLowerCase().includes(search.toLowerCase()),
|
|
35
|
+
)
|
|
36
|
+
: allFields,
|
|
37
|
+
);
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<input
|
|
41
|
+
{placeholder}
|
|
42
|
+
bind:value={search}
|
|
43
|
+
class="w-full rounded-md border bg-muted px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
|
44
|
+
/>
|
|
45
|
+
<div class="mt-2 max-h-60 overflow-auto">
|
|
46
|
+
{#each filteredFields as fieldName}
|
|
47
|
+
{@const FieldIcon = getFieldIcon(ctx, fieldName, collectionName)}
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onclick={() => onPick(fieldName)}
|
|
51
|
+
class="flex w-full items-center gap-2 rounded-md p-2 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
52
|
+
>
|
|
53
|
+
<FieldIcon size="14" />
|
|
54
|
+
<span>{fieldName}</span>
|
|
55
|
+
</button>
|
|
56
|
+
{:else}
|
|
57
|
+
<div class="p-2 text-center text-xs text-muted-foreground">
|
|
58
|
+
No matching fields
|
|
59
|
+
</div>
|
|
60
|
+
{/each}
|
|
61
|
+
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import * as Popover from "../ui/popover/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { Settings2 } from "lucide-svelte";
|
|
4
4
|
import { buttonVariants } from "../ui/button";
|
|
5
5
|
import Filter from "./filter.svelte";
|
|
6
6
|
|
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
class={buttonVariants({
|
|
23
23
|
variant: "ghost",
|
|
24
24
|
size: "sm",
|
|
25
|
+
class: "text-muted-foreground",
|
|
25
26
|
})}
|
|
26
27
|
>
|
|
27
|
-
<
|
|
28
|
+
<Settings2 />
|
|
28
29
|
{#if showText}
|
|
29
30
|
{#if Object.keys(filter).length}
|
|
30
31
|
Filtered by {Object.keys(filter).length} rules
|