@lobb-js/studio 0.42.0 → 0.44.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.
Files changed (38) hide show
  1. package/dist/components/confirmationDialog/confirmationDialog.svelte +1 -1
  2. package/dist/components/dataTable/dataTable.svelte +49 -17
  3. package/dist/components/dataTable/filter.svelte +26 -23
  4. package/dist/components/dataTable/header.svelte +17 -13
  5. package/dist/components/dataTable/header.svelte.d.ts +1 -0
  6. package/dist/components/dataTable/listViewChildren.svelte +60 -77
  7. package/dist/components/dataTable/listViewChildren.svelte.d.ts +1 -1
  8. package/dist/components/dataTable/table.svelte +20 -61
  9. package/dist/components/dataTable/table.svelte.d.ts +2 -2
  10. package/dist/components/detailView/changeTreeUtils.d.ts +7 -0
  11. package/dist/components/detailView/changeTreeUtils.js +47 -0
  12. package/dist/components/detailView/create/createDetailView.svelte +17 -13
  13. package/dist/components/detailView/detailView.svelte +7 -2
  14. package/dist/components/detailView/detailView.svelte.d.ts +1 -0
  15. package/dist/components/detailView/fieldInput.svelte +10 -9
  16. package/dist/components/detailView/fieldInput.svelte.d.ts +1 -0
  17. package/dist/components/detailView/update/updateDetailView.svelte +22 -18
  18. package/dist/components/drawer.svelte +15 -2
  19. package/dist/components/foreingKeyInput.svelte +32 -60
  20. package/dist/components/foreingKeyInput.svelte.d.ts +1 -1
  21. package/dist/components/polymorphicInput.svelte +42 -66
  22. package/dist/components/polymorphicInput.svelte.d.ts +1 -0
  23. package/package.json +2 -2
  24. package/src/app.css +2 -2
  25. package/src/lib/components/confirmationDialog/confirmationDialog.svelte +1 -1
  26. package/src/lib/components/dataTable/dataTable.svelte +49 -17
  27. package/src/lib/components/dataTable/filter.svelte +26 -23
  28. package/src/lib/components/dataTable/header.svelte +17 -13
  29. package/src/lib/components/dataTable/listViewChildren.svelte +60 -77
  30. package/src/lib/components/dataTable/table.svelte +20 -61
  31. package/src/lib/components/detailView/changeTreeUtils.ts +39 -0
  32. package/src/lib/components/detailView/create/createDetailView.svelte +17 -13
  33. package/src/lib/components/detailView/detailView.svelte +7 -2
  34. package/src/lib/components/detailView/fieldInput.svelte +10 -9
  35. package/src/lib/components/detailView/update/updateDetailView.svelte +22 -18
  36. package/src/lib/components/drawer.svelte +15 -2
  37. package/src/lib/components/foreingKeyInput.svelte +32 -60
  38. package/src/lib/components/polymorphicInput.svelte +42 -66
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { getStudioContext } from "../../context";
3
- import { ChevronRight, Table, Plus, Link, ArrowLeftRight, GitFork } from "lucide-svelte";
3
+ import { Table, Plus } 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";
@@ -11,93 +11,76 @@
11
11
  interface Props {
12
12
  collectionName: string;
13
13
  recordId: string;
14
- width: number;
14
+ width?: number;
15
15
  }
16
16
 
17
- let { collectionName, recordId, width }: Props = $props();
17
+ let { collectionName, recordId, width = 0 }: Props = $props();
18
18
 
19
19
  const children = (ctx.meta.collections[collectionName]?.children ?? [])
20
20
  .filter((c: any) => c.type === "fk" || c.type === "m2m" || c.type === "polymorphic");
21
21
 
22
- let expandedRows: boolean[] = $state(new Array(children.length).fill(false));
23
- let refreshDataTable = $state(true);
24
- let tableHeaderWidth = $state(0);
22
+ let activeTab = $state(children[0]?.collection ?? '');
23
+ let refreshKey = $state(0);
24
+ let counts = $state<Record<string, number>>({});
25
+
26
+ const activeChild = $derived(children.find((c: any) => c.collection === activeTab));
25
27
  </script>
26
28
 
27
- <div class="flex" style="width: {width}px;">
28
- <div
29
- class="flex justify-center border-r bg-background"
30
- style="width: 40px"
31
- ></div>
32
- <div class="flex-1 flex flex-col">
33
- {#each children as child, index}
34
- {@const lastRow = children.length - 1 === index}
35
- <div class="overflow-hidden bg-background">
36
- <div
37
- bind:clientWidth={tableHeaderWidth}
38
- class="flex justify-between items-center gap-2 text-sm h-10 {expandedRows[index] || !lastRow ? 'border-b' : ''}"
29
+ <div class="flex flex-col h-full overflow-hidden">
30
+ <!-- Tab bar -->
31
+ <div class="flex border-b shrink-0 overflow-x-auto">
32
+ {#each children as child}
33
+ <button
34
+ class="flex items-center gap-1.5 px-4 h-10 text-xs font-medium whitespace-nowrap border-b-2 transition-colors
35
+ {activeTab === child.collection
36
+ ? 'border-foreground text-foreground'
37
+ : 'border-transparent text-muted-foreground hover:text-foreground'}"
38
+ onclick={() => { activeTab = child.collection; }}
39
+ >
40
+ <Table size="11" class="opacity-50" />
41
+ {child.collection}
42
+ {#if counts[child.collection] !== undefined}
43
+ <span class="rounded-full bg-muted px-1.5 py-0.5 text-[0.65rem] text-muted-foreground">{counts[child.collection]}</span>
44
+ {/if}
45
+ </button>
46
+ {/each}
47
+ <!-- Create button for FK children -->
48
+ {#if activeChild?.type === "fk"}
49
+ <div class="ml-auto flex items-center px-2">
50
+ <CreateDetailViewButton
51
+ collectionName={activeChild.collection}
52
+ variant="ghost"
53
+ size="sm"
54
+ Icon={Plus}
55
+ values={{ [activeChild.field]: recordId }}
56
+ onSuccessfullSave={async () => { refreshKey++; }}
39
57
  >
40
- <button
41
- class="flex gap-2 px-2 flex-1 h-full items-center"
42
- onclick={() => { expandedRows[index] = !expandedRows[index]; }}
58
+ Create
59
+ </CreateDetailViewButton>
60
+ </div>
61
+ {/if}
62
+ </div>
63
+
64
+ <!-- Tab content — render all but hide inactive so counts load for all tabs -->
65
+ <div class="flex-1 overflow-hidden relative">
66
+ {#each children as child}
67
+ <div class="absolute inset-0 {activeTab === child.collection ? '' : 'invisible pointer-events-none'}">
68
+ {#key refreshKey}
69
+ <ExtensionsComponents
70
+ name="listView.entry.children.{child.collection}"
71
+ collectionName={child.collection}
72
+ searchParams={{ children_of: collectionName, parent_id: recordId }}
73
+ utils={getExtensionUtils(lobb, ctx)}
43
74
  >
44
- <ChevronRight
45
- size="17.5"
46
- class="text-muted-foreground transition-transform"
47
- style={expandedRows[index] ? "transform: rotate(90deg);" : "transform: rotate(0deg);"}
75
+ <DataTable
76
+ collectionName={child.collection}
77
+ searchParams={{ children_of: collectionName, parent_id: recordId }}
78
+ showDelete={child.type === "fk" || child.type === "m2m"}
79
+ tableProps={{ showLastRowBorder: true, showLastColumnBorder: true }}
80
+ onDataLoad={(total) => { counts[child.collection] = total; }}
48
81
  />
49
- <Table size="17.5" class="text-muted-foreground" />
50
- <div class="text-muted-foreground">{child.collection}</div>
51
- {#if child.type === "fk"}
52
- <span title="Direct (FK)"><Link size="13" class="text-muted-foreground/50" /></span>
53
- {:else if child.type === "m2m"}
54
- <span title="Many to Many"><ArrowLeftRight size="13" class="text-muted-foreground/50" /></span>
55
- {:else if child.type === "polymorphic"}
56
- <span title="Polymorphic"><GitFork size="13" class="text-muted-foreground/50" /></span>
57
- {/if}
58
- </button>
59
- {#if child.type === "fk"}
60
- <div class="flex items-center px-2">
61
- <CreateDetailViewButton
62
- collectionName={child.collection}
63
- variant="ghost"
64
- size="sm"
65
- Icon={Plus}
66
- values={{ [child.field]: recordId }}
67
- onSuccessfullSave={async () => { refreshDataTable = !refreshDataTable; }}
68
- >
69
- Create
70
- </CreateDetailViewButton>
71
- </div>
72
- {/if}
73
- </div>
74
- {#if expandedRows[index]}
75
- <div class="flex max-h-96 overflow-auto {lastRow ? '' : 'border-b'}">
76
- <div
77
- class="border-r"
78
- style="width: 100vw; max-width: 40px"
79
- ></div>
80
- <div class="flex-1" style="width: {tableHeaderWidth - 40}px;">
81
- {#key refreshDataTable}
82
- <ExtensionsComponents
83
- name="listView.entry.children.{child.collection}"
84
- collectionName={child.collection}
85
- searchParams={{ children_of: collectionName, parent_id: recordId }}
86
- utils={getExtensionUtils(lobb, ctx)}
87
- >
88
- <DataTable
89
- collectionName={child.collection}
90
- searchParams={{ children_of: collectionName, parent_id: recordId }}
91
- showHeader={false}
92
- showFooter={false}
93
- showDelete={child.type === "fk"}
94
- tableProps={{ showLastRowBorder: false, showLastColumnBorder: false, showCheckboxes: false }}
95
- />
96
- </ExtensionsComponents>
97
- {/key}
98
- </div>
99
- </div>
100
- {/if}
82
+ </ExtensionsComponents>
83
+ {/key}
101
84
  </div>
102
85
  {/each}
103
86
  </div>
@@ -15,8 +15,6 @@
15
15
  export interface TableProps {
16
16
  data: Entry[];
17
17
  columns?: Column[];
18
- showCollapsible?: boolean;
19
-
20
18
  // sorting
21
19
  sort?: Record<string, "asc" | "desc">;
22
20
  localSorting?: boolean;
@@ -33,15 +31,19 @@
33
31
 
34
32
  // snippets
35
33
  overrideCell?: Snippet<[any, Column, Entry]>;
34
+ preTools?: Snippet<[Entry, number]>;
36
35
  tools?: Snippet<[Entry, number]>;
37
36
  rowActions?: Snippet<[Entry, number]>;
38
- collapsible?: Snippet<[Entry, number]>;
39
-
40
37
  // other
41
38
  parentWidth?: number;
42
39
  select?: Select;
43
40
  tableWidth?: number;
44
41
 
42
+ // Hide the left sticky tools column entirely when the caller knows
43
+ // nothing inside it would render (e.g. select-mode drawer with no
44
+ // checkboxes, no edit/delete buttons, and no children-network icon).
45
+ showLeftTools?: boolean;
46
+
45
47
  // recording mode row visuals — cellIndex 0 = tools cell, 1+ = data/action cells
46
48
  onCellClass?: (entry: Entry, cellIndex: number) => string;
47
49
  }
@@ -51,7 +53,6 @@
51
53
  import {
52
54
  ArrowDownNarrowWide,
53
55
  ArrowUpWideNarrow,
54
- ChevronRight,
55
56
  CircleOff,
56
57
  } from "lucide-svelte";
57
58
  import Checkbox from "../ui/checkbox/checkbox.svelte";
@@ -66,7 +67,6 @@
66
67
  id: key,
67
68
  };
68
69
  }),
69
- showCollapsible = false,
70
70
  sort = $bindable({}),
71
71
  localSorting = false,
72
72
  selectedRecords = $bindable(),
@@ -77,20 +77,19 @@
77
77
  headerBorderTop = false,
78
78
  parentWidth,
79
79
  overrideCell,
80
+ preTools,
80
81
  tools,
81
82
  rowActions,
82
- collapsible,
83
83
  select,
84
84
  tableWidth = $bindable(),
85
+ showLeftTools = true,
85
86
  onCellClass,
86
87
  }: TableProps = $props();
87
88
 
88
- let expandedRows: boolean[] = $state(new Array(data.length).fill(false));
89
-
90
89
  // calculate columns count
91
- const toolsExists = selectedRecords || tools ? 1 : 0;
90
+ const toolsExists = $derived(showLeftTools && (selectedRecords || tools || preTools) ? 1 : 0);
92
91
  const rowActionsExists = $derived(rowActions ? 1 : 0);
93
- const columnsLength = columns.length + toolsExists;
92
+ const columnsLength = $derived(columns.length + toolsExists);
94
93
 
95
94
  // set table width
96
95
  let columnsWidths: number[] = $state([]);
@@ -173,13 +172,14 @@
173
172
  </script>
174
173
 
175
174
  <div
175
+ class="min-w-max"
176
176
  style="
177
177
  display: grid;
178
- grid-template-columns: minmax(auto, 10rem) repeat({columnsLength - 1}, minmax(auto, 15rem)){rowActionsExists ? ' minmax(auto, 7.5rem)' : ''};
178
+ grid-template-columns: fit-content(10rem) repeat({columnsLength - 1}, fit-content(15rem)){rowActionsExists ? ' fit-content(7.5rem)' : ''};
179
179
  grid-template-rows: 2.5rem;
180
180
  "
181
181
  >
182
- {#if selectedRecords || tools}
182
+ {#if showLeftTools && (selectedRecords || tools || preTools)}
183
183
  <div
184
184
  bind:clientWidth={columnsWidths[0]}
185
185
  class="
@@ -187,13 +187,9 @@
187
187
  flex items-center p-2.5 text-xs h-10
188
188
  border-r border-b gap-2
189
189
  {headerBorderTop ? 'border-t' : ''}
190
- bg-muted/50
190
+ bg-muted
191
191
  "
192
192
  >
193
- <!-- collapsable toggle -->
194
- {#if showCollapsible}
195
- <div class="w-[20px]"></div>
196
- {/if}
197
193
  {#if selectedRecords && showCheckboxes}
198
194
  <Checkbox
199
195
  class="border-muted-foreground hover:border-foreground"
@@ -212,7 +208,7 @@
212
208
  class="
213
209
  sticky top-0 z-10
214
210
  flex items-center p-2.5 text-xs h-10
215
- bg-muted/50
211
+ bg-muted
216
212
  {lastColumn && !showLastColumnBorder ? '' : 'border-r'}
217
213
  border-b gap-2
218
214
  {headerBorderTop ? 'border-t' : ''}
@@ -239,7 +235,7 @@
239
235
  class="
240
236
  sticky top-0 right-0 z-20
241
237
  flex items-center p-2.5 h-10
242
- bg-muted/50
238
+ bg-muted
243
239
  border-l border-b
244
240
  {headerBorderTop ? 'border-t' : ''}
245
241
  "
@@ -249,25 +245,12 @@
249
245
  {#each data as entry, index}
250
246
  {@const isDisabled = Boolean(entry.__disabled)}
251
247
  {@const lastRow = data.length - 1 === index}
252
- {#if selectedRecords || tools}
248
+ {#if showLeftTools && (selectedRecords || tools || preTools)}
253
249
  <div
254
250
  class="sticky left-0 flex items-center p-2.5 text-xs h-10 bg-card border-r gap-2 {onCellClass?.(entry, 0) ?? ''}"
255
251
  >
256
- <!-- collapsable toggle -->
257
- {#if showCollapsible}
258
- <Button
259
- variant="ghost"
260
- class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent transition-transform"
261
- style={expandedRows[index]
262
- ? "transform: rotate(90deg);"
263
- : "transform: rotate(0deg);"}
264
- Icon={ChevronRight}
265
- onclick={() => {
266
- expandedRows[index] = !expandedRows[index];
267
- expandedRows = [...expandedRows];
268
- }}
269
- disabled={isDisabled}
270
- ></Button>
252
+ {#if preTools && !isDisabled}
253
+ {@render preTools(entry, index)}
271
254
  {/if}
272
255
  {#if selectedRecords && showCheckboxes}
273
256
  <Checkbox
@@ -309,31 +292,7 @@
309
292
  {@render rowActions?.(entry, index)}
310
293
  </div>
311
294
  {/if}
312
- <!-- nested data -->
313
- <div
314
- style="grid-column: span {columnsLength + rowActionsExists};"
315
- class="
316
- {!showLastColumnBorder ? '' : 'border-r'}
317
- {lastRow && !showLastRowBorder ? '' : 'border-b'}
318
- "
319
- >
320
- <div
321
- style="
322
- {parentWidth ? `width: ${parentWidth}px` : ''};
323
- max-width: 100vw;
324
- {expandedRows[index] ? '' : 'height: 0px;'}
325
- "
326
- class="
327
- sticky left-0 top-0 overflow-auto bg-muted
328
-
329
- {expandedRows[index] ? 'border-t' : ''}
330
- "
331
- >
332
- {#if collapsible && expandedRows[index]}
333
- {@render collapsible(entry, index)}
334
- {/if}
335
- </div>
336
- </div>
295
+ <div style="grid-column: span {columnsLength + rowActionsExists};" class="{!showLastColumnBorder ? '' : 'border-r'} {lastRow && !showLastRowBorder ? '' : 'border-b'}"></div>
337
296
  {/each}
338
297
  {/if}
339
298
  </div>
@@ -0,0 +1,39 @@
1
+ import type { Changes, ChildrenChanges } from "./utils";
2
+
3
+ export interface TreeNode { label: string; children: TreeNode[]; }
4
+
5
+ export function buildChangeTree(c: Changes, fieldLabel = 'changed'): TreeNode[] {
6
+ const nodes: TreeNode[] = [];
7
+ let fieldCount = 0;
8
+ for (const [fieldName, val] of Object.entries(c.data ?? {})) {
9
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
10
+ const v = val as any;
11
+ const t = v.collection ?? fieldName;
12
+ if (v.delete === true) nodes.push({ label: `${t}: inline delete`, children: [] });
13
+ else if (v.update) nodes.push({ label: `${t}: inline edit`, children: [] });
14
+ else if (v.create) nodes.push({ label: `${t}: inline create`, children: [] });
15
+ } else { fieldCount++; }
16
+ }
17
+ if (fieldCount > 0) nodes.unshift({ label: `${fieldCount} field${fieldCount > 1 ? 's' : ''} ${fieldLabel}`, children: [] });
18
+ for (const [col, ch] of Object.entries(c.children ?? {}) as [string, ChildrenChanges][]) {
19
+ const kids: TreeNode[] = [];
20
+ for (let i = 0; i < ch.created.length; i++) kids.push({ label: 'new record', children: [] });
21
+ for (const r of ch.linked) kids.push({ label: `#${(r as any).id} linked`, children: [] });
22
+ for (const u of ch.updated) kids.push({ label: `#${u.id}`, children: buildChangeTree(u.changes) });
23
+ for (const r of ch.deleted) kids.push({ label: `#${(r as any).id} deleted`, children: [] });
24
+ for (const r of ch.unlinked) kids.push({ label: `#${(r as any).id} unlinked`, children: [] });
25
+ if (kids.length) nodes.push({ label: col, children: kids });
26
+ }
27
+ return nodes;
28
+ }
29
+
30
+ export function renderTree(nodes: TreeNode[], prefix = ''): string[] {
31
+ const lines: string[] = [];
32
+ nodes.forEach((node, i) => {
33
+ const last = i === nodes.length - 1;
34
+ lines.push(`${prefix}${last ? '└── ' : '├── '}${node.label}`);
35
+ if (node.children.length)
36
+ lines.push(...renderTree(node.children, prefix + (last ? ' ' : '│ ')));
37
+ });
38
+ return lines;
39
+ }
@@ -61,19 +61,15 @@
61
61
  const hasChildChanges = $derived(
62
62
  Object.values(changes.children).some((ch: ChildrenChanges) =>
63
63
  ch.created.length || ch.linked.length
64
+ ) ||
65
+ Object.values(changes.data).some((val: any) =>
66
+ val && typeof val === 'object' && !Array.isArray(val) && (val.create || val.update || val.delete)
64
67
  )
65
68
  );
66
69
 
67
- const changeSummaryLines = $derived.by(() => {
68
- const lines: string[] = [];
69
- const fieldCount = Object.keys(changes.data).length;
70
- if (fieldCount > 0) lines.push(`${fieldCount} field${fieldCount > 1 ? 's' : ''} filled`);
71
- for (const [col, ch] of Object.entries(changes.children) as [string, ChildrenChanges][]) {
72
- if (ch.created.length) lines.push(`${ch.created.length} created in ${col}`);
73
- if (ch.linked.length) lines.push(`${ch.linked.length} linked in ${col}`);
74
- }
75
- return lines;
76
- });
70
+ import { buildChangeTree, renderTree } from "../changeTreeUtils";
71
+
72
+ const changeSummaryLines = $derived(renderTree(buildChangeTree(changes, 'filled')));
77
73
 
78
74
  const fieldNames = Object.keys(ctx.meta.collections[collectionName].fields);
79
75
  let values = $state(getDefaultEntry(ctx, fieldNames, collectionName, passedValues));
@@ -92,7 +88,15 @@
92
88
  });
93
89
  });
94
90
 
91
+ function cleanFkField(val: any): any {
92
+ if (!val || typeof val !== 'object' || Array.isArray(val)) return val;
93
+ if (val.id && val.update) return { ...(val.collection ? { collection: val.collection } : {}), update: val.update };
94
+ return val;
95
+ }
96
+
95
97
  function buildPayload(changes: Changes): { data: Record<string, any>; children?: Record<string, any> } {
98
+ const data: Record<string, any> = {};
99
+ for (const [key, val] of Object.entries(changes.data)) data[key] = cleanFkField(val);
96
100
  const result: Record<string, any> = {};
97
101
  for (const [collection, ops] of Object.entries(changes.children)) {
98
102
  const hasOps = ops.created.length || ops.linked.length;
@@ -103,7 +107,7 @@
103
107
  };
104
108
  }
105
109
  const children = Object.keys(result).length ? result : undefined;
106
- return { data: changes.data, ...(children ? { children } : {}) };
110
+ return { data, ...(children ? { children } : {}) };
107
111
  }
108
112
 
109
113
  function handleCancel() {
@@ -114,7 +118,7 @@
114
118
  if (!isRecordingMode && hasChildChanges && changeSummaryLines.length > 0) {
115
119
  const confirmed = await showDialog(
116
120
  "Confirm changes",
117
- changeSummaryLines.map(l => `• ${l}`).join('\n')
121
+ changeSummaryLines.join('\n')
118
122
  );
119
123
  if (!confirmed) return;
120
124
  }
@@ -126,7 +130,7 @@
126
130
  onChanges?.(snap);
127
131
  if (onSuccessfullSave) await onSuccessfullSave(snap);
128
132
  await onCreated?.(snap.data);
129
- toast.success(`The record was successfully created`);
133
+ if (!isRecordingMode) toast.success(`The record was successfully created`);
130
134
  handleCancel();
131
135
  return;
132
136
  }
@@ -12,12 +12,14 @@
12
12
  collectionName: string;
13
13
  entry: Record<string, any>;
14
14
  fieldsErrors?: Record<string, string[]>;
15
+ changedFields?: string[];
15
16
  }
16
17
 
17
18
  let {
18
19
  collectionName,
19
20
  entry = $bindable(),
20
21
  fieldsErrors = {},
22
+ changedFields = [],
21
23
  }: Props = $props();
22
24
 
23
25
  const { lobb, ctx } = getStudioContext();
@@ -31,13 +33,15 @@
31
33
  );
32
34
  </script>
33
35
 
34
- <div class="flex flex-col gap-4 p-4">
36
+ <div class="grid grid-cols-2 gap-4 p-4">
35
37
  {#each fieldNames as fieldName}
36
38
  {#if !ctx.meta.collections[collectionName].fields[fieldName]?.ui?.hidden}
37
39
  {@const field = getField(ctx, fieldName, collectionName)}
38
40
  {@const FieldIcon = getFieldIcon(ctx, fieldName, collectionName)}
39
41
  {@const description = ctx.meta.collections[collectionName].fields[fieldName]?.description}
40
- <div class="flex flex-col gap-2">
42
+ {@const fieldDef = ctx.meta.collections[collectionName].fields[fieldName]}
43
+ {@const isFullWidth = field.type === "text" || field.type === "polymorphic" || fieldDef?.ui?.input?.type === "richtext" || fieldDef?.ui?.span === 2}
44
+ <div class="flex flex-col gap-2 {isFullWidth ? 'col-span-2' : 'col-span-1'}">
41
45
  <div class="flex flex-1 items-end justify-between gap-2 text-xs">
42
46
  <div class="flex items-center gap-1.5">
43
47
  <ExtensionsComponents
@@ -80,6 +84,7 @@
80
84
  bind:value={entry[fieldName]}
81
85
  bind:entry
82
86
  errorMessages={fieldsErrors[fieldName]}
87
+ changed={changedFields.includes(fieldName)}
83
88
  />
84
89
  </div>
85
90
  {/if}
@@ -24,6 +24,7 @@
24
24
  value: any;
25
25
  errorMessages?: string[];
26
26
  entry?: Record<string, any>;
27
+ changed?: boolean;
27
28
  }
28
29
 
29
30
  let {
@@ -32,6 +33,7 @@
32
33
  value = $bindable(),
33
34
  errorMessages = [],
34
35
  entry = $bindable(),
36
+ changed = false,
35
37
  }: Props = $props();
36
38
 
37
39
  const ui_input =
@@ -44,6 +46,7 @@
44
46
  const isDisabled = field.key === 'id' || Boolean(ui?.disabled)
45
47
  const disabledClasses = "pointer-events-none opacity-50";
46
48
  const destructive: boolean = $derived(!isDisabled && Boolean(errorMessages.length));
49
+ const changedClass = $derived(changed && !destructive ? '!bg-orange-500/5' : '');
47
50
 
48
51
  </script>
49
52
 
@@ -72,6 +75,7 @@
72
75
  <PolymorphicInput
73
76
  collectionField={polymorphicRelation.from.collection_field}
74
77
  idField={polymorphicRelation.from.id_field}
78
+ virtualField={polymorphicRelation.from.virtual_field ?? ''}
75
79
  targetCollections={polymorphicRelation.to}
76
80
  bind:entry
77
81
  {destructive}
@@ -87,7 +91,7 @@
87
91
  {:else if field.label === "id"}
88
92
  <Input
89
93
  placeholder="AUTO GENERATED"
90
- class="bg-muted text-xs"
94
+ class="bg-muted text-xs {changedClass}"
91
95
  bind:value
92
96
  />
93
97
  {:else if fieldRelationTarget && entry}
@@ -126,10 +130,7 @@
126
130
  }
127
131
  >
128
132
  <Select.Trigger
129
- class="
130
- h-9 w-full bg-muted pr-8
131
- {destructive ? 'border-destructive bg-destructive/10' : ''}
132
- "
133
+ class="h-9 w-full bg-muted pr-8 {changedClass} {destructive ? 'border-destructive !bg-destructive/10' : ''}"
133
134
  >
134
135
  {#if value != null && enumOptions}
135
136
  <EnumBadge value={String(value)} enum={enumOptions} />
@@ -159,7 +160,7 @@
159
160
  placeholder={ui?.placeholder ? ui.placeholder : "NULL"}
160
161
  type="text"
161
162
  class="
162
- bg-muted text-xs
163
+ bg-muted text-xs {changedClass}
163
164
  {destructive ? 'border-destructive bg-destructive/10' : ''}
164
165
  "
165
166
  bind:value
@@ -169,7 +170,7 @@
169
170
  placeholder={ui?.placeholder ? ui.placeholder : value === "" ? "EMPTY STRING" : "NULL"}
170
171
  rows={5}
171
172
  class="
172
- bg-muted text-xs
173
+ bg-muted text-xs {changedClass}
173
174
  {destructive ? 'border-destructive bg-destructive/10' : ''}
174
175
  "
175
176
  bind:value
@@ -265,7 +266,7 @@
265
266
  scale={isFloat ? 20 : 0}
266
267
  groupDigits={ui?.groupDigits ?? false}
267
268
  class="
268
- bg-muted text-xs
269
+ bg-muted text-xs {changedClass}
269
270
  {destructive ? 'border-destructive bg-destructive/10' : ''}
270
271
  "
271
272
  bind:value
@@ -275,7 +276,7 @@
275
276
  placeholder={ui?.placeholder ? ui.placeholder : "NULL"}
276
277
  type="text"
277
278
  class="
278
- bg-muted text-xs
279
+ bg-muted text-xs {changedClass}
279
280
  {destructive ? 'border-destructive bg-destructive/10' : ''}
280
281
  "
281
282
  bind:value
@@ -29,6 +29,7 @@
29
29
  import { untrack } from "svelte";
30
30
  import { showDialog } from "../../../actions";
31
31
 
32
+
32
33
  const { lobb, ctx } = getStudioContext();
33
34
  import { getChangedProperties } from "../../../utils";
34
35
  import UpdateDetailViewChildren from "./updateDetailViewChildren.svelte";
@@ -81,22 +82,15 @@
81
82
  const hasChildChanges = $derived(
82
83
  Object.values(localChanges.children).some((ch: ChildrenChanges) =>
83
84
  ch.created.length || ch.updated.length || ch.deleted.length || ch.linked.length || ch.unlinked.length
85
+ ) ||
86
+ Object.values(localChanges.data).some((val: any) =>
87
+ val && typeof val === 'object' && !Array.isArray(val) && (val.create || val.update || val.delete)
84
88
  )
85
89
  );
86
90
 
87
- const changeSummaryLines = $derived.by(() => {
88
- const lines: string[] = [];
89
- const fieldCount = Object.keys(localChanges.data).length;
90
- if (fieldCount > 0) lines.push(`${fieldCount} field${fieldCount > 1 ? 's' : ''} changed`);
91
- for (const [col, ch] of Object.entries(localChanges.children) as [string, ChildrenChanges][]) {
92
- if (ch.created.length) lines.push(`${ch.created.length} created in ${col}`);
93
- if (ch.linked.length) lines.push(`${ch.linked.length} linked in ${col}`);
94
- if (ch.updated.length) lines.push(`${ch.updated.length} edited in ${col}`);
95
- if (ch.deleted.length) lines.push(`${ch.deleted.length} deleted from ${col}`);
96
- if (ch.unlinked.length) lines.push(`${ch.unlinked.length} unlinked from ${col}`);
97
- }
98
- return lines;
99
- });
91
+ import { buildChangeTree, renderTree } from "../changeTreeUtils";
92
+
93
+ const changeSummaryLines = $derived(renderTree(buildChangeTree(localChanges)));
100
94
 
101
95
  $effect(() => {
102
96
  const currentEntrySnap = $state.snapshot(values);
@@ -106,8 +100,18 @@
106
100
  });
107
101
  });
108
102
 
103
+ function cleanFkField(val: any): any {
104
+ if (!val || typeof val !== 'object' || Array.isArray(val)) return val;
105
+ // strip internal id from pending edit (server reads from DB)
106
+ if (val.id && val.update) return { ...(val.collection ? { collection: val.collection } : {}), update: val.update };
107
+ // { delete: true } — pass through as-is (no id needed)
108
+ return val;
109
+ }
110
+
109
111
  function buildPayload(changes: Changes): { data: Record<string, any>; children?: Record<string, any> } {
110
- const { id: _id, ...data } = changes.data;
112
+ const { id: _id, ...rawData } = changes.data;
113
+ const data: Record<string, any> = {};
114
+ for (const [key, val] of Object.entries(rawData)) data[key] = cleanFkField(val);
111
115
  const children = buildChildren(changes.children);
112
116
  return { data, ...(children ? { children } : {}) };
113
117
  }
@@ -140,7 +144,7 @@
140
144
  if (!isRecordingMode && hasChildChanges && changeSummaryLines.length > 0) {
141
145
  const confirmed = await showDialog(
142
146
  "Confirm changes",
143
- changeSummaryLines.map(l => `• ${l}`).join('\n')
147
+ changeSummaryLines.join('\n')
144
148
  );
145
149
  if (!confirmed) return;
146
150
  }
@@ -151,7 +155,7 @@
151
155
  if (response.status === 204) {
152
156
  onChanges?.(snap);
153
157
  if (onSuccessfullSave) await onSuccessfullSave(snap);
154
- toast.success(`The record was successfully updated`);
158
+ if (!isRecordingMode) toast.success(`The record was successfully updated`);
155
159
  onCancel?.();
156
160
  return;
157
161
  }
@@ -171,7 +175,7 @@
171
175
 
172
176
  onChanges?.(snap);
173
177
  if (onSuccessfullSave) await onSuccessfullSave(snap);
174
- toast.success(`The record was successfully updated`);
178
+ if (!isRecordingMode) toast.success(`The record was successfully updated`);
175
179
  onCancel?.();
176
180
  }
177
181
  </script>
@@ -196,7 +200,7 @@
196
200
  </div>
197
201
  </div>
198
202
  <div class="flex-1 overflow-y-auto">
199
- <DetailView {collectionName} bind:entry={values} {fieldsErrors} />
203
+ <DetailView {collectionName} bind:entry={values} {fieldsErrors} changedFields={Object.keys(localChanges.data)} />
200
204
  {#if showRelatedRecords}
201
205
  <UpdateDetailViewChildren {collectionName} entry={values} changes={localChanges} onChanges={(children) => { localChanges.children = children; }} />
202
206
  {/if}