@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 +2 -16
- package/dist/actions.js +4 -16
- package/dist/components/importButton.svelte +85 -22
- package/dist/components/popup/popup.svelte +98 -0
- package/dist/components/popup/popup.svelte.d.ts +13 -0
- package/dist/extensions/extension.types.d.ts +10 -1
- package/dist/extensions/extensionUtils.js +3 -1
- package/dist/popup.d.ts +31 -0
- package/dist/popup.js +104 -0
- package/package.json +1 -1
- package/src/lib/actions.ts +5 -29
- package/src/lib/components/importButton.svelte +85 -22
- package/src/lib/components/popup/popup.svelte +98 -0
- package/src/lib/extensions/extension.types.ts +10 -1
- package/src/lib/extensions/extensionUtils.ts +3 -1
- package/src/lib/popup.ts +147 -0
- 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,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
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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:
|
|
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).
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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:
|
|
476
|
-
the
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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="
|
|
525
|
+
class="group flex w-full items-center gap-1.5 text-left text-destructive"
|
|
484
526
|
>
|
|
485
|
-
{
|
|
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="
|
|
531
|
+
class="w-96 p-0"
|
|
489
532
|
side="bottom"
|
|
490
533
|
align="start"
|
|
491
534
|
>
|
|
492
|
-
<div class="
|
|
493
|
-
<
|
|
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
|
|
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
|
|
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(),
|
package/dist/popup.d.ts
ADDED
|
@@ -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
package/src/lib/actions.ts
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 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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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:
|
|
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).
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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:
|
|
476
|
-
the
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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="
|
|
525
|
+
class="group flex w-full items-center gap-1.5 text-left text-destructive"
|
|
484
526
|
>
|
|
485
|
-
{
|
|
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="
|
|
531
|
+
class="w-96 p-0"
|
|
489
532
|
side="bottom"
|
|
490
533
|
align="start"
|
|
491
534
|
>
|
|
492
|
-
<div class="
|
|
493
|
-
<
|
|
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
|
|
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
|
|
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(),
|
package/src/lib/popup.ts
ADDED
|
@@ -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>
|