@lobb-js/studio 0.26.0 → 0.27.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 +70 -9
- package/dist/components/dataTable/dataTable.svelte.d.ts +24 -0
- package/dist/components/dataTable/header.svelte +88 -24
- package/dist/components/dataTable/header.svelte.d.ts +4 -0
- package/dist/components/dataTable/listViewChildren.svelte +9 -2
- package/dist/components/dataTable/table.svelte +1 -1
- package/dist/components/detailView/update/detailViewChildren.svelte +50 -55
- package/dist/components/detailView/update/detailViewChildren.svelte.d.ts +8 -1
- package/dist/components/detailView/update/updateDetailView.svelte +5 -2
- package/dist/utils.js +2 -1
- package/package.json +2 -2
- package/src/lib/components/dataTable/dataTable.svelte +70 -9
- package/src/lib/components/dataTable/header.svelte +88 -24
- package/src/lib/components/dataTable/listViewChildren.svelte +9 -2
- package/src/lib/components/dataTable/table.svelte +1 -1
- package/src/lib/components/detailView/update/detailViewChildren.svelte +50 -55
- package/src/lib/components/detailView/update/updateDetailView.svelte +5 -2
- package/src/lib/utils.ts +2 -1
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface ParentContext {
|
|
3
|
+
collectionName: string;
|
|
4
|
+
recordId: string | number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type RecordOperation =
|
|
8
|
+
| { type: "link"; record: any }
|
|
9
|
+
| { type: "unlink"; id: string | number }
|
|
10
|
+
| { type: "delete"; id: string | number }
|
|
11
|
+
| { type: "create"; record: any }
|
|
12
|
+
| { type: "update"; id: string | number; data: any };
|
|
13
|
+
</script>
|
|
14
|
+
|
|
1
15
|
<script lang="ts">
|
|
2
16
|
import _ from "lodash";
|
|
3
17
|
import { getStudioContext } from "../../context";
|
|
@@ -5,7 +19,7 @@
|
|
|
5
19
|
import Header from "./header.svelte";
|
|
6
20
|
import Table, { type TableProps } from "./table.svelte";
|
|
7
21
|
import { getCollectionColumns, getCollectionParamsFields } from "./utils";
|
|
8
|
-
import { Pencil, Trash } from "lucide-svelte";
|
|
22
|
+
import { Pencil, Trash, Unlink } from "lucide-svelte";
|
|
9
23
|
import * as icons from "lucide-svelte";
|
|
10
24
|
import ListViewChildren from "./listViewChildren.svelte";
|
|
11
25
|
import FieldCell from "./fieldCell.svelte";
|
|
@@ -26,8 +40,11 @@
|
|
|
26
40
|
collectionName: string;
|
|
27
41
|
filter?: any;
|
|
28
42
|
searchParams?: Record<string, any>;
|
|
43
|
+
parentContext?: ParentContext;
|
|
44
|
+
onOperation?: (op: RecordOperation) => void;
|
|
29
45
|
showHeader?: boolean;
|
|
30
46
|
showFooter?: boolean;
|
|
47
|
+
showImport?: boolean;
|
|
31
48
|
unifiedBgColor?: "bg-muted/30" | "bg-background";
|
|
32
49
|
showDelete?: boolean;
|
|
33
50
|
tableProps?: Partial<TableProps>;
|
|
@@ -38,8 +55,11 @@
|
|
|
38
55
|
collectionName,
|
|
39
56
|
filter,
|
|
40
57
|
searchParams,
|
|
58
|
+
parentContext,
|
|
59
|
+
onOperation,
|
|
41
60
|
showHeader = true,
|
|
42
61
|
showFooter = true,
|
|
62
|
+
showImport = true,
|
|
43
63
|
unifiedBgColor,
|
|
44
64
|
showDelete = false,
|
|
45
65
|
tableProps,
|
|
@@ -78,7 +98,7 @@
|
|
|
78
98
|
let dataTableWidth: number = $state(0);
|
|
79
99
|
const doesCollectionHasChildren = $derived(
|
|
80
100
|
(ctx.meta.collections[collectionName]?.children ?? [])
|
|
81
|
-
.some((c: any) => c.type === "fk" || c.type === "m2m")
|
|
101
|
+
.some((c: any) => c.type === "fk" || c.type === "m2m" || c.type === "polymorphic")
|
|
82
102
|
);
|
|
83
103
|
|
|
84
104
|
// requests the data from the server when the params is changed
|
|
@@ -109,14 +129,44 @@
|
|
|
109
129
|
loading = false;
|
|
110
130
|
}
|
|
111
131
|
|
|
132
|
+
// Internal handler: updates data optimistically then calls onOperation
|
|
133
|
+
function applyOperation(op: RecordOperation) {
|
|
134
|
+
if (op.type === "link") {
|
|
135
|
+
data = [...data, op.record];
|
|
136
|
+
} else if (op.type === "unlink" || op.type === "delete") {
|
|
137
|
+
data = data.filter((r: any) => String(r.id) !== String(op.id));
|
|
138
|
+
} else if (op.type === "create") {
|
|
139
|
+
data = [...data, { ...op.record, _pending: true }];
|
|
140
|
+
} else if (op.type === "update") {
|
|
141
|
+
data = data.map((r: any) => String(r.id) === String(op.id) ? { ...r, ...op.data } : r);
|
|
142
|
+
}
|
|
143
|
+
onOperation?.(op);
|
|
144
|
+
}
|
|
145
|
+
|
|
112
146
|
async function handleDelete(entryId: string) {
|
|
113
|
-
const result = await showDialog(
|
|
114
|
-
"Are you sure?",
|
|
115
|
-
"This will delete the record you selected.",
|
|
116
|
-
);
|
|
147
|
+
const result = await showDialog("Are you sure?", "This will permanently delete the record.");
|
|
117
148
|
if (result) {
|
|
118
|
-
|
|
119
|
-
|
|
149
|
+
if (onOperation) {
|
|
150
|
+
applyOperation({ type: "delete", id: entryId });
|
|
151
|
+
} else if (parentContext) {
|
|
152
|
+
await lobb.updateOne(parentContext.collectionName, String(parentContext.recordId), {}, { [collectionName]: { delete: [entryId] } });
|
|
153
|
+
params = { ...params };
|
|
154
|
+
} else {
|
|
155
|
+
await lobb.deleteOne(collectionName, entryId);
|
|
156
|
+
params = { ...params };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function handleUnlink(entryId: string) {
|
|
162
|
+
const result = await showDialog("Are you sure?", "This will unlink the record without deleting it.");
|
|
163
|
+
if (result) {
|
|
164
|
+
if (onOperation) {
|
|
165
|
+
applyOperation({ type: "unlink", id: entryId });
|
|
166
|
+
} else {
|
|
167
|
+
await lobb.updateOne(parentContext!.collectionName, String(parentContext!.recordId), {}, { [collectionName]: { unlink: [entryId] } });
|
|
168
|
+
params = { ...params };
|
|
169
|
+
}
|
|
120
170
|
}
|
|
121
171
|
}
|
|
122
172
|
|
|
@@ -159,7 +209,7 @@
|
|
|
159
209
|
{/snippet}
|
|
160
210
|
|
|
161
211
|
{#if showHeader}
|
|
162
|
-
<Header bind:params {collectionName} bind:selectedRecords>
|
|
212
|
+
<Header bind:params {collectionName} bind:selectedRecords {parentContext} {showImport} onOperation={onOperation ? applyOperation : undefined}>
|
|
163
213
|
{#snippet left()}
|
|
164
214
|
{@render headerLeft?.()}
|
|
165
215
|
{/snippet}
|
|
@@ -200,6 +250,16 @@
|
|
|
200
250
|
params = { ...params };
|
|
201
251
|
}}
|
|
202
252
|
></UpdateDetailViewButton>
|
|
253
|
+
{#if parentContext}
|
|
254
|
+
<Button
|
|
255
|
+
class="h-6 w-6 text-muted-foreground hover:bg-transparent"
|
|
256
|
+
variant="ghost"
|
|
257
|
+
size="icon"
|
|
258
|
+
onclick={() => handleUnlink(entry.id)}
|
|
259
|
+
Icon={Unlink}
|
|
260
|
+
title="Remove from this entry"
|
|
261
|
+
></Button>
|
|
262
|
+
{/if}
|
|
203
263
|
{#if showDelete}
|
|
204
264
|
<Button
|
|
205
265
|
class="h-6 w-6 text-muted-foreground hover:bg-transparent"
|
|
@@ -207,6 +267,7 @@
|
|
|
207
267
|
size="icon"
|
|
208
268
|
onclick={() => handleDelete(entry.id)}
|
|
209
269
|
Icon={Trash}
|
|
270
|
+
title="Delete permanently"
|
|
210
271
|
></Button>
|
|
211
272
|
{/if}
|
|
212
273
|
{#await getWorkflowTools($state.snapshot(entry))}
|
|
@@ -1,11 +1,35 @@
|
|
|
1
|
+
export interface ParentContext {
|
|
2
|
+
collectionName: string;
|
|
3
|
+
recordId: string | number;
|
|
4
|
+
}
|
|
5
|
+
export type RecordOperation = {
|
|
6
|
+
type: "link";
|
|
7
|
+
record: any;
|
|
8
|
+
} | {
|
|
9
|
+
type: "unlink";
|
|
10
|
+
id: string | number;
|
|
11
|
+
} | {
|
|
12
|
+
type: "delete";
|
|
13
|
+
id: string | number;
|
|
14
|
+
} | {
|
|
15
|
+
type: "create";
|
|
16
|
+
record: any;
|
|
17
|
+
} | {
|
|
18
|
+
type: "update";
|
|
19
|
+
id: string | number;
|
|
20
|
+
data: any;
|
|
21
|
+
};
|
|
1
22
|
import { type TableProps } from "./table.svelte";
|
|
2
23
|
import type { Snippet } from "svelte";
|
|
3
24
|
interface Props {
|
|
4
25
|
collectionName: string;
|
|
5
26
|
filter?: any;
|
|
6
27
|
searchParams?: Record<string, any>;
|
|
28
|
+
parentContext?: ParentContext;
|
|
29
|
+
onOperation?: (op: RecordOperation) => void;
|
|
7
30
|
showHeader?: boolean;
|
|
8
31
|
showFooter?: boolean;
|
|
32
|
+
showImport?: boolean;
|
|
9
33
|
unifiedBgColor?: "bg-muted/30" | "bg-background";
|
|
10
34
|
showDelete?: boolean;
|
|
11
35
|
tableProps?: Partial<TableProps>;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { getStudioContext } from "../../context";
|
|
3
3
|
|
|
4
4
|
const { lobb, ctx } = getStudioContext();
|
|
5
|
-
import { Download, ListRestart, Plus, Trash } from "lucide-svelte";
|
|
5
|
+
import { Download, ListRestart, Plus, Trash, Link } from "lucide-svelte";
|
|
6
6
|
import * as Tooltip from "../ui/tooltip";
|
|
7
7
|
import LlmButton from "../LlmButton.svelte";
|
|
8
8
|
import FilterButton from "./filterButton.svelte";
|
|
@@ -11,14 +11,19 @@
|
|
|
11
11
|
import ImportButton from "../importButton.svelte";
|
|
12
12
|
import { showDialog } from "../../actions";
|
|
13
13
|
import CreateDetailViewButton from "../detailView/create/createDetailViewButton.svelte";
|
|
14
|
+
import SelectRecord from "../selectRecord.svelte";
|
|
14
15
|
import ExtensionsComponents from "../extensionsComponents.svelte";
|
|
15
16
|
import { getExtensionUtils } from "../../extensions/extensionUtils";
|
|
16
17
|
import type { Snippet } from "svelte";
|
|
18
|
+
import type { ParentContext, RecordOperation } from "./dataTable.svelte";
|
|
17
19
|
|
|
18
20
|
interface Props {
|
|
19
21
|
collectionName: string;
|
|
20
22
|
params: any;
|
|
21
23
|
selectedRecords: string[];
|
|
24
|
+
parentContext?: ParentContext;
|
|
25
|
+
onOperation?: (op: RecordOperation) => void;
|
|
26
|
+
showImport?: boolean;
|
|
22
27
|
left?: Snippet<[]>;
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -26,9 +31,42 @@
|
|
|
26
31
|
collectionName,
|
|
27
32
|
params = $bindable(),
|
|
28
33
|
selectedRecords = $bindable(),
|
|
34
|
+
parentContext,
|
|
35
|
+
onOperation,
|
|
36
|
+
showImport = true,
|
|
29
37
|
left
|
|
30
38
|
}: Props = $props();
|
|
31
39
|
|
|
40
|
+
async function handleLink(selected: any) {
|
|
41
|
+
if (!parentContext) return;
|
|
42
|
+
if (onOperation) {
|
|
43
|
+
onOperation({ type: "link", record: selected });
|
|
44
|
+
} else {
|
|
45
|
+
await lobb.updateOne(
|
|
46
|
+
parentContext.collectionName,
|
|
47
|
+
String(parentContext.recordId),
|
|
48
|
+
{},
|
|
49
|
+
{ [collectionName]: { link: [selected.id] } },
|
|
50
|
+
);
|
|
51
|
+
resetTable();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function handleChildCreate(formData: any) {
|
|
56
|
+
if (!parentContext) return;
|
|
57
|
+
if (onOperation) {
|
|
58
|
+
onOperation({ type: "create", record: formData });
|
|
59
|
+
} else {
|
|
60
|
+
await lobb.updateOne(
|
|
61
|
+
parentContext.collectionName,
|
|
62
|
+
String(parentContext.recordId),
|
|
63
|
+
{},
|
|
64
|
+
{ [collectionName]: { create: [formData] } },
|
|
65
|
+
);
|
|
66
|
+
resetTable();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
32
70
|
let headerWidth: number = $state(0);
|
|
33
71
|
let headerIsSmall: boolean = $derived(headerWidth < 560);
|
|
34
72
|
|
|
@@ -138,34 +176,60 @@
|
|
|
138
176
|
>
|
|
139
177
|
{headerIsSmall ? "" : "Refresh"}
|
|
140
178
|
</Button>
|
|
141
|
-
|
|
142
|
-
<Tooltip.
|
|
143
|
-
<Tooltip.
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
179
|
+
{#if showImport}
|
|
180
|
+
<Tooltip.Provider delayDuration={0}>
|
|
181
|
+
<Tooltip.Root>
|
|
182
|
+
<Tooltip.Trigger>
|
|
183
|
+
<ImportButton
|
|
184
|
+
{collectionName}
|
|
185
|
+
variant="outline"
|
|
186
|
+
class="h-7 px-2 text-xs font-normal"
|
|
187
|
+
Icon={Download}
|
|
188
|
+
onSuccessfullSave={() => (params = { ...params })}
|
|
189
|
+
/>
|
|
190
|
+
</Tooltip.Trigger>
|
|
191
|
+
<Tooltip.Content>Import</Tooltip.Content>
|
|
192
|
+
</Tooltip.Root>
|
|
193
|
+
</Tooltip.Provider>
|
|
194
|
+
{/if}
|
|
155
195
|
<ExtensionsComponents
|
|
156
196
|
name="listView.header.actions"
|
|
157
197
|
utils={getExtensionUtils(lobb, ctx)}
|
|
158
198
|
{collectionName}
|
|
159
199
|
refresh={() => { params = { ...params }; }}
|
|
160
200
|
/>
|
|
161
|
-
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
201
|
+
{#if parentContext}
|
|
202
|
+
{#if parentContext}
|
|
203
|
+
<SelectRecord
|
|
204
|
+
{collectionName}
|
|
205
|
+
variant="outline"
|
|
206
|
+
class="h-7 px-3 text-xs font-normal"
|
|
207
|
+
Icon={Link}
|
|
208
|
+
onSelect={handleLink}
|
|
209
|
+
>
|
|
210
|
+
{headerIsSmall ? "" : "Link"}
|
|
211
|
+
</SelectRecord>
|
|
212
|
+
{/if}
|
|
213
|
+
<CreateDetailViewButton
|
|
214
|
+
{collectionName}
|
|
215
|
+
variant="default"
|
|
216
|
+
class="h-7 px-3 text-xs font-normal"
|
|
217
|
+
Icon={Plus}
|
|
218
|
+
rollback={true}
|
|
219
|
+
onSuccessfullSave={handleChildCreate}
|
|
220
|
+
>
|
|
221
|
+
{headerIsSmall ? "" : "Create"}
|
|
222
|
+
</CreateDetailViewButton>
|
|
223
|
+
{:else}
|
|
224
|
+
<CreateDetailViewButton
|
|
225
|
+
{collectionName}
|
|
226
|
+
variant="default"
|
|
227
|
+
class="h-7 px-3 text-xs font-normal"
|
|
228
|
+
Icon={Plus}
|
|
229
|
+
onSuccessfullSave={() => (params = { ...params })}
|
|
230
|
+
>
|
|
231
|
+
{headerIsSmall ? "" : "Create"}
|
|
232
|
+
</CreateDetailViewButton>
|
|
233
|
+
{/if}
|
|
170
234
|
</div>
|
|
171
235
|
</div>
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { Snippet } from "svelte";
|
|
2
|
+
import type { ParentContext, RecordOperation } from "./dataTable.svelte";
|
|
2
3
|
interface Props {
|
|
3
4
|
collectionName: string;
|
|
4
5
|
params: any;
|
|
5
6
|
selectedRecords: string[];
|
|
7
|
+
parentContext?: ParentContext;
|
|
8
|
+
onOperation?: (op: RecordOperation) => void;
|
|
9
|
+
showImport?: boolean;
|
|
6
10
|
left?: Snippet<[]>;
|
|
7
11
|
}
|
|
8
12
|
declare const Header: import("svelte").Component<Props, {}, "params" | "selectedRecords">;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { getStudioContext } from "../../context";
|
|
3
|
-
import { ChevronRight, Table, Plus } from "lucide-svelte";
|
|
3
|
+
import { ChevronRight, Table, Plus, Link, ArrowLeftRight, GitFork } from "lucide-svelte";
|
|
4
4
|
import DataTable from "./dataTable.svelte";
|
|
5
5
|
import CreateDetailViewButton from "../detailView/create/createDetailViewButton.svelte";
|
|
6
6
|
import ExtensionsComponents from "../extensionsComponents.svelte";
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
let { collectionName, recordId, width, unifiedBgColor }: Props = $props();
|
|
19
19
|
|
|
20
20
|
const children = (ctx.meta.collections[collectionName]?.children ?? [])
|
|
21
|
-
.filter((c: any) => c.type === "fk" || c.type === "m2m");
|
|
21
|
+
.filter((c: any) => c.type === "fk" || c.type === "m2m" || c.type === "polymorphic");
|
|
22
22
|
|
|
23
23
|
let expandedRows: boolean[] = $state(new Array(children.length).fill(false));
|
|
24
24
|
let refreshDataTable = $state(true);
|
|
@@ -49,6 +49,13 @@
|
|
|
49
49
|
/>
|
|
50
50
|
<Table size="17.5" class="text-muted-foreground" />
|
|
51
51
|
<div class="text-muted-foreground">{child.collection}</div>
|
|
52
|
+
{#if child.type === "fk"}
|
|
53
|
+
<span title="Direct (FK)"><Link size="13" class="text-muted-foreground/50" /></span>
|
|
54
|
+
{:else if child.type === "m2m"}
|
|
55
|
+
<span title="Many to Many"><ArrowLeftRight size="13" class="text-muted-foreground/50" /></span>
|
|
56
|
+
{:else if child.type === "polymorphic"}
|
|
57
|
+
<span title="Polymorphic"><GitFork size="13" class="text-muted-foreground/50" /></span>
|
|
58
|
+
{/if}
|
|
52
59
|
</button>
|
|
53
60
|
{#if child.type === "fk"}
|
|
54
61
|
<div class="flex items-center px-2">
|
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
<div
|
|
173
173
|
style="
|
|
174
174
|
display: grid;
|
|
175
|
-
grid-template-columns: minmax(auto,
|
|
175
|
+
grid-template-columns: minmax(auto, 10rem) repeat({columnsLength - 1}, minmax(auto, 15rem)){rowActionsExists ? ' minmax(auto, 7.5rem)' : ''};
|
|
176
176
|
grid-template-rows: 2.5rem;
|
|
177
177
|
"
|
|
178
178
|
>
|
|
@@ -1,77 +1,72 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import DataTable from "../../../components/dataTable/dataTable.svelte";
|
|
2
|
+
import DataTable, { type RecordOperation } from "../../../components/dataTable/dataTable.svelte";
|
|
3
3
|
import { getStudioContext } from "../../../context";
|
|
4
|
-
import {
|
|
5
|
-
import CreateDetailViewButton from "../create/createDetailViewButton.svelte";
|
|
6
|
-
import ExtensionsComponents from "../../../components/extensionsComponents.svelte";
|
|
7
|
-
import { getExtensionUtils } from "../../../extensions/extensionUtils";
|
|
4
|
+
import { Table, Link } from "lucide-svelte";
|
|
8
5
|
|
|
9
|
-
const { ctx
|
|
6
|
+
const { ctx } = getStudioContext();
|
|
7
|
+
|
|
8
|
+
type PendingOps = {
|
|
9
|
+
link?: (string | number)[];
|
|
10
|
+
unlink?: (string | number)[];
|
|
11
|
+
delete?: (string | number)[];
|
|
12
|
+
create?: any[];
|
|
13
|
+
};
|
|
10
14
|
|
|
11
15
|
interface LocalProp {
|
|
12
16
|
collectionName: string;
|
|
13
17
|
entry: any;
|
|
18
|
+
pendingChildren?: Record<string, PendingOps>;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
let { collectionName, entry }: LocalProp = $props();
|
|
21
|
+
let { collectionName, entry, pendingChildren = $bindable({}) }: LocalProp = $props();
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
function makeHandler(collection: string) {
|
|
24
|
+
return (op: RecordOperation) => {
|
|
25
|
+
if (!pendingChildren[collection]) pendingChildren[collection] = {};
|
|
26
|
+
const c = pendingChildren[collection];
|
|
27
|
+
if (op.type === "link") {
|
|
28
|
+
(c.link ??= []).push(op.record.id);
|
|
29
|
+
} else if (op.type === "unlink") {
|
|
30
|
+
(c.unlink ??= []).push(op.id);
|
|
31
|
+
} else if (op.type === "delete") {
|
|
32
|
+
(c.delete ??= []).push(op.id);
|
|
33
|
+
} else if (op.type === "create") {
|
|
34
|
+
(c.create ??= []).push(op.record);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
20
38
|
|
|
21
|
-
const
|
|
39
|
+
const children = (ctx.meta.collections[collectionName]?.children ?? [])
|
|
40
|
+
.filter((c: any) => c.type === "fk" || c.type === "m2m" || c.type === "polymorphic");
|
|
22
41
|
</script>
|
|
23
42
|
|
|
24
43
|
{#if children.length}
|
|
25
|
-
<div class="flex flex-col gap-
|
|
44
|
+
<div class="flex flex-col gap-3 border-t p-4">
|
|
26
45
|
<div class="flex items-center gap-2">
|
|
27
|
-
<Link size="
|
|
28
|
-
<
|
|
46
|
+
<Link size="14" class="text-muted-foreground" />
|
|
47
|
+
<span class="text-sm font-medium">Sub Records</span>
|
|
29
48
|
</div>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<
|
|
33
|
-
name="detailView.update.subRecords.{child.collection}"
|
|
34
|
-
utils={getExtensionUtils(lobb, ctx)}
|
|
49
|
+
{#each children as child}
|
|
50
|
+
<div class="rounded-lg border bg-background overflow-hidden flex flex-col max-h-96">
|
|
51
|
+
<DataTable
|
|
35
52
|
collectionName={child.collection}
|
|
36
53
|
searchParams={{ children_of: collectionName, parent_id: entry.id }}
|
|
37
|
-
|
|
54
|
+
parentContext={{ collectionName, recordId: entry.id }}
|
|
55
|
+
onOperation={makeHandler(child.collection)}
|
|
56
|
+
showImport={false}
|
|
57
|
+
showHeader={true}
|
|
58
|
+
showFooter={true}
|
|
59
|
+
showDelete={child.type === "fk" || child.type === "m2m"}
|
|
60
|
+
tableProps={{ showLastColumnBorder: false, showLastRowBorder: true }}
|
|
38
61
|
>
|
|
39
|
-
|
|
40
|
-
<div class="flex items-center
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
<div class="text-sm text-muted-foreground">{child.collection}</div>
|
|
44
|
-
</div>
|
|
45
|
-
{#if child.type === "fk"}
|
|
46
|
-
<div class="flex gap-2">
|
|
47
|
-
<CreateDetailViewButton
|
|
48
|
-
variant="ghost"
|
|
49
|
-
class="h-7 px-2 font-normal text-xs"
|
|
50
|
-
Icon={Plus}
|
|
51
|
-
collectionName={child.collection}
|
|
52
|
-
onSuccessfullSave={async () => { refresh[index] = !refresh[index]; }}
|
|
53
|
-
>
|
|
54
|
-
Create
|
|
55
|
-
</CreateDetailViewButton>
|
|
56
|
-
</div>
|
|
57
|
-
{/if}
|
|
58
|
-
</div>
|
|
59
|
-
<div class="max-h-72 overflow-auto rounded-md">
|
|
60
|
-
{#key refresh[index]}
|
|
61
|
-
<DataTable
|
|
62
|
-
collectionName={child.collection}
|
|
63
|
-
searchParams={{ children_of: collectionName, parent_id: entry.id }}
|
|
64
|
-
unifiedBgColor="bg-muted/30"
|
|
65
|
-
showHeader={false}
|
|
66
|
-
showFooter={false}
|
|
67
|
-
showDelete={child.type === "fk"}
|
|
68
|
-
tableProps={{ showLastColumnBorder: false, showLastRowBorder: false, showCheckboxes: false }}
|
|
69
|
-
/>
|
|
70
|
-
{/key}
|
|
62
|
+
{#snippet headerLeft()}
|
|
63
|
+
<div class="flex items-center gap-2 px-1">
|
|
64
|
+
<Table size="14" class="text-muted-foreground" />
|
|
65
|
+
<span class="text-sm font-medium">{child.collection}</span>
|
|
71
66
|
</div>
|
|
72
|
-
|
|
73
|
-
</
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
{/snippet}
|
|
68
|
+
</DataTable>
|
|
69
|
+
</div>
|
|
70
|
+
{/each}
|
|
76
71
|
</div>
|
|
77
72
|
{/if}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
type PendingOps = {
|
|
2
|
+
link?: (string | number)[];
|
|
3
|
+
unlink?: (string | number)[];
|
|
4
|
+
delete?: (string | number)[];
|
|
5
|
+
create?: any[];
|
|
6
|
+
};
|
|
1
7
|
interface LocalProp {
|
|
2
8
|
collectionName: string;
|
|
3
9
|
entry: any;
|
|
10
|
+
pendingChildren?: Record<string, PendingOps>;
|
|
4
11
|
}
|
|
5
|
-
declare const DetailViewChildren: import("svelte").Component<LocalProp, {}, "">;
|
|
12
|
+
declare const DetailViewChildren: import("svelte").Component<LocalProp, {}, "pendingChildren">;
|
|
6
13
|
type DetailViewChildren = ReturnType<typeof DetailViewChildren>;
|
|
7
14
|
export default DetailViewChildren;
|
|
@@ -57,15 +57,18 @@
|
|
|
57
57
|
getChangedProperties(initialEntry, $state.snapshot(entry)),
|
|
58
58
|
);
|
|
59
59
|
let fieldsErrors: Record<string, any> = $state({});
|
|
60
|
+
let pendingChildren = $state<Record<string, any>>({});
|
|
60
61
|
|
|
61
62
|
async function handleSave() {
|
|
62
63
|
delete localEntry.id;
|
|
63
64
|
localEntry = serializeEntry(ctx, collectionName, localEntry);
|
|
64
65
|
|
|
66
|
+
const children = Object.keys(pendingChildren).length ? pendingChildren : undefined;
|
|
65
67
|
const response = await lobb.updateOne(
|
|
66
68
|
collectionName,
|
|
67
69
|
recordId,
|
|
68
70
|
localEntry,
|
|
71
|
+
children,
|
|
69
72
|
);
|
|
70
73
|
|
|
71
74
|
if (!response.bodyUsed) {
|
|
@@ -145,7 +148,7 @@
|
|
|
145
148
|
{/each}
|
|
146
149
|
</div>
|
|
147
150
|
{#if showRelatedRecords}
|
|
148
|
-
<DetailViewChildren {collectionName} {entry} />
|
|
151
|
+
<DetailViewChildren {collectionName} {entry} bind:pendingChildren />
|
|
149
152
|
{/if}
|
|
150
153
|
</div>
|
|
151
154
|
<div class="flex h-12 items-center justify-end gap-2 border-t px-4">
|
|
@@ -163,7 +166,7 @@
|
|
|
163
166
|
class="h-7 px-3 text-xs font-normal"
|
|
164
167
|
Icon={submitButton?.icon ? submitButton.icon : Pencil}
|
|
165
168
|
onclick={handleSave}
|
|
166
|
-
disabled={!Object.keys(localEntry).length}
|
|
169
|
+
disabled={!Object.keys(localEntry).length && !Object.keys(pendingChildren).length}
|
|
167
170
|
>
|
|
168
171
|
{submitButton?.text ? submitButton.text : "Update"}
|
|
169
172
|
</Button>
|
package/dist/utils.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { clsx } from "clsx";
|
|
2
2
|
import { twMerge } from "tailwind-merge";
|
|
3
|
+
import { isEqual } from "lodash";
|
|
3
4
|
import { MediaQuery } from 'svelte/reactivity';
|
|
4
5
|
export function cn() {
|
|
5
6
|
var inputs = [];
|
|
@@ -26,7 +27,7 @@ export function getChangedProperties(oldObj, newObj) {
|
|
|
26
27
|
var changes = {};
|
|
27
28
|
for (var _i = 0, _a = Object.keys(newObj); _i < _a.length; _i++) {
|
|
28
29
|
var key = _a[_i];
|
|
29
|
-
if (oldObj[key]
|
|
30
|
+
if (!isEqual(oldObj[key], newObj[key])) {
|
|
30
31
|
changes[key] = newObj[key];
|
|
31
32
|
}
|
|
32
33
|
}
|
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.27.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.30.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",
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface ParentContext {
|
|
3
|
+
collectionName: string;
|
|
4
|
+
recordId: string | number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type RecordOperation =
|
|
8
|
+
| { type: "link"; record: any }
|
|
9
|
+
| { type: "unlink"; id: string | number }
|
|
10
|
+
| { type: "delete"; id: string | number }
|
|
11
|
+
| { type: "create"; record: any }
|
|
12
|
+
| { type: "update"; id: string | number; data: any };
|
|
13
|
+
</script>
|
|
14
|
+
|
|
1
15
|
<script lang="ts">
|
|
2
16
|
import _ from "lodash";
|
|
3
17
|
import { getStudioContext } from "../../context";
|
|
@@ -5,7 +19,7 @@
|
|
|
5
19
|
import Header from "./header.svelte";
|
|
6
20
|
import Table, { type TableProps } from "./table.svelte";
|
|
7
21
|
import { getCollectionColumns, getCollectionParamsFields } from "./utils";
|
|
8
|
-
import { Pencil, Trash } from "lucide-svelte";
|
|
22
|
+
import { Pencil, Trash, Unlink } from "lucide-svelte";
|
|
9
23
|
import * as icons from "lucide-svelte";
|
|
10
24
|
import ListViewChildren from "./listViewChildren.svelte";
|
|
11
25
|
import FieldCell from "./fieldCell.svelte";
|
|
@@ -26,8 +40,11 @@
|
|
|
26
40
|
collectionName: string;
|
|
27
41
|
filter?: any;
|
|
28
42
|
searchParams?: Record<string, any>;
|
|
43
|
+
parentContext?: ParentContext;
|
|
44
|
+
onOperation?: (op: RecordOperation) => void;
|
|
29
45
|
showHeader?: boolean;
|
|
30
46
|
showFooter?: boolean;
|
|
47
|
+
showImport?: boolean;
|
|
31
48
|
unifiedBgColor?: "bg-muted/30" | "bg-background";
|
|
32
49
|
showDelete?: boolean;
|
|
33
50
|
tableProps?: Partial<TableProps>;
|
|
@@ -38,8 +55,11 @@
|
|
|
38
55
|
collectionName,
|
|
39
56
|
filter,
|
|
40
57
|
searchParams,
|
|
58
|
+
parentContext,
|
|
59
|
+
onOperation,
|
|
41
60
|
showHeader = true,
|
|
42
61
|
showFooter = true,
|
|
62
|
+
showImport = true,
|
|
43
63
|
unifiedBgColor,
|
|
44
64
|
showDelete = false,
|
|
45
65
|
tableProps,
|
|
@@ -78,7 +98,7 @@
|
|
|
78
98
|
let dataTableWidth: number = $state(0);
|
|
79
99
|
const doesCollectionHasChildren = $derived(
|
|
80
100
|
(ctx.meta.collections[collectionName]?.children ?? [])
|
|
81
|
-
.some((c: any) => c.type === "fk" || c.type === "m2m")
|
|
101
|
+
.some((c: any) => c.type === "fk" || c.type === "m2m" || c.type === "polymorphic")
|
|
82
102
|
);
|
|
83
103
|
|
|
84
104
|
// requests the data from the server when the params is changed
|
|
@@ -109,14 +129,44 @@
|
|
|
109
129
|
loading = false;
|
|
110
130
|
}
|
|
111
131
|
|
|
132
|
+
// Internal handler: updates data optimistically then calls onOperation
|
|
133
|
+
function applyOperation(op: RecordOperation) {
|
|
134
|
+
if (op.type === "link") {
|
|
135
|
+
data = [...data, op.record];
|
|
136
|
+
} else if (op.type === "unlink" || op.type === "delete") {
|
|
137
|
+
data = data.filter((r: any) => String(r.id) !== String(op.id));
|
|
138
|
+
} else if (op.type === "create") {
|
|
139
|
+
data = [...data, { ...op.record, _pending: true }];
|
|
140
|
+
} else if (op.type === "update") {
|
|
141
|
+
data = data.map((r: any) => String(r.id) === String(op.id) ? { ...r, ...op.data } : r);
|
|
142
|
+
}
|
|
143
|
+
onOperation?.(op);
|
|
144
|
+
}
|
|
145
|
+
|
|
112
146
|
async function handleDelete(entryId: string) {
|
|
113
|
-
const result = await showDialog(
|
|
114
|
-
"Are you sure?",
|
|
115
|
-
"This will delete the record you selected.",
|
|
116
|
-
);
|
|
147
|
+
const result = await showDialog("Are you sure?", "This will permanently delete the record.");
|
|
117
148
|
if (result) {
|
|
118
|
-
|
|
119
|
-
|
|
149
|
+
if (onOperation) {
|
|
150
|
+
applyOperation({ type: "delete", id: entryId });
|
|
151
|
+
} else if (parentContext) {
|
|
152
|
+
await lobb.updateOne(parentContext.collectionName, String(parentContext.recordId), {}, { [collectionName]: { delete: [entryId] } });
|
|
153
|
+
params = { ...params };
|
|
154
|
+
} else {
|
|
155
|
+
await lobb.deleteOne(collectionName, entryId);
|
|
156
|
+
params = { ...params };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function handleUnlink(entryId: string) {
|
|
162
|
+
const result = await showDialog("Are you sure?", "This will unlink the record without deleting it.");
|
|
163
|
+
if (result) {
|
|
164
|
+
if (onOperation) {
|
|
165
|
+
applyOperation({ type: "unlink", id: entryId });
|
|
166
|
+
} else {
|
|
167
|
+
await lobb.updateOne(parentContext!.collectionName, String(parentContext!.recordId), {}, { [collectionName]: { unlink: [entryId] } });
|
|
168
|
+
params = { ...params };
|
|
169
|
+
}
|
|
120
170
|
}
|
|
121
171
|
}
|
|
122
172
|
|
|
@@ -159,7 +209,7 @@
|
|
|
159
209
|
{/snippet}
|
|
160
210
|
|
|
161
211
|
{#if showHeader}
|
|
162
|
-
<Header bind:params {collectionName} bind:selectedRecords>
|
|
212
|
+
<Header bind:params {collectionName} bind:selectedRecords {parentContext} {showImport} onOperation={onOperation ? applyOperation : undefined}>
|
|
163
213
|
{#snippet left()}
|
|
164
214
|
{@render headerLeft?.()}
|
|
165
215
|
{/snippet}
|
|
@@ -200,6 +250,16 @@
|
|
|
200
250
|
params = { ...params };
|
|
201
251
|
}}
|
|
202
252
|
></UpdateDetailViewButton>
|
|
253
|
+
{#if parentContext}
|
|
254
|
+
<Button
|
|
255
|
+
class="h-6 w-6 text-muted-foreground hover:bg-transparent"
|
|
256
|
+
variant="ghost"
|
|
257
|
+
size="icon"
|
|
258
|
+
onclick={() => handleUnlink(entry.id)}
|
|
259
|
+
Icon={Unlink}
|
|
260
|
+
title="Remove from this entry"
|
|
261
|
+
></Button>
|
|
262
|
+
{/if}
|
|
203
263
|
{#if showDelete}
|
|
204
264
|
<Button
|
|
205
265
|
class="h-6 w-6 text-muted-foreground hover:bg-transparent"
|
|
@@ -207,6 +267,7 @@
|
|
|
207
267
|
size="icon"
|
|
208
268
|
onclick={() => handleDelete(entry.id)}
|
|
209
269
|
Icon={Trash}
|
|
270
|
+
title="Delete permanently"
|
|
210
271
|
></Button>
|
|
211
272
|
{/if}
|
|
212
273
|
{#await getWorkflowTools($state.snapshot(entry))}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { getStudioContext } from "../../context";
|
|
3
3
|
|
|
4
4
|
const { lobb, ctx } = getStudioContext();
|
|
5
|
-
import { Download, ListRestart, Plus, Trash } from "lucide-svelte";
|
|
5
|
+
import { Download, ListRestart, Plus, Trash, Link } from "lucide-svelte";
|
|
6
6
|
import * as Tooltip from "../ui/tooltip";
|
|
7
7
|
import LlmButton from "../LlmButton.svelte";
|
|
8
8
|
import FilterButton from "./filterButton.svelte";
|
|
@@ -11,14 +11,19 @@
|
|
|
11
11
|
import ImportButton from "../importButton.svelte";
|
|
12
12
|
import { showDialog } from "../../actions";
|
|
13
13
|
import CreateDetailViewButton from "../detailView/create/createDetailViewButton.svelte";
|
|
14
|
+
import SelectRecord from "../selectRecord.svelte";
|
|
14
15
|
import ExtensionsComponents from "../extensionsComponents.svelte";
|
|
15
16
|
import { getExtensionUtils } from "../../extensions/extensionUtils";
|
|
16
17
|
import type { Snippet } from "svelte";
|
|
18
|
+
import type { ParentContext, RecordOperation } from "./dataTable.svelte";
|
|
17
19
|
|
|
18
20
|
interface Props {
|
|
19
21
|
collectionName: string;
|
|
20
22
|
params: any;
|
|
21
23
|
selectedRecords: string[];
|
|
24
|
+
parentContext?: ParentContext;
|
|
25
|
+
onOperation?: (op: RecordOperation) => void;
|
|
26
|
+
showImport?: boolean;
|
|
22
27
|
left?: Snippet<[]>;
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -26,9 +31,42 @@
|
|
|
26
31
|
collectionName,
|
|
27
32
|
params = $bindable(),
|
|
28
33
|
selectedRecords = $bindable(),
|
|
34
|
+
parentContext,
|
|
35
|
+
onOperation,
|
|
36
|
+
showImport = true,
|
|
29
37
|
left
|
|
30
38
|
}: Props = $props();
|
|
31
39
|
|
|
40
|
+
async function handleLink(selected: any) {
|
|
41
|
+
if (!parentContext) return;
|
|
42
|
+
if (onOperation) {
|
|
43
|
+
onOperation({ type: "link", record: selected });
|
|
44
|
+
} else {
|
|
45
|
+
await lobb.updateOne(
|
|
46
|
+
parentContext.collectionName,
|
|
47
|
+
String(parentContext.recordId),
|
|
48
|
+
{},
|
|
49
|
+
{ [collectionName]: { link: [selected.id] } },
|
|
50
|
+
);
|
|
51
|
+
resetTable();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function handleChildCreate(formData: any) {
|
|
56
|
+
if (!parentContext) return;
|
|
57
|
+
if (onOperation) {
|
|
58
|
+
onOperation({ type: "create", record: formData });
|
|
59
|
+
} else {
|
|
60
|
+
await lobb.updateOne(
|
|
61
|
+
parentContext.collectionName,
|
|
62
|
+
String(parentContext.recordId),
|
|
63
|
+
{},
|
|
64
|
+
{ [collectionName]: { create: [formData] } },
|
|
65
|
+
);
|
|
66
|
+
resetTable();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
32
70
|
let headerWidth: number = $state(0);
|
|
33
71
|
let headerIsSmall: boolean = $derived(headerWidth < 560);
|
|
34
72
|
|
|
@@ -138,34 +176,60 @@
|
|
|
138
176
|
>
|
|
139
177
|
{headerIsSmall ? "" : "Refresh"}
|
|
140
178
|
</Button>
|
|
141
|
-
|
|
142
|
-
<Tooltip.
|
|
143
|
-
<Tooltip.
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
179
|
+
{#if showImport}
|
|
180
|
+
<Tooltip.Provider delayDuration={0}>
|
|
181
|
+
<Tooltip.Root>
|
|
182
|
+
<Tooltip.Trigger>
|
|
183
|
+
<ImportButton
|
|
184
|
+
{collectionName}
|
|
185
|
+
variant="outline"
|
|
186
|
+
class="h-7 px-2 text-xs font-normal"
|
|
187
|
+
Icon={Download}
|
|
188
|
+
onSuccessfullSave={() => (params = { ...params })}
|
|
189
|
+
/>
|
|
190
|
+
</Tooltip.Trigger>
|
|
191
|
+
<Tooltip.Content>Import</Tooltip.Content>
|
|
192
|
+
</Tooltip.Root>
|
|
193
|
+
</Tooltip.Provider>
|
|
194
|
+
{/if}
|
|
155
195
|
<ExtensionsComponents
|
|
156
196
|
name="listView.header.actions"
|
|
157
197
|
utils={getExtensionUtils(lobb, ctx)}
|
|
158
198
|
{collectionName}
|
|
159
199
|
refresh={() => { params = { ...params }; }}
|
|
160
200
|
/>
|
|
161
|
-
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
201
|
+
{#if parentContext}
|
|
202
|
+
{#if parentContext}
|
|
203
|
+
<SelectRecord
|
|
204
|
+
{collectionName}
|
|
205
|
+
variant="outline"
|
|
206
|
+
class="h-7 px-3 text-xs font-normal"
|
|
207
|
+
Icon={Link}
|
|
208
|
+
onSelect={handleLink}
|
|
209
|
+
>
|
|
210
|
+
{headerIsSmall ? "" : "Link"}
|
|
211
|
+
</SelectRecord>
|
|
212
|
+
{/if}
|
|
213
|
+
<CreateDetailViewButton
|
|
214
|
+
{collectionName}
|
|
215
|
+
variant="default"
|
|
216
|
+
class="h-7 px-3 text-xs font-normal"
|
|
217
|
+
Icon={Plus}
|
|
218
|
+
rollback={true}
|
|
219
|
+
onSuccessfullSave={handleChildCreate}
|
|
220
|
+
>
|
|
221
|
+
{headerIsSmall ? "" : "Create"}
|
|
222
|
+
</CreateDetailViewButton>
|
|
223
|
+
{:else}
|
|
224
|
+
<CreateDetailViewButton
|
|
225
|
+
{collectionName}
|
|
226
|
+
variant="default"
|
|
227
|
+
class="h-7 px-3 text-xs font-normal"
|
|
228
|
+
Icon={Plus}
|
|
229
|
+
onSuccessfullSave={() => (params = { ...params })}
|
|
230
|
+
>
|
|
231
|
+
{headerIsSmall ? "" : "Create"}
|
|
232
|
+
</CreateDetailViewButton>
|
|
233
|
+
{/if}
|
|
170
234
|
</div>
|
|
171
235
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { getStudioContext } from "../../context";
|
|
3
|
-
import { ChevronRight, Table, Plus } from "lucide-svelte";
|
|
3
|
+
import { ChevronRight, Table, Plus, Link, ArrowLeftRight, GitFork } from "lucide-svelte";
|
|
4
4
|
import DataTable from "./dataTable.svelte";
|
|
5
5
|
import CreateDetailViewButton from "../detailView/create/createDetailViewButton.svelte";
|
|
6
6
|
import ExtensionsComponents from "../extensionsComponents.svelte";
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
let { collectionName, recordId, width, unifiedBgColor }: Props = $props();
|
|
19
19
|
|
|
20
20
|
const children = (ctx.meta.collections[collectionName]?.children ?? [])
|
|
21
|
-
.filter((c: any) => c.type === "fk" || c.type === "m2m");
|
|
21
|
+
.filter((c: any) => c.type === "fk" || c.type === "m2m" || c.type === "polymorphic");
|
|
22
22
|
|
|
23
23
|
let expandedRows: boolean[] = $state(new Array(children.length).fill(false));
|
|
24
24
|
let refreshDataTable = $state(true);
|
|
@@ -49,6 +49,13 @@
|
|
|
49
49
|
/>
|
|
50
50
|
<Table size="17.5" class="text-muted-foreground" />
|
|
51
51
|
<div class="text-muted-foreground">{child.collection}</div>
|
|
52
|
+
{#if child.type === "fk"}
|
|
53
|
+
<span title="Direct (FK)"><Link size="13" class="text-muted-foreground/50" /></span>
|
|
54
|
+
{:else if child.type === "m2m"}
|
|
55
|
+
<span title="Many to Many"><ArrowLeftRight size="13" class="text-muted-foreground/50" /></span>
|
|
56
|
+
{:else if child.type === "polymorphic"}
|
|
57
|
+
<span title="Polymorphic"><GitFork size="13" class="text-muted-foreground/50" /></span>
|
|
58
|
+
{/if}
|
|
52
59
|
</button>
|
|
53
60
|
{#if child.type === "fk"}
|
|
54
61
|
<div class="flex items-center px-2">
|
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
<div
|
|
173
173
|
style="
|
|
174
174
|
display: grid;
|
|
175
|
-
grid-template-columns: minmax(auto,
|
|
175
|
+
grid-template-columns: minmax(auto, 10rem) repeat({columnsLength - 1}, minmax(auto, 15rem)){rowActionsExists ? ' minmax(auto, 7.5rem)' : ''};
|
|
176
176
|
grid-template-rows: 2.5rem;
|
|
177
177
|
"
|
|
178
178
|
>
|
|
@@ -1,77 +1,72 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import DataTable from "../../../components/dataTable/dataTable.svelte";
|
|
2
|
+
import DataTable, { type RecordOperation } from "../../../components/dataTable/dataTable.svelte";
|
|
3
3
|
import { getStudioContext } from "../../../context";
|
|
4
|
-
import {
|
|
5
|
-
import CreateDetailViewButton from "../create/createDetailViewButton.svelte";
|
|
6
|
-
import ExtensionsComponents from "../../../components/extensionsComponents.svelte";
|
|
7
|
-
import { getExtensionUtils } from "../../../extensions/extensionUtils";
|
|
4
|
+
import { Table, Link } from "lucide-svelte";
|
|
8
5
|
|
|
9
|
-
const { ctx
|
|
6
|
+
const { ctx } = getStudioContext();
|
|
7
|
+
|
|
8
|
+
type PendingOps = {
|
|
9
|
+
link?: (string | number)[];
|
|
10
|
+
unlink?: (string | number)[];
|
|
11
|
+
delete?: (string | number)[];
|
|
12
|
+
create?: any[];
|
|
13
|
+
};
|
|
10
14
|
|
|
11
15
|
interface LocalProp {
|
|
12
16
|
collectionName: string;
|
|
13
17
|
entry: any;
|
|
18
|
+
pendingChildren?: Record<string, PendingOps>;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
let { collectionName, entry }: LocalProp = $props();
|
|
21
|
+
let { collectionName, entry, pendingChildren = $bindable({}) }: LocalProp = $props();
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
function makeHandler(collection: string) {
|
|
24
|
+
return (op: RecordOperation) => {
|
|
25
|
+
if (!pendingChildren[collection]) pendingChildren[collection] = {};
|
|
26
|
+
const c = pendingChildren[collection];
|
|
27
|
+
if (op.type === "link") {
|
|
28
|
+
(c.link ??= []).push(op.record.id);
|
|
29
|
+
} else if (op.type === "unlink") {
|
|
30
|
+
(c.unlink ??= []).push(op.id);
|
|
31
|
+
} else if (op.type === "delete") {
|
|
32
|
+
(c.delete ??= []).push(op.id);
|
|
33
|
+
} else if (op.type === "create") {
|
|
34
|
+
(c.create ??= []).push(op.record);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
20
38
|
|
|
21
|
-
const
|
|
39
|
+
const children = (ctx.meta.collections[collectionName]?.children ?? [])
|
|
40
|
+
.filter((c: any) => c.type === "fk" || c.type === "m2m" || c.type === "polymorphic");
|
|
22
41
|
</script>
|
|
23
42
|
|
|
24
43
|
{#if children.length}
|
|
25
|
-
<div class="flex flex-col gap-
|
|
44
|
+
<div class="flex flex-col gap-3 border-t p-4">
|
|
26
45
|
<div class="flex items-center gap-2">
|
|
27
|
-
<Link size="
|
|
28
|
-
<
|
|
46
|
+
<Link size="14" class="text-muted-foreground" />
|
|
47
|
+
<span class="text-sm font-medium">Sub Records</span>
|
|
29
48
|
</div>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<
|
|
33
|
-
name="detailView.update.subRecords.{child.collection}"
|
|
34
|
-
utils={getExtensionUtils(lobb, ctx)}
|
|
49
|
+
{#each children as child}
|
|
50
|
+
<div class="rounded-lg border bg-background overflow-hidden flex flex-col max-h-96">
|
|
51
|
+
<DataTable
|
|
35
52
|
collectionName={child.collection}
|
|
36
53
|
searchParams={{ children_of: collectionName, parent_id: entry.id }}
|
|
37
|
-
|
|
54
|
+
parentContext={{ collectionName, recordId: entry.id }}
|
|
55
|
+
onOperation={makeHandler(child.collection)}
|
|
56
|
+
showImport={false}
|
|
57
|
+
showHeader={true}
|
|
58
|
+
showFooter={true}
|
|
59
|
+
showDelete={child.type === "fk" || child.type === "m2m"}
|
|
60
|
+
tableProps={{ showLastColumnBorder: false, showLastRowBorder: true }}
|
|
38
61
|
>
|
|
39
|
-
|
|
40
|
-
<div class="flex items-center
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
<div class="text-sm text-muted-foreground">{child.collection}</div>
|
|
44
|
-
</div>
|
|
45
|
-
{#if child.type === "fk"}
|
|
46
|
-
<div class="flex gap-2">
|
|
47
|
-
<CreateDetailViewButton
|
|
48
|
-
variant="ghost"
|
|
49
|
-
class="h-7 px-2 font-normal text-xs"
|
|
50
|
-
Icon={Plus}
|
|
51
|
-
collectionName={child.collection}
|
|
52
|
-
onSuccessfullSave={async () => { refresh[index] = !refresh[index]; }}
|
|
53
|
-
>
|
|
54
|
-
Create
|
|
55
|
-
</CreateDetailViewButton>
|
|
56
|
-
</div>
|
|
57
|
-
{/if}
|
|
58
|
-
</div>
|
|
59
|
-
<div class="max-h-72 overflow-auto rounded-md">
|
|
60
|
-
{#key refresh[index]}
|
|
61
|
-
<DataTable
|
|
62
|
-
collectionName={child.collection}
|
|
63
|
-
searchParams={{ children_of: collectionName, parent_id: entry.id }}
|
|
64
|
-
unifiedBgColor="bg-muted/30"
|
|
65
|
-
showHeader={false}
|
|
66
|
-
showFooter={false}
|
|
67
|
-
showDelete={child.type === "fk"}
|
|
68
|
-
tableProps={{ showLastColumnBorder: false, showLastRowBorder: false, showCheckboxes: false }}
|
|
69
|
-
/>
|
|
70
|
-
{/key}
|
|
62
|
+
{#snippet headerLeft()}
|
|
63
|
+
<div class="flex items-center gap-2 px-1">
|
|
64
|
+
<Table size="14" class="text-muted-foreground" />
|
|
65
|
+
<span class="text-sm font-medium">{child.collection}</span>
|
|
71
66
|
</div>
|
|
72
|
-
|
|
73
|
-
</
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
{/snippet}
|
|
68
|
+
</DataTable>
|
|
69
|
+
</div>
|
|
70
|
+
{/each}
|
|
76
71
|
</div>
|
|
77
72
|
{/if}
|
|
@@ -57,15 +57,18 @@
|
|
|
57
57
|
getChangedProperties(initialEntry, $state.snapshot(entry)),
|
|
58
58
|
);
|
|
59
59
|
let fieldsErrors: Record<string, any> = $state({});
|
|
60
|
+
let pendingChildren = $state<Record<string, any>>({});
|
|
60
61
|
|
|
61
62
|
async function handleSave() {
|
|
62
63
|
delete localEntry.id;
|
|
63
64
|
localEntry = serializeEntry(ctx, collectionName, localEntry);
|
|
64
65
|
|
|
66
|
+
const children = Object.keys(pendingChildren).length ? pendingChildren : undefined;
|
|
65
67
|
const response = await lobb.updateOne(
|
|
66
68
|
collectionName,
|
|
67
69
|
recordId,
|
|
68
70
|
localEntry,
|
|
71
|
+
children,
|
|
69
72
|
);
|
|
70
73
|
|
|
71
74
|
if (!response.bodyUsed) {
|
|
@@ -145,7 +148,7 @@
|
|
|
145
148
|
{/each}
|
|
146
149
|
</div>
|
|
147
150
|
{#if showRelatedRecords}
|
|
148
|
-
<DetailViewChildren {collectionName} {entry} />
|
|
151
|
+
<DetailViewChildren {collectionName} {entry} bind:pendingChildren />
|
|
149
152
|
{/if}
|
|
150
153
|
</div>
|
|
151
154
|
<div class="flex h-12 items-center justify-end gap-2 border-t px-4">
|
|
@@ -163,7 +166,7 @@
|
|
|
163
166
|
class="h-7 px-3 text-xs font-normal"
|
|
164
167
|
Icon={submitButton?.icon ? submitButton.icon : Pencil}
|
|
165
168
|
onclick={handleSave}
|
|
166
|
-
disabled={!Object.keys(localEntry).length}
|
|
169
|
+
disabled={!Object.keys(localEntry).length && !Object.keys(pendingChildren).length}
|
|
167
170
|
>
|
|
168
171
|
{submitButton?.text ? submitButton.text : "Update"}
|
|
169
172
|
</Button>
|
package/src/lib/utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { clsx, type ClassValue } from "clsx";
|
|
2
2
|
import { twMerge } from "tailwind-merge";
|
|
3
|
+
import { isEqual } from "lodash";
|
|
3
4
|
|
|
4
5
|
import { MediaQuery } from 'svelte/reactivity';
|
|
5
6
|
|
|
@@ -34,7 +35,7 @@ export function calculateDrawerWidth() {
|
|
|
34
35
|
export function getChangedProperties(oldObj: Record<string, any>, newObj: Record<string, any>) {
|
|
35
36
|
const changes: Record<string, any> = {};
|
|
36
37
|
for (const key of Object.keys(newObj)) {
|
|
37
|
-
if (oldObj[key]
|
|
38
|
+
if (!isEqual(oldObj[key], newObj[key])) {
|
|
38
39
|
changes[key] = newObj[key];
|
|
39
40
|
}
|
|
40
41
|
}
|