@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.
@@ -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
- {#if loading}
158
- <div class="flex flex-col gap-2 p-2 w-full">
159
- <Skeleton class="h-8 w-full" />
160
- <Skeleton class="h-8 w-[80%]" />
161
- <Skeleton class="h-8 w-[60%]" />
162
- </div>
163
- {:else}
164
- <Table
165
- {data}
166
- {columns}
167
- showCollapsible={doesCollectionHasChildren}
168
- selectByColumn="id"
169
- showLastRowBorder={true}
170
- showLastColumnBorder={true}
171
- bind:sort={params.sort}
172
- bind:selectedRecords
173
- {unifiedBgColor}
174
- bind:tableWidth={dataTableWidth}
175
- {...tableProps}
176
- rowActions={hasRowActions ? rowActionsSnippet : undefined}>
177
- {#snippet tools(entry)}
178
- <UpdateDetailViewButton
179
- {collectionName}
180
- recordId={entry.id}
181
- variant="ghost"
182
- class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent"
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={icons[
205
- workflowTool.icon as keyof typeof icons
206
- ]}
207
- onclick={workflowTool.onclick}
208
- ></Button>
209
- {/each}
210
- {/await}
211
- {/snippet}
212
- {#snippet overrideCell(value, column, entry)}
213
- <FieldCell
214
- {collectionName}
215
- fieldName={column.id}
216
- {value}
217
- {entry}
218
- tableParams={params}
219
- />
220
- {/snippet}
221
- {#snippet collapsible(entry)}
222
- <ChildRecords
223
- {collectionName}
224
- recordId={entry.id}
225
- width={dataTableWidth > dataTableContainerWidth
226
- ? dataTableContainerWidth
227
- : dataTableWidth}
228
- unifiedBgColor={unifiedBgColor ?? "bg-background"}
229
- />
230
- {/snippet}
231
- </Table>
232
- {/if}
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, lobb } = getStudioContext();
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
- <!-- TODO: add support in here for the views for each collection view -->
21
- <!-- {#if true}
22
- <ExtensionsComponents
23
- name="studio.listView"
24
- utils={getExtensionUtils(lobb, ctx)}
25
- ></ExtensionsComponents>
26
- {:else if isSingletonCollection} -->
27
- {#if isSingletonCollection}
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 $$ComponentProps = {
10
- collectionName: any;
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.readSingleton(collectionName);
29
- const json = await result.json();
30
- if (result.status === 404) {
31
- singletonExists = false;
32
- } else {
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 bg-background h-10">
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-2 w-full">
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
- <DetailViewForm bind:value={entry} fields={formFields} />
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;
@@ -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.12.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.18.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
- {#if loading}
158
- <div class="flex flex-col gap-2 p-2 w-full">
159
- <Skeleton class="h-8 w-full" />
160
- <Skeleton class="h-8 w-[80%]" />
161
- <Skeleton class="h-8 w-[60%]" />
162
- </div>
163
- {:else}
164
- <Table
165
- {data}
166
- {columns}
167
- showCollapsible={doesCollectionHasChildren}
168
- selectByColumn="id"
169
- showLastRowBorder={true}
170
- showLastColumnBorder={true}
171
- bind:sort={params.sort}
172
- bind:selectedRecords
173
- {unifiedBgColor}
174
- bind:tableWidth={dataTableWidth}
175
- {...tableProps}
176
- rowActions={hasRowActions ? rowActionsSnippet : undefined}>
177
- {#snippet tools(entry)}
178
- <UpdateDetailViewButton
179
- {collectionName}
180
- recordId={entry.id}
181
- variant="ghost"
182
- class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent"
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={icons[
205
- workflowTool.icon as keyof typeof icons
206
- ]}
207
- onclick={workflowTool.onclick}
208
- ></Button>
209
- {/each}
210
- {/await}
211
- {/snippet}
212
- {#snippet overrideCell(value, column, entry)}
213
- <FieldCell
214
- {collectionName}
215
- fieldName={column.id}
216
- {value}
217
- {entry}
218
- tableParams={params}
219
- />
220
- {/snippet}
221
- {#snippet collapsible(entry)}
222
- <ChildRecords
223
- {collectionName}
224
- recordId={entry.id}
225
- width={dataTableWidth > dataTableContainerWidth
226
- ? dataTableContainerWidth
227
- : dataTableWidth}
228
- unifiedBgColor={unifiedBgColor ?? "bg-background"}
229
- />
230
- {/snippet}
231
- </Table>
232
- {/if}
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, lobb } = getStudioContext();
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
- <!-- TODO: add support in here for the views for each collection view -->
21
- <!-- {#if true}
22
- <ExtensionsComponents
23
- name="studio.listView"
24
- utils={getExtensionUtils(lobb, ctx)}
25
- ></ExtensionsComponents>
26
- {:else if isSingletonCollection} -->
27
- {#if isSingletonCollection}
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.readSingleton(collectionName);
29
- const json = await result.json();
30
- if (result.status === 404) {
31
- singletonExists = false;
32
- } else {
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 bg-background h-10">
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-2 w-full">
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
- <DetailViewForm bind:value={entry} fields={formFields} />
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
@@ -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