@lobb-js/studio 0.32.0 → 0.33.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 +4 -0
- package/dist/applyStudioTheme.d.ts +2 -0
- package/dist/applyStudioTheme.js +36 -0
- package/dist/components/Studio.svelte +2 -0
- package/dist/components/canAccess.svelte +52 -0
- package/dist/components/canAccess.svelte.d.ts +10 -0
- package/dist/components/dataTable/dataTable.svelte +46 -27
- package/dist/components/dataTable/dataTable.svelte.d.ts +4 -0
- package/dist/components/dataTable/header.svelte +20 -19
- package/dist/components/dataTable/header.svelte.d.ts +0 -1
- package/dist/components/dataTablePopup/dataTablePopup.svelte +3 -0
- package/dist/components/dataTablePopup/dataTablePopup.svelte.d.ts +4 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/store.types.d.ts +7 -0
- package/package.json +2 -2
- package/src/lib/actions.ts +1 -0
- package/src/lib/applyStudioTheme.ts +38 -0
- package/src/lib/components/Studio.svelte +2 -0
- package/src/lib/components/canAccess.svelte +52 -0
- package/src/lib/components/dataTable/dataTable.svelte +46 -27
- package/src/lib/components/dataTable/header.svelte +20 -19
- package/src/lib/components/dataTablePopup/dataTablePopup.svelte +3 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/store.types.ts +6 -0
package/dist/actions.d.ts
CHANGED
|
@@ -20,6 +20,10 @@ export interface OpenDataTablePopupProps {
|
|
|
20
20
|
showHeader?: boolean;
|
|
21
21
|
showFooter?: boolean;
|
|
22
22
|
tabs?: CollectionTab[];
|
|
23
|
+
view?: {
|
|
24
|
+
id: string;
|
|
25
|
+
[key: string]: any;
|
|
26
|
+
};
|
|
23
27
|
}
|
|
24
28
|
export declare function showDialog(title: string, description: string): Promise<boolean>;
|
|
25
29
|
export declare function openCreateDetailView(studioContext: StudioContext, props: CreateDetailViewProp): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Studio theme injection. Writes the configured CSS-variable overrides
|
|
2
|
+
// into a single <style> tag — light overrides under `:root`, dark under
|
|
3
|
+
// `.dark` — so each mode picks up its own variant. Idempotent: re-runs
|
|
4
|
+
// replace the previous block.
|
|
5
|
+
const STYLE_ID = "lobb-studio-theme";
|
|
6
|
+
function buildDeclarations(vars) {
|
|
7
|
+
if (!vars)
|
|
8
|
+
return "";
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
11
|
+
if (!key.startsWith("--") || !value)
|
|
12
|
+
continue;
|
|
13
|
+
out.push(`${key}: ${value};`);
|
|
14
|
+
}
|
|
15
|
+
return out.join(" ");
|
|
16
|
+
}
|
|
17
|
+
export function applyStudioTheme(theme) {
|
|
18
|
+
if (typeof document === "undefined")
|
|
19
|
+
return;
|
|
20
|
+
document.getElementById(STYLE_ID)?.remove();
|
|
21
|
+
if (!theme)
|
|
22
|
+
return;
|
|
23
|
+
const light = buildDeclarations(theme.light);
|
|
24
|
+
const dark = buildDeclarations(theme.dark);
|
|
25
|
+
if (!light && !dark)
|
|
26
|
+
return;
|
|
27
|
+
const rules = [];
|
|
28
|
+
if (light)
|
|
29
|
+
rules.push(`:root { ${light} }`);
|
|
30
|
+
if (dark)
|
|
31
|
+
rules.push(`.dark { ${dark} }`);
|
|
32
|
+
const style = document.createElement("style");
|
|
33
|
+
style.id = STYLE_ID;
|
|
34
|
+
style.textContent = rules.join(" ");
|
|
35
|
+
document.head.appendChild(style);
|
|
36
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
} from "../extensions/extensionUtils";
|
|
18
18
|
import extensionMap from 'virtual:lobb-studio-extensions';
|
|
19
19
|
import { mediaQueries } from "../utils";
|
|
20
|
+
import { applyStudioTheme } from "../applyStudioTheme";
|
|
20
21
|
import Home from "./routes/home.svelte";
|
|
21
22
|
import DataModel from "./routes/data_model/dataModel.svelte";
|
|
22
23
|
import Collections from "./routes/collections/collections.svelte";
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
document.getElementById("app-loading")?.remove();
|
|
51
52
|
try {
|
|
52
53
|
ctx.meta = await lobb.getMeta();
|
|
54
|
+
applyStudioTheme(ctx.meta.studio?.theme);
|
|
53
55
|
ctx.extensions = await loadExtensions(lobb, ctx, extensionMap);
|
|
54
56
|
await executeExtensionsOnStartup(lobb, ctx);
|
|
55
57
|
loadExtensionWorkflows(ctx as any);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Declarative permission gate. Replaces the recurring pattern of
|
|
3
|
+
// `let canX = $state(false); onMount(async () => canX = await emitEvent
|
|
4
|
+
// ("auth.canAccess", { collection, action }) === true);` plus an `{#if canX}`.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// <CanAccess collection="risks" action="update">
|
|
8
|
+
// <EditButton ... />
|
|
9
|
+
// </CanAccess>
|
|
10
|
+
//
|
|
11
|
+
// Optional `fallback` snippet renders when the user is NOT allowed
|
|
12
|
+
// (defaults to nothing). The brief in-flight window before the answer
|
|
13
|
+
// is known renders nothing so we don't flash unauthorized content.
|
|
14
|
+
import type { Snippet } from "svelte";
|
|
15
|
+
import { onMount } from "svelte";
|
|
16
|
+
import { getStudioContext } from "../context";
|
|
17
|
+
import { emitEvent } from "../eventSystem";
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
collection: string;
|
|
21
|
+
action: "read" | "create" | "update" | "delete" | string;
|
|
22
|
+
children: Snippet;
|
|
23
|
+
fallback?: Snippet;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { collection, action, children, fallback }: Props = $props();
|
|
27
|
+
const { lobb, ctx } = getStudioContext();
|
|
28
|
+
|
|
29
|
+
// null = "haven't checked yet" — distinguished from false so the fallback
|
|
30
|
+
// doesn't flash during the async resolution.
|
|
31
|
+
let allowed = $state<boolean | null>(null);
|
|
32
|
+
|
|
33
|
+
onMount(async () => {
|
|
34
|
+
try {
|
|
35
|
+
const result = await emitEvent({ lobb, ctx }, "auth.canAccess", {
|
|
36
|
+
collection,
|
|
37
|
+
action,
|
|
38
|
+
});
|
|
39
|
+
allowed = result === true;
|
|
40
|
+
} catch {
|
|
41
|
+
// No handler registered (auth extension not loaded), or the
|
|
42
|
+
// handler threw — fail closed.
|
|
43
|
+
allowed = false;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
{#if allowed === true}
|
|
49
|
+
{@render children()}
|
|
50
|
+
{:else if allowed === false && fallback}
|
|
51
|
+
{@render fallback()}
|
|
52
|
+
{/if}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
interface Props {
|
|
3
|
+
collection: string;
|
|
4
|
+
action: "read" | "create" | "update" | "delete" | string;
|
|
5
|
+
children: Snippet;
|
|
6
|
+
fallback?: Snippet;
|
|
7
|
+
}
|
|
8
|
+
declare const CanAccess: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type CanAccess = ReturnType<typeof CanAccess>;
|
|
10
|
+
export default CanAccess;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import Header from "./header.svelte";
|
|
14
14
|
import Table, { type TableProps } from "./table.svelte";
|
|
15
15
|
import { getCollectionColumns, getCollectionParamsFields } from "./utils";
|
|
16
|
+
import CanAccess from "../canAccess.svelte";
|
|
16
17
|
import { Pencil, Trash, Unlink } from "lucide-svelte";
|
|
17
18
|
import ListViewChildren from "./listViewChildren.svelte";
|
|
18
19
|
import FieldCell from "./fieldCell.svelte";
|
|
@@ -24,8 +25,7 @@
|
|
|
24
25
|
import type { ChildrenChanges } from "../detailView/utils";
|
|
25
26
|
import ExtensionsComponents from "../extensionsComponents.svelte";
|
|
26
27
|
import { getExtensionUtils, loadExtensionComponents } from "../../extensions/extensionUtils";
|
|
27
|
-
import {
|
|
28
|
-
import { onMount, untrack } from "svelte";
|
|
28
|
+
import { untrack } from "svelte";
|
|
29
29
|
import Tabs from "./dataTableTabs.svelte";
|
|
30
30
|
import { fade } from "svelte/transition";
|
|
31
31
|
import type { CollectionTab } from "../../store.types";
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
tableProps?: Partial<TableProps>;
|
|
49
49
|
tabs?: CollectionTab[];
|
|
50
50
|
headerLeft?: Snippet<[]>;
|
|
51
|
+
view?: { id: string; [key: string]: any };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
let {
|
|
@@ -66,6 +67,7 @@
|
|
|
66
67
|
tableProps,
|
|
67
68
|
tabs,
|
|
68
69
|
headerLeft,
|
|
70
|
+
view,
|
|
69
71
|
}: Props = $props();
|
|
70
72
|
|
|
71
73
|
const isRecordingMode = onChanges !== undefined;
|
|
@@ -73,19 +75,6 @@
|
|
|
73
75
|
untrack(() => changes) ?? { created: [], updated: [], deleted: [], linked: [], unlinked: [] }
|
|
74
76
|
);
|
|
75
77
|
|
|
76
|
-
// Gate row/header buttons by the current user's permissions:
|
|
77
|
-
// - showUpdate → per-row edit button
|
|
78
|
-
// - showCreate → header's Create + Import buttons (passed to Header)
|
|
79
|
-
let showUpdate = $state(false);
|
|
80
|
-
let showCreate = $state(false);
|
|
81
|
-
onMount(async () => {
|
|
82
|
-
const [update, create] = await Promise.all([
|
|
83
|
-
emitEvent({ lobb, ctx }, "auth.canAccess", { collection: collectionName, action: "update" }),
|
|
84
|
-
emitEvent({ lobb, ctx }, "auth.canAccess", { collection: collectionName, action: "create" }),
|
|
85
|
-
]);
|
|
86
|
-
showUpdate = update === true;
|
|
87
|
-
showCreate = create === true;
|
|
88
|
-
});
|
|
89
78
|
|
|
90
79
|
// Derives the displayed rows by applying localChanges on top of server data.
|
|
91
80
|
const data = $derived.by(() => {
|
|
@@ -122,6 +111,23 @@
|
|
|
122
111
|
|
|
123
112
|
let activeTabFilter = $state<any>(undefined);
|
|
124
113
|
|
|
114
|
+
// Named-view lookup: when a `view` prop is supplied, resolve the
|
|
115
|
+
// matching `dataTable.view.<view.id>` registration. Exact key match,
|
|
116
|
+
// no `when` predicate — the caller already picked the view they want.
|
|
117
|
+
const customViewComponent = $derived.by(() => {
|
|
118
|
+
if (!view?.id) return null;
|
|
119
|
+
const key = `dataTable.view.${view.id}`;
|
|
120
|
+
for (const ext of Object.values(ctx.extensions ?? {})) {
|
|
121
|
+
const components = (ext as any)?.components ?? {};
|
|
122
|
+
const entry = components[key];
|
|
123
|
+
if (!entry) continue;
|
|
124
|
+
return entry && typeof entry === "object" && "component" in entry
|
|
125
|
+
? entry.component
|
|
126
|
+
: entry;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
});
|
|
130
|
+
|
|
125
131
|
// Canonicalize the incoming filter so values like `{ status: "Open" }`
|
|
126
132
|
// become `{ status: { $eq: "Open" } }`. The Filter UI and the server
|
|
127
133
|
// both expect operator objects, so doing this once at the boundary
|
|
@@ -291,7 +297,6 @@
|
|
|
291
297
|
{collectionName}
|
|
292
298
|
bind:selectedRecords
|
|
293
299
|
{showImport}
|
|
294
|
-
{showCreate}
|
|
295
300
|
{parentContext}
|
|
296
301
|
onLink={isRecordingMode ? handleLink : undefined}
|
|
297
302
|
onCreate={isRecordingMode ? handleCreate : undefined}
|
|
@@ -311,6 +316,18 @@
|
|
|
311
316
|
<Skeleton class="h-8 w-[80%]" />
|
|
312
317
|
<Skeleton class="h-8 w-[60%]" />
|
|
313
318
|
</div>
|
|
319
|
+
{:else if customViewComponent}
|
|
320
|
+
{@const CustomView = customViewComponent}
|
|
321
|
+
<CustomView
|
|
322
|
+
{collectionName}
|
|
323
|
+
{data}
|
|
324
|
+
{columns}
|
|
325
|
+
bind:params
|
|
326
|
+
{loading}
|
|
327
|
+
refresh={() => { params = { ...params }; }}
|
|
328
|
+
{view}
|
|
329
|
+
utils={getExtensionUtils(lobb, ctx)}
|
|
330
|
+
/>
|
|
314
331
|
{:else}
|
|
315
332
|
<Table
|
|
316
333
|
{data}
|
|
@@ -325,17 +342,19 @@
|
|
|
325
342
|
{...tableProps}
|
|
326
343
|
rowActions={hasRowActions ? rowActionsSnippet : undefined}>
|
|
327
344
|
{#snippet tools(entry)}
|
|
328
|
-
{#if
|
|
329
|
-
<
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
345
|
+
{#if showEdit}
|
|
346
|
+
<CanAccess collection={collectionName} action="update">
|
|
347
|
+
<UpdateDetailViewButton
|
|
348
|
+
{collectionName}
|
|
349
|
+
recordId={entry.id}
|
|
350
|
+
variant="ghost"
|
|
351
|
+
class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent"
|
|
352
|
+
Icon={Pencil}
|
|
353
|
+
changes={isRecordingMode ? localChanges.updated.find((u) => String(u.id) === String(entry.id))?.changes : undefined}
|
|
354
|
+
onChanges={isRecordingMode ? (c) => handleUpdate(String(entry.id), c) : undefined}
|
|
355
|
+
onSuccessfullSave={!isRecordingMode ? async () => { params = { ...params }; } : undefined}
|
|
356
|
+
></UpdateDetailViewButton>
|
|
357
|
+
</CanAccess>
|
|
339
358
|
{/if}
|
|
340
359
|
{#if parentContext}
|
|
341
360
|
<Button
|
|
@@ -22,6 +22,10 @@ interface Props {
|
|
|
22
22
|
tableProps?: Partial<TableProps>;
|
|
23
23
|
tabs?: CollectionTab[];
|
|
24
24
|
headerLeft?: Snippet<[]>;
|
|
25
|
+
view?: {
|
|
26
|
+
id: string;
|
|
27
|
+
[key: string]: any;
|
|
28
|
+
};
|
|
25
29
|
}
|
|
26
30
|
declare const DataTable: import("svelte").Component<Props, {}, "">;
|
|
27
31
|
type DataTable = ReturnType<typeof DataTable>;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { getStudioContext } from "../../context";
|
|
3
3
|
import type { Changes } from "../detailView/utils";
|
|
4
4
|
import type { ParentContext } from "./dataTable.svelte";
|
|
5
|
+
import CanAccess from "../canAccess.svelte";
|
|
5
6
|
import { Download, ListRestart, Plus, Trash, Link } from "lucide-svelte";
|
|
6
7
|
import * as Tooltip from "../ui/tooltip";
|
|
7
8
|
import LlmButton from "../LlmButton.svelte";
|
|
@@ -26,7 +27,6 @@
|
|
|
26
27
|
onLink?: (record: any) => void;
|
|
27
28
|
onCreate?: (changes: Changes) => void;
|
|
28
29
|
showImport?: boolean;
|
|
29
|
-
showCreate?: boolean;
|
|
30
30
|
left?: Snippet<[]>;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
onLink,
|
|
39
39
|
onCreate,
|
|
40
40
|
showImport = true,
|
|
41
|
-
showCreate = false,
|
|
42
41
|
left
|
|
43
42
|
}: Props = $props();
|
|
44
43
|
|
|
@@ -168,21 +167,23 @@
|
|
|
168
167
|
>
|
|
169
168
|
{headerIsSmall ? "" : "Refresh"}
|
|
170
169
|
</Button>
|
|
171
|
-
{#if showImport
|
|
172
|
-
<
|
|
173
|
-
<Tooltip.
|
|
174
|
-
<Tooltip.
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
170
|
+
{#if showImport}
|
|
171
|
+
<CanAccess collection={collectionName} action="create">
|
|
172
|
+
<Tooltip.Provider delayDuration={0}>
|
|
173
|
+
<Tooltip.Root>
|
|
174
|
+
<Tooltip.Trigger>
|
|
175
|
+
<ImportButton
|
|
176
|
+
{collectionName}
|
|
177
|
+
variant="outline"
|
|
178
|
+
class="h-7 px-2 text-xs font-normal"
|
|
179
|
+
Icon={Download}
|
|
180
|
+
onSuccessfullSave={() => (params = { ...params })}
|
|
181
|
+
/>
|
|
182
|
+
</Tooltip.Trigger>
|
|
183
|
+
<Tooltip.Content>Import</Tooltip.Content>
|
|
184
|
+
</Tooltip.Root>
|
|
185
|
+
</Tooltip.Provider>
|
|
186
|
+
</CanAccess>
|
|
186
187
|
{/if}
|
|
187
188
|
<ExtensionsComponents
|
|
188
189
|
name="listView.header.actions"
|
|
@@ -201,7 +202,7 @@
|
|
|
201
202
|
{headerIsSmall ? "" : "Link"}
|
|
202
203
|
</SelectRecord>
|
|
203
204
|
{/if}
|
|
204
|
-
{
|
|
205
|
+
<CanAccess collection={collectionName} action="create">
|
|
205
206
|
<CreateDetailViewButton
|
|
206
207
|
{collectionName}
|
|
207
208
|
variant="default"
|
|
@@ -212,6 +213,6 @@
|
|
|
212
213
|
>
|
|
213
214
|
{headerIsSmall ? "" : "Create"}
|
|
214
215
|
</CreateDetailViewButton>
|
|
215
|
-
|
|
216
|
+
</CanAccess>
|
|
216
217
|
</div>
|
|
217
218
|
</div>
|
|
@@ -9,7 +9,6 @@ interface Props {
|
|
|
9
9
|
onLink?: (record: any) => void;
|
|
10
10
|
onCreate?: (changes: Changes) => void;
|
|
11
11
|
showImport?: boolean;
|
|
12
|
-
showCreate?: boolean;
|
|
13
12
|
left?: Snippet<[]>;
|
|
14
13
|
}
|
|
15
14
|
declare const Header: import("svelte").Component<Props, {}, "selectedRecords" | "params">;
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
showFooter?: boolean;
|
|
20
20
|
tableProps?: Partial<TableProps>;
|
|
21
21
|
tabs?: CollectionTab[];
|
|
22
|
+
view?: { id: string; [key: string]: any };
|
|
22
23
|
onClose?: () => void;
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -32,6 +33,7 @@
|
|
|
32
33
|
showFooter = true,
|
|
33
34
|
tableProps,
|
|
34
35
|
tabs,
|
|
36
|
+
view,
|
|
35
37
|
onClose,
|
|
36
38
|
}: Props = $props();
|
|
37
39
|
|
|
@@ -78,6 +80,7 @@
|
|
|
78
80
|
{showFooter}
|
|
79
81
|
{tableProps}
|
|
80
82
|
{tabs}
|
|
83
|
+
{view}
|
|
81
84
|
/>
|
|
82
85
|
</div>
|
|
83
86
|
</div>
|
|
@@ -10,6 +10,10 @@ interface Props {
|
|
|
10
10
|
showFooter?: boolean;
|
|
11
11
|
tableProps?: Partial<TableProps>;
|
|
12
12
|
tabs?: CollectionTab[];
|
|
13
|
+
view?: {
|
|
14
|
+
id: string;
|
|
15
|
+
[key: string]: any;
|
|
16
|
+
};
|
|
13
17
|
onClose?: () => void;
|
|
14
18
|
}
|
|
15
19
|
declare const DataTablePopup: import("svelte").Component<Props, {}, "">;
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export { default as SidebarTrigger } from "./components/sidebar/sidebarTrigger.s
|
|
|
12
12
|
export { default as CreateDetailViewButton } from "./components/detailView/create/createDetailViewButton.svelte";
|
|
13
13
|
export { default as UpdateDetailViewButton } from "./components/detailView/update/updateDetailViewButton.svelte";
|
|
14
14
|
export { default as DetailView } from "./components/detailView/detailView.svelte";
|
|
15
|
+
export { default as CanAccess } from "./components/canAccess.svelte";
|
|
16
|
+
export { default as ExtensionsComponents } from "./components/extensionsComponents.svelte";
|
|
15
17
|
export * as Tooltip from "./components/ui/tooltip";
|
|
16
18
|
export * as Breadcrumb from "./components/ui/breadcrumb";
|
|
17
19
|
export { ContextMenu } from "bits-ui";
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,8 @@ export { default as SidebarTrigger } from "./components/sidebar/sidebarTrigger.s
|
|
|
11
11
|
export { default as CreateDetailViewButton } from "./components/detailView/create/createDetailViewButton.svelte";
|
|
12
12
|
export { default as UpdateDetailViewButton } from "./components/detailView/update/updateDetailViewButton.svelte";
|
|
13
13
|
export { default as DetailView } from "./components/detailView/detailView.svelte";
|
|
14
|
+
export { default as CanAccess } from "./components/canAccess.svelte";
|
|
15
|
+
export { default as ExtensionsComponents } from "./components/extensionsComponents.svelte";
|
|
14
16
|
export * as Tooltip from "./components/ui/tooltip";
|
|
15
17
|
export * as Breadcrumb from "./components/ui/breadcrumb";
|
|
16
18
|
export { ContextMenu } from "bits-ui";
|
package/dist/store.types.d.ts
CHANGED
|
@@ -30,8 +30,15 @@ interface Collection {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
type Collections = Record<string, Collection>;
|
|
33
|
+
export interface StudioTheme {
|
|
34
|
+
light?: Record<string, string>;
|
|
35
|
+
dark?: Record<string, string>;
|
|
36
|
+
}
|
|
33
37
|
interface Meta {
|
|
34
38
|
version: string;
|
|
39
|
+
studio?: {
|
|
40
|
+
theme?: StudioTheme;
|
|
41
|
+
};
|
|
35
42
|
relations: Array<any>;
|
|
36
43
|
collections: Collections;
|
|
37
44
|
extensions: Record<string, any>;
|
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.33.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.34.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",
|
package/src/lib/actions.ts
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { StudioTheme } from "./store.types";
|
|
2
|
+
|
|
3
|
+
// Studio theme injection. Writes the configured CSS-variable overrides
|
|
4
|
+
// into a single <style> tag — light overrides under `:root`, dark under
|
|
5
|
+
// `.dark` — so each mode picks up its own variant. Idempotent: re-runs
|
|
6
|
+
// replace the previous block.
|
|
7
|
+
|
|
8
|
+
const STYLE_ID = "lobb-studio-theme";
|
|
9
|
+
|
|
10
|
+
function buildDeclarations(vars: Record<string, string> | undefined): string {
|
|
11
|
+
if (!vars) return "";
|
|
12
|
+
const out: string[] = [];
|
|
13
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
14
|
+
if (!key.startsWith("--") || !value) continue;
|
|
15
|
+
out.push(`${key}: ${value};`);
|
|
16
|
+
}
|
|
17
|
+
return out.join(" ");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function applyStudioTheme(theme: StudioTheme | undefined): void {
|
|
21
|
+
if (typeof document === "undefined") return;
|
|
22
|
+
|
|
23
|
+
document.getElementById(STYLE_ID)?.remove();
|
|
24
|
+
if (!theme) return;
|
|
25
|
+
|
|
26
|
+
const light = buildDeclarations(theme.light);
|
|
27
|
+
const dark = buildDeclarations(theme.dark);
|
|
28
|
+
if (!light && !dark) return;
|
|
29
|
+
|
|
30
|
+
const rules: string[] = [];
|
|
31
|
+
if (light) rules.push(`:root { ${light} }`);
|
|
32
|
+
if (dark) rules.push(`.dark { ${dark} }`);
|
|
33
|
+
|
|
34
|
+
const style = document.createElement("style");
|
|
35
|
+
style.id = STYLE_ID;
|
|
36
|
+
style.textContent = rules.join(" ");
|
|
37
|
+
document.head.appendChild(style);
|
|
38
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
} from "../extensions/extensionUtils";
|
|
18
18
|
import extensionMap from 'virtual:lobb-studio-extensions';
|
|
19
19
|
import { mediaQueries } from "../utils";
|
|
20
|
+
import { applyStudioTheme } from "../applyStudioTheme";
|
|
20
21
|
import Home from "./routes/home.svelte";
|
|
21
22
|
import DataModel from "./routes/data_model/dataModel.svelte";
|
|
22
23
|
import Collections from "./routes/collections/collections.svelte";
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
document.getElementById("app-loading")?.remove();
|
|
51
52
|
try {
|
|
52
53
|
ctx.meta = await lobb.getMeta();
|
|
54
|
+
applyStudioTheme(ctx.meta.studio?.theme);
|
|
53
55
|
ctx.extensions = await loadExtensions(lobb, ctx, extensionMap);
|
|
54
56
|
await executeExtensionsOnStartup(lobb, ctx);
|
|
55
57
|
loadExtensionWorkflows(ctx as any);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Declarative permission gate. Replaces the recurring pattern of
|
|
3
|
+
// `let canX = $state(false); onMount(async () => canX = await emitEvent
|
|
4
|
+
// ("auth.canAccess", { collection, action }) === true);` plus an `{#if canX}`.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// <CanAccess collection="risks" action="update">
|
|
8
|
+
// <EditButton ... />
|
|
9
|
+
// </CanAccess>
|
|
10
|
+
//
|
|
11
|
+
// Optional `fallback` snippet renders when the user is NOT allowed
|
|
12
|
+
// (defaults to nothing). The brief in-flight window before the answer
|
|
13
|
+
// is known renders nothing so we don't flash unauthorized content.
|
|
14
|
+
import type { Snippet } from "svelte";
|
|
15
|
+
import { onMount } from "svelte";
|
|
16
|
+
import { getStudioContext } from "../context";
|
|
17
|
+
import { emitEvent } from "../eventSystem";
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
collection: string;
|
|
21
|
+
action: "read" | "create" | "update" | "delete" | string;
|
|
22
|
+
children: Snippet;
|
|
23
|
+
fallback?: Snippet;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { collection, action, children, fallback }: Props = $props();
|
|
27
|
+
const { lobb, ctx } = getStudioContext();
|
|
28
|
+
|
|
29
|
+
// null = "haven't checked yet" — distinguished from false so the fallback
|
|
30
|
+
// doesn't flash during the async resolution.
|
|
31
|
+
let allowed = $state<boolean | null>(null);
|
|
32
|
+
|
|
33
|
+
onMount(async () => {
|
|
34
|
+
try {
|
|
35
|
+
const result = await emitEvent({ lobb, ctx }, "auth.canAccess", {
|
|
36
|
+
collection,
|
|
37
|
+
action,
|
|
38
|
+
});
|
|
39
|
+
allowed = result === true;
|
|
40
|
+
} catch {
|
|
41
|
+
// No handler registered (auth extension not loaded), or the
|
|
42
|
+
// handler threw — fail closed.
|
|
43
|
+
allowed = false;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
{#if allowed === true}
|
|
49
|
+
{@render children()}
|
|
50
|
+
{:else if allowed === false && fallback}
|
|
51
|
+
{@render fallback()}
|
|
52
|
+
{/if}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import Header from "./header.svelte";
|
|
14
14
|
import Table, { type TableProps } from "./table.svelte";
|
|
15
15
|
import { getCollectionColumns, getCollectionParamsFields } from "./utils";
|
|
16
|
+
import CanAccess from "../canAccess.svelte";
|
|
16
17
|
import { Pencil, Trash, Unlink } from "lucide-svelte";
|
|
17
18
|
import ListViewChildren from "./listViewChildren.svelte";
|
|
18
19
|
import FieldCell from "./fieldCell.svelte";
|
|
@@ -24,8 +25,7 @@
|
|
|
24
25
|
import type { ChildrenChanges } from "../detailView/utils";
|
|
25
26
|
import ExtensionsComponents from "../extensionsComponents.svelte";
|
|
26
27
|
import { getExtensionUtils, loadExtensionComponents } from "../../extensions/extensionUtils";
|
|
27
|
-
import {
|
|
28
|
-
import { onMount, untrack } from "svelte";
|
|
28
|
+
import { untrack } from "svelte";
|
|
29
29
|
import Tabs from "./dataTableTabs.svelte";
|
|
30
30
|
import { fade } from "svelte/transition";
|
|
31
31
|
import type { CollectionTab } from "../../store.types";
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
tableProps?: Partial<TableProps>;
|
|
49
49
|
tabs?: CollectionTab[];
|
|
50
50
|
headerLeft?: Snippet<[]>;
|
|
51
|
+
view?: { id: string; [key: string]: any };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
let {
|
|
@@ -66,6 +67,7 @@
|
|
|
66
67
|
tableProps,
|
|
67
68
|
tabs,
|
|
68
69
|
headerLeft,
|
|
70
|
+
view,
|
|
69
71
|
}: Props = $props();
|
|
70
72
|
|
|
71
73
|
const isRecordingMode = onChanges !== undefined;
|
|
@@ -73,19 +75,6 @@
|
|
|
73
75
|
untrack(() => changes) ?? { created: [], updated: [], deleted: [], linked: [], unlinked: [] }
|
|
74
76
|
);
|
|
75
77
|
|
|
76
|
-
// Gate row/header buttons by the current user's permissions:
|
|
77
|
-
// - showUpdate → per-row edit button
|
|
78
|
-
// - showCreate → header's Create + Import buttons (passed to Header)
|
|
79
|
-
let showUpdate = $state(false);
|
|
80
|
-
let showCreate = $state(false);
|
|
81
|
-
onMount(async () => {
|
|
82
|
-
const [update, create] = await Promise.all([
|
|
83
|
-
emitEvent({ lobb, ctx }, "auth.canAccess", { collection: collectionName, action: "update" }),
|
|
84
|
-
emitEvent({ lobb, ctx }, "auth.canAccess", { collection: collectionName, action: "create" }),
|
|
85
|
-
]);
|
|
86
|
-
showUpdate = update === true;
|
|
87
|
-
showCreate = create === true;
|
|
88
|
-
});
|
|
89
78
|
|
|
90
79
|
// Derives the displayed rows by applying localChanges on top of server data.
|
|
91
80
|
const data = $derived.by(() => {
|
|
@@ -122,6 +111,23 @@
|
|
|
122
111
|
|
|
123
112
|
let activeTabFilter = $state<any>(undefined);
|
|
124
113
|
|
|
114
|
+
// Named-view lookup: when a `view` prop is supplied, resolve the
|
|
115
|
+
// matching `dataTable.view.<view.id>` registration. Exact key match,
|
|
116
|
+
// no `when` predicate — the caller already picked the view they want.
|
|
117
|
+
const customViewComponent = $derived.by(() => {
|
|
118
|
+
if (!view?.id) return null;
|
|
119
|
+
const key = `dataTable.view.${view.id}`;
|
|
120
|
+
for (const ext of Object.values(ctx.extensions ?? {})) {
|
|
121
|
+
const components = (ext as any)?.components ?? {};
|
|
122
|
+
const entry = components[key];
|
|
123
|
+
if (!entry) continue;
|
|
124
|
+
return entry && typeof entry === "object" && "component" in entry
|
|
125
|
+
? entry.component
|
|
126
|
+
: entry;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
});
|
|
130
|
+
|
|
125
131
|
// Canonicalize the incoming filter so values like `{ status: "Open" }`
|
|
126
132
|
// become `{ status: { $eq: "Open" } }`. The Filter UI and the server
|
|
127
133
|
// both expect operator objects, so doing this once at the boundary
|
|
@@ -291,7 +297,6 @@
|
|
|
291
297
|
{collectionName}
|
|
292
298
|
bind:selectedRecords
|
|
293
299
|
{showImport}
|
|
294
|
-
{showCreate}
|
|
295
300
|
{parentContext}
|
|
296
301
|
onLink={isRecordingMode ? handleLink : undefined}
|
|
297
302
|
onCreate={isRecordingMode ? handleCreate : undefined}
|
|
@@ -311,6 +316,18 @@
|
|
|
311
316
|
<Skeleton class="h-8 w-[80%]" />
|
|
312
317
|
<Skeleton class="h-8 w-[60%]" />
|
|
313
318
|
</div>
|
|
319
|
+
{:else if customViewComponent}
|
|
320
|
+
{@const CustomView = customViewComponent}
|
|
321
|
+
<CustomView
|
|
322
|
+
{collectionName}
|
|
323
|
+
{data}
|
|
324
|
+
{columns}
|
|
325
|
+
bind:params
|
|
326
|
+
{loading}
|
|
327
|
+
refresh={() => { params = { ...params }; }}
|
|
328
|
+
{view}
|
|
329
|
+
utils={getExtensionUtils(lobb, ctx)}
|
|
330
|
+
/>
|
|
314
331
|
{:else}
|
|
315
332
|
<Table
|
|
316
333
|
{data}
|
|
@@ -325,17 +342,19 @@
|
|
|
325
342
|
{...tableProps}
|
|
326
343
|
rowActions={hasRowActions ? rowActionsSnippet : undefined}>
|
|
327
344
|
{#snippet tools(entry)}
|
|
328
|
-
{#if
|
|
329
|
-
<
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
345
|
+
{#if showEdit}
|
|
346
|
+
<CanAccess collection={collectionName} action="update">
|
|
347
|
+
<UpdateDetailViewButton
|
|
348
|
+
{collectionName}
|
|
349
|
+
recordId={entry.id}
|
|
350
|
+
variant="ghost"
|
|
351
|
+
class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent"
|
|
352
|
+
Icon={Pencil}
|
|
353
|
+
changes={isRecordingMode ? localChanges.updated.find((u) => String(u.id) === String(entry.id))?.changes : undefined}
|
|
354
|
+
onChanges={isRecordingMode ? (c) => handleUpdate(String(entry.id), c) : undefined}
|
|
355
|
+
onSuccessfullSave={!isRecordingMode ? async () => { params = { ...params }; } : undefined}
|
|
356
|
+
></UpdateDetailViewButton>
|
|
357
|
+
</CanAccess>
|
|
339
358
|
{/if}
|
|
340
359
|
{#if parentContext}
|
|
341
360
|
<Button
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { getStudioContext } from "../../context";
|
|
3
3
|
import type { Changes } from "../detailView/utils";
|
|
4
4
|
import type { ParentContext } from "./dataTable.svelte";
|
|
5
|
+
import CanAccess from "../canAccess.svelte";
|
|
5
6
|
import { Download, ListRestart, Plus, Trash, Link } from "lucide-svelte";
|
|
6
7
|
import * as Tooltip from "../ui/tooltip";
|
|
7
8
|
import LlmButton from "../LlmButton.svelte";
|
|
@@ -26,7 +27,6 @@
|
|
|
26
27
|
onLink?: (record: any) => void;
|
|
27
28
|
onCreate?: (changes: Changes) => void;
|
|
28
29
|
showImport?: boolean;
|
|
29
|
-
showCreate?: boolean;
|
|
30
30
|
left?: Snippet<[]>;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
onLink,
|
|
39
39
|
onCreate,
|
|
40
40
|
showImport = true,
|
|
41
|
-
showCreate = false,
|
|
42
41
|
left
|
|
43
42
|
}: Props = $props();
|
|
44
43
|
|
|
@@ -168,21 +167,23 @@
|
|
|
168
167
|
>
|
|
169
168
|
{headerIsSmall ? "" : "Refresh"}
|
|
170
169
|
</Button>
|
|
171
|
-
{#if showImport
|
|
172
|
-
<
|
|
173
|
-
<Tooltip.
|
|
174
|
-
<Tooltip.
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
170
|
+
{#if showImport}
|
|
171
|
+
<CanAccess collection={collectionName} action="create">
|
|
172
|
+
<Tooltip.Provider delayDuration={0}>
|
|
173
|
+
<Tooltip.Root>
|
|
174
|
+
<Tooltip.Trigger>
|
|
175
|
+
<ImportButton
|
|
176
|
+
{collectionName}
|
|
177
|
+
variant="outline"
|
|
178
|
+
class="h-7 px-2 text-xs font-normal"
|
|
179
|
+
Icon={Download}
|
|
180
|
+
onSuccessfullSave={() => (params = { ...params })}
|
|
181
|
+
/>
|
|
182
|
+
</Tooltip.Trigger>
|
|
183
|
+
<Tooltip.Content>Import</Tooltip.Content>
|
|
184
|
+
</Tooltip.Root>
|
|
185
|
+
</Tooltip.Provider>
|
|
186
|
+
</CanAccess>
|
|
186
187
|
{/if}
|
|
187
188
|
<ExtensionsComponents
|
|
188
189
|
name="listView.header.actions"
|
|
@@ -201,7 +202,7 @@
|
|
|
201
202
|
{headerIsSmall ? "" : "Link"}
|
|
202
203
|
</SelectRecord>
|
|
203
204
|
{/if}
|
|
204
|
-
{
|
|
205
|
+
<CanAccess collection={collectionName} action="create">
|
|
205
206
|
<CreateDetailViewButton
|
|
206
207
|
{collectionName}
|
|
207
208
|
variant="default"
|
|
@@ -212,6 +213,6 @@
|
|
|
212
213
|
>
|
|
213
214
|
{headerIsSmall ? "" : "Create"}
|
|
214
215
|
</CreateDetailViewButton>
|
|
215
|
-
|
|
216
|
+
</CanAccess>
|
|
216
217
|
</div>
|
|
217
218
|
</div>
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
showFooter?: boolean;
|
|
20
20
|
tableProps?: Partial<TableProps>;
|
|
21
21
|
tabs?: CollectionTab[];
|
|
22
|
+
view?: { id: string; [key: string]: any };
|
|
22
23
|
onClose?: () => void;
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -32,6 +33,7 @@
|
|
|
32
33
|
showFooter = true,
|
|
33
34
|
tableProps,
|
|
34
35
|
tabs,
|
|
36
|
+
view,
|
|
35
37
|
onClose,
|
|
36
38
|
}: Props = $props();
|
|
37
39
|
|
|
@@ -78,6 +80,7 @@
|
|
|
78
80
|
{showFooter}
|
|
79
81
|
{tableProps}
|
|
80
82
|
{tabs}
|
|
83
|
+
{view}
|
|
81
84
|
/>
|
|
82
85
|
</div>
|
|
83
86
|
</div>
|
package/src/lib/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ export { default as SidebarTrigger } from "./components/sidebar/sidebarTrigger.s
|
|
|
13
13
|
export { default as CreateDetailViewButton } from "./components/detailView/create/createDetailViewButton.svelte";
|
|
14
14
|
export { default as UpdateDetailViewButton } from "./components/detailView/update/updateDetailViewButton.svelte";
|
|
15
15
|
export { default as DetailView } from "./components/detailView/detailView.svelte";
|
|
16
|
+
export { default as CanAccess } from "./components/canAccess.svelte";
|
|
17
|
+
export { default as ExtensionsComponents } from "./components/extensionsComponents.svelte";
|
|
16
18
|
export * as Tooltip from "./components/ui/tooltip";
|
|
17
19
|
export * as Breadcrumb from "./components/ui/breadcrumb";
|
|
18
20
|
export { ContextMenu } from "bits-ui";
|
package/src/lib/store.types.ts
CHANGED
|
@@ -27,8 +27,14 @@ interface Collection {
|
|
|
27
27
|
}
|
|
28
28
|
type Collections = Record<string, Collection>;
|
|
29
29
|
|
|
30
|
+
export interface StudioTheme {
|
|
31
|
+
light?: Record<string, string>;
|
|
32
|
+
dark?: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
interface Meta {
|
|
31
36
|
version: string;
|
|
37
|
+
studio?: { theme?: StudioTheme };
|
|
32
38
|
relations: Array<any>;
|
|
33
39
|
collections: Collections;
|
|
34
40
|
extensions: Record<string, any>;
|