@lobb-js/studio 0.37.1 → 0.39.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.
Files changed (29) hide show
  1. package/dist/actions.d.ts +1 -0
  2. package/dist/components/dataTable/dataTable.svelte +3 -0
  3. package/dist/components/dataTable/dataTable.svelte.d.ts +1 -0
  4. package/dist/components/dataTable/fieldPicker.svelte +61 -0
  5. package/dist/components/dataTable/fieldPicker.svelte.d.ts +9 -0
  6. package/dist/components/dataTable/filter.svelte +469 -238
  7. package/dist/components/dataTable/filter.svelte.d.ts +1 -4
  8. package/dist/components/dataTable/filterButton.svelte +24 -6
  9. package/dist/components/dataTable/header.svelte +9 -31
  10. package/dist/components/dataTable/header.svelte.d.ts +1 -0
  11. package/dist/components/dataTable/sort.svelte +169 -104
  12. package/dist/components/dataTable/sortButton.svelte +33 -7
  13. package/dist/components/dataTable/table.svelte +2 -1
  14. package/dist/components/dataTable/table.svelte.d.ts +1 -0
  15. package/dist/components/dataTablePopup/dataTablePopup.svelte +7 -0
  16. package/dist/components/dataTablePopup/dataTablePopup.svelte.d.ts +1 -0
  17. package/dist/components/importButton.svelte +154 -31
  18. package/package.json +4 -3
  19. package/src/lib/actions.ts +1 -0
  20. package/src/lib/components/dataTable/dataTable.svelte +3 -0
  21. package/src/lib/components/dataTable/fieldPicker.svelte +61 -0
  22. package/src/lib/components/dataTable/filter.svelte +469 -238
  23. package/src/lib/components/dataTable/filterButton.svelte +24 -6
  24. package/src/lib/components/dataTable/header.svelte +9 -31
  25. package/src/lib/components/dataTable/sort.svelte +169 -104
  26. package/src/lib/components/dataTable/sortButton.svelte +33 -7
  27. package/src/lib/components/dataTable/table.svelte +2 -1
  28. package/src/lib/components/dataTablePopup/dataTablePopup.svelte +7 -0
  29. 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[]): any[] {
35
- if (!rows.length || !collectionColumns.length) return rows;
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
- return rows.map((row) =>
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
- transformedRows = applyColumnMapping(rows);
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">{succeeded.length} {succeeded.length === 1 ? "record" : "records"} imported successfully</p>
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
- <!-- Summary strip -->
332
- <div class="shrink-0 px-4 py-3 flex items-center gap-4">
333
- <div class="flex items-center gap-1.5">
334
- <span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted">
335
- <Check size="11" class="text-foreground" />
336
- </span>
337
- <span class="text-xs text-muted-foreground">{succeeded.length} imported</span>
338
- </div>
339
- <div class="flex items-center gap-1.5">
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
- <span class="text-xs text-muted-foreground">{failed.length} failed</span>
344
- </div>
345
- </div>
346
- <div class="relative flex-1 overflow-auto w-full">
347
- <Table
348
- data={failedData}
349
- columns={[{ id: "__error", icon: AlertCircle }, ...collectionColumns.filter((c) => c.id !== "id")]}
350
- showCheckboxes={false}
351
- headerBorderTop={true}
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
- {#snippet overrideCell(value, column)}
354
- {#if column.id === "__error"}
355
- <span class="text-destructive">{value}</span>
356
- {:else}
357
- {value}
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}>