@lobb-js/studio 0.12.0 → 0.14.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/dataTable/dataTable.svelte +90 -75
- package/dist/components/dataTable/dataTableTabs.svelte +65 -0
- package/dist/components/dataTable/dataTableTabs.svelte.d.ts +8 -0
- package/dist/components/dataTable/header.svelte +8 -0
- package/dist/components/routes/collections/collection.svelte +16 -13
- package/dist/components/routes/collections/collection.svelte.d.ts +2 -8
- package/dist/components/singletone.svelte +15 -20
- package/dist/extensions/extension.types.d.ts +1 -1
- package/dist/store.types.d.ts +10 -0
- package/package.json +2 -2
- package/src/lib/components/dataTable/dataTable.svelte +90 -75
- package/src/lib/components/dataTable/dataTableTabs.svelte +65 -0
- package/src/lib/components/dataTable/header.svelte +8 -0
- package/src/lib/components/routes/collections/collection.svelte +16 -13
- package/src/lib/components/singletone.svelte +15 -20
- package/src/lib/extensions/extension.types.ts +2 -1
- package/src/lib/store.types.ts +11 -0
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
import type { Snippet } from "svelte";
|
|
18
18
|
import ExtensionsComponents from "../extensionsComponents.svelte";
|
|
19
19
|
import { getExtensionUtils, loadExtensionComponents } from "../../extensions/extensionUtils";
|
|
20
|
+
import Tabs from "./dataTableTabs.svelte";
|
|
21
|
+
import { fade } from "svelte/transition";
|
|
20
22
|
|
|
21
23
|
const { lobb, ctx } = getStudioContext();
|
|
22
24
|
|
|
@@ -46,15 +48,22 @@
|
|
|
46
48
|
loadExtensionComponents(ctx, "listView.entry.actions", undefined, { collectionName }).length > 0
|
|
47
49
|
);
|
|
48
50
|
|
|
51
|
+
let activeTabFilter = $state<any>(undefined);
|
|
52
|
+
|
|
49
53
|
const fields = getCollectionParamsFields(ctx, collectionName);
|
|
50
54
|
let params = $state({
|
|
51
55
|
fields: fields,
|
|
52
|
-
filter: filter
|
|
56
|
+
filter: { ...filter, ...activeTabFilter },
|
|
53
57
|
sort: {},
|
|
54
58
|
limit: "100",
|
|
55
59
|
page: 1,
|
|
56
60
|
});
|
|
57
61
|
|
|
62
|
+
$effect(() => {
|
|
63
|
+
const tabFilter = activeTabFilter;
|
|
64
|
+
params.filter = { ...filter, ...tabFilter };
|
|
65
|
+
});
|
|
66
|
+
|
|
58
67
|
let selectedRecords = $state([]);
|
|
59
68
|
let totalCount = $state(0);
|
|
60
69
|
let data: TableProps["data"] = $state([]);
|
|
@@ -76,6 +85,7 @@
|
|
|
76
85
|
});
|
|
77
86
|
|
|
78
87
|
async function loadData(params: any) {
|
|
88
|
+
loading = true;
|
|
79
89
|
// parsing sort before sending the request
|
|
80
90
|
const paramsCopy = $state.snapshot(params);
|
|
81
91
|
const sort: TableProps["sort"] = paramsCopy.sort;
|
|
@@ -153,83 +163,88 @@
|
|
|
153
163
|
{/snippet}
|
|
154
164
|
</Header>
|
|
155
165
|
{/if}
|
|
166
|
+
<Tabs {collectionName} {filter} bind:activeTabFilter />
|
|
156
167
|
<div class="relative flex-1 overflow-auto w-full">
|
|
157
|
-
{#
|
|
158
|
-
<div class="
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
{
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
{
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
Icon={Pencil}
|
|
184
|
-
onSuccessfullSave={async () => {
|
|
185
|
-
params = { ...params };
|
|
186
|
-
}}
|
|
187
|
-
></UpdateDetailViewButton>
|
|
188
|
-
{#if showDelete}
|
|
189
|
-
<Button
|
|
190
|
-
class="h-6 w-6 text-muted-foreground hover:bg-transparent"
|
|
191
|
-
variant="ghost"
|
|
192
|
-
size="icon"
|
|
193
|
-
onclick={() => handleDelete(entry.id)}
|
|
194
|
-
Icon={Trash}
|
|
195
|
-
></Button>
|
|
196
|
-
{/if}
|
|
197
|
-
{#await getWorkflowTools($state.snapshot(entry))}
|
|
198
|
-
<div></div>
|
|
199
|
-
{:then workflowTools}
|
|
200
|
-
{#each workflowTools as workflowTool}
|
|
201
|
-
<Button
|
|
168
|
+
{#key activeTabFilter}
|
|
169
|
+
<div class="h-full w-full" in:fade={{ duration: 120 }}>
|
|
170
|
+
{#if loading}
|
|
171
|
+
<div class="flex flex-col gap-2 p-2 w-full">
|
|
172
|
+
<Skeleton class="h-8 w-full" />
|
|
173
|
+
<Skeleton class="h-8 w-[80%]" />
|
|
174
|
+
<Skeleton class="h-8 w-[60%]" />
|
|
175
|
+
</div>
|
|
176
|
+
{:else}
|
|
177
|
+
<Table
|
|
178
|
+
{data}
|
|
179
|
+
{columns}
|
|
180
|
+
showCollapsible={doesCollectionHasChildren}
|
|
181
|
+
selectByColumn="id"
|
|
182
|
+
showLastRowBorder={true}
|
|
183
|
+
showLastColumnBorder={true}
|
|
184
|
+
bind:sort={params.sort}
|
|
185
|
+
bind:selectedRecords
|
|
186
|
+
{unifiedBgColor}
|
|
187
|
+
bind:tableWidth={dataTableWidth}
|
|
188
|
+
{...tableProps}
|
|
189
|
+
rowActions={hasRowActions ? rowActionsSnippet : undefined}>
|
|
190
|
+
{#snippet tools(entry)}
|
|
191
|
+
<UpdateDetailViewButton
|
|
192
|
+
{collectionName}
|
|
193
|
+
recordId={entry.id}
|
|
202
194
|
variant="ghost"
|
|
203
195
|
class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent"
|
|
204
|
-
Icon={
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
></
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
196
|
+
Icon={Pencil}
|
|
197
|
+
onSuccessfullSave={async () => {
|
|
198
|
+
params = { ...params };
|
|
199
|
+
}}
|
|
200
|
+
></UpdateDetailViewButton>
|
|
201
|
+
{#if showDelete}
|
|
202
|
+
<Button
|
|
203
|
+
class="h-6 w-6 text-muted-foreground hover:bg-transparent"
|
|
204
|
+
variant="ghost"
|
|
205
|
+
size="icon"
|
|
206
|
+
onclick={() => handleDelete(entry.id)}
|
|
207
|
+
Icon={Trash}
|
|
208
|
+
></Button>
|
|
209
|
+
{/if}
|
|
210
|
+
{#await getWorkflowTools($state.snapshot(entry))}
|
|
211
|
+
<div></div>
|
|
212
|
+
{:then workflowTools}
|
|
213
|
+
{#each workflowTools as workflowTool}
|
|
214
|
+
<Button
|
|
215
|
+
variant="ghost"
|
|
216
|
+
class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent"
|
|
217
|
+
Icon={icons[
|
|
218
|
+
workflowTool.icon as keyof typeof icons
|
|
219
|
+
]}
|
|
220
|
+
onclick={workflowTool.onclick}
|
|
221
|
+
></Button>
|
|
222
|
+
{/each}
|
|
223
|
+
{/await}
|
|
224
|
+
{/snippet}
|
|
225
|
+
{#snippet overrideCell(value, column, entry)}
|
|
226
|
+
<FieldCell
|
|
227
|
+
{collectionName}
|
|
228
|
+
fieldName={column.id}
|
|
229
|
+
{value}
|
|
230
|
+
{entry}
|
|
231
|
+
tableParams={params}
|
|
232
|
+
/>
|
|
233
|
+
{/snippet}
|
|
234
|
+
{#snippet collapsible(entry)}
|
|
235
|
+
<ChildRecords
|
|
236
|
+
{collectionName}
|
|
237
|
+
recordId={entry.id}
|
|
238
|
+
width={dataTableWidth > dataTableContainerWidth
|
|
239
|
+
? dataTableContainerWidth
|
|
240
|
+
: dataTableWidth}
|
|
241
|
+
unifiedBgColor={unifiedBgColor ?? "bg-background"}
|
|
242
|
+
/>
|
|
243
|
+
{/snippet}
|
|
244
|
+
</Table>
|
|
245
|
+
{/if}
|
|
246
|
+
</div>
|
|
247
|
+
{/key}
|
|
233
248
|
</div>
|
|
234
249
|
{#if showFooter}
|
|
235
250
|
<Footer
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getStudioContext } from "../../context";
|
|
3
|
+
|
|
4
|
+
const { lobb, ctx } = getStudioContext();
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
collectionName: string;
|
|
8
|
+
filter?: any;
|
|
9
|
+
activeTabFilter?: any;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { collectionName, filter, activeTabFilter = $bindable() }: Props = $props();
|
|
13
|
+
|
|
14
|
+
const tabs = ctx.meta.collections[collectionName].ui?.tabs;
|
|
15
|
+
let activeTab = $state<string | null>(null);
|
|
16
|
+
let tabCounts = $state<Record<string, number>>({});
|
|
17
|
+
|
|
18
|
+
async function loadTabCounts() {
|
|
19
|
+
if (!tabs) return;
|
|
20
|
+
const results = await Promise.all(
|
|
21
|
+
tabs.map(async (tab) => {
|
|
22
|
+
const res = await lobb.findAll(collectionName, { filter: { ...filter, ...tab.filter }, limit: 1 });
|
|
23
|
+
const json = await res.json();
|
|
24
|
+
return { key: tab.id ?? tab.label, count: json.meta.totalCount };
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
tabCounts = Object.fromEntries(results.map(({ key, count }) => [key, count]));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
$effect(() => {
|
|
31
|
+
loadTabCounts();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
$effect(() => {
|
|
35
|
+
if (!tabs) return;
|
|
36
|
+
const key = (t: typeof tabs[0]) => t.id ?? t.label;
|
|
37
|
+
const tab = activeTab
|
|
38
|
+
? tabs.find((t) => key(t) === activeTab)
|
|
39
|
+
: tabs.find((t) => t.default) ?? tabs[0];
|
|
40
|
+
activeTabFilter = tab?.filter;
|
|
41
|
+
});
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
{#if tabs}
|
|
45
|
+
<div class="flex items-center gap-1 px-3 py-1.5 border-b shrink-0 bg-background">
|
|
46
|
+
{#each tabs as tab}
|
|
47
|
+
{@const key = tab.id ?? tab.label}
|
|
48
|
+
{@const isActive = activeTab ? activeTab === key : (tab.default ?? tabs[0] === tab)}
|
|
49
|
+
<button
|
|
50
|
+
onclick={() => activeTab = key}
|
|
51
|
+
class="inline-flex items-center px-2.5 py-1 text-[11px] rounded-md transition-colors
|
|
52
|
+
{isActive
|
|
53
|
+
? 'bg-muted text-foreground font-medium'
|
|
54
|
+
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
|
|
55
|
+
>
|
|
56
|
+
{tab.label}
|
|
57
|
+
{#if tabCounts[key] !== undefined}
|
|
58
|
+
<span class="ml-1.5 px-1.5 py-0.5 rounded-full text-[10px] {isActive ? 'bg-muted-foreground/20 text-foreground' : 'bg-muted text-muted-foreground'}">
|
|
59
|
+
{tabCounts[key]}
|
|
60
|
+
</span>
|
|
61
|
+
{/if}
|
|
62
|
+
</button>
|
|
63
|
+
{/each}
|
|
64
|
+
</div>
|
|
65
|
+
{/if}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
collectionName: string;
|
|
3
|
+
filter?: any;
|
|
4
|
+
activeTabFilter?: any;
|
|
5
|
+
}
|
|
6
|
+
declare const DataTableTabs: import("svelte").Component<Props, {}, "activeTabFilter">;
|
|
7
|
+
type DataTableTabs = ReturnType<typeof DataTableTabs>;
|
|
8
|
+
export default DataTableTabs;
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import CreateManyButton from "../createManyButton.svelte";
|
|
11
11
|
import { showDialog } from "../confirmationDialog/store.svelte";
|
|
12
12
|
import CreateDetailViewButton from "../detailView/create/createDetailViewButton.svelte";
|
|
13
|
+
import ExtensionsComponents from "../extensionsComponents.svelte";
|
|
14
|
+
import { getExtensionUtils } from "../../extensions/extensionUtils";
|
|
13
15
|
import type { Snippet } from "svelte";
|
|
14
16
|
|
|
15
17
|
interface Props {
|
|
@@ -142,6 +144,12 @@
|
|
|
142
144
|
Icon={SquareStack}
|
|
143
145
|
onSuccessfullSave={() => (params = { ...params })}
|
|
144
146
|
></CreateManyButton>
|
|
147
|
+
<ExtensionsComponents
|
|
148
|
+
name="listView.header.actions"
|
|
149
|
+
utils={getExtensionUtils(lobb, ctx)}
|
|
150
|
+
{collectionName}
|
|
151
|
+
refresh={() => { params = { ...params }; }}
|
|
152
|
+
/>
|
|
145
153
|
<CreateDetailViewButton
|
|
146
154
|
{collectionName}
|
|
147
155
|
variant="default"
|
|
@@ -1,30 +1,33 @@
|
|
|
1
|
-
<script>
|
|
2
|
-
import { CircleSlash2 } from "lucide-svelte";
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { CircleSlash2, Zap } from "lucide-svelte";
|
|
3
3
|
import DataTable from "../../../components/dataTable/dataTable.svelte";
|
|
4
4
|
import SidebarTrigger from "../../../components/sidebar/sidebarTrigger.svelte";
|
|
5
5
|
import { getStudioContext } from "../../../context";
|
|
6
6
|
import Singletone from "../../../components/singletone.svelte";
|
|
7
|
-
import { getExtensionUtils } from "../../../extensions/extensionUtils";
|
|
8
|
-
import ExtensionsComponents from "../../../components/extensionsComponents.svelte";
|
|
9
7
|
|
|
10
|
-
const { ctx
|
|
8
|
+
const { ctx } = getStudioContext();
|
|
11
9
|
|
|
12
10
|
let { collectionName } = $props();
|
|
13
11
|
let isSingletonCollection = $derived(ctx.meta.collections[collectionName].singleton);
|
|
12
|
+
let isVirtualCollection = $derived(ctx.meta.collections[collectionName].virtual);
|
|
14
13
|
|
|
15
14
|
let containerWidth = $state();
|
|
16
15
|
</script>
|
|
17
16
|
|
|
18
17
|
<div bind:clientWidth={containerWidth} class="h-full">
|
|
19
18
|
{#if collectionName}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
{#if isVirtualCollection}
|
|
20
|
+
<div class="relative flex h-full w-full flex-col items-center justify-center gap-4 text-muted-foreground">
|
|
21
|
+
<Zap class="opacity-50" size="50" />
|
|
22
|
+
<div class="flex flex-col items-center justify-center">
|
|
23
|
+
<div>Virtual collection</div>
|
|
24
|
+
<div class="text-xs">This collection has no database table. It exists only as an API endpoint for workflows to intercept.</div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="absolute top-0 left-0 p-2.5">
|
|
27
|
+
<SidebarTrigger />
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
{:else if isSingletonCollection}
|
|
28
31
|
<Singletone collectionName={collectionName} />
|
|
29
32
|
{:else}
|
|
30
33
|
<DataTable
|
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
export default Collection;
|
|
2
|
-
type Collection = {
|
|
3
|
-
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
-
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
-
};
|
|
6
1
|
declare const Collection: import("svelte").Component<{
|
|
7
2
|
collectionName: any;
|
|
8
3
|
}, {}, "">;
|
|
9
|
-
type
|
|
10
|
-
|
|
11
|
-
};
|
|
4
|
+
type Collection = ReturnType<typeof Collection>;
|
|
5
|
+
export default Collection;
|
|
@@ -6,30 +6,27 @@
|
|
|
6
6
|
import Button from "./ui/button/button.svelte";
|
|
7
7
|
import { onMount } from "svelte";
|
|
8
8
|
import { getStudioContext } from "../context";
|
|
9
|
+
import { toast } from "svelte-sonner";
|
|
10
|
+
import Skeleton from "./ui/skeleton/skeleton.svelte";
|
|
9
11
|
|
|
10
12
|
const { lobb, ctx } = getStudioContext();
|
|
11
|
-
import { toast } from "svelte-sonner";
|
|
12
|
-
import Skeleton from "./ui/skeleton/skeleton.svelte";
|
|
13
13
|
|
|
14
14
|
interface Props {
|
|
15
15
|
collectionName: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
let {
|
|
19
|
-
collectionName,
|
|
20
|
-
}: Props = $props();
|
|
18
|
+
let { collectionName }: Props = $props();
|
|
21
19
|
|
|
22
20
|
let entry = $state({});
|
|
23
21
|
let loading = $state(true);
|
|
24
|
-
let singletonExists = $state(true);
|
|
25
22
|
const formFields = getCollectionFields(ctx, collectionName);
|
|
26
23
|
|
|
27
24
|
onMount(async () => {
|
|
28
|
-
const result = await lobb.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
const result = await fetch(`${lobb.lobbUrl}/api/collections/${collectionName}/singleton`, {
|
|
26
|
+
headers: lobb.getHeaders() as HeadersInit,
|
|
27
|
+
});
|
|
28
|
+
if (result.status !== 404) {
|
|
29
|
+
const json = await result.json();
|
|
33
30
|
entry = json.data;
|
|
34
31
|
}
|
|
35
32
|
loading = false;
|
|
@@ -43,29 +40,27 @@
|
|
|
43
40
|
}
|
|
44
41
|
</script>
|
|
45
42
|
|
|
46
|
-
<div>
|
|
47
|
-
<div class="flex justify-between items-center gap-2 p-2 border-b
|
|
43
|
+
<div class="flex flex-col h-full bg-background">
|
|
44
|
+
<div class="flex justify-between items-center gap-2 p-2 border-b h-10 shrink-0">
|
|
48
45
|
<div class="flex items-center gap-1">
|
|
49
46
|
<SidebarTrigger />
|
|
50
47
|
</div>
|
|
51
48
|
<div>
|
|
52
|
-
<Button
|
|
53
|
-
class="h-7 px-2 font-normal text-xs"
|
|
54
|
-
Icon={Save}
|
|
55
|
-
onclick={handleSave}
|
|
56
|
-
>
|
|
49
|
+
<Button class="h-7 px-2 font-normal text-xs" Icon={Save} onclick={handleSave}>
|
|
57
50
|
Save
|
|
58
51
|
</Button>
|
|
59
52
|
</div>
|
|
60
53
|
</div>
|
|
61
54
|
|
|
62
55
|
{#if loading}
|
|
63
|
-
<div class="flex flex-col gap-2 p-
|
|
56
|
+
<div class="flex flex-col gap-2 p-4 w-full">
|
|
64
57
|
<Skeleton class="h-8 w-full" />
|
|
65
58
|
<Skeleton class="h-8 w-[80%]" />
|
|
66
59
|
<Skeleton class="h-8 w-[60%]" />
|
|
67
60
|
</div>
|
|
68
61
|
{:else}
|
|
69
|
-
<
|
|
62
|
+
<div class="flex-1 overflow-y-auto max-w-xl">
|
|
63
|
+
<DetailViewForm bind:value={entry} fields={formFields} />
|
|
64
|
+
</div>
|
|
70
65
|
{/if}
|
|
71
66
|
</div>
|
|
@@ -73,7 +73,7 @@ export interface ExtensionProps {
|
|
|
73
73
|
utils: ExtensionUtils;
|
|
74
74
|
[key: string]: any;
|
|
75
75
|
}
|
|
76
|
-
export type ExtensionComponentKey = `pages.${string}` | "studio.listView" | `dvFields.topRight.${string}.${string}` | `detailView.update.subRecords.${string}` | `detailView.create.subRecords.${string}` | `detailView.fields.topRight.${string}.${string}` | `detailView.fields.foreignKey.${string}` | `listView.entry.children.${string}` | `listView.entry.actions`;
|
|
76
|
+
export type ExtensionComponentKey = `pages.${string}` | "studio.listView" | `dvFields.topRight.${string}.${string}` | `detailView.update.subRecords.${string}` | `detailView.create.subRecords.${string}` | `detailView.fields.topRight.${string}.${string}` | `detailView.fields.foreignKey.${string}` | `listView.entry.children.${string}` | `listView.entry.actions` | `listView.header.actions`;
|
|
77
77
|
export type ExtensionComponent = any | {
|
|
78
78
|
component: any;
|
|
79
79
|
when: (props: Record<string, any>) => boolean;
|
package/dist/store.types.d.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import type { Extension } from "../extensions/extension.types";
|
|
2
|
+
interface CollectionTab {
|
|
3
|
+
id?: string;
|
|
4
|
+
label: string;
|
|
5
|
+
filter?: Record<string, any>;
|
|
6
|
+
default?: boolean;
|
|
7
|
+
}
|
|
2
8
|
interface Collection {
|
|
3
9
|
category: string;
|
|
4
10
|
owner: string;
|
|
5
11
|
fields: Record<string, any>;
|
|
6
12
|
singleton: boolean;
|
|
13
|
+
virtual?: boolean;
|
|
14
|
+
ui?: {
|
|
15
|
+
tabs?: CollectionTab[];
|
|
16
|
+
};
|
|
7
17
|
}
|
|
8
18
|
type Collections = Record<string, Collection>;
|
|
9
19
|
interface Meta {
|
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.14.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.19.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",
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
import type { Snippet } from "svelte";
|
|
18
18
|
import ExtensionsComponents from "../extensionsComponents.svelte";
|
|
19
19
|
import { getExtensionUtils, loadExtensionComponents } from "../../extensions/extensionUtils";
|
|
20
|
+
import Tabs from "./dataTableTabs.svelte";
|
|
21
|
+
import { fade } from "svelte/transition";
|
|
20
22
|
|
|
21
23
|
const { lobb, ctx } = getStudioContext();
|
|
22
24
|
|
|
@@ -46,15 +48,22 @@
|
|
|
46
48
|
loadExtensionComponents(ctx, "listView.entry.actions", undefined, { collectionName }).length > 0
|
|
47
49
|
);
|
|
48
50
|
|
|
51
|
+
let activeTabFilter = $state<any>(undefined);
|
|
52
|
+
|
|
49
53
|
const fields = getCollectionParamsFields(ctx, collectionName);
|
|
50
54
|
let params = $state({
|
|
51
55
|
fields: fields,
|
|
52
|
-
filter: filter
|
|
56
|
+
filter: { ...filter, ...activeTabFilter },
|
|
53
57
|
sort: {},
|
|
54
58
|
limit: "100",
|
|
55
59
|
page: 1,
|
|
56
60
|
});
|
|
57
61
|
|
|
62
|
+
$effect(() => {
|
|
63
|
+
const tabFilter = activeTabFilter;
|
|
64
|
+
params.filter = { ...filter, ...tabFilter };
|
|
65
|
+
});
|
|
66
|
+
|
|
58
67
|
let selectedRecords = $state([]);
|
|
59
68
|
let totalCount = $state(0);
|
|
60
69
|
let data: TableProps["data"] = $state([]);
|
|
@@ -76,6 +85,7 @@
|
|
|
76
85
|
});
|
|
77
86
|
|
|
78
87
|
async function loadData(params: any) {
|
|
88
|
+
loading = true;
|
|
79
89
|
// parsing sort before sending the request
|
|
80
90
|
const paramsCopy = $state.snapshot(params);
|
|
81
91
|
const sort: TableProps["sort"] = paramsCopy.sort;
|
|
@@ -153,83 +163,88 @@
|
|
|
153
163
|
{/snippet}
|
|
154
164
|
</Header>
|
|
155
165
|
{/if}
|
|
166
|
+
<Tabs {collectionName} {filter} bind:activeTabFilter />
|
|
156
167
|
<div class="relative flex-1 overflow-auto w-full">
|
|
157
|
-
{#
|
|
158
|
-
<div class="
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
{
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
{
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
Icon={Pencil}
|
|
184
|
-
onSuccessfullSave={async () => {
|
|
185
|
-
params = { ...params };
|
|
186
|
-
}}
|
|
187
|
-
></UpdateDetailViewButton>
|
|
188
|
-
{#if showDelete}
|
|
189
|
-
<Button
|
|
190
|
-
class="h-6 w-6 text-muted-foreground hover:bg-transparent"
|
|
191
|
-
variant="ghost"
|
|
192
|
-
size="icon"
|
|
193
|
-
onclick={() => handleDelete(entry.id)}
|
|
194
|
-
Icon={Trash}
|
|
195
|
-
></Button>
|
|
196
|
-
{/if}
|
|
197
|
-
{#await getWorkflowTools($state.snapshot(entry))}
|
|
198
|
-
<div></div>
|
|
199
|
-
{:then workflowTools}
|
|
200
|
-
{#each workflowTools as workflowTool}
|
|
201
|
-
<Button
|
|
168
|
+
{#key activeTabFilter}
|
|
169
|
+
<div class="h-full w-full" in:fade={{ duration: 120 }}>
|
|
170
|
+
{#if loading}
|
|
171
|
+
<div class="flex flex-col gap-2 p-2 w-full">
|
|
172
|
+
<Skeleton class="h-8 w-full" />
|
|
173
|
+
<Skeleton class="h-8 w-[80%]" />
|
|
174
|
+
<Skeleton class="h-8 w-[60%]" />
|
|
175
|
+
</div>
|
|
176
|
+
{:else}
|
|
177
|
+
<Table
|
|
178
|
+
{data}
|
|
179
|
+
{columns}
|
|
180
|
+
showCollapsible={doesCollectionHasChildren}
|
|
181
|
+
selectByColumn="id"
|
|
182
|
+
showLastRowBorder={true}
|
|
183
|
+
showLastColumnBorder={true}
|
|
184
|
+
bind:sort={params.sort}
|
|
185
|
+
bind:selectedRecords
|
|
186
|
+
{unifiedBgColor}
|
|
187
|
+
bind:tableWidth={dataTableWidth}
|
|
188
|
+
{...tableProps}
|
|
189
|
+
rowActions={hasRowActions ? rowActionsSnippet : undefined}>
|
|
190
|
+
{#snippet tools(entry)}
|
|
191
|
+
<UpdateDetailViewButton
|
|
192
|
+
{collectionName}
|
|
193
|
+
recordId={entry.id}
|
|
202
194
|
variant="ghost"
|
|
203
195
|
class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent"
|
|
204
|
-
Icon={
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
></
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
196
|
+
Icon={Pencil}
|
|
197
|
+
onSuccessfullSave={async () => {
|
|
198
|
+
params = { ...params };
|
|
199
|
+
}}
|
|
200
|
+
></UpdateDetailViewButton>
|
|
201
|
+
{#if showDelete}
|
|
202
|
+
<Button
|
|
203
|
+
class="h-6 w-6 text-muted-foreground hover:bg-transparent"
|
|
204
|
+
variant="ghost"
|
|
205
|
+
size="icon"
|
|
206
|
+
onclick={() => handleDelete(entry.id)}
|
|
207
|
+
Icon={Trash}
|
|
208
|
+
></Button>
|
|
209
|
+
{/if}
|
|
210
|
+
{#await getWorkflowTools($state.snapshot(entry))}
|
|
211
|
+
<div></div>
|
|
212
|
+
{:then workflowTools}
|
|
213
|
+
{#each workflowTools as workflowTool}
|
|
214
|
+
<Button
|
|
215
|
+
variant="ghost"
|
|
216
|
+
class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent"
|
|
217
|
+
Icon={icons[
|
|
218
|
+
workflowTool.icon as keyof typeof icons
|
|
219
|
+
]}
|
|
220
|
+
onclick={workflowTool.onclick}
|
|
221
|
+
></Button>
|
|
222
|
+
{/each}
|
|
223
|
+
{/await}
|
|
224
|
+
{/snippet}
|
|
225
|
+
{#snippet overrideCell(value, column, entry)}
|
|
226
|
+
<FieldCell
|
|
227
|
+
{collectionName}
|
|
228
|
+
fieldName={column.id}
|
|
229
|
+
{value}
|
|
230
|
+
{entry}
|
|
231
|
+
tableParams={params}
|
|
232
|
+
/>
|
|
233
|
+
{/snippet}
|
|
234
|
+
{#snippet collapsible(entry)}
|
|
235
|
+
<ChildRecords
|
|
236
|
+
{collectionName}
|
|
237
|
+
recordId={entry.id}
|
|
238
|
+
width={dataTableWidth > dataTableContainerWidth
|
|
239
|
+
? dataTableContainerWidth
|
|
240
|
+
: dataTableWidth}
|
|
241
|
+
unifiedBgColor={unifiedBgColor ?? "bg-background"}
|
|
242
|
+
/>
|
|
243
|
+
{/snippet}
|
|
244
|
+
</Table>
|
|
245
|
+
{/if}
|
|
246
|
+
</div>
|
|
247
|
+
{/key}
|
|
233
248
|
</div>
|
|
234
249
|
{#if showFooter}
|
|
235
250
|
<Footer
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getStudioContext } from "../../context";
|
|
3
|
+
|
|
4
|
+
const { lobb, ctx } = getStudioContext();
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
collectionName: string;
|
|
8
|
+
filter?: any;
|
|
9
|
+
activeTabFilter?: any;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { collectionName, filter, activeTabFilter = $bindable() }: Props = $props();
|
|
13
|
+
|
|
14
|
+
const tabs = ctx.meta.collections[collectionName].ui?.tabs;
|
|
15
|
+
let activeTab = $state<string | null>(null);
|
|
16
|
+
let tabCounts = $state<Record<string, number>>({});
|
|
17
|
+
|
|
18
|
+
async function loadTabCounts() {
|
|
19
|
+
if (!tabs) return;
|
|
20
|
+
const results = await Promise.all(
|
|
21
|
+
tabs.map(async (tab) => {
|
|
22
|
+
const res = await lobb.findAll(collectionName, { filter: { ...filter, ...tab.filter }, limit: 1 });
|
|
23
|
+
const json = await res.json();
|
|
24
|
+
return { key: tab.id ?? tab.label, count: json.meta.totalCount };
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
tabCounts = Object.fromEntries(results.map(({ key, count }) => [key, count]));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
$effect(() => {
|
|
31
|
+
loadTabCounts();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
$effect(() => {
|
|
35
|
+
if (!tabs) return;
|
|
36
|
+
const key = (t: typeof tabs[0]) => t.id ?? t.label;
|
|
37
|
+
const tab = activeTab
|
|
38
|
+
? tabs.find((t) => key(t) === activeTab)
|
|
39
|
+
: tabs.find((t) => t.default) ?? tabs[0];
|
|
40
|
+
activeTabFilter = tab?.filter;
|
|
41
|
+
});
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
{#if tabs}
|
|
45
|
+
<div class="flex items-center gap-1 px-3 py-1.5 border-b shrink-0 bg-background">
|
|
46
|
+
{#each tabs as tab}
|
|
47
|
+
{@const key = tab.id ?? tab.label}
|
|
48
|
+
{@const isActive = activeTab ? activeTab === key : (tab.default ?? tabs[0] === tab)}
|
|
49
|
+
<button
|
|
50
|
+
onclick={() => activeTab = key}
|
|
51
|
+
class="inline-flex items-center px-2.5 py-1 text-[11px] rounded-md transition-colors
|
|
52
|
+
{isActive
|
|
53
|
+
? 'bg-muted text-foreground font-medium'
|
|
54
|
+
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
|
|
55
|
+
>
|
|
56
|
+
{tab.label}
|
|
57
|
+
{#if tabCounts[key] !== undefined}
|
|
58
|
+
<span class="ml-1.5 px-1.5 py-0.5 rounded-full text-[10px] {isActive ? 'bg-muted-foreground/20 text-foreground' : 'bg-muted text-muted-foreground'}">
|
|
59
|
+
{tabCounts[key]}
|
|
60
|
+
</span>
|
|
61
|
+
{/if}
|
|
62
|
+
</button>
|
|
63
|
+
{/each}
|
|
64
|
+
</div>
|
|
65
|
+
{/if}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import CreateManyButton from "../createManyButton.svelte";
|
|
11
11
|
import { showDialog } from "../confirmationDialog/store.svelte";
|
|
12
12
|
import CreateDetailViewButton from "../detailView/create/createDetailViewButton.svelte";
|
|
13
|
+
import ExtensionsComponents from "../extensionsComponents.svelte";
|
|
14
|
+
import { getExtensionUtils } from "../../extensions/extensionUtils";
|
|
13
15
|
import type { Snippet } from "svelte";
|
|
14
16
|
|
|
15
17
|
interface Props {
|
|
@@ -142,6 +144,12 @@
|
|
|
142
144
|
Icon={SquareStack}
|
|
143
145
|
onSuccessfullSave={() => (params = { ...params })}
|
|
144
146
|
></CreateManyButton>
|
|
147
|
+
<ExtensionsComponents
|
|
148
|
+
name="listView.header.actions"
|
|
149
|
+
utils={getExtensionUtils(lobb, ctx)}
|
|
150
|
+
{collectionName}
|
|
151
|
+
refresh={() => { params = { ...params }; }}
|
|
152
|
+
/>
|
|
145
153
|
<CreateDetailViewButton
|
|
146
154
|
{collectionName}
|
|
147
155
|
variant="default"
|
|
@@ -1,30 +1,33 @@
|
|
|
1
|
-
<script>
|
|
2
|
-
import { CircleSlash2 } from "lucide-svelte";
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { CircleSlash2, Zap } from "lucide-svelte";
|
|
3
3
|
import DataTable from "../../../components/dataTable/dataTable.svelte";
|
|
4
4
|
import SidebarTrigger from "../../../components/sidebar/sidebarTrigger.svelte";
|
|
5
5
|
import { getStudioContext } from "../../../context";
|
|
6
6
|
import Singletone from "../../../components/singletone.svelte";
|
|
7
|
-
import { getExtensionUtils } from "../../../extensions/extensionUtils";
|
|
8
|
-
import ExtensionsComponents from "../../../components/extensionsComponents.svelte";
|
|
9
7
|
|
|
10
|
-
const { ctx
|
|
8
|
+
const { ctx } = getStudioContext();
|
|
11
9
|
|
|
12
10
|
let { collectionName } = $props();
|
|
13
11
|
let isSingletonCollection = $derived(ctx.meta.collections[collectionName].singleton);
|
|
12
|
+
let isVirtualCollection = $derived(ctx.meta.collections[collectionName].virtual);
|
|
14
13
|
|
|
15
14
|
let containerWidth = $state();
|
|
16
15
|
</script>
|
|
17
16
|
|
|
18
17
|
<div bind:clientWidth={containerWidth} class="h-full">
|
|
19
18
|
{#if collectionName}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
{#if isVirtualCollection}
|
|
20
|
+
<div class="relative flex h-full w-full flex-col items-center justify-center gap-4 text-muted-foreground">
|
|
21
|
+
<Zap class="opacity-50" size="50" />
|
|
22
|
+
<div class="flex flex-col items-center justify-center">
|
|
23
|
+
<div>Virtual collection</div>
|
|
24
|
+
<div class="text-xs">This collection has no database table. It exists only as an API endpoint for workflows to intercept.</div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="absolute top-0 left-0 p-2.5">
|
|
27
|
+
<SidebarTrigger />
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
{:else if isSingletonCollection}
|
|
28
31
|
<Singletone collectionName={collectionName} />
|
|
29
32
|
{:else}
|
|
30
33
|
<DataTable
|
|
@@ -6,30 +6,27 @@
|
|
|
6
6
|
import Button from "./ui/button/button.svelte";
|
|
7
7
|
import { onMount } from "svelte";
|
|
8
8
|
import { getStudioContext } from "../context";
|
|
9
|
+
import { toast } from "svelte-sonner";
|
|
10
|
+
import Skeleton from "./ui/skeleton/skeleton.svelte";
|
|
9
11
|
|
|
10
12
|
const { lobb, ctx } = getStudioContext();
|
|
11
|
-
import { toast } from "svelte-sonner";
|
|
12
|
-
import Skeleton from "./ui/skeleton/skeleton.svelte";
|
|
13
13
|
|
|
14
14
|
interface Props {
|
|
15
15
|
collectionName: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
let {
|
|
19
|
-
collectionName,
|
|
20
|
-
}: Props = $props();
|
|
18
|
+
let { collectionName }: Props = $props();
|
|
21
19
|
|
|
22
20
|
let entry = $state({});
|
|
23
21
|
let loading = $state(true);
|
|
24
|
-
let singletonExists = $state(true);
|
|
25
22
|
const formFields = getCollectionFields(ctx, collectionName);
|
|
26
23
|
|
|
27
24
|
onMount(async () => {
|
|
28
|
-
const result = await lobb.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
const result = await fetch(`${lobb.lobbUrl}/api/collections/${collectionName}/singleton`, {
|
|
26
|
+
headers: lobb.getHeaders() as HeadersInit,
|
|
27
|
+
});
|
|
28
|
+
if (result.status !== 404) {
|
|
29
|
+
const json = await result.json();
|
|
33
30
|
entry = json.data;
|
|
34
31
|
}
|
|
35
32
|
loading = false;
|
|
@@ -43,29 +40,27 @@
|
|
|
43
40
|
}
|
|
44
41
|
</script>
|
|
45
42
|
|
|
46
|
-
<div>
|
|
47
|
-
<div class="flex justify-between items-center gap-2 p-2 border-b
|
|
43
|
+
<div class="flex flex-col h-full bg-background">
|
|
44
|
+
<div class="flex justify-between items-center gap-2 p-2 border-b h-10 shrink-0">
|
|
48
45
|
<div class="flex items-center gap-1">
|
|
49
46
|
<SidebarTrigger />
|
|
50
47
|
</div>
|
|
51
48
|
<div>
|
|
52
|
-
<Button
|
|
53
|
-
class="h-7 px-2 font-normal text-xs"
|
|
54
|
-
Icon={Save}
|
|
55
|
-
onclick={handleSave}
|
|
56
|
-
>
|
|
49
|
+
<Button class="h-7 px-2 font-normal text-xs" Icon={Save} onclick={handleSave}>
|
|
57
50
|
Save
|
|
58
51
|
</Button>
|
|
59
52
|
</div>
|
|
60
53
|
</div>
|
|
61
54
|
|
|
62
55
|
{#if loading}
|
|
63
|
-
<div class="flex flex-col gap-2 p-
|
|
56
|
+
<div class="flex flex-col gap-2 p-4 w-full">
|
|
64
57
|
<Skeleton class="h-8 w-full" />
|
|
65
58
|
<Skeleton class="h-8 w-[80%]" />
|
|
66
59
|
<Skeleton class="h-8 w-[60%]" />
|
|
67
60
|
</div>
|
|
68
61
|
{:else}
|
|
69
|
-
<
|
|
62
|
+
<div class="flex-1 overflow-y-auto max-w-xl">
|
|
63
|
+
<DetailViewForm bind:value={entry} fields={formFields} />
|
|
64
|
+
</div>
|
|
70
65
|
{/if}
|
|
71
66
|
</div>
|
|
@@ -93,7 +93,8 @@ export type ExtensionComponentKey =
|
|
|
93
93
|
| `detailView.fields.topRight.${string}.${string}`
|
|
94
94
|
| `detailView.fields.foreignKey.${string}`
|
|
95
95
|
| `listView.entry.children.${string}`
|
|
96
|
-
| `listView.entry.actions
|
|
96
|
+
| `listView.entry.actions`
|
|
97
|
+
| `listView.header.actions`;
|
|
97
98
|
|
|
98
99
|
export type ExtensionComponent =
|
|
99
100
|
| any
|
package/src/lib/store.types.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import type { Extension } from "../extensions/extension.types";
|
|
2
2
|
|
|
3
|
+
interface CollectionTab {
|
|
4
|
+
id?: string;
|
|
5
|
+
label: string;
|
|
6
|
+
filter?: Record<string, any>;
|
|
7
|
+
default?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
interface Collection {
|
|
4
11
|
category: string;
|
|
5
12
|
owner: string;
|
|
6
13
|
fields: Record<string, any>;
|
|
7
14
|
singleton: boolean;
|
|
15
|
+
virtual?: boolean;
|
|
16
|
+
ui?: {
|
|
17
|
+
tabs?: CollectionTab[];
|
|
18
|
+
};
|
|
8
19
|
}
|
|
9
20
|
type Collections = Record<string, Collection>;
|
|
10
21
|
|