@lobb-js/studio 0.46.0 → 0.48.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,23 +11,9 @@ export interface OpenDataTableDrawerProps {
11
11
  position?: "side" | "bottom";
12
12
  tabs?: CollectionTab[];
13
13
  }
14
- export interface OpenDataTablePopupProps {
15
- collectionName: string;
16
- filter?: Record<string, any>;
17
- sort?: Record<string, "asc" | "desc">;
18
- limit?: number;
19
- title?: string;
20
- showHeader?: boolean;
21
- showFooter?: boolean;
22
- showFilter?: boolean;
23
- tabs?: CollectionTab[];
24
- view?: {
25
- id: string;
26
- [key: string]: any;
27
- };
28
- }
29
14
  export declare function showDialog(title: string, description: string): Promise<boolean>;
30
15
  export declare function openCreateDetailView(studioContext: StudioContext, props: CreateDetailViewProp): void;
31
16
  export declare function openUpdateDetailView(studioContext: StudioContext, props: UpdateDetailViewProp): Promise<void>;
32
17
  export declare function openDataTableDrawer(studioContext: StudioContext, props: OpenDataTableDrawerProps): void;
33
- export declare function openDataTablePopup(studioContext: StudioContext, props: OpenDataTablePopupProps): void;
18
+ export { openPopup, openDataTablePopup, goBackPopup } from "./popup";
19
+ export type { OpenPopupProps, OpenDataTablePopupProps } from "./popup";
package/dist/actions.js CHANGED
@@ -3,7 +3,6 @@ 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";
7
6
  import { createStudioContextMap } from "./context";
8
7
  import { getCollectionParamsFields } from "./components/dataTable/utils";
9
8
  export function showDialog(title, description) {
@@ -81,18 +80,7 @@ export function openDataTableDrawer(studioContext, props) {
81
80
  },
82
81
  });
83
82
  }
84
- export function openDataTablePopup(studioContext, props) {
85
- const targetElement = document.querySelector("main");
86
- if (!targetElement)
87
- throw new Error("main html element doesn't exist");
88
- const mounted = mount(DataTablePopup, {
89
- target: targetElement,
90
- context: createStudioContextMap(studioContext),
91
- props: {
92
- ...props,
93
- onClose: async () => {
94
- await unmount(mounted, { outro: true });
95
- },
96
- },
97
- });
98
- }
83
+ // Popup helpers (openPopup, openDataTablePopup, goBackPopup) live in
84
+ // `./popup` they share enough state (active overlay, back-history)
85
+ // to warrant their own module.
86
+ export { openPopup, openDataTablePopup, goBackPopup } from "./popup";
@@ -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,98 @@
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 { ArrowLeft, 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
+ // Set by the action layer when there's a popup-history entry to
32
+ // pop. The chrome renders a back arrow in the title bar; bodies
33
+ // that `hideHeader: true` get `canGoBack` + `onBack` forwarded
34
+ // so they can render their own back affordance.
35
+ canGoBack?: boolean;
36
+ onBack?: () => void;
37
+ }
38
+
39
+ let {
40
+ component: Body,
41
+ componentProps = {},
42
+ title,
43
+ size = "default",
44
+ hideHeader = false,
45
+ onClose,
46
+ canGoBack = false,
47
+ onBack,
48
+ }: Props = $props();
49
+ </script>
50
+
51
+ <Portal target="body">
52
+ <button
53
+ transition:fade={{ duration: 200 }}
54
+ onclick={() => onClose?.()}
55
+ class="fixed left-0 top-0 z-40 h-screen w-screen bg-black/50 cursor-default"
56
+ aria-label="background used to close the popup"
57
+ ></button>
58
+
59
+ <div
60
+ transition:scale={{ duration: 200, easing: cubicOut, start: 0.95 }}
61
+ 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
62
+ {size === 'sm' ? 'max-h-[calc(100vh-6rem)] max-w-2xl' : 'h-[85vh] max-w-7xl'}"
63
+ >
64
+ {#if !hideHeader}
65
+ <div class="flex h-12 shrink-0 items-center justify-between gap-2 border-b px-4">
66
+ <div class="flex min-w-0 items-center gap-2">
67
+ {#if canGoBack}
68
+ <Button
69
+ variant="ghost"
70
+ size="icon"
71
+ onclick={() => onBack?.()}
72
+ class="h-8 w-8 rounded-full text-muted-foreground"
73
+ Icon={ArrowLeft}
74
+ />
75
+ {/if}
76
+ <div class="truncate text-sm font-medium">{title ?? ""}</div>
77
+ </div>
78
+ <Button
79
+ variant="ghost"
80
+ size="icon"
81
+ onclick={() => onClose?.()}
82
+ class="h-8 w-8 rounded-full text-muted-foreground"
83
+ Icon={X}
84
+ />
85
+ </div>
86
+ {/if}
87
+ <!-- Bodies own their own scrolling so they can decide what's
88
+ anchored vs. scrollable (e.g. a static header above a
89
+ scrolling section list). The wrapper is itself a flex
90
+ column so bodies can claim a bounded height with
91
+ `flex-1 min-h-0` — that avoids relying on `h-full`
92
+ percentages, which don't resolve when the popup uses
93
+ `max-h` (size="sm") instead of a fixed `h`. -->
94
+ <div class="flex min-h-0 flex-1 flex-col overflow-hidden">
95
+ <Body {...componentProps} {onClose} {canGoBack} {onBack} />
96
+ </div>
97
+ </div>
98
+ </Portal>
@@ -0,0 +1,13 @@
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
+ canGoBack?: boolean;
9
+ onBack?: () => void;
10
+ }
11
+ declare const Popup: import("svelte").Component<Props, {}, "">;
12
+ type Popup = ReturnType<typeof Popup>;
13
+ export default Popup;
@@ -23,7 +23,8 @@ 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 } from "../actions";
27
+ import type { OpenDataTablePopupProps, OpenPopupProps } from "../popup";
27
28
  import { toast } from "svelte-sonner";
28
29
  import { Switch } from "../components/ui/switch";
29
30
  export interface Components {
@@ -68,6 +69,14 @@ export interface ExtensionUtils {
68
69
  showDialog: typeof showDialog;
69
70
  openDataTableDrawer: (props: OpenDataTableDrawerProps) => void;
70
71
  openDataTablePopup: (props: OpenDataTablePopupProps) => void;
72
+ /**
73
+ * Mount any Svelte component inside the standard popup chrome
74
+ * (backdrop, fade/scale transitions, close button, optional title
75
+ * bar, optional `replaceLast` swap with the active overlay). The
76
+ * body component receives `componentProps` spread onto it plus
77
+ * `onClose` so it can dismiss the popup itself.
78
+ */
79
+ openPopup: (props: OpenPopupProps) => void;
71
80
  emitEvent: (eventName: string, input: any) => Promise<any>;
72
81
  /**
73
82
  * Shorthand for the standalone `isHidden(ctx, id)` helper — returns
@@ -1,5 +1,6 @@
1
1
  import { toast } from "svelte-sonner";
2
- import { showDialog, openDataTableDrawer, openDataTablePopup } from "../actions";
2
+ import { showDialog, openDataTableDrawer } from "../actions";
3
+ import { openDataTablePopup, openPopup } from "../popup";
3
4
  import { emitEvent } from "../eventSystem";
4
5
  import { Button } from "../components/ui/button";
5
6
  import { Input } from "../components/ui/input";
@@ -59,6 +60,7 @@ export function getExtensionUtils(lobb, ctx) {
59
60
  showDialog: showDialog,
60
61
  openDataTableDrawer: (props) => openDataTableDrawer({ lobb, ctx }, props),
61
62
  openDataTablePopup: (props) => openDataTablePopup({ lobb, ctx }, props),
63
+ openPopup: (props) => openPopup({ lobb, ctx }, props),
62
64
  emitEvent: (eventName, input) => emitEvent({ lobb, ctx }, eventName, input),
63
65
  isHidden: (id) => ctx.meta?.ui?.hide?.[id] === true,
64
66
  components: getComponents(),
@@ -0,0 +1,31 @@
1
+ import { type StudioContext } from "./context";
2
+ import type { CollectionTab } from "./store.types";
3
+ export interface OpenPopupProps {
4
+ component: any;
5
+ componentProps?: Record<string, any>;
6
+ title?: string;
7
+ size?: "default" | "sm";
8
+ hideHeader?: boolean;
9
+ replaceLast?: boolean;
10
+ }
11
+ export interface OpenDataTablePopupProps {
12
+ collectionName: string;
13
+ filter?: Record<string, any>;
14
+ sort?: Record<string, "asc" | "desc">;
15
+ limit?: number;
16
+ title?: string;
17
+ showHeader?: boolean;
18
+ showFooter?: boolean;
19
+ showFilter?: boolean;
20
+ tabs?: CollectionTab[];
21
+ view?: {
22
+ id: string;
23
+ [key: string]: any;
24
+ };
25
+ replaceLast?: boolean;
26
+ size?: "default" | "sm";
27
+ hideHeader?: boolean;
28
+ }
29
+ export declare function openPopup(studioContext: StudioContext, props: OpenPopupProps): void;
30
+ export declare function goBackPopup(studioContext: StudioContext): void;
31
+ export declare function openDataTablePopup(studioContext: StudioContext, props: OpenDataTablePopupProps): void;
package/dist/popup.js ADDED
@@ -0,0 +1,104 @@
1
+ import { mount, unmount } from "svelte";
2
+ import DataTable from "./components/dataTable/dataTable.svelte";
3
+ import Popup from "./components/popup/popup.svelte";
4
+ import { createStudioContextMap } from "./context";
5
+ let activePopup = null;
6
+ // Props that produced the currently-mounted popup. Tracked so we can
7
+ // push them onto `popupHistory` when this popup is swapped out by a
8
+ // `replaceLast: true` opener, enabling back navigation.
9
+ let activePopupProps = null;
10
+ // Stack of popup-props that were swapped out via `replaceLast: true`.
11
+ // The back arrow re-opens the top of the stack; closing via X or the
12
+ // backdrop wipes the whole stack (a deliberate full-reset, not back).
13
+ const popupHistory = [];
14
+ // Internal mount path used by both `openPopup` (user-initiated) and
15
+ // `goBackPopup` (history-driven). The `fromBack` flag suppresses
16
+ // pushing the outgoing popup onto the history stack — when the user
17
+ // hits Back, the popup they're leaving shouldn't reappear if they hit
18
+ // Back again.
19
+ function mountPopup(studioContext, props, opts = {}) {
20
+ const targetElement = document.querySelector("main");
21
+ if (!targetElement)
22
+ throw new Error("main html element doesn't exist");
23
+ if (props.replaceLast && activePopup) {
24
+ // Swap: archive the outgoing popup's props (unless this open is
25
+ // itself a back-navigation) and unmount it. Outro plays in
26
+ // parallel with the new popup's intro.
27
+ if (!opts.fromBack && activePopupProps) {
28
+ popupHistory.push(activePopupProps);
29
+ }
30
+ const toUnmount = activePopup;
31
+ activePopup = null;
32
+ activePopupProps = null;
33
+ unmount(toUnmount, { outro: true });
34
+ }
35
+ else if (!props.replaceLast) {
36
+ // A fresh, non-stacking popup starts a new context — drop any
37
+ // accumulated back history from the previous chain.
38
+ popupHistory.length = 0;
39
+ }
40
+ const canGoBack = popupHistory.length > 0;
41
+ const mounted = mount(Popup, {
42
+ target: targetElement,
43
+ context: createStudioContextMap(studioContext),
44
+ props: {
45
+ component: props.component,
46
+ componentProps: props.componentProps,
47
+ title: props.title,
48
+ size: props.size,
49
+ hideHeader: props.hideHeader,
50
+ canGoBack,
51
+ onBack: () => goBackPopup(studioContext),
52
+ onClose: async () => {
53
+ if (activePopup === mounted) {
54
+ activePopup = null;
55
+ activePopupProps = null;
56
+ // X / backdrop is a full reset — nuke the back
57
+ // stack so reopening anything starts fresh.
58
+ popupHistory.length = 0;
59
+ }
60
+ await unmount(mounted, { outro: true });
61
+ },
62
+ },
63
+ });
64
+ activePopup = mounted;
65
+ activePopupProps = props;
66
+ }
67
+ export function openPopup(studioContext, props) {
68
+ mountPopup(studioContext, props);
69
+ }
70
+ export function goBackPopup(studioContext) {
71
+ const prev = popupHistory.pop();
72
+ if (!prev)
73
+ return;
74
+ // Force `replaceLast: true` so the swap+outro path runs, and tell
75
+ // `mountPopup` not to push the current popup onto history.
76
+ mountPopup(studioContext, { ...prev, replaceLast: true }, { fromBack: true });
77
+ }
78
+ export function openDataTablePopup(studioContext, props) {
79
+ // DataTable reads `searchParams` once during its initial $state setup,
80
+ // so sort/limit need to be folded into the searchParams object before
81
+ // we mount. Built here in plain TS — no Svelte reactivity to escape.
82
+ const searchParams = {};
83
+ if (props.sort)
84
+ searchParams.sort = props.sort;
85
+ if (props.limit != null)
86
+ searchParams.limit = String(props.limit);
87
+ openPopup(studioContext, {
88
+ component: DataTable,
89
+ componentProps: {
90
+ collectionName: props.collectionName,
91
+ filter: props.filter,
92
+ searchParams,
93
+ showHeader: props.showHeader,
94
+ showFooter: props.showFooter,
95
+ showFilter: props.showFilter ?? false,
96
+ tabs: props.tabs,
97
+ view: props.view,
98
+ },
99
+ title: props.title ?? props.collectionName,
100
+ size: props.size,
101
+ hideHeader: props.hideHeader,
102
+ replaceLast: props.replaceLast,
103
+ });
104
+ }
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.48.0",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -3,7 +3,6 @@ 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";
7
6
  import type { CreateDetailViewProp } from "./components/detailView/create/createDetailView.svelte";
8
7
  import type { UpdateDetailViewProp } from "./components/detailView/update/updateDetailView.svelte";
9
8
  import type { StudioContext } from "./context";
@@ -21,19 +20,6 @@ export interface OpenDataTableDrawerProps {
21
20
  tabs?: CollectionTab[];
22
21
  }
23
22
 
24
- export interface OpenDataTablePopupProps {
25
- collectionName: string;
26
- filter?: Record<string, any>;
27
- sort?: Record<string, "asc" | "desc">;
28
- limit?: number;
29
- title?: string;
30
- showHeader?: boolean;
31
- showFooter?: boolean;
32
- showFilter?: boolean;
33
- tabs?: CollectionTab[];
34
- view?: { id: string; [key: string]: any };
35
- }
36
-
37
23
  export function showDialog(title: string, description: string): Promise<boolean> {
38
24
  return new Promise((resolve) => {
39
25
  const targetElement = document.querySelector("main");
@@ -114,18 +100,8 @@ export function openDataTableDrawer(studioContext: StudioContext, props: OpenDat
114
100
  });
115
101
  }
116
102
 
117
- export function openDataTablePopup(studioContext: StudioContext, props: OpenDataTablePopupProps) {
118
- const targetElement = document.querySelector("main");
119
- if (!targetElement) throw new Error("main html element doesn't exist");
120
-
121
- const mounted = mount(DataTablePopup, {
122
- target: targetElement,
123
- context: createStudioContextMap(studioContext),
124
- props: {
125
- ...props,
126
- onClose: async () => {
127
- await unmount(mounted, { outro: true });
128
- },
129
- },
130
- });
131
- }
103
+ // Popup helpers (openPopup, openDataTablePopup, goBackPopup) live in
104
+ // `./popup` they share enough state (active overlay, back-history)
105
+ // to warrant their own module.
106
+ export { openPopup, openDataTablePopup, goBackPopup } from "./popup";
107
+ export type { OpenPopupProps, OpenDataTablePopupProps } from "./popup";
@@ -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,98 @@
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 { ArrowLeft, 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
+ // Set by the action layer when there's a popup-history entry to
32
+ // pop. The chrome renders a back arrow in the title bar; bodies
33
+ // that `hideHeader: true` get `canGoBack` + `onBack` forwarded
34
+ // so they can render their own back affordance.
35
+ canGoBack?: boolean;
36
+ onBack?: () => void;
37
+ }
38
+
39
+ let {
40
+ component: Body,
41
+ componentProps = {},
42
+ title,
43
+ size = "default",
44
+ hideHeader = false,
45
+ onClose,
46
+ canGoBack = false,
47
+ onBack,
48
+ }: Props = $props();
49
+ </script>
50
+
51
+ <Portal target="body">
52
+ <button
53
+ transition:fade={{ duration: 200 }}
54
+ onclick={() => onClose?.()}
55
+ class="fixed left-0 top-0 z-40 h-screen w-screen bg-black/50 cursor-default"
56
+ aria-label="background used to close the popup"
57
+ ></button>
58
+
59
+ <div
60
+ transition:scale={{ duration: 200, easing: cubicOut, start: 0.95 }}
61
+ 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
62
+ {size === 'sm' ? 'max-h-[calc(100vh-6rem)] max-w-2xl' : 'h-[85vh] max-w-7xl'}"
63
+ >
64
+ {#if !hideHeader}
65
+ <div class="flex h-12 shrink-0 items-center justify-between gap-2 border-b px-4">
66
+ <div class="flex min-w-0 items-center gap-2">
67
+ {#if canGoBack}
68
+ <Button
69
+ variant="ghost"
70
+ size="icon"
71
+ onclick={() => onBack?.()}
72
+ class="h-8 w-8 rounded-full text-muted-foreground"
73
+ Icon={ArrowLeft}
74
+ />
75
+ {/if}
76
+ <div class="truncate text-sm font-medium">{title ?? ""}</div>
77
+ </div>
78
+ <Button
79
+ variant="ghost"
80
+ size="icon"
81
+ onclick={() => onClose?.()}
82
+ class="h-8 w-8 rounded-full text-muted-foreground"
83
+ Icon={X}
84
+ />
85
+ </div>
86
+ {/if}
87
+ <!-- Bodies own their own scrolling so they can decide what's
88
+ anchored vs. scrollable (e.g. a static header above a
89
+ scrolling section list). The wrapper is itself a flex
90
+ column so bodies can claim a bounded height with
91
+ `flex-1 min-h-0` — that avoids relying on `h-full`
92
+ percentages, which don't resolve when the popup uses
93
+ `max-h` (size="sm") instead of a fixed `h`. -->
94
+ <div class="flex min-h-0 flex-1 flex-col overflow-hidden">
95
+ <Body {...componentProps} {onClose} {canGoBack} {onBack} />
96
+ </div>
97
+ </div>
98
+ </Portal>
@@ -23,7 +23,8 @@ 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 } from "../actions";
27
+ import type { OpenDataTablePopupProps, OpenPopupProps } from "../popup";
27
28
  import { toast } from "svelte-sonner";
28
29
  import type Drawer from "../components/drawer.svelte";
29
30
  import { Switch } from "../components/ui/switch";
@@ -72,6 +73,14 @@ export interface ExtensionUtils {
72
73
  showDialog: typeof showDialog;
73
74
  openDataTableDrawer: (props: OpenDataTableDrawerProps) => void;
74
75
  openDataTablePopup: (props: OpenDataTablePopupProps) => void;
76
+ /**
77
+ * Mount any Svelte component inside the standard popup chrome
78
+ * (backdrop, fade/scale transitions, close button, optional title
79
+ * bar, optional `replaceLast` swap with the active overlay). The
80
+ * body component receives `componentProps` spread onto it plus
81
+ * `onClose` so it can dismiss the popup itself.
82
+ */
83
+ openPopup: (props: OpenPopupProps) => void;
75
84
  emitEvent: (eventName: string, input: any) => Promise<any>;
76
85
  /**
77
86
  * Shorthand for the standalone `isHidden(ctx, id)` helper — returns
@@ -7,7 +7,8 @@ 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 } from "../actions";
11
+ import { openDataTablePopup, openPopup } from "../popup";
11
12
  import { emitEvent } from "../eventSystem";
12
13
  import { Button } from "../components/ui/button";
13
14
  import { Input } from "../components/ui/input";
@@ -69,6 +70,7 @@ export function getExtensionUtils(lobb: LobbClient, ctx: CTX): ExtensionUtils {
69
70
  showDialog: showDialog,
70
71
  openDataTableDrawer: (props) => openDataTableDrawer({ lobb, ctx }, props),
71
72
  openDataTablePopup: (props) => openDataTablePopup({ lobb, ctx }, props),
73
+ openPopup: (props) => openPopup({ lobb, ctx }, props),
72
74
  emitEvent: (eventName, input) => emitEvent({ lobb, ctx }, eventName, input),
73
75
  isHidden: (id) => ctx.meta?.ui?.hide?.[id] === true,
74
76
  components: getComponents(),
@@ -0,0 +1,147 @@
1
+ import { mount, unmount } from "svelte";
2
+ import DataTable from "./components/dataTable/dataTable.svelte";
3
+ import Popup from "./components/popup/popup.svelte";
4
+ import { createStudioContextMap, type StudioContext } from "./context";
5
+ import type { CollectionTab } from "./store.types";
6
+
7
+ export interface OpenPopupProps {
8
+ // Component rendered inside the popup. It receives `componentProps`
9
+ // spread onto it plus `onClose` so it can close the popup itself.
10
+ component: any;
11
+ componentProps?: Record<string, any>;
12
+ title?: string;
13
+ // `default` is the wide data-table popup (max-w-7xl, fixed 85vh).
14
+ // `sm` is the narrow detail-view variant (max-w-2xl, content-height).
15
+ size?: "default" | "sm";
16
+ // When true, the popup renders no chrome at all (no title bar, no
17
+ // close button). The body uses the `onClose` prop the popup passes
18
+ // it to render its own close affordance.
19
+ hideHeader?: boolean;
20
+ // Close the previously-opened popup before mounting this one —
21
+ // drill-down semantics. Only popups participate; drawers don't.
22
+ replaceLast?: boolean;
23
+ }
24
+
25
+ export interface OpenDataTablePopupProps {
26
+ collectionName: string;
27
+ filter?: Record<string, any>;
28
+ sort?: Record<string, "asc" | "desc">;
29
+ limit?: number;
30
+ title?: string;
31
+ showHeader?: boolean;
32
+ showFooter?: boolean;
33
+ showFilter?: boolean;
34
+ tabs?: CollectionTab[];
35
+ view?: { id: string; [key: string]: any };
36
+ replaceLast?: boolean;
37
+ size?: "default" | "sm";
38
+ hideHeader?: boolean;
39
+ }
40
+
41
+ let activePopup: any = null;
42
+ // Props that produced the currently-mounted popup. Tracked so we can
43
+ // push them onto `popupHistory` when this popup is swapped out by a
44
+ // `replaceLast: true` opener, enabling back navigation.
45
+ let activePopupProps: OpenPopupProps | null = null;
46
+ // Stack of popup-props that were swapped out via `replaceLast: true`.
47
+ // The back arrow re-opens the top of the stack; closing via X or the
48
+ // backdrop wipes the whole stack (a deliberate full-reset, not back).
49
+ const popupHistory: OpenPopupProps[] = [];
50
+
51
+ // Internal mount path used by both `openPopup` (user-initiated) and
52
+ // `goBackPopup` (history-driven). The `fromBack` flag suppresses
53
+ // pushing the outgoing popup onto the history stack — when the user
54
+ // hits Back, the popup they're leaving shouldn't reappear if they hit
55
+ // Back again.
56
+ function mountPopup(
57
+ studioContext: StudioContext,
58
+ props: OpenPopupProps,
59
+ opts: { fromBack?: boolean } = {},
60
+ ) {
61
+ const targetElement = document.querySelector("main");
62
+ if (!targetElement) throw new Error("main html element doesn't exist");
63
+
64
+ if (props.replaceLast && activePopup) {
65
+ // Swap: archive the outgoing popup's props (unless this open is
66
+ // itself a back-navigation) and unmount it. Outro plays in
67
+ // parallel with the new popup's intro.
68
+ if (!opts.fromBack && activePopupProps) {
69
+ popupHistory.push(activePopupProps);
70
+ }
71
+ const toUnmount = activePopup;
72
+ activePopup = null;
73
+ activePopupProps = null;
74
+ unmount(toUnmount, { outro: true });
75
+ } else if (!props.replaceLast) {
76
+ // A fresh, non-stacking popup starts a new context — drop any
77
+ // accumulated back history from the previous chain.
78
+ popupHistory.length = 0;
79
+ }
80
+
81
+ const canGoBack = popupHistory.length > 0;
82
+
83
+ const mounted = mount(Popup, {
84
+ target: targetElement,
85
+ context: createStudioContextMap(studioContext),
86
+ props: {
87
+ component: props.component,
88
+ componentProps: props.componentProps,
89
+ title: props.title,
90
+ size: props.size,
91
+ hideHeader: props.hideHeader,
92
+ canGoBack,
93
+ onBack: () => goBackPopup(studioContext),
94
+ onClose: async () => {
95
+ if (activePopup === mounted) {
96
+ activePopup = null;
97
+ activePopupProps = null;
98
+ // X / backdrop is a full reset — nuke the back
99
+ // stack so reopening anything starts fresh.
100
+ popupHistory.length = 0;
101
+ }
102
+ await unmount(mounted, { outro: true });
103
+ },
104
+ },
105
+ });
106
+ activePopup = mounted;
107
+ activePopupProps = props;
108
+ }
109
+
110
+ export function openPopup(studioContext: StudioContext, props: OpenPopupProps) {
111
+ mountPopup(studioContext, props);
112
+ }
113
+
114
+ export function goBackPopup(studioContext: StudioContext) {
115
+ const prev = popupHistory.pop();
116
+ if (!prev) return;
117
+ // Force `replaceLast: true` so the swap+outro path runs, and tell
118
+ // `mountPopup` not to push the current popup onto history.
119
+ mountPopup(studioContext, { ...prev, replaceLast: true }, { fromBack: true });
120
+ }
121
+
122
+ export function openDataTablePopup(studioContext: StudioContext, props: OpenDataTablePopupProps) {
123
+ // DataTable reads `searchParams` once during its initial $state setup,
124
+ // so sort/limit need to be folded into the searchParams object before
125
+ // we mount. Built here in plain TS — no Svelte reactivity to escape.
126
+ const searchParams: Record<string, any> = {};
127
+ if (props.sort) searchParams.sort = props.sort;
128
+ if (props.limit != null) searchParams.limit = String(props.limit);
129
+
130
+ openPopup(studioContext, {
131
+ component: DataTable,
132
+ componentProps: {
133
+ collectionName: props.collectionName,
134
+ filter: props.filter,
135
+ searchParams,
136
+ showHeader: props.showHeader,
137
+ showFooter: props.showFooter,
138
+ showFilter: props.showFilter ?? false,
139
+ tabs: props.tabs,
140
+ view: props.view,
141
+ },
142
+ title: props.title ?? props.collectionName,
143
+ size: props.size,
144
+ hideHeader: props.hideHeader,
145
+ replaceLast: props.replaceLast,
146
+ });
147
+ }
@@ -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>