@lobb-js/studio 0.15.0 → 0.16.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/components/Studio.svelte +2 -0
- package/dist/components/dataTable/fieldCell.svelte +3 -1
- package/dist/components/dataTable/header.svelte +17 -9
- package/dist/components/importButton.svelte +281 -0
- package/dist/components/importButton.svelte.d.ts +8 -0
- package/dist/extensions/extension.types.d.ts +10 -0
- package/dist/extensions/extensionUtils.d.ts +1 -0
- package/dist/extensions/extensionUtils.js +11 -0
- package/package.json +3 -2
- package/src/lib/components/Studio.svelte +2 -0
- package/src/lib/components/dataTable/fieldCell.svelte +3 -1
- package/src/lib/components/dataTable/header.svelte +17 -9
- package/src/lib/components/importButton.svelte +281 -0
- package/src/lib/extensions/extension.types.ts +13 -0
- package/src/lib/extensions/extensionUtils.ts +10 -0
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import {
|
|
13
13
|
executeExtensionsOnStartup,
|
|
14
14
|
loadExtensions,
|
|
15
|
+
loadExtensionWorkflows,
|
|
15
16
|
} from "../extensions/extensionUtils";
|
|
16
17
|
import extensionMap from 'virtual:lobb-studio-extensions';
|
|
17
18
|
import { mediaQueries } from "../utils";
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
ctx.meta = await lobb.getMeta();
|
|
49
50
|
ctx.extensions = await loadExtensions(lobb, ctx, extensionMap);
|
|
50
51
|
await executeExtensionsOnStartup(lobb, ctx);
|
|
52
|
+
loadExtensionWorkflows(ctx as any);
|
|
51
53
|
status = "ready";
|
|
52
54
|
} catch (err) {
|
|
53
55
|
console.error(err);
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
{:else if value === null || value === undefined}
|
|
41
41
|
<div class="text-muted-foreground">NULL</div>
|
|
42
42
|
{:else if isRefrenceField}
|
|
43
|
-
{#if value
|
|
43
|
+
{#if typeof value !== "object"}
|
|
44
|
+
<div>{value}</div>
|
|
45
|
+
{:else if value.id !== 0}
|
|
44
46
|
{@const primaryField = Object.values(value)[1]}
|
|
45
47
|
{#if value.id}
|
|
46
48
|
<div class="flex items-center gap-2">
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
import { getStudioContext } from "../../context";
|
|
3
3
|
|
|
4
4
|
const { lobb, ctx } = getStudioContext();
|
|
5
|
-
import { ListRestart, Plus,
|
|
5
|
+
import { Download, ListRestart, Plus, Trash } from "lucide-svelte";
|
|
6
|
+
import * as Tooltip from "../ui/tooltip";
|
|
6
7
|
import LlmButton from "../LlmButton.svelte";
|
|
7
8
|
import FilterButton from "./filterButton.svelte";
|
|
8
9
|
import SortButton from "./sortButton.svelte";
|
|
9
10
|
import Button from "../ui/button/button.svelte";
|
|
10
|
-
import
|
|
11
|
+
import ImportButton from "../importButton.svelte";
|
|
11
12
|
import { showDialog } from "../confirmationDialog/store.svelte";
|
|
12
13
|
import CreateDetailViewButton from "../detailView/create/createDetailViewButton.svelte";
|
|
13
14
|
import ExtensionsComponents from "../extensionsComponents.svelte";
|
|
@@ -137,13 +138,20 @@
|
|
|
137
138
|
>
|
|
138
139
|
{headerIsSmall ? "" : "Refresh"}
|
|
139
140
|
</Button>
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
<Tooltip.Provider delayDuration={0}>
|
|
142
|
+
<Tooltip.Root>
|
|
143
|
+
<Tooltip.Trigger>
|
|
144
|
+
<ImportButton
|
|
145
|
+
{collectionName}
|
|
146
|
+
variant="outline"
|
|
147
|
+
class="h-7 px-2 text-xs font-normal"
|
|
148
|
+
Icon={Download}
|
|
149
|
+
onSuccessfullSave={() => (params = { ...params })}
|
|
150
|
+
/>
|
|
151
|
+
</Tooltip.Trigger>
|
|
152
|
+
<Tooltip.Content>Import</Tooltip.Content>
|
|
153
|
+
</Tooltip.Root>
|
|
154
|
+
</Tooltip.Provider>
|
|
147
155
|
<ExtensionsComponents
|
|
148
156
|
name="listView.header.actions"
|
|
149
157
|
utils={getExtensionUtils(lobb, ctx)}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { AlertCircle, Check, FileText, LoaderCircle, Upload, X } from "lucide-svelte";
|
|
3
|
+
import Button, { type ButtonProps } from "./ui/button/button.svelte";
|
|
4
|
+
import { toast } from "svelte-sonner";
|
|
5
|
+
import { getStudioContext } from "../context";
|
|
6
|
+
import * as Dialog from "./ui/dialog";
|
|
7
|
+
import Table from "./dataTable/table.svelte";
|
|
8
|
+
import FieldCell from "./dataTable/fieldCell.svelte";
|
|
9
|
+
import { getCollectionColumns } from "./dataTable/utils";
|
|
10
|
+
import Fuse from "fuse.js";
|
|
11
|
+
import { emitEvent } from "../eventSystem";
|
|
12
|
+
|
|
13
|
+
const { lobb, ctx } = getStudioContext();
|
|
14
|
+
|
|
15
|
+
interface LocalProps extends ButtonProps {
|
|
16
|
+
collectionName: string;
|
|
17
|
+
onSuccessfullSave?: () => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let { collectionName, onSuccessfullSave, ...rest }: LocalProps = $props();
|
|
21
|
+
|
|
22
|
+
let openDrawer = $state(false);
|
|
23
|
+
let activeTab = $state<"upload" | "paste">("upload");
|
|
24
|
+
let step = $state<"input" | "processing" | "preview">("input");
|
|
25
|
+
let pasteContent = $state("");
|
|
26
|
+
let isDragging = $state(false);
|
|
27
|
+
let parseError = $state("");
|
|
28
|
+
let transformedRows = $state<any[]>([]);
|
|
29
|
+
|
|
30
|
+
const collectionColumns = $derived(getCollectionColumns(ctx, collectionName) ?? []);
|
|
31
|
+
|
|
32
|
+
function applyColumnMapping(rows: any[]): any[] {
|
|
33
|
+
if (!rows.length || !collectionColumns.length) return rows;
|
|
34
|
+
const csvColumns = Object.keys(rows[0]);
|
|
35
|
+
const fieldNames = collectionColumns.map((c) => c.id);
|
|
36
|
+
const fuse = new Fuse(fieldNames, { includeScore: true, threshold: 0.4 });
|
|
37
|
+
const mapping: Record<string, string | null> = {};
|
|
38
|
+
for (const csvCol of csvColumns) {
|
|
39
|
+
const results = fuse.search(csvCol);
|
|
40
|
+
mapping[csvCol] = results[0]?.item ?? null;
|
|
41
|
+
}
|
|
42
|
+
return rows.map((row) =>
|
|
43
|
+
Object.fromEntries(
|
|
44
|
+
collectionColumns.map((c) => {
|
|
45
|
+
const csvCol = Object.keys(mapping).find((k) => mapping[k] === c.id);
|
|
46
|
+
return [c.id, csvCol ? row[csvCol] ?? null : null];
|
|
47
|
+
})
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
let fileInput: HTMLInputElement;
|
|
52
|
+
|
|
53
|
+
function parseCSV(text: string): any[] {
|
|
54
|
+
const lines = text.trim().split("\n");
|
|
55
|
+
if (lines.length < 2) throw new Error("CSV must have a header row and at least one data row");
|
|
56
|
+
|
|
57
|
+
const parseRow = (line: string): string[] => {
|
|
58
|
+
const result: string[] = [];
|
|
59
|
+
let current = "";
|
|
60
|
+
let inQuotes = false;
|
|
61
|
+
for (let i = 0; i < line.length; i++) {
|
|
62
|
+
const char = line[i];
|
|
63
|
+
if (char === '"') {
|
|
64
|
+
if (inQuotes && line[i + 1] === '"') { current += '"'; i++; }
|
|
65
|
+
else inQuotes = !inQuotes;
|
|
66
|
+
} else if (char === "," && !inQuotes) {
|
|
67
|
+
result.push(current.trim());
|
|
68
|
+
current = "";
|
|
69
|
+
} else {
|
|
70
|
+
current += char;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
result.push(current.trim());
|
|
74
|
+
return result;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const headers = parseRow(lines[0]).map((h) => h.trim());
|
|
78
|
+
return lines
|
|
79
|
+
.slice(1)
|
|
80
|
+
.filter((l) => l.trim())
|
|
81
|
+
.map((line) => {
|
|
82
|
+
const values = parseRow(line);
|
|
83
|
+
return Object.fromEntries(headers.map((h, i) => [h, values[i] ?? ""]));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function detectAndParse(content: string): any[] {
|
|
88
|
+
const trimmed = content.trim();
|
|
89
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
90
|
+
const parsed = JSON.parse(trimmed);
|
|
91
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
92
|
+
}
|
|
93
|
+
return parseCSV(trimmed);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function processContent(content: string) {
|
|
97
|
+
parseError = "";
|
|
98
|
+
try {
|
|
99
|
+
const rows = detectAndParse(content);
|
|
100
|
+
if (rows.length === 0) throw new Error("No data rows found");
|
|
101
|
+
const mapped = applyColumnMapping(rows);
|
|
102
|
+
step = "processing";
|
|
103
|
+
const eventResult = await emitEvent({ lobb, ctx }, "studio.collections.import", {
|
|
104
|
+
collectionName,
|
|
105
|
+
rows: mapped,
|
|
106
|
+
});
|
|
107
|
+
transformedRows = eventResult.rows ?? mapped;
|
|
108
|
+
step = "preview";
|
|
109
|
+
} catch (e: any) {
|
|
110
|
+
step = "input";
|
|
111
|
+
parseError = e.message ?? "Failed to parse content";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function handleFile(file: File) {
|
|
116
|
+
const content = await file.text();
|
|
117
|
+
await processContent(content);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handleDrop(e: DragEvent) {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
isDragging = false;
|
|
123
|
+
const file = e.dataTransfer?.files[0];
|
|
124
|
+
if (file) handleFile(file);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function handleImport() {
|
|
128
|
+
const res = await lobb.createMany(collectionName, transformedRows);
|
|
129
|
+
if (res.status >= 400) {
|
|
130
|
+
toast.error("Import failed");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
toast.success(`Imported ${transformedRows.length} records`);
|
|
134
|
+
if (onSuccessfullSave) await onSuccessfullSave();
|
|
135
|
+
hideDrawer();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function showDrawer() {
|
|
139
|
+
if (!collectionName) { toast.error("No collection is selected"); return; }
|
|
140
|
+
openDrawer = true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hideDrawer() {
|
|
144
|
+
openDrawer = false;
|
|
145
|
+
step = "input";
|
|
146
|
+
activeTab = "upload";
|
|
147
|
+
pasteContent = "";
|
|
148
|
+
transformedRows = [];
|
|
149
|
+
parseError = "";
|
|
150
|
+
}
|
|
151
|
+
</script>
|
|
152
|
+
|
|
153
|
+
<Button variant={rest.variant} class={rest.class} Icon={rest.Icon} onclick={showDrawer}>
|
|
154
|
+
{#if rest.children}
|
|
155
|
+
{@render rest.children()}
|
|
156
|
+
{/if}
|
|
157
|
+
</Button>
|
|
158
|
+
|
|
159
|
+
<Dialog.Root
|
|
160
|
+
open={openDrawer}
|
|
161
|
+
onOpenChange={(open) => { if (!open) hideDrawer(); }}
|
|
162
|
+
>
|
|
163
|
+
<Dialog.Content
|
|
164
|
+
class="flex flex-col gap-0 p-0 overflow-hidden {step === 'preview' ? 'max-w-5xl h-[80vh]' : 'max-w-lg'}"
|
|
165
|
+
>
|
|
166
|
+
<!-- Header -->
|
|
167
|
+
<div class="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
|
168
|
+
<div class="flex items-center gap-2">
|
|
169
|
+
<div class="text-sm font-medium">Import</div>
|
|
170
|
+
<span class="rounded-md border bg-muted px-2 py-0.5 text-sm">{collectionName}</span>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{#if step === "processing"}
|
|
175
|
+
<div class="flex flex-1 flex-col items-center justify-center gap-3 py-16 text-muted-foreground">
|
|
176
|
+
<LoaderCircle class="animate-spin" size="28" />
|
|
177
|
+
<p class="text-sm">Processing rows...</p>
|
|
178
|
+
</div>
|
|
179
|
+
{:else if step === "input"}
|
|
180
|
+
<!-- Tabs -->
|
|
181
|
+
<div class="flex shrink-0 border-b">
|
|
182
|
+
<button
|
|
183
|
+
class="border-b-2 px-4 py-2 text-sm transition-colors {activeTab === 'upload' ? 'border-primary font-medium' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
|
184
|
+
onclick={() => (activeTab = "upload")}
|
|
185
|
+
>
|
|
186
|
+
Upload File
|
|
187
|
+
</button>
|
|
188
|
+
<button
|
|
189
|
+
class="border-b-2 px-4 py-2 text-sm transition-colors {activeTab === 'paste' ? 'border-primary font-medium' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
|
190
|
+
onclick={() => (activeTab = "paste")}
|
|
191
|
+
>
|
|
192
|
+
Paste
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div class="p-4">
|
|
197
|
+
{#if activeTab === "upload"}
|
|
198
|
+
<div
|
|
199
|
+
class="flex h-56 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-center transition-colors {isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}"
|
|
200
|
+
ondragover={(e) => { e.preventDefault(); isDragging = true; }}
|
|
201
|
+
ondragleave={() => (isDragging = false)}
|
|
202
|
+
ondrop={handleDrop}
|
|
203
|
+
onclick={() => fileInput.click()}
|
|
204
|
+
role="button"
|
|
205
|
+
tabindex="0"
|
|
206
|
+
onkeydown={(e) => e.key === "Enter" && fileInput.click()}
|
|
207
|
+
>
|
|
208
|
+
<Upload class="mb-3 h-8 w-8 text-muted-foreground" />
|
|
209
|
+
<p class="text-sm font-medium">Drop a file here or click to browse</p>
|
|
210
|
+
<p class="mt-1 text-xs text-muted-foreground">Supports .csv and .json</p>
|
|
211
|
+
</div>
|
|
212
|
+
<input
|
|
213
|
+
bind:this={fileInput}
|
|
214
|
+
type="file"
|
|
215
|
+
accept=".csv,.json"
|
|
216
|
+
class="hidden"
|
|
217
|
+
onchange={(e) => { const f = e.currentTarget.files?.[0]; if (f) handleFile(f); }}
|
|
218
|
+
/>
|
|
219
|
+
{:else}
|
|
220
|
+
<textarea
|
|
221
|
+
class="block h-56 w-full resize-none rounded-md border bg-muted/30 p-3 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
|
222
|
+
placeholder="Paste CSV or JSON here..."
|
|
223
|
+
bind:value={pasteContent}
|
|
224
|
+
></textarea>
|
|
225
|
+
{/if}
|
|
226
|
+
|
|
227
|
+
{#if parseError}
|
|
228
|
+
<div class="mt-3 flex items-center gap-2 text-sm text-destructive">
|
|
229
|
+
<AlertCircle class="h-4 w-4 shrink-0" />
|
|
230
|
+
{parseError}
|
|
231
|
+
</div>
|
|
232
|
+
{/if}
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<div class="flex h-12 shrink-0 items-center justify-end gap-2 border-t px-4">
|
|
236
|
+
<Button variant="outline" onclick={hideDrawer} class="h-7 px-3 text-xs font-normal" Icon={X}>
|
|
237
|
+
Cancel
|
|
238
|
+
</Button>
|
|
239
|
+
{#if activeTab === "paste"}
|
|
240
|
+
<Button onclick={() => processContent(pasteContent)} class="h-7 px-3 text-xs font-normal" Icon={FileText}>
|
|
241
|
+
Parse
|
|
242
|
+
</Button>
|
|
243
|
+
{/if}
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{:else}
|
|
247
|
+
<!-- Preview -->
|
|
248
|
+
<div class="shrink-0 border-b px-4 py-2 text-sm text-muted-foreground">
|
|
249
|
+
{transformedRows.length} rows ready to import
|
|
250
|
+
</div>
|
|
251
|
+
<div class="flex-1 overflow-auto">
|
|
252
|
+
<Table
|
|
253
|
+
data={transformedRows}
|
|
254
|
+
columns={collectionColumns}
|
|
255
|
+
showLastRowBorder={true}
|
|
256
|
+
showLastColumnBorder={true}
|
|
257
|
+
showCheckboxes={false}
|
|
258
|
+
unifiedBgColor="bg-background"
|
|
259
|
+
>
|
|
260
|
+
{#snippet overrideCell(value, column, entry)}
|
|
261
|
+
<FieldCell
|
|
262
|
+
{collectionName}
|
|
263
|
+
fieldName={column.id}
|
|
264
|
+
{value}
|
|
265
|
+
{entry}
|
|
266
|
+
/>
|
|
267
|
+
{/snippet}
|
|
268
|
+
</Table>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div class="flex h-12 shrink-0 items-center justify-end gap-2 border-t px-4">
|
|
272
|
+
<Button variant="outline" onclick={() => (step = "input")} class="h-7 px-3 text-xs font-normal" Icon={X}>
|
|
273
|
+
Cancel
|
|
274
|
+
</Button>
|
|
275
|
+
<Button onclick={handleImport} class="h-7 px-3 text-xs font-normal" Icon={Check}>
|
|
276
|
+
Import {transformedRows.length} records
|
|
277
|
+
</Button>
|
|
278
|
+
</div>
|
|
279
|
+
{/if}
|
|
280
|
+
</Dialog.Content>
|
|
281
|
+
</Dialog.Root>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ButtonProps } from "./ui/button/button.svelte";
|
|
2
|
+
interface LocalProps extends ButtonProps {
|
|
3
|
+
collectionName: string;
|
|
4
|
+
onSuccessfullSave?: () => Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
declare const ImportButton: import("svelte").Component<LocalProps, {}, "">;
|
|
7
|
+
type ImportButton = ReturnType<typeof ImportButton>;
|
|
8
|
+
export default ImportButton;
|
|
@@ -78,10 +78,20 @@ export type ExtensionComponent = any | {
|
|
|
78
78
|
component: any;
|
|
79
79
|
when: (props: Record<string, any>) => boolean;
|
|
80
80
|
};
|
|
81
|
+
export interface StudioWorkflowContext {
|
|
82
|
+
toast: typeof import("svelte-sonner")["toast"];
|
|
83
|
+
openCreateDetailView: (props: any) => void;
|
|
84
|
+
openUpdateDetailView: (props: any) => void;
|
|
85
|
+
}
|
|
86
|
+
export interface StudioWorkflow {
|
|
87
|
+
eventName: string;
|
|
88
|
+
handler: (input: Record<string, any>, context: StudioWorkflowContext) => Promise<any>;
|
|
89
|
+
}
|
|
81
90
|
export interface Extension {
|
|
82
91
|
name: string;
|
|
83
92
|
onStartup?: (utils: ExtensionUtils) => Promise<void>;
|
|
84
93
|
components?: Partial<Record<ExtensionComponentKey, ExtensionComponent>>;
|
|
85
94
|
dashboardNavs?: DashboardNavs;
|
|
95
|
+
workflows?: StudioWorkflow[];
|
|
86
96
|
}
|
|
87
97
|
export {};
|
|
@@ -5,5 +5,6 @@ export declare function getComponents(): Components;
|
|
|
5
5
|
export declare function getExtensionUtils(lobb: LobbClient, ctx: CTX): ExtensionUtils;
|
|
6
6
|
export declare function loadExtensions(lobb: LobbClient, ctx: CTX, extensionMap?: Record<string, any>): Promise<Record<string, Extension>>;
|
|
7
7
|
export declare function loadExtensionComponents(ctx: CTX, name: string, filterByExtensions?: string[], props?: Record<string, any>): any[];
|
|
8
|
+
export declare function loadExtensionWorkflows(ctx: CTX): void;
|
|
8
9
|
export declare function executeExtensionsOnStartup(lobb: LobbClient, ctx: CTX): Promise<void>;
|
|
9
10
|
export declare function getDashboardNavs(ctx: CTX): DashboardNavs;
|
|
@@ -145,6 +145,17 @@ export function loadExtensionComponents(ctx, name, filterByExtensions, props) {
|
|
|
145
145
|
}
|
|
146
146
|
return components;
|
|
147
147
|
}
|
|
148
|
+
export function loadExtensionWorkflows(ctx) {
|
|
149
|
+
var _a;
|
|
150
|
+
var extensionNames = Object.keys(ctx.extensions);
|
|
151
|
+
for (var _i = 0, extensionNames_1 = extensionNames; _i < extensionNames_1.length; _i++) {
|
|
152
|
+
var extensionName = extensionNames_1[_i];
|
|
153
|
+
var extension = ctx.extensions[extensionName];
|
|
154
|
+
if (extension === null || extension === void 0 ? void 0 : extension.workflows) {
|
|
155
|
+
(_a = ctx.meta.studio_workflows).push.apply(_a, extension.workflows);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
148
159
|
export function executeExtensionsOnStartup(lobb, ctx) {
|
|
149
160
|
return __awaiter(this, void 0, void 0, function () {
|
|
150
161
|
var extensionNames, index, extensionName, extension;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobb-js/studio",
|
|
3
3
|
"license": "UNLICENSED",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.16.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"postpublish": "./scripts/postpublish.sh"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@lobb-js/core": "^0.
|
|
45
|
+
"@lobb-js/core": "^0.22.0",
|
|
46
46
|
"@chromatic-com/storybook": "^4.1.2",
|
|
47
47
|
"@storybook/addon-a11y": "^10.0.1",
|
|
48
48
|
"@storybook/addon-docs": "^10.0.1",
|
|
@@ -101,6 +101,7 @@
|
|
|
101
101
|
"clsx": "^2.1.1",
|
|
102
102
|
"codemirror": "^6.0.2",
|
|
103
103
|
"fflate": "^0.8.2",
|
|
104
|
+
"fuse.js": "^7.3.0",
|
|
104
105
|
"javascript-time-ago": "^2.6.4",
|
|
105
106
|
"json-stable-stringify": "^1.3.0",
|
|
106
107
|
"lodash": "^4.17.21",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import {
|
|
13
13
|
executeExtensionsOnStartup,
|
|
14
14
|
loadExtensions,
|
|
15
|
+
loadExtensionWorkflows,
|
|
15
16
|
} from "../extensions/extensionUtils";
|
|
16
17
|
import extensionMap from 'virtual:lobb-studio-extensions';
|
|
17
18
|
import { mediaQueries } from "../utils";
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
ctx.meta = await lobb.getMeta();
|
|
49
50
|
ctx.extensions = await loadExtensions(lobb, ctx, extensionMap);
|
|
50
51
|
await executeExtensionsOnStartup(lobb, ctx);
|
|
52
|
+
loadExtensionWorkflows(ctx as any);
|
|
51
53
|
status = "ready";
|
|
52
54
|
} catch (err) {
|
|
53
55
|
console.error(err);
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
{:else if value === null || value === undefined}
|
|
41
41
|
<div class="text-muted-foreground">NULL</div>
|
|
42
42
|
{:else if isRefrenceField}
|
|
43
|
-
{#if value
|
|
43
|
+
{#if typeof value !== "object"}
|
|
44
|
+
<div>{value}</div>
|
|
45
|
+
{:else if value.id !== 0}
|
|
44
46
|
{@const primaryField = Object.values(value)[1]}
|
|
45
47
|
{#if value.id}
|
|
46
48
|
<div class="flex items-center gap-2">
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
import { getStudioContext } from "../../context";
|
|
3
3
|
|
|
4
4
|
const { lobb, ctx } = getStudioContext();
|
|
5
|
-
import { ListRestart, Plus,
|
|
5
|
+
import { Download, ListRestart, Plus, Trash } from "lucide-svelte";
|
|
6
|
+
import * as Tooltip from "../ui/tooltip";
|
|
6
7
|
import LlmButton from "../LlmButton.svelte";
|
|
7
8
|
import FilterButton from "./filterButton.svelte";
|
|
8
9
|
import SortButton from "./sortButton.svelte";
|
|
9
10
|
import Button from "../ui/button/button.svelte";
|
|
10
|
-
import
|
|
11
|
+
import ImportButton from "../importButton.svelte";
|
|
11
12
|
import { showDialog } from "../confirmationDialog/store.svelte";
|
|
12
13
|
import CreateDetailViewButton from "../detailView/create/createDetailViewButton.svelte";
|
|
13
14
|
import ExtensionsComponents from "../extensionsComponents.svelte";
|
|
@@ -137,13 +138,20 @@
|
|
|
137
138
|
>
|
|
138
139
|
{headerIsSmall ? "" : "Refresh"}
|
|
139
140
|
</Button>
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
<Tooltip.Provider delayDuration={0}>
|
|
142
|
+
<Tooltip.Root>
|
|
143
|
+
<Tooltip.Trigger>
|
|
144
|
+
<ImportButton
|
|
145
|
+
{collectionName}
|
|
146
|
+
variant="outline"
|
|
147
|
+
class="h-7 px-2 text-xs font-normal"
|
|
148
|
+
Icon={Download}
|
|
149
|
+
onSuccessfullSave={() => (params = { ...params })}
|
|
150
|
+
/>
|
|
151
|
+
</Tooltip.Trigger>
|
|
152
|
+
<Tooltip.Content>Import</Tooltip.Content>
|
|
153
|
+
</Tooltip.Root>
|
|
154
|
+
</Tooltip.Provider>
|
|
147
155
|
<ExtensionsComponents
|
|
148
156
|
name="listView.header.actions"
|
|
149
157
|
utils={getExtensionUtils(lobb, ctx)}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { AlertCircle, Check, FileText, LoaderCircle, Upload, X } from "lucide-svelte";
|
|
3
|
+
import Button, { type ButtonProps } from "./ui/button/button.svelte";
|
|
4
|
+
import { toast } from "svelte-sonner";
|
|
5
|
+
import { getStudioContext } from "../context";
|
|
6
|
+
import * as Dialog from "./ui/dialog";
|
|
7
|
+
import Table from "./dataTable/table.svelte";
|
|
8
|
+
import FieldCell from "./dataTable/fieldCell.svelte";
|
|
9
|
+
import { getCollectionColumns } from "./dataTable/utils";
|
|
10
|
+
import Fuse from "fuse.js";
|
|
11
|
+
import { emitEvent } from "../eventSystem";
|
|
12
|
+
|
|
13
|
+
const { lobb, ctx } = getStudioContext();
|
|
14
|
+
|
|
15
|
+
interface LocalProps extends ButtonProps {
|
|
16
|
+
collectionName: string;
|
|
17
|
+
onSuccessfullSave?: () => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let { collectionName, onSuccessfullSave, ...rest }: LocalProps = $props();
|
|
21
|
+
|
|
22
|
+
let openDrawer = $state(false);
|
|
23
|
+
let activeTab = $state<"upload" | "paste">("upload");
|
|
24
|
+
let step = $state<"input" | "processing" | "preview">("input");
|
|
25
|
+
let pasteContent = $state("");
|
|
26
|
+
let isDragging = $state(false);
|
|
27
|
+
let parseError = $state("");
|
|
28
|
+
let transformedRows = $state<any[]>([]);
|
|
29
|
+
|
|
30
|
+
const collectionColumns = $derived(getCollectionColumns(ctx, collectionName) ?? []);
|
|
31
|
+
|
|
32
|
+
function applyColumnMapping(rows: any[]): any[] {
|
|
33
|
+
if (!rows.length || !collectionColumns.length) return rows;
|
|
34
|
+
const csvColumns = Object.keys(rows[0]);
|
|
35
|
+
const fieldNames = collectionColumns.map((c) => c.id);
|
|
36
|
+
const fuse = new Fuse(fieldNames, { includeScore: true, threshold: 0.4 });
|
|
37
|
+
const mapping: Record<string, string | null> = {};
|
|
38
|
+
for (const csvCol of csvColumns) {
|
|
39
|
+
const results = fuse.search(csvCol);
|
|
40
|
+
mapping[csvCol] = results[0]?.item ?? null;
|
|
41
|
+
}
|
|
42
|
+
return rows.map((row) =>
|
|
43
|
+
Object.fromEntries(
|
|
44
|
+
collectionColumns.map((c) => {
|
|
45
|
+
const csvCol = Object.keys(mapping).find((k) => mapping[k] === c.id);
|
|
46
|
+
return [c.id, csvCol ? row[csvCol] ?? null : null];
|
|
47
|
+
})
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
let fileInput: HTMLInputElement;
|
|
52
|
+
|
|
53
|
+
function parseCSV(text: string): any[] {
|
|
54
|
+
const lines = text.trim().split("\n");
|
|
55
|
+
if (lines.length < 2) throw new Error("CSV must have a header row and at least one data row");
|
|
56
|
+
|
|
57
|
+
const parseRow = (line: string): string[] => {
|
|
58
|
+
const result: string[] = [];
|
|
59
|
+
let current = "";
|
|
60
|
+
let inQuotes = false;
|
|
61
|
+
for (let i = 0; i < line.length; i++) {
|
|
62
|
+
const char = line[i];
|
|
63
|
+
if (char === '"') {
|
|
64
|
+
if (inQuotes && line[i + 1] === '"') { current += '"'; i++; }
|
|
65
|
+
else inQuotes = !inQuotes;
|
|
66
|
+
} else if (char === "," && !inQuotes) {
|
|
67
|
+
result.push(current.trim());
|
|
68
|
+
current = "";
|
|
69
|
+
} else {
|
|
70
|
+
current += char;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
result.push(current.trim());
|
|
74
|
+
return result;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const headers = parseRow(lines[0]).map((h) => h.trim());
|
|
78
|
+
return lines
|
|
79
|
+
.slice(1)
|
|
80
|
+
.filter((l) => l.trim())
|
|
81
|
+
.map((line) => {
|
|
82
|
+
const values = parseRow(line);
|
|
83
|
+
return Object.fromEntries(headers.map((h, i) => [h, values[i] ?? ""]));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function detectAndParse(content: string): any[] {
|
|
88
|
+
const trimmed = content.trim();
|
|
89
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
90
|
+
const parsed = JSON.parse(trimmed);
|
|
91
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
92
|
+
}
|
|
93
|
+
return parseCSV(trimmed);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function processContent(content: string) {
|
|
97
|
+
parseError = "";
|
|
98
|
+
try {
|
|
99
|
+
const rows = detectAndParse(content);
|
|
100
|
+
if (rows.length === 0) throw new Error("No data rows found");
|
|
101
|
+
const mapped = applyColumnMapping(rows);
|
|
102
|
+
step = "processing";
|
|
103
|
+
const eventResult = await emitEvent({ lobb, ctx }, "studio.collections.import", {
|
|
104
|
+
collectionName,
|
|
105
|
+
rows: mapped,
|
|
106
|
+
});
|
|
107
|
+
transformedRows = eventResult.rows ?? mapped;
|
|
108
|
+
step = "preview";
|
|
109
|
+
} catch (e: any) {
|
|
110
|
+
step = "input";
|
|
111
|
+
parseError = e.message ?? "Failed to parse content";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function handleFile(file: File) {
|
|
116
|
+
const content = await file.text();
|
|
117
|
+
await processContent(content);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handleDrop(e: DragEvent) {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
isDragging = false;
|
|
123
|
+
const file = e.dataTransfer?.files[0];
|
|
124
|
+
if (file) handleFile(file);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function handleImport() {
|
|
128
|
+
const res = await lobb.createMany(collectionName, transformedRows);
|
|
129
|
+
if (res.status >= 400) {
|
|
130
|
+
toast.error("Import failed");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
toast.success(`Imported ${transformedRows.length} records`);
|
|
134
|
+
if (onSuccessfullSave) await onSuccessfullSave();
|
|
135
|
+
hideDrawer();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function showDrawer() {
|
|
139
|
+
if (!collectionName) { toast.error("No collection is selected"); return; }
|
|
140
|
+
openDrawer = true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hideDrawer() {
|
|
144
|
+
openDrawer = false;
|
|
145
|
+
step = "input";
|
|
146
|
+
activeTab = "upload";
|
|
147
|
+
pasteContent = "";
|
|
148
|
+
transformedRows = [];
|
|
149
|
+
parseError = "";
|
|
150
|
+
}
|
|
151
|
+
</script>
|
|
152
|
+
|
|
153
|
+
<Button variant={rest.variant} class={rest.class} Icon={rest.Icon} onclick={showDrawer}>
|
|
154
|
+
{#if rest.children}
|
|
155
|
+
{@render rest.children()}
|
|
156
|
+
{/if}
|
|
157
|
+
</Button>
|
|
158
|
+
|
|
159
|
+
<Dialog.Root
|
|
160
|
+
open={openDrawer}
|
|
161
|
+
onOpenChange={(open) => { if (!open) hideDrawer(); }}
|
|
162
|
+
>
|
|
163
|
+
<Dialog.Content
|
|
164
|
+
class="flex flex-col gap-0 p-0 overflow-hidden {step === 'preview' ? 'max-w-5xl h-[80vh]' : 'max-w-lg'}"
|
|
165
|
+
>
|
|
166
|
+
<!-- Header -->
|
|
167
|
+
<div class="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
|
168
|
+
<div class="flex items-center gap-2">
|
|
169
|
+
<div class="text-sm font-medium">Import</div>
|
|
170
|
+
<span class="rounded-md border bg-muted px-2 py-0.5 text-sm">{collectionName}</span>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{#if step === "processing"}
|
|
175
|
+
<div class="flex flex-1 flex-col items-center justify-center gap-3 py-16 text-muted-foreground">
|
|
176
|
+
<LoaderCircle class="animate-spin" size="28" />
|
|
177
|
+
<p class="text-sm">Processing rows...</p>
|
|
178
|
+
</div>
|
|
179
|
+
{:else if step === "input"}
|
|
180
|
+
<!-- Tabs -->
|
|
181
|
+
<div class="flex shrink-0 border-b">
|
|
182
|
+
<button
|
|
183
|
+
class="border-b-2 px-4 py-2 text-sm transition-colors {activeTab === 'upload' ? 'border-primary font-medium' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
|
184
|
+
onclick={() => (activeTab = "upload")}
|
|
185
|
+
>
|
|
186
|
+
Upload File
|
|
187
|
+
</button>
|
|
188
|
+
<button
|
|
189
|
+
class="border-b-2 px-4 py-2 text-sm transition-colors {activeTab === 'paste' ? 'border-primary font-medium' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
|
190
|
+
onclick={() => (activeTab = "paste")}
|
|
191
|
+
>
|
|
192
|
+
Paste
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div class="p-4">
|
|
197
|
+
{#if activeTab === "upload"}
|
|
198
|
+
<div
|
|
199
|
+
class="flex h-56 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-center transition-colors {isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}"
|
|
200
|
+
ondragover={(e) => { e.preventDefault(); isDragging = true; }}
|
|
201
|
+
ondragleave={() => (isDragging = false)}
|
|
202
|
+
ondrop={handleDrop}
|
|
203
|
+
onclick={() => fileInput.click()}
|
|
204
|
+
role="button"
|
|
205
|
+
tabindex="0"
|
|
206
|
+
onkeydown={(e) => e.key === "Enter" && fileInput.click()}
|
|
207
|
+
>
|
|
208
|
+
<Upload class="mb-3 h-8 w-8 text-muted-foreground" />
|
|
209
|
+
<p class="text-sm font-medium">Drop a file here or click to browse</p>
|
|
210
|
+
<p class="mt-1 text-xs text-muted-foreground">Supports .csv and .json</p>
|
|
211
|
+
</div>
|
|
212
|
+
<input
|
|
213
|
+
bind:this={fileInput}
|
|
214
|
+
type="file"
|
|
215
|
+
accept=".csv,.json"
|
|
216
|
+
class="hidden"
|
|
217
|
+
onchange={(e) => { const f = e.currentTarget.files?.[0]; if (f) handleFile(f); }}
|
|
218
|
+
/>
|
|
219
|
+
{:else}
|
|
220
|
+
<textarea
|
|
221
|
+
class="block h-56 w-full resize-none rounded-md border bg-muted/30 p-3 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
|
222
|
+
placeholder="Paste CSV or JSON here..."
|
|
223
|
+
bind:value={pasteContent}
|
|
224
|
+
></textarea>
|
|
225
|
+
{/if}
|
|
226
|
+
|
|
227
|
+
{#if parseError}
|
|
228
|
+
<div class="mt-3 flex items-center gap-2 text-sm text-destructive">
|
|
229
|
+
<AlertCircle class="h-4 w-4 shrink-0" />
|
|
230
|
+
{parseError}
|
|
231
|
+
</div>
|
|
232
|
+
{/if}
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<div class="flex h-12 shrink-0 items-center justify-end gap-2 border-t px-4">
|
|
236
|
+
<Button variant="outline" onclick={hideDrawer} class="h-7 px-3 text-xs font-normal" Icon={X}>
|
|
237
|
+
Cancel
|
|
238
|
+
</Button>
|
|
239
|
+
{#if activeTab === "paste"}
|
|
240
|
+
<Button onclick={() => processContent(pasteContent)} class="h-7 px-3 text-xs font-normal" Icon={FileText}>
|
|
241
|
+
Parse
|
|
242
|
+
</Button>
|
|
243
|
+
{/if}
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{:else}
|
|
247
|
+
<!-- Preview -->
|
|
248
|
+
<div class="shrink-0 border-b px-4 py-2 text-sm text-muted-foreground">
|
|
249
|
+
{transformedRows.length} rows ready to import
|
|
250
|
+
</div>
|
|
251
|
+
<div class="flex-1 overflow-auto">
|
|
252
|
+
<Table
|
|
253
|
+
data={transformedRows}
|
|
254
|
+
columns={collectionColumns}
|
|
255
|
+
showLastRowBorder={true}
|
|
256
|
+
showLastColumnBorder={true}
|
|
257
|
+
showCheckboxes={false}
|
|
258
|
+
unifiedBgColor="bg-background"
|
|
259
|
+
>
|
|
260
|
+
{#snippet overrideCell(value, column, entry)}
|
|
261
|
+
<FieldCell
|
|
262
|
+
{collectionName}
|
|
263
|
+
fieldName={column.id}
|
|
264
|
+
{value}
|
|
265
|
+
{entry}
|
|
266
|
+
/>
|
|
267
|
+
{/snippet}
|
|
268
|
+
</Table>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div class="flex h-12 shrink-0 items-center justify-end gap-2 border-t px-4">
|
|
272
|
+
<Button variant="outline" onclick={() => (step = "input")} class="h-7 px-3 text-xs font-normal" Icon={X}>
|
|
273
|
+
Cancel
|
|
274
|
+
</Button>
|
|
275
|
+
<Button onclick={handleImport} class="h-7 px-3 text-xs font-normal" Icon={Check}>
|
|
276
|
+
Import {transformedRows.length} records
|
|
277
|
+
</Button>
|
|
278
|
+
</div>
|
|
279
|
+
{/if}
|
|
280
|
+
</Dialog.Content>
|
|
281
|
+
</Dialog.Root>
|
|
@@ -100,10 +100,23 @@ export type ExtensionComponent =
|
|
|
100
100
|
| any
|
|
101
101
|
| { component: any; when: (props: Record<string, any>) => boolean };
|
|
102
102
|
|
|
103
|
+
// studio workflows
|
|
104
|
+
export interface StudioWorkflowContext {
|
|
105
|
+
toast: typeof import("svelte-sonner")["toast"];
|
|
106
|
+
openCreateDetailView: (props: any) => void;
|
|
107
|
+
openUpdateDetailView: (props: any) => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface StudioWorkflow {
|
|
111
|
+
eventName: string;
|
|
112
|
+
handler: (input: Record<string, any>, context: StudioWorkflowContext) => Promise<any>;
|
|
113
|
+
}
|
|
114
|
+
|
|
103
115
|
// extension exported object
|
|
104
116
|
export interface Extension {
|
|
105
117
|
name: string;
|
|
106
118
|
onStartup?: (utils: ExtensionUtils) => Promise<void>;
|
|
107
119
|
components?: Partial<Record<ExtensionComponentKey, ExtensionComponent>>;
|
|
108
120
|
dashboardNavs?: DashboardNavs;
|
|
121
|
+
workflows?: StudioWorkflow[];
|
|
109
122
|
}
|
|
@@ -116,6 +116,16 @@ export function loadExtensionComponents(
|
|
|
116
116
|
return components;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
export function loadExtensionWorkflows(ctx: CTX): void {
|
|
120
|
+
const extensionNames = Object.keys(ctx.extensions);
|
|
121
|
+
for (const extensionName of extensionNames) {
|
|
122
|
+
const extension = ctx.extensions[extensionName];
|
|
123
|
+
if (extension?.workflows) {
|
|
124
|
+
ctx.meta.studio_workflows.push(...extension.workflows);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
119
129
|
export async function executeExtensionsOnStartup(lobb: LobbClient, ctx: CTX) {
|
|
120
130
|
const extensionNames: string[] = Object.keys(ctx.extensions);
|
|
121
131
|
for (let index = 0; index < extensionNames.length; index++) {
|