@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.
@@ -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.id !== 0}
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, SquareStack, Trash } from "lucide-svelte";
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 CreateManyButton from "../createManyButton.svelte";
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
- <CreateManyButton
141
- {collectionName}
142
- variant="outline"
143
- class="h-7 px-2 text-xs font-normal"
144
- Icon={SquareStack}
145
- onSuccessfullSave={() => (params = { ...params })}
146
- ></CreateManyButton>
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.15.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.21.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.id !== 0}
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, SquareStack, Trash } from "lucide-svelte";
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 CreateManyButton from "../createManyButton.svelte";
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
- <CreateManyButton
141
- {collectionName}
142
- variant="outline"
143
- class="h-7 px-2 text-xs font-normal"
144
- Icon={SquareStack}
145
- onSuccessfullSave={() => (params = { ...params })}
146
- ></CreateManyButton>
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++) {