@lobb-js/studio 0.46.0 → 0.47.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/actions.d.ts CHANGED
@@ -11,6 +11,14 @@ export interface OpenDataTableDrawerProps {
11
11
  position?: "side" | "bottom";
12
12
  tabs?: CollectionTab[];
13
13
  }
14
+ export interface OpenPopupProps {
15
+ component: any;
16
+ componentProps?: Record<string, any>;
17
+ title?: string;
18
+ size?: "default" | "sm";
19
+ hideHeader?: boolean;
20
+ replaceLast?: boolean;
21
+ }
14
22
  export interface OpenDataTablePopupProps {
15
23
  collectionName: string;
16
24
  filter?: Record<string, any>;
@@ -25,9 +33,13 @@ export interface OpenDataTablePopupProps {
25
33
  id: string;
26
34
  [key: string]: any;
27
35
  };
36
+ replaceLast?: boolean;
37
+ size?: "default" | "sm";
38
+ hideHeader?: boolean;
28
39
  }
29
40
  export declare function showDialog(title: string, description: string): Promise<boolean>;
30
41
  export declare function openCreateDetailView(studioContext: StudioContext, props: CreateDetailViewProp): void;
31
42
  export declare function openUpdateDetailView(studioContext: StudioContext, props: UpdateDetailViewProp): Promise<void>;
32
43
  export declare function openDataTableDrawer(studioContext: StudioContext, props: OpenDataTableDrawerProps): void;
44
+ export declare function openPopup(studioContext: StudioContext, props: OpenPopupProps): void;
33
45
  export declare function openDataTablePopup(studioContext: StudioContext, props: OpenDataTablePopupProps): void;
package/dist/actions.js CHANGED
@@ -3,9 +3,18 @@ import ConfirmationDialog from "./components/confirmationDialog/confirmationDial
3
3
  import CreateDetailView from "./components/detailView/create/createDetailView.svelte";
4
4
  import UpdateDetailView from "./components/detailView/update/updateDetailView.svelte";
5
5
  import DataTableDrawer from "./components/dataTableDrawer/dataTableDrawer.svelte";
6
- import DataTablePopup from "./components/dataTablePopup/dataTablePopup.svelte";
6
+ import DataTable from "./components/dataTable/dataTable.svelte";
7
+ import Popup from "./components/popup/popup.svelte";
7
8
  import { createStudioContextMap } from "./context";
8
9
  import { getCollectionParamsFields } from "./components/dataTable/utils";
10
+ let activePopup = null;
11
+ function closeActivePopupIfRequested(replaceLast) {
12
+ if (!replaceLast || !activePopup)
13
+ return;
14
+ const toUnmount = activePopup;
15
+ activePopup = null;
16
+ unmount(toUnmount, { outro: true });
17
+ }
9
18
  export function showDialog(title, description) {
10
19
  return new Promise((resolve) => {
11
20
  const targetElement = document.querySelector("main");
@@ -81,18 +90,53 @@ export function openDataTableDrawer(studioContext, props) {
81
90
  },
82
91
  });
83
92
  }
84
- export function openDataTablePopup(studioContext, props) {
93
+ export function openPopup(studioContext, props) {
85
94
  const targetElement = document.querySelector("main");
86
95
  if (!targetElement)
87
96
  throw new Error("main html element doesn't exist");
88
- const mounted = mount(DataTablePopup, {
97
+ closeActivePopupIfRequested(props.replaceLast);
98
+ const mounted = mount(Popup, {
89
99
  target: targetElement,
90
100
  context: createStudioContextMap(studioContext),
91
101
  props: {
92
- ...props,
102
+ component: props.component,
103
+ componentProps: props.componentProps,
104
+ title: props.title,
105
+ size: props.size,
106
+ hideHeader: props.hideHeader,
93
107
  onClose: async () => {
108
+ if (activePopup === mounted)
109
+ activePopup = null;
94
110
  await unmount(mounted, { outro: true });
95
111
  },
96
112
  },
97
113
  });
114
+ activePopup = mounted;
115
+ }
116
+ export function openDataTablePopup(studioContext, props) {
117
+ // DataTable reads `searchParams` once during its initial $state setup,
118
+ // so sort/limit need to be folded into the searchParams object before
119
+ // we mount. Built here in plain TS — no Svelte reactivity to escape.
120
+ const searchParams = {};
121
+ if (props.sort)
122
+ searchParams.sort = props.sort;
123
+ if (props.limit != null)
124
+ searchParams.limit = String(props.limit);
125
+ openPopup(studioContext, {
126
+ component: DataTable,
127
+ componentProps: {
128
+ collectionName: props.collectionName,
129
+ filter: props.filter,
130
+ searchParams,
131
+ showHeader: props.showHeader,
132
+ showFooter: props.showFooter,
133
+ showFilter: props.showFilter ?? false,
134
+ tabs: props.tabs,
135
+ view: props.view,
136
+ },
137
+ title: props.title ?? props.collectionName,
138
+ size: props.size,
139
+ hideHeader: props.hideHeader,
140
+ replaceLast: props.replaceLast,
141
+ });
98
142
  }
@@ -33,7 +33,15 @@
33
33
  // the record was created or updated. The default code path leaves it
34
34
  // undefined; the UI then just says "imported".
35
35
  type ImportAction = "created" | "updated";
36
- let importResults = $state<{ row: any; error: string | null; action?: ImportAction }[]>([]);
36
+ // Errors are kept structured all the way from the server (or workflow)
37
+ // to the UI — `details` is the per-field validation map Lobb already
38
+ // emits as JSON ({ field: ["msg1", "msg2"] }), so the popover can
39
+ // render a clean labelled list without parsing strings.
40
+ type ImportError = {
41
+ message: string;
42
+ details?: Record<string, string[]>;
43
+ };
44
+ let importResults = $state<{ row: any; error: ImportError | null; action?: ImportAction }[]>([]);
37
45
  // Which results tab is visible. Defaults to "failed" when there are
38
46
  // any failures (most users want to fix those first); falls back to
39
47
  // "imported" when everything went through.
@@ -45,6 +53,24 @@
45
53
 
46
54
  const collectionColumns = $derived(getCollectionColumns(ctx, collectionName) ?? []);
47
55
 
56
+ // Map a snake_case field id to a friendly label using the column
57
+ // metadata when available, falling back to Title Casing the id.
58
+ function fieldLabel(
59
+ id: string,
60
+ columns: Array<{ id: string; label?: string }>,
61
+ ): string {
62
+ const col = columns.find((c) => c.id === id);
63
+ if (col?.label && col.label !== col.id) return col.label;
64
+ return id
65
+ .split("_")
66
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
67
+ .join(" ");
68
+ }
69
+
70
+ function capitalise(s: string): string {
71
+ return s ? s[0].toUpperCase() + s.slice(1) : s;
72
+ }
73
+
48
74
  function applyColumnMapping(rows: any[]): {
49
75
  rows: any[];
50
76
  unmapped: Array<{ column: string; sample: string }>;
@@ -184,7 +210,7 @@
184
210
  // results verbatim, no extra writes from our side.
185
211
  const r = eventResult.results as {
186
212
  imported?: Array<{ row: any; action: ImportAction }>;
187
- failed?: Array<{ row: any; error: string; action?: ImportAction }>;
213
+ failed?: Array<{ row: any; error: ImportError; action?: ImportAction }>;
188
214
  };
189
215
  for (const item of r.imported ?? []) {
190
216
  importResults.push({ row: item.row, error: null, action: item.action });
@@ -195,19 +221,32 @@
195
221
  }
196
222
  } else {
197
223
  // Default path — workflow only transformed rows (or did
198
- // nothing). Loop createOne ourselves and track results.
224
+ // nothing). Send every row as a single batch call: each op
225
+ // runs independently and we get a 1:1 result array back, so
226
+ // one row failing doesn't stop the others and we surface
227
+ // failures alongside successes in a single round trip.
199
228
  const finalRows = eventResult.rows ?? transformedRows;
200
- for (const row of finalRows) {
201
- const response = await lobb.createOne(collectionName, { data: row });
202
- if (response.ok) {
229
+ const ops = finalRows.map((row: any) => ({
230
+ method: "createOne",
231
+ collectionName,
232
+ props: { data: row },
233
+ }));
234
+ const batchRes = await lobb.batch(ops);
235
+ const batchBody: any[] = await batchRes.json();
236
+ for (let i = 0; i < finalRows.length; i++) {
237
+ const row = finalRows[i];
238
+ const entry = batchBody[i];
239
+ if (entry?.data) {
203
240
  importResults.push({ row, error: null });
204
241
  hasSuccess = true;
205
242
  } else {
206
- const body = await response.json().catch(() => null);
207
- const message = body?.details
208
- ? Object.entries(body.details).map(([f, msgs]) => `${f}: ${(msgs as string[]).join(", ")}`).join(" | ")
209
- : (body?.message ?? `HTTP ${response.status}`);
210
- importResults.push({ row, error: message });
243
+ importResults.push({
244
+ row,
245
+ error: {
246
+ message: entry?.message ?? `Error: ${entry?.code ?? "unknown"}`,
247
+ details: entry?.details,
248
+ },
249
+ });
211
250
  }
212
251
  }
213
252
  }
@@ -472,26 +511,50 @@
472
511
  >
473
512
  {#snippet overrideCell(value, column)}
474
513
  {#if column.id === "__error"}
475
- <!-- Error cell: truncated single-line preview in
476
- the table. Hovering reveals the full message
477
- in a tooltip with monospace + wrap so
478
- multi-line errors / JSON dumps render
479
- legibly. -->
480
- <Tooltip.Provider delayDuration={150}>
514
+ <!-- Error cell: hover to reveal a tooltip with
515
+ the structured `details` map rendered as a
516
+ labelled list (friendly field names like
517
+ "Top Ten" instead of "top_ten") so non-
518
+ technical users can see exactly what to
519
+ fix per row. -->
520
+ {@const err = value as ImportError}
521
+ {@const detailEntries = Object.entries(err?.details ?? {})}
522
+ <Tooltip.Provider delayDuration={120}>
481
523
  <Tooltip.Root>
482
524
  <Tooltip.Trigger
483
- class="block w-full truncate text-left text-destructive"
525
+ class="group flex w-full items-center gap-1.5 text-left text-destructive"
484
526
  >
485
- {value}
527
+ <AlertCircle size={12} class="shrink-0" />
528
+ <span class="truncate group-hover:underline">{err?.message ?? ""}</span>
486
529
  </Tooltip.Trigger>
487
530
  <Tooltip.Content
488
- class="max-w-md p-0"
531
+ class="w-96 p-0"
489
532
  side="bottom"
490
533
  align="start"
491
534
  >
492
- <div class="max-h-64 overflow-auto p-3">
493
- <pre class="whitespace-pre-wrap break-words font-mono text-xs text-destructive">{value}</pre>
535
+ <div class="border-b px-3 py-2">
536
+ <p class="text-sm font-medium">This row couldn't be imported</p>
537
+ <p class="mt-0.5 text-xs text-muted-foreground">
538
+ {#if detailEntries.length > 0}
539
+ Fix the issue{detailEntries.length === 1 ? "" : "s"} below and try again.
540
+ {:else}
541
+ {err?.message ?? "Unknown error"}
542
+ {/if}
543
+ </p>
494
544
  </div>
545
+ {#if detailEntries.length > 0}
546
+ <ul class="flex max-h-64 flex-col gap-1.5 overflow-auto px-3 py-2.5">
547
+ {#each detailEntries as [fieldId, messages]}
548
+ <li class="flex gap-2 text-xs">
549
+ <AlertCircle size={11} class="mt-0.5 shrink-0 text-destructive" />
550
+ <div class="min-w-0">
551
+ <span class="font-semibold text-foreground">{fieldLabel(fieldId, collectionColumns)}</span>
552
+ <span class="text-muted-foreground"> — {capitalise(messages.join(", "))}</span>
553
+ </div>
554
+ </li>
555
+ {/each}
556
+ </ul>
557
+ {/if}
495
558
  </Tooltip.Content>
496
559
  </Tooltip.Root>
497
560
  </Tooltip.Provider>
@@ -0,0 +1,79 @@
1
+ <script lang="ts">
2
+ // Generic centred-modal popup chrome — backdrop, fade/scale
3
+ // transitions, optional title bar with close button. When
4
+ // `hideHeader` is true the popup renders no chrome and the body is
5
+ // responsible for the close affordance (it gets `onClose` as a prop).
6
+ // `openPopup` mounts this; specialised popup helpers like
7
+ // `openDataTablePopup` are thin wrappers that supply the body
8
+ // component + props.
9
+ import { X } from "lucide-svelte";
10
+ import { fade, scale } from "svelte/transition";
11
+ import { cubicOut } from "svelte/easing";
12
+ import Portal from "svelte-portal";
13
+ import Button from "../ui/button/button.svelte";
14
+
15
+ interface Props {
16
+ // The component rendered inside the popup body. It receives
17
+ // `componentProps` spread onto it plus `onClose` so it can
18
+ // close the popup itself when needed.
19
+ component: any;
20
+ componentProps?: Record<string, any>;
21
+ title?: string;
22
+ // `default` is wide (max-w-7xl, fixed 85vh) — for data tables.
23
+ // `sm` is narrow (max-w-2xl, content-height) — for detail views.
24
+ size?: "default" | "sm";
25
+ // When true, the popup renders no chrome at all — no title bar,
26
+ // no close button. The body is responsible for the close
27
+ // affordance (it can use the `onClose` prop the popup passes in).
28
+ // Use when the body brings its own header.
29
+ hideHeader?: boolean;
30
+ onClose?: () => void;
31
+ }
32
+
33
+ let {
34
+ component: Body,
35
+ componentProps = {},
36
+ title,
37
+ size = "default",
38
+ hideHeader = false,
39
+ onClose,
40
+ }: Props = $props();
41
+ </script>
42
+
43
+ <Portal target="body">
44
+ <button
45
+ transition:fade={{ duration: 200 }}
46
+ onclick={() => onClose?.()}
47
+ class="fixed left-0 top-0 z-40 h-screen w-screen bg-black/50 cursor-default"
48
+ aria-label="background used to close the popup"
49
+ ></button>
50
+
51
+ <div
52
+ transition:scale={{ duration: 200, easing: cubicOut, start: 0.95 }}
53
+ class="fixed left-1/2 top-1/2 z-40 flex w-[95vw] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border bg-card shadow-2xl
54
+ {size === 'sm' ? 'max-h-[calc(100vh-6rem)] max-w-2xl' : 'h-[85vh] max-w-7xl'}"
55
+ >
56
+ {#if !hideHeader}
57
+ <div class="flex h-12 shrink-0 items-center justify-between gap-4 border-b px-4">
58
+ <div class="text-sm font-medium">{title ?? ""}</div>
59
+ <Button
60
+ variant="ghost"
61
+ size="icon"
62
+ onclick={() => onClose?.()}
63
+ class="h-8 w-8 rounded-full text-muted-foreground"
64
+ Icon={X}
65
+ />
66
+ </div>
67
+ {/if}
68
+ <!-- Bodies own their own scrolling so they can decide what's
69
+ anchored vs. scrollable (e.g. a static header above a
70
+ scrolling section list). The wrapper is itself a flex
71
+ column so bodies can claim a bounded height with
72
+ `flex-1 min-h-0` — that avoids relying on `h-full`
73
+ percentages, which don't resolve when the popup uses
74
+ `max-h` (size="sm") instead of a fixed `h`. -->
75
+ <div class="flex min-h-0 flex-1 flex-col overflow-hidden">
76
+ <Body {...componentProps} {onClose} />
77
+ </div>
78
+ </div>
79
+ </Portal>
@@ -0,0 +1,11 @@
1
+ interface Props {
2
+ component: any;
3
+ componentProps?: Record<string, any>;
4
+ title?: string;
5
+ size?: "default" | "sm";
6
+ hideHeader?: boolean;
7
+ onClose?: () => void;
8
+ }
9
+ declare const Popup: import("svelte").Component<Props, {}, "">;
10
+ type Popup = ReturnType<typeof Popup>;
11
+ export default Popup;
@@ -23,7 +23,7 @@ import * as Icons from "lucide-svelte";
23
23
  import { ContextMenu } from "bits-ui";
24
24
  import * as Tooltip from "../components/ui/tooltip";
25
25
  import * as Breadcrumb from "../components/ui/breadcrumb";
26
- import { showDialog, type OpenDataTableDrawerProps, type OpenDataTablePopupProps } from "../actions";
26
+ import { showDialog, type OpenDataTableDrawerProps, type OpenDataTablePopupProps, type OpenPopupProps } from "../actions";
27
27
  import { toast } from "svelte-sonner";
28
28
  import { Switch } from "../components/ui/switch";
29
29
  export interface Components {
@@ -68,6 +68,14 @@ export interface ExtensionUtils {
68
68
  showDialog: typeof showDialog;
69
69
  openDataTableDrawer: (props: OpenDataTableDrawerProps) => void;
70
70
  openDataTablePopup: (props: OpenDataTablePopupProps) => void;
71
+ /**
72
+ * Mount any Svelte component inside the standard popup chrome
73
+ * (backdrop, fade/scale transitions, close button, optional title
74
+ * bar, optional `replaceLast` swap with the active overlay). The
75
+ * body component receives `componentProps` spread onto it plus
76
+ * `onClose` so it can dismiss the popup itself.
77
+ */
78
+ openPopup: (props: OpenPopupProps) => void;
71
79
  emitEvent: (eventName: string, input: any) => Promise<any>;
72
80
  /**
73
81
  * Shorthand for the standalone `isHidden(ctx, id)` helper — returns
@@ -1,5 +1,5 @@
1
1
  import { toast } from "svelte-sonner";
2
- import { showDialog, openDataTableDrawer, openDataTablePopup } from "../actions";
2
+ import { showDialog, openDataTableDrawer, openDataTablePopup, openPopup } from "../actions";
3
3
  import { emitEvent } from "../eventSystem";
4
4
  import { Button } from "../components/ui/button";
5
5
  import { Input } from "../components/ui/input";
@@ -59,6 +59,7 @@ export function getExtensionUtils(lobb, ctx) {
59
59
  showDialog: showDialog,
60
60
  openDataTableDrawer: (props) => openDataTableDrawer({ lobb, ctx }, props),
61
61
  openDataTablePopup: (props) => openDataTablePopup({ lobb, ctx }, props),
62
+ openPopup: (props) => openPopup({ lobb, ctx }, props),
62
63
  emitEvent: (eventName, input) => emitEvent({ lobb, ctx }, eventName, input),
63
64
  isHidden: (id) => ctx.meta?.ui?.hide?.[id] === true,
64
65
  components: getComponents(),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lobb-js/studio",
3
3
  "license": "UNLICENSED",
4
- "version": "0.46.0",
4
+ "version": "0.47.0",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -3,7 +3,8 @@ import ConfirmationDialog from "./components/confirmationDialog/confirmationDial
3
3
  import CreateDetailView from "./components/detailView/create/createDetailView.svelte";
4
4
  import UpdateDetailView from "./components/detailView/update/updateDetailView.svelte";
5
5
  import DataTableDrawer from "./components/dataTableDrawer/dataTableDrawer.svelte";
6
- import DataTablePopup from "./components/dataTablePopup/dataTablePopup.svelte";
6
+ import DataTable from "./components/dataTable/dataTable.svelte";
7
+ import Popup from "./components/popup/popup.svelte";
7
8
  import type { CreateDetailViewProp } from "./components/detailView/create/createDetailView.svelte";
8
9
  import type { UpdateDetailViewProp } from "./components/detailView/update/updateDetailView.svelte";
9
10
  import type { StudioContext } from "./context";
@@ -21,6 +22,24 @@ export interface OpenDataTableDrawerProps {
21
22
  tabs?: CollectionTab[];
22
23
  }
23
24
 
25
+ export interface OpenPopupProps {
26
+ // Component rendered inside the popup. It receives `componentProps`
27
+ // spread onto it plus `onClose` so it can close the popup itself.
28
+ component: any;
29
+ componentProps?: Record<string, any>;
30
+ title?: string;
31
+ // `default` is the wide data-table popup (max-w-7xl, fixed 85vh).
32
+ // `sm` is the narrow detail-view variant (max-w-2xl, content-height).
33
+ size?: "default" | "sm";
34
+ // When true, the popup renders no chrome at all (no title bar, no
35
+ // close button). The body uses the `onClose` prop the popup passes
36
+ // it to render its own close affordance.
37
+ hideHeader?: boolean;
38
+ // Close the previously-opened popup before mounting this one —
39
+ // drill-down semantics. Only popups participate; drawers don't.
40
+ replaceLast?: boolean;
41
+ }
42
+
24
43
  export interface OpenDataTablePopupProps {
25
44
  collectionName: string;
26
45
  filter?: Record<string, any>;
@@ -32,6 +51,18 @@ export interface OpenDataTablePopupProps {
32
51
  showFilter?: boolean;
33
52
  tabs?: CollectionTab[];
34
53
  view?: { id: string; [key: string]: any };
54
+ replaceLast?: boolean;
55
+ size?: "default" | "sm";
56
+ hideHeader?: boolean;
57
+ }
58
+
59
+ let activePopup: any = null;
60
+
61
+ function closeActivePopupIfRequested(replaceLast: boolean | undefined) {
62
+ if (!replaceLast || !activePopup) return;
63
+ const toUnmount = activePopup;
64
+ activePopup = null;
65
+ unmount(toUnmount, { outro: true });
35
66
  }
36
67
 
37
68
  export function showDialog(title: string, description: string): Promise<boolean> {
@@ -114,18 +145,53 @@ export function openDataTableDrawer(studioContext: StudioContext, props: OpenDat
114
145
  });
115
146
  }
116
147
 
117
- export function openDataTablePopup(studioContext: StudioContext, props: OpenDataTablePopupProps) {
148
+ export function openPopup(studioContext: StudioContext, props: OpenPopupProps) {
118
149
  const targetElement = document.querySelector("main");
119
150
  if (!targetElement) throw new Error("main html element doesn't exist");
120
151
 
121
- const mounted = mount(DataTablePopup, {
152
+ closeActivePopupIfRequested(props.replaceLast);
153
+
154
+ const mounted = mount(Popup, {
122
155
  target: targetElement,
123
156
  context: createStudioContextMap(studioContext),
124
157
  props: {
125
- ...props,
158
+ component: props.component,
159
+ componentProps: props.componentProps,
160
+ title: props.title,
161
+ size: props.size,
162
+ hideHeader: props.hideHeader,
126
163
  onClose: async () => {
164
+ if (activePopup === mounted) activePopup = null;
127
165
  await unmount(mounted, { outro: true });
128
166
  },
129
167
  },
130
168
  });
169
+ activePopup = mounted;
170
+ }
171
+
172
+ export function openDataTablePopup(studioContext: StudioContext, props: OpenDataTablePopupProps) {
173
+ // DataTable reads `searchParams` once during its initial $state setup,
174
+ // so sort/limit need to be folded into the searchParams object before
175
+ // we mount. Built here in plain TS — no Svelte reactivity to escape.
176
+ const searchParams: Record<string, any> = {};
177
+ if (props.sort) searchParams.sort = props.sort;
178
+ if (props.limit != null) searchParams.limit = String(props.limit);
179
+
180
+ openPopup(studioContext, {
181
+ component: DataTable,
182
+ componentProps: {
183
+ collectionName: props.collectionName,
184
+ filter: props.filter,
185
+ searchParams,
186
+ showHeader: props.showHeader,
187
+ showFooter: props.showFooter,
188
+ showFilter: props.showFilter ?? false,
189
+ tabs: props.tabs,
190
+ view: props.view,
191
+ },
192
+ title: props.title ?? props.collectionName,
193
+ size: props.size,
194
+ hideHeader: props.hideHeader,
195
+ replaceLast: props.replaceLast,
196
+ });
131
197
  }
@@ -33,7 +33,15 @@
33
33
  // the record was created or updated. The default code path leaves it
34
34
  // undefined; the UI then just says "imported".
35
35
  type ImportAction = "created" | "updated";
36
- let importResults = $state<{ row: any; error: string | null; action?: ImportAction }[]>([]);
36
+ // Errors are kept structured all the way from the server (or workflow)
37
+ // to the UI — `details` is the per-field validation map Lobb already
38
+ // emits as JSON ({ field: ["msg1", "msg2"] }), so the popover can
39
+ // render a clean labelled list without parsing strings.
40
+ type ImportError = {
41
+ message: string;
42
+ details?: Record<string, string[]>;
43
+ };
44
+ let importResults = $state<{ row: any; error: ImportError | null; action?: ImportAction }[]>([]);
37
45
  // Which results tab is visible. Defaults to "failed" when there are
38
46
  // any failures (most users want to fix those first); falls back to
39
47
  // "imported" when everything went through.
@@ -45,6 +53,24 @@
45
53
 
46
54
  const collectionColumns = $derived(getCollectionColumns(ctx, collectionName) ?? []);
47
55
 
56
+ // Map a snake_case field id to a friendly label using the column
57
+ // metadata when available, falling back to Title Casing the id.
58
+ function fieldLabel(
59
+ id: string,
60
+ columns: Array<{ id: string; label?: string }>,
61
+ ): string {
62
+ const col = columns.find((c) => c.id === id);
63
+ if (col?.label && col.label !== col.id) return col.label;
64
+ return id
65
+ .split("_")
66
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
67
+ .join(" ");
68
+ }
69
+
70
+ function capitalise(s: string): string {
71
+ return s ? s[0].toUpperCase() + s.slice(1) : s;
72
+ }
73
+
48
74
  function applyColumnMapping(rows: any[]): {
49
75
  rows: any[];
50
76
  unmapped: Array<{ column: string; sample: string }>;
@@ -184,7 +210,7 @@
184
210
  // results verbatim, no extra writes from our side.
185
211
  const r = eventResult.results as {
186
212
  imported?: Array<{ row: any; action: ImportAction }>;
187
- failed?: Array<{ row: any; error: string; action?: ImportAction }>;
213
+ failed?: Array<{ row: any; error: ImportError; action?: ImportAction }>;
188
214
  };
189
215
  for (const item of r.imported ?? []) {
190
216
  importResults.push({ row: item.row, error: null, action: item.action });
@@ -195,19 +221,32 @@
195
221
  }
196
222
  } else {
197
223
  // Default path — workflow only transformed rows (or did
198
- // nothing). Loop createOne ourselves and track results.
224
+ // nothing). Send every row as a single batch call: each op
225
+ // runs independently and we get a 1:1 result array back, so
226
+ // one row failing doesn't stop the others and we surface
227
+ // failures alongside successes in a single round trip.
199
228
  const finalRows = eventResult.rows ?? transformedRows;
200
- for (const row of finalRows) {
201
- const response = await lobb.createOne(collectionName, { data: row });
202
- if (response.ok) {
229
+ const ops = finalRows.map((row: any) => ({
230
+ method: "createOne",
231
+ collectionName,
232
+ props: { data: row },
233
+ }));
234
+ const batchRes = await lobb.batch(ops);
235
+ const batchBody: any[] = await batchRes.json();
236
+ for (let i = 0; i < finalRows.length; i++) {
237
+ const row = finalRows[i];
238
+ const entry = batchBody[i];
239
+ if (entry?.data) {
203
240
  importResults.push({ row, error: null });
204
241
  hasSuccess = true;
205
242
  } else {
206
- const body = await response.json().catch(() => null);
207
- const message = body?.details
208
- ? Object.entries(body.details).map(([f, msgs]) => `${f}: ${(msgs as string[]).join(", ")}`).join(" | ")
209
- : (body?.message ?? `HTTP ${response.status}`);
210
- importResults.push({ row, error: message });
243
+ importResults.push({
244
+ row,
245
+ error: {
246
+ message: entry?.message ?? `Error: ${entry?.code ?? "unknown"}`,
247
+ details: entry?.details,
248
+ },
249
+ });
211
250
  }
212
251
  }
213
252
  }
@@ -472,26 +511,50 @@
472
511
  >
473
512
  {#snippet overrideCell(value, column)}
474
513
  {#if column.id === "__error"}
475
- <!-- Error cell: truncated single-line preview in
476
- the table. Hovering reveals the full message
477
- in a tooltip with monospace + wrap so
478
- multi-line errors / JSON dumps render
479
- legibly. -->
480
- <Tooltip.Provider delayDuration={150}>
514
+ <!-- Error cell: hover to reveal a tooltip with
515
+ the structured `details` map rendered as a
516
+ labelled list (friendly field names like
517
+ "Top Ten" instead of "top_ten") so non-
518
+ technical users can see exactly what to
519
+ fix per row. -->
520
+ {@const err = value as ImportError}
521
+ {@const detailEntries = Object.entries(err?.details ?? {})}
522
+ <Tooltip.Provider delayDuration={120}>
481
523
  <Tooltip.Root>
482
524
  <Tooltip.Trigger
483
- class="block w-full truncate text-left text-destructive"
525
+ class="group flex w-full items-center gap-1.5 text-left text-destructive"
484
526
  >
485
- {value}
527
+ <AlertCircle size={12} class="shrink-0" />
528
+ <span class="truncate group-hover:underline">{err?.message ?? ""}</span>
486
529
  </Tooltip.Trigger>
487
530
  <Tooltip.Content
488
- class="max-w-md p-0"
531
+ class="w-96 p-0"
489
532
  side="bottom"
490
533
  align="start"
491
534
  >
492
- <div class="max-h-64 overflow-auto p-3">
493
- <pre class="whitespace-pre-wrap break-words font-mono text-xs text-destructive">{value}</pre>
535
+ <div class="border-b px-3 py-2">
536
+ <p class="text-sm font-medium">This row couldn't be imported</p>
537
+ <p class="mt-0.5 text-xs text-muted-foreground">
538
+ {#if detailEntries.length > 0}
539
+ Fix the issue{detailEntries.length === 1 ? "" : "s"} below and try again.
540
+ {:else}
541
+ {err?.message ?? "Unknown error"}
542
+ {/if}
543
+ </p>
494
544
  </div>
545
+ {#if detailEntries.length > 0}
546
+ <ul class="flex max-h-64 flex-col gap-1.5 overflow-auto px-3 py-2.5">
547
+ {#each detailEntries as [fieldId, messages]}
548
+ <li class="flex gap-2 text-xs">
549
+ <AlertCircle size={11} class="mt-0.5 shrink-0 text-destructive" />
550
+ <div class="min-w-0">
551
+ <span class="font-semibold text-foreground">{fieldLabel(fieldId, collectionColumns)}</span>
552
+ <span class="text-muted-foreground"> — {capitalise(messages.join(", "))}</span>
553
+ </div>
554
+ </li>
555
+ {/each}
556
+ </ul>
557
+ {/if}
495
558
  </Tooltip.Content>
496
559
  </Tooltip.Root>
497
560
  </Tooltip.Provider>
@@ -0,0 +1,79 @@
1
+ <script lang="ts">
2
+ // Generic centred-modal popup chrome — backdrop, fade/scale
3
+ // transitions, optional title bar with close button. When
4
+ // `hideHeader` is true the popup renders no chrome and the body is
5
+ // responsible for the close affordance (it gets `onClose` as a prop).
6
+ // `openPopup` mounts this; specialised popup helpers like
7
+ // `openDataTablePopup` are thin wrappers that supply the body
8
+ // component + props.
9
+ import { X } from "lucide-svelte";
10
+ import { fade, scale } from "svelte/transition";
11
+ import { cubicOut } from "svelte/easing";
12
+ import Portal from "svelte-portal";
13
+ import Button from "../ui/button/button.svelte";
14
+
15
+ interface Props {
16
+ // The component rendered inside the popup body. It receives
17
+ // `componentProps` spread onto it plus `onClose` so it can
18
+ // close the popup itself when needed.
19
+ component: any;
20
+ componentProps?: Record<string, any>;
21
+ title?: string;
22
+ // `default` is wide (max-w-7xl, fixed 85vh) — for data tables.
23
+ // `sm` is narrow (max-w-2xl, content-height) — for detail views.
24
+ size?: "default" | "sm";
25
+ // When true, the popup renders no chrome at all — no title bar,
26
+ // no close button. The body is responsible for the close
27
+ // affordance (it can use the `onClose` prop the popup passes in).
28
+ // Use when the body brings its own header.
29
+ hideHeader?: boolean;
30
+ onClose?: () => void;
31
+ }
32
+
33
+ let {
34
+ component: Body,
35
+ componentProps = {},
36
+ title,
37
+ size = "default",
38
+ hideHeader = false,
39
+ onClose,
40
+ }: Props = $props();
41
+ </script>
42
+
43
+ <Portal target="body">
44
+ <button
45
+ transition:fade={{ duration: 200 }}
46
+ onclick={() => onClose?.()}
47
+ class="fixed left-0 top-0 z-40 h-screen w-screen bg-black/50 cursor-default"
48
+ aria-label="background used to close the popup"
49
+ ></button>
50
+
51
+ <div
52
+ transition:scale={{ duration: 200, easing: cubicOut, start: 0.95 }}
53
+ class="fixed left-1/2 top-1/2 z-40 flex w-[95vw] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border bg-card shadow-2xl
54
+ {size === 'sm' ? 'max-h-[calc(100vh-6rem)] max-w-2xl' : 'h-[85vh] max-w-7xl'}"
55
+ >
56
+ {#if !hideHeader}
57
+ <div class="flex h-12 shrink-0 items-center justify-between gap-4 border-b px-4">
58
+ <div class="text-sm font-medium">{title ?? ""}</div>
59
+ <Button
60
+ variant="ghost"
61
+ size="icon"
62
+ onclick={() => onClose?.()}
63
+ class="h-8 w-8 rounded-full text-muted-foreground"
64
+ Icon={X}
65
+ />
66
+ </div>
67
+ {/if}
68
+ <!-- Bodies own their own scrolling so they can decide what's
69
+ anchored vs. scrollable (e.g. a static header above a
70
+ scrolling section list). The wrapper is itself a flex
71
+ column so bodies can claim a bounded height with
72
+ `flex-1 min-h-0` — that avoids relying on `h-full`
73
+ percentages, which don't resolve when the popup uses
74
+ `max-h` (size="sm") instead of a fixed `h`. -->
75
+ <div class="flex min-h-0 flex-1 flex-col overflow-hidden">
76
+ <Body {...componentProps} {onClose} />
77
+ </div>
78
+ </div>
79
+ </Portal>
@@ -23,7 +23,7 @@ import * as Icons from "lucide-svelte"
23
23
  import { ContextMenu } from "bits-ui";
24
24
  import * as Tooltip from "../components/ui/tooltip";
25
25
  import * as Breadcrumb from "../components/ui/breadcrumb";
26
- import { showDialog, type OpenDataTableDrawerProps, type OpenDataTablePopupProps } from "../actions";
26
+ import { showDialog, type OpenDataTableDrawerProps, type OpenDataTablePopupProps, type OpenPopupProps } from "../actions";
27
27
  import { toast } from "svelte-sonner";
28
28
  import type Drawer from "../components/drawer.svelte";
29
29
  import { Switch } from "../components/ui/switch";
@@ -72,6 +72,14 @@ export interface ExtensionUtils {
72
72
  showDialog: typeof showDialog;
73
73
  openDataTableDrawer: (props: OpenDataTableDrawerProps) => void;
74
74
  openDataTablePopup: (props: OpenDataTablePopupProps) => void;
75
+ /**
76
+ * Mount any Svelte component inside the standard popup chrome
77
+ * (backdrop, fade/scale transitions, close button, optional title
78
+ * bar, optional `replaceLast` swap with the active overlay). The
79
+ * body component receives `componentProps` spread onto it plus
80
+ * `onClose` so it can dismiss the popup itself.
81
+ */
82
+ openPopup: (props: OpenPopupProps) => void;
75
83
  emitEvent: (eventName: string, input: any) => Promise<any>;
76
84
  /**
77
85
  * Shorthand for the standalone `isHidden(ctx, id)` helper — returns
@@ -7,7 +7,7 @@ import type {
7
7
  import type { LobbClient } from "@lobb-js/sdk";
8
8
  import type { CTX } from "../store.types";
9
9
  import { toast } from "svelte-sonner";
10
- import { showDialog, openDataTableDrawer, openDataTablePopup } from "../actions";
10
+ import { showDialog, openDataTableDrawer, openDataTablePopup, openPopup } from "../actions";
11
11
  import { emitEvent } from "../eventSystem";
12
12
  import { Button } from "../components/ui/button";
13
13
  import { Input } from "../components/ui/input";
@@ -69,6 +69,7 @@ export function getExtensionUtils(lobb: LobbClient, ctx: CTX): ExtensionUtils {
69
69
  showDialog: showDialog,
70
70
  openDataTableDrawer: (props) => openDataTableDrawer({ lobb, ctx }, props),
71
71
  openDataTablePopup: (props) => openDataTablePopup({ lobb, ctx }, props),
72
+ openPopup: (props) => openPopup({ lobb, ctx }, props),
72
73
  emitEvent: (eventName, input) => emitEvent({ lobb, ctx }, eventName, input),
73
74
  isHidden: (id) => ctx.meta?.ui?.hide?.[id] === true,
74
75
  components: getComponents(),
@@ -1,94 +0,0 @@
1
- <script lang="ts">
2
- import { X } from "lucide-svelte";
3
- import { untrack } from "svelte";
4
- import { fade, scale } from "svelte/transition";
5
- import { cubicOut } from "svelte/easing";
6
- import Portal from "svelte-portal";
7
- import Button from "../ui/button/button.svelte";
8
- import DataTable from "../dataTable/dataTable.svelte";
9
- import type { TableProps } from "../dataTable/table.svelte";
10
- import type { CollectionTab } from "../../store.types";
11
-
12
- interface Props {
13
- collectionName: string;
14
- filter?: Record<string, any>;
15
- sort?: Record<string, "asc" | "desc">;
16
- limit?: number;
17
- title?: string;
18
- showHeader?: boolean;
19
- showFooter?: boolean;
20
- // Popups are usually opened from chart drill-downs with a
21
- // preset filter — re-filtering inside the popup almost never
22
- // makes sense, so the filter button is hidden by default here.
23
- // Pass showFilter={true} to surface it.
24
- showFilter?: boolean;
25
- tableProps?: Partial<TableProps>;
26
- tabs?: CollectionTab[];
27
- view?: { id: string; [key: string]: any };
28
- onClose?: () => void;
29
- }
30
-
31
- let {
32
- collectionName,
33
- filter,
34
- sort,
35
- limit,
36
- title,
37
- showHeader = true,
38
- showFooter = true,
39
- showFilter = false,
40
- tableProps,
41
- tabs,
42
- view,
43
- onClose,
44
- }: Props = $props();
45
-
46
- // Read once on mount — sort/limit are fixed for the popup's lifetime,
47
- // and DataTable only reads searchParams during its initial $state setup
48
- // so even live updates wouldn't propagate. untrack makes that intent
49
- // explicit and silences Svelte's "captures initial value" warning.
50
- const searchParams = untrack(() => {
51
- const p: Record<string, any> = {};
52
- if (sort) p.sort = sort;
53
- if (limit != null) p.limit = String(limit);
54
- return p;
55
- });
56
- </script>
57
-
58
- <Portal target="body">
59
- <button
60
- transition:fade={{ duration: 200 }}
61
- onclick={() => onClose?.()}
62
- class="fixed left-0 top-0 z-40 h-screen w-screen bg-black/50 cursor-default"
63
- aria-label="background used to close the popup"
64
- ></button>
65
-
66
- <div
67
- transition:scale={{ duration: 200, easing: cubicOut, start: 0.95 }}
68
- class="fixed left-1/2 top-1/2 z-40 flex h-[85vh] w-[95vw] max-w-7xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border bg-card shadow-2xl"
69
- >
70
- <div class="flex h-12 shrink-0 items-center justify-between gap-4 border-b px-4">
71
- <div class="text-sm font-medium">{title ?? collectionName}</div>
72
- <Button
73
- variant="ghost"
74
- size="icon"
75
- onclick={() => onClose?.()}
76
- class="h-8 w-8 rounded-full text-muted-foreground"
77
- Icon={X}
78
- />
79
- </div>
80
- <div class="min-h-0 flex-1 overflow-auto">
81
- <DataTable
82
- {collectionName}
83
- {filter}
84
- {searchParams}
85
- {showHeader}
86
- {showFooter}
87
- {showFilter}
88
- {tableProps}
89
- {tabs}
90
- {view}
91
- />
92
- </div>
93
- </div>
94
- </Portal>
@@ -1,22 +0,0 @@
1
- import type { TableProps } from "../dataTable/table.svelte";
2
- import type { CollectionTab } from "../../store.types";
3
- interface Props {
4
- collectionName: string;
5
- filter?: Record<string, any>;
6
- sort?: Record<string, "asc" | "desc">;
7
- limit?: number;
8
- title?: string;
9
- showHeader?: boolean;
10
- showFooter?: boolean;
11
- showFilter?: boolean;
12
- tableProps?: Partial<TableProps>;
13
- tabs?: CollectionTab[];
14
- view?: {
15
- id: string;
16
- [key: string]: any;
17
- };
18
- onClose?: () => void;
19
- }
20
- declare const DataTablePopup: import("svelte").Component<Props, {}, "">;
21
- type DataTablePopup = ReturnType<typeof DataTablePopup>;
22
- export default DataTablePopup;
@@ -1,94 +0,0 @@
1
- <script lang="ts">
2
- import { X } from "lucide-svelte";
3
- import { untrack } from "svelte";
4
- import { fade, scale } from "svelte/transition";
5
- import { cubicOut } from "svelte/easing";
6
- import Portal from "svelte-portal";
7
- import Button from "../ui/button/button.svelte";
8
- import DataTable from "../dataTable/dataTable.svelte";
9
- import type { TableProps } from "../dataTable/table.svelte";
10
- import type { CollectionTab } from "../../store.types";
11
-
12
- interface Props {
13
- collectionName: string;
14
- filter?: Record<string, any>;
15
- sort?: Record<string, "asc" | "desc">;
16
- limit?: number;
17
- title?: string;
18
- showHeader?: boolean;
19
- showFooter?: boolean;
20
- // Popups are usually opened from chart drill-downs with a
21
- // preset filter — re-filtering inside the popup almost never
22
- // makes sense, so the filter button is hidden by default here.
23
- // Pass showFilter={true} to surface it.
24
- showFilter?: boolean;
25
- tableProps?: Partial<TableProps>;
26
- tabs?: CollectionTab[];
27
- view?: { id: string; [key: string]: any };
28
- onClose?: () => void;
29
- }
30
-
31
- let {
32
- collectionName,
33
- filter,
34
- sort,
35
- limit,
36
- title,
37
- showHeader = true,
38
- showFooter = true,
39
- showFilter = false,
40
- tableProps,
41
- tabs,
42
- view,
43
- onClose,
44
- }: Props = $props();
45
-
46
- // Read once on mount — sort/limit are fixed for the popup's lifetime,
47
- // and DataTable only reads searchParams during its initial $state setup
48
- // so even live updates wouldn't propagate. untrack makes that intent
49
- // explicit and silences Svelte's "captures initial value" warning.
50
- const searchParams = untrack(() => {
51
- const p: Record<string, any> = {};
52
- if (sort) p.sort = sort;
53
- if (limit != null) p.limit = String(limit);
54
- return p;
55
- });
56
- </script>
57
-
58
- <Portal target="body">
59
- <button
60
- transition:fade={{ duration: 200 }}
61
- onclick={() => onClose?.()}
62
- class="fixed left-0 top-0 z-40 h-screen w-screen bg-black/50 cursor-default"
63
- aria-label="background used to close the popup"
64
- ></button>
65
-
66
- <div
67
- transition:scale={{ duration: 200, easing: cubicOut, start: 0.95 }}
68
- class="fixed left-1/2 top-1/2 z-40 flex h-[85vh] w-[95vw] max-w-7xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border bg-card shadow-2xl"
69
- >
70
- <div class="flex h-12 shrink-0 items-center justify-between gap-4 border-b px-4">
71
- <div class="text-sm font-medium">{title ?? collectionName}</div>
72
- <Button
73
- variant="ghost"
74
- size="icon"
75
- onclick={() => onClose?.()}
76
- class="h-8 w-8 rounded-full text-muted-foreground"
77
- Icon={X}
78
- />
79
- </div>
80
- <div class="min-h-0 flex-1 overflow-auto">
81
- <DataTable
82
- {collectionName}
83
- {filter}
84
- {searchParams}
85
- {showHeader}
86
- {showFooter}
87
- {showFilter}
88
- {tableProps}
89
- {tabs}
90
- {view}
91
- />
92
- </div>
93
- </div>
94
- </Portal>