@lobb-js/studio 0.46.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions.d.ts +12 -0
- package/dist/actions.js +48 -4
- package/dist/components/importButton.svelte +85 -22
- 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 +1 -1
- package/src/lib/actions.ts +70 -4
- package/src/lib/components/importButton.svelte +85 -22
- 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
|
}
|
|
@@ -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,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
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
|
}
|
|
@@ -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,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>
|