@lobb-js/studio 0.45.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 +12 -0
- package/dist/actions.js +48 -4
- package/dist/components/importButton.svelte +120 -24
- package/dist/components/popup/popup.svelte +79 -0
- package/dist/components/popup/popup.svelte.d.ts +11 -0
- package/dist/extensions/extension.types.d.ts +9 -1
- package/dist/extensions/extensionUtils.js +2 -1
- package/package.json +3 -3
- package/src/lib/actions.ts +70 -4
- package/src/lib/components/importButton.svelte +120 -24
- package/src/lib/components/popup/popup.svelte +79 -0
- package/src/lib/extensions/extension.types.ts +9 -1
- package/src/lib/extensions/extensionUtils.ts +2 -1
- package/dist/components/dataTablePopup/dataTablePopup.svelte +0 -94
- package/dist/components/dataTablePopup/dataTablePopup.svelte.d.ts +0 -22
- package/src/lib/components/dataTablePopup/dataTablePopup.svelte +0 -94
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
|
|
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
|
|
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
|
-
|
|
97
|
+
closeActivePopupIfRequested(props.replaceLast);
|
|
98
|
+
const mounted = mount(Popup, {
|
|
89
99
|
target: targetElement,
|
|
90
100
|
context: createStudioContextMap(studioContext),
|
|
91
101
|
props: {
|
|
92
|
-
|
|
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
|
}
|
|
@@ -28,7 +28,20 @@
|
|
|
28
28
|
let isDragging = $state(false);
|
|
29
29
|
let parseError = $state("");
|
|
30
30
|
let transformedRows = $state<any[]>([]);
|
|
31
|
-
|
|
31
|
+
// `action` is only set when a `studio.collections.import` workflow takes
|
|
32
|
+
// ownership of the writes (handled: true) and tells us per row whether
|
|
33
|
+
// the record was created or updated. The default code path leaves it
|
|
34
|
+
// undefined; the UI then just says "imported".
|
|
35
|
+
type ImportAction = "created" | "updated";
|
|
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 }[]>([]);
|
|
32
45
|
// Which results tab is visible. Defaults to "failed" when there are
|
|
33
46
|
// any failures (most users want to fix those first); falls back to
|
|
34
47
|
// "imported" when everything went through.
|
|
@@ -40,6 +53,24 @@
|
|
|
40
53
|
|
|
41
54
|
const collectionColumns = $derived(getCollectionColumns(ctx, collectionName) ?? []);
|
|
42
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
|
+
|
|
43
74
|
function applyColumnMapping(rows: any[]): {
|
|
44
75
|
rows: any[];
|
|
45
76
|
unmapped: Array<{ column: string; sample: string }>;
|
|
@@ -168,23 +199,58 @@
|
|
|
168
199
|
collectionName,
|
|
169
200
|
rows: transformedRows.map((r) => ({ ...r })),
|
|
170
201
|
});
|
|
171
|
-
const finalRows = eventResult.rows ?? transformedRows;
|
|
172
202
|
|
|
173
203
|
importResults = [];
|
|
174
204
|
let hasSuccess = false;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
205
|
+
|
|
206
|
+
if (eventResult?.handled && eventResult.results) {
|
|
207
|
+
// The workflow took ownership — it did the writes itself
|
|
208
|
+
// (typical use case: upsert-style imports where the workflow
|
|
209
|
+
// needs to decide create-vs-update per row). Render its
|
|
210
|
+
// results verbatim, no extra writes from our side.
|
|
211
|
+
const r = eventResult.results as {
|
|
212
|
+
imported?: Array<{ row: any; action: ImportAction }>;
|
|
213
|
+
failed?: Array<{ row: any; error: ImportError; action?: ImportAction }>;
|
|
214
|
+
};
|
|
215
|
+
for (const item of r.imported ?? []) {
|
|
216
|
+
importResults.push({ row: item.row, error: null, action: item.action });
|
|
179
217
|
hasSuccess = true;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
218
|
+
}
|
|
219
|
+
for (const item of r.failed ?? []) {
|
|
220
|
+
importResults.push({ row: item.row, error: item.error, action: item.action });
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
// Default path — workflow only transformed rows (or did
|
|
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.
|
|
228
|
+
const finalRows = eventResult.rows ?? transformedRows;
|
|
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) {
|
|
240
|
+
importResults.push({ row, error: null });
|
|
241
|
+
hasSuccess = true;
|
|
242
|
+
} else {
|
|
243
|
+
importResults.push({
|
|
244
|
+
row,
|
|
245
|
+
error: {
|
|
246
|
+
message: entry?.message ?? `Error: ${entry?.code ?? "unknown"}`,
|
|
247
|
+
details: entry?.details,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
186
251
|
}
|
|
187
252
|
}
|
|
253
|
+
|
|
188
254
|
if (hasSuccess && onSuccessfullSave) await onSuccessfullSave();
|
|
189
255
|
|
|
190
256
|
// Default to the failed tab when there are any failures; if
|
|
@@ -379,6 +445,8 @@
|
|
|
379
445
|
{:else if step === "results"}
|
|
380
446
|
{@const failed = importResults.filter((r) => r.error !== null)}
|
|
381
447
|
{@const succeeded = importResults.filter((r) => r.error === null)}
|
|
448
|
+
{@const createdCount = succeeded.filter((r) => r.action === "created").length}
|
|
449
|
+
{@const updatedCount = succeeded.filter((r) => r.action === "updated").length}
|
|
382
450
|
{@const failedData = failed.map((r) => ({ __error: r.error, ...r.row }))}
|
|
383
451
|
{#if failed.length === 0}
|
|
384
452
|
<!-- All rows imported successfully: nothing to audit here —
|
|
@@ -392,7 +460,11 @@
|
|
|
392
460
|
<div class="text-center">
|
|
393
461
|
<p class="text-sm font-medium text-foreground">Import complete</p>
|
|
394
462
|
<p class="mt-1 text-xs text-muted-foreground">
|
|
395
|
-
{
|
|
463
|
+
{#if createdCount + updatedCount > 0}
|
|
464
|
+
{createdCount} created · {updatedCount} updated
|
|
465
|
+
{:else}
|
|
466
|
+
{succeeded.length} {succeeded.length === 1 ? "record" : "records"} imported successfully
|
|
467
|
+
{/if}
|
|
396
468
|
</p>
|
|
397
469
|
</div>
|
|
398
470
|
</div>
|
|
@@ -439,26 +511,50 @@
|
|
|
439
511
|
>
|
|
440
512
|
{#snippet overrideCell(value, column)}
|
|
441
513
|
{#if column.id === "__error"}
|
|
442
|
-
<!-- Error cell:
|
|
443
|
-
the
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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}>
|
|
448
523
|
<Tooltip.Root>
|
|
449
524
|
<Tooltip.Trigger
|
|
450
|
-
class="
|
|
525
|
+
class="group flex w-full items-center gap-1.5 text-left text-destructive"
|
|
451
526
|
>
|
|
452
|
-
{
|
|
527
|
+
<AlertCircle size={12} class="shrink-0" />
|
|
528
|
+
<span class="truncate group-hover:underline">{err?.message ?? ""}</span>
|
|
453
529
|
</Tooltip.Trigger>
|
|
454
530
|
<Tooltip.Content
|
|
455
|
-
class="
|
|
531
|
+
class="w-96 p-0"
|
|
456
532
|
side="bottom"
|
|
457
533
|
align="start"
|
|
458
534
|
>
|
|
459
|
-
<div class="
|
|
460
|
-
<
|
|
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>
|
|
461
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}
|
|
462
558
|
</Tooltip.Content>
|
|
463
559
|
</Tooltip.Root>
|
|
464
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.
|
|
4
|
+
"version": "0.47.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"postpublish": "./scripts/postpublish.sh"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@lobb-js/core": "^0.
|
|
48
|
+
"@lobb-js/core": "^0.40.0",
|
|
49
49
|
"@chromatic-com/storybook": "^4.1.2",
|
|
50
50
|
"@playwright/test": "^1.60.0",
|
|
51
51
|
"@storybook/addon-a11y": "^10.0.1",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"@codemirror/view": "^6.39.12",
|
|
92
92
|
"@dagrejs/dagre": "^1.1.5",
|
|
93
93
|
"@internationalized/date": "^3.12.0",
|
|
94
|
-
"@lobb-js/sdk": "^0.
|
|
94
|
+
"@lobb-js/sdk": "^0.4.0",
|
|
95
95
|
"@lucide/svelte": "^0.563.1",
|
|
96
96
|
"@tailwindcss/vite": "^4.3.0",
|
|
97
97
|
"@tiptap/core": "^3.0.0",
|
package/src/lib/actions.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
152
|
+
closeActivePopupIfRequested(props.replaceLast);
|
|
153
|
+
|
|
154
|
+
const mounted = mount(Popup, {
|
|
122
155
|
target: targetElement,
|
|
123
156
|
context: createStudioContextMap(studioContext),
|
|
124
157
|
props: {
|
|
125
|
-
|
|
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
|
}
|
|
@@ -28,7 +28,20 @@
|
|
|
28
28
|
let isDragging = $state(false);
|
|
29
29
|
let parseError = $state("");
|
|
30
30
|
let transformedRows = $state<any[]>([]);
|
|
31
|
-
|
|
31
|
+
// `action` is only set when a `studio.collections.import` workflow takes
|
|
32
|
+
// ownership of the writes (handled: true) and tells us per row whether
|
|
33
|
+
// the record was created or updated. The default code path leaves it
|
|
34
|
+
// undefined; the UI then just says "imported".
|
|
35
|
+
type ImportAction = "created" | "updated";
|
|
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 }[]>([]);
|
|
32
45
|
// Which results tab is visible. Defaults to "failed" when there are
|
|
33
46
|
// any failures (most users want to fix those first); falls back to
|
|
34
47
|
// "imported" when everything went through.
|
|
@@ -40,6 +53,24 @@
|
|
|
40
53
|
|
|
41
54
|
const collectionColumns = $derived(getCollectionColumns(ctx, collectionName) ?? []);
|
|
42
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
|
+
|
|
43
74
|
function applyColumnMapping(rows: any[]): {
|
|
44
75
|
rows: any[];
|
|
45
76
|
unmapped: Array<{ column: string; sample: string }>;
|
|
@@ -168,23 +199,58 @@
|
|
|
168
199
|
collectionName,
|
|
169
200
|
rows: transformedRows.map((r) => ({ ...r })),
|
|
170
201
|
});
|
|
171
|
-
const finalRows = eventResult.rows ?? transformedRows;
|
|
172
202
|
|
|
173
203
|
importResults = [];
|
|
174
204
|
let hasSuccess = false;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
205
|
+
|
|
206
|
+
if (eventResult?.handled && eventResult.results) {
|
|
207
|
+
// The workflow took ownership — it did the writes itself
|
|
208
|
+
// (typical use case: upsert-style imports where the workflow
|
|
209
|
+
// needs to decide create-vs-update per row). Render its
|
|
210
|
+
// results verbatim, no extra writes from our side.
|
|
211
|
+
const r = eventResult.results as {
|
|
212
|
+
imported?: Array<{ row: any; action: ImportAction }>;
|
|
213
|
+
failed?: Array<{ row: any; error: ImportError; action?: ImportAction }>;
|
|
214
|
+
};
|
|
215
|
+
for (const item of r.imported ?? []) {
|
|
216
|
+
importResults.push({ row: item.row, error: null, action: item.action });
|
|
179
217
|
hasSuccess = true;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
218
|
+
}
|
|
219
|
+
for (const item of r.failed ?? []) {
|
|
220
|
+
importResults.push({ row: item.row, error: item.error, action: item.action });
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
// Default path — workflow only transformed rows (or did
|
|
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.
|
|
228
|
+
const finalRows = eventResult.rows ?? transformedRows;
|
|
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) {
|
|
240
|
+
importResults.push({ row, error: null });
|
|
241
|
+
hasSuccess = true;
|
|
242
|
+
} else {
|
|
243
|
+
importResults.push({
|
|
244
|
+
row,
|
|
245
|
+
error: {
|
|
246
|
+
message: entry?.message ?? `Error: ${entry?.code ?? "unknown"}`,
|
|
247
|
+
details: entry?.details,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
186
251
|
}
|
|
187
252
|
}
|
|
253
|
+
|
|
188
254
|
if (hasSuccess && onSuccessfullSave) await onSuccessfullSave();
|
|
189
255
|
|
|
190
256
|
// Default to the failed tab when there are any failures; if
|
|
@@ -379,6 +445,8 @@
|
|
|
379
445
|
{:else if step === "results"}
|
|
380
446
|
{@const failed = importResults.filter((r) => r.error !== null)}
|
|
381
447
|
{@const succeeded = importResults.filter((r) => r.error === null)}
|
|
448
|
+
{@const createdCount = succeeded.filter((r) => r.action === "created").length}
|
|
449
|
+
{@const updatedCount = succeeded.filter((r) => r.action === "updated").length}
|
|
382
450
|
{@const failedData = failed.map((r) => ({ __error: r.error, ...r.row }))}
|
|
383
451
|
{#if failed.length === 0}
|
|
384
452
|
<!-- All rows imported successfully: nothing to audit here —
|
|
@@ -392,7 +460,11 @@
|
|
|
392
460
|
<div class="text-center">
|
|
393
461
|
<p class="text-sm font-medium text-foreground">Import complete</p>
|
|
394
462
|
<p class="mt-1 text-xs text-muted-foreground">
|
|
395
|
-
{
|
|
463
|
+
{#if createdCount + updatedCount > 0}
|
|
464
|
+
{createdCount} created · {updatedCount} updated
|
|
465
|
+
{:else}
|
|
466
|
+
{succeeded.length} {succeeded.length === 1 ? "record" : "records"} imported successfully
|
|
467
|
+
{/if}
|
|
396
468
|
</p>
|
|
397
469
|
</div>
|
|
398
470
|
</div>
|
|
@@ -439,26 +511,50 @@
|
|
|
439
511
|
>
|
|
440
512
|
{#snippet overrideCell(value, column)}
|
|
441
513
|
{#if column.id === "__error"}
|
|
442
|
-
<!-- Error cell:
|
|
443
|
-
the
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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}>
|
|
448
523
|
<Tooltip.Root>
|
|
449
524
|
<Tooltip.Trigger
|
|
450
|
-
class="
|
|
525
|
+
class="group flex w-full items-center gap-1.5 text-left text-destructive"
|
|
451
526
|
>
|
|
452
|
-
{
|
|
527
|
+
<AlertCircle size={12} class="shrink-0" />
|
|
528
|
+
<span class="truncate group-hover:underline">{err?.message ?? ""}</span>
|
|
453
529
|
</Tooltip.Trigger>
|
|
454
530
|
<Tooltip.Content
|
|
455
|
-
class="
|
|
531
|
+
class="w-96 p-0"
|
|
456
532
|
side="bottom"
|
|
457
533
|
align="start"
|
|
458
534
|
>
|
|
459
|
-
<div class="
|
|
460
|
-
<
|
|
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>
|
|
461
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}
|
|
462
558
|
</Tooltip.Content>
|
|
463
559
|
</Tooltip.Root>
|
|
464
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>
|