@lobb-js/studio 0.41.0 → 0.43.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 (39) hide show
  1. package/dist/components/confirmationDialog/confirmationDialog.svelte +1 -1
  2. package/dist/components/dataTable/dataTable.svelte +215 -57
  3. package/dist/components/dataTable/header.svelte +5 -2
  4. package/dist/components/dataTable/header.svelte.d.ts +1 -0
  5. package/dist/components/dataTable/listViewChildren.svelte +60 -77
  6. package/dist/components/dataTable/listViewChildren.svelte.d.ts +1 -1
  7. package/dist/components/dataTable/table.svelte +18 -77
  8. package/dist/components/dataTable/table.svelte.d.ts +2 -2
  9. package/dist/components/detailView/changeTreeUtils.d.ts +7 -0
  10. package/dist/components/detailView/changeTreeUtils.js +47 -0
  11. package/dist/components/detailView/create/createDetailView.svelte +49 -3
  12. package/dist/components/detailView/create/createDetailView.svelte.d.ts +1 -0
  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 +46 -15
  18. package/dist/components/detailView/update/updateDetailViewButton.svelte +7 -0
  19. package/dist/components/detailView/update/updateDetailViewButton.svelte.d.ts +1 -0
  20. package/dist/components/drawer.svelte +16 -2
  21. package/dist/components/foreingKeyInput.svelte +177 -56
  22. package/dist/components/foreingKeyInput.svelte.d.ts +1 -1
  23. package/dist/components/polymorphicInput.svelte +128 -55
  24. package/dist/components/polymorphicInput.svelte.d.ts +1 -0
  25. package/package.json +2 -2
  26. package/src/lib/components/confirmationDialog/confirmationDialog.svelte +1 -1
  27. package/src/lib/components/dataTable/dataTable.svelte +215 -57
  28. package/src/lib/components/dataTable/header.svelte +5 -2
  29. package/src/lib/components/dataTable/listViewChildren.svelte +60 -77
  30. package/src/lib/components/dataTable/table.svelte +18 -77
  31. package/src/lib/components/detailView/changeTreeUtils.ts +39 -0
  32. package/src/lib/components/detailView/create/createDetailView.svelte +49 -3
  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 +46 -15
  36. package/src/lib/components/detailView/update/updateDetailViewButton.svelte +7 -0
  37. package/src/lib/components/drawer.svelte +16 -2
  38. package/src/lib/components/foreingKeyInput.svelte +177 -56
  39. package/src/lib/components/polymorphicInput.svelte +128 -55
@@ -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,14 +31,16 @@
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;
41
+
42
+ // recording mode row visuals — cellIndex 0 = tools cell, 1+ = data/action cells
43
+ onCellClass?: (entry: Entry, cellIndex: number) => string;
44
44
  }
45
45
  </script>
46
46
 
@@ -48,7 +48,6 @@
48
48
  import {
49
49
  ArrowDownNarrowWide,
50
50
  ArrowUpWideNarrow,
51
- ChevronRight,
52
51
  CircleOff,
53
52
  } from "lucide-svelte";
54
53
  import Checkbox from "../ui/checkbox/checkbox.svelte";
@@ -63,7 +62,6 @@
63
62
  id: key,
64
63
  };
65
64
  }),
66
- showCollapsible = false,
67
65
  sort = $bindable({}),
68
66
  localSorting = false,
69
67
  selectedRecords = $bindable(),
@@ -74,17 +72,16 @@
74
72
  headerBorderTop = false,
75
73
  parentWidth,
76
74
  overrideCell,
75
+ preTools,
77
76
  tools,
78
77
  rowActions,
79
- collapsible,
80
78
  select,
81
79
  tableWidth = $bindable(),
80
+ onCellClass,
82
81
  }: TableProps = $props();
83
82
 
84
- let expandedRows: boolean[] = $state(new Array(data.length).fill(false));
85
-
86
83
  // calculate columns count
87
- const toolsExists = selectedRecords || tools ? 1 : 0;
84
+ const toolsExists = selectedRecords || tools || preTools ? 1 : 0;
88
85
  const rowActionsExists = $derived(rowActions ? 1 : 0);
89
86
  const columnsLength = columns.length + toolsExists;
90
87
 
@@ -175,7 +172,7 @@
175
172
  grid-template-rows: 2.5rem;
176
173
  "
177
174
  >
178
- {#if selectedRecords || tools}
175
+ {#if selectedRecords || tools || preTools}
179
176
  <div
180
177
  bind:clientWidth={columnsWidths[0]}
181
178
  class="
@@ -183,13 +180,9 @@
183
180
  flex items-center p-2.5 text-xs h-10
184
181
  border-r border-b gap-2
185
182
  {headerBorderTop ? 'border-t' : ''}
186
- bg-muted
183
+ bg-muted/50
187
184
  "
188
185
  >
189
- <!-- collapsable toggle -->
190
- {#if showCollapsible}
191
- <div class="w-[20px]"></div>
192
- {/if}
193
186
  {#if selectedRecords && showCheckboxes}
194
187
  <Checkbox
195
188
  class="border-muted-foreground hover:border-foreground"
@@ -208,7 +201,7 @@
208
201
  class="
209
202
  sticky top-0 z-10
210
203
  flex items-center p-2.5 text-xs h-10
211
- bg-muted
204
+ bg-muted/50
212
205
  {lastColumn && !showLastColumnBorder ? '' : 'border-r'}
213
206
  border-b gap-2
214
207
  {headerBorderTop ? 'border-t' : ''}
@@ -235,7 +228,7 @@
235
228
  class="
236
229
  sticky top-0 right-0 z-20
237
230
  flex items-center p-2.5 h-10
238
- bg-muted
231
+ bg-muted/50
239
232
  border-l border-b
240
233
  {headerBorderTop ? 'border-t' : ''}
241
234
  "
@@ -245,30 +238,12 @@
245
238
  {#each data as entry, index}
246
239
  {@const isDisabled = Boolean(entry.__disabled)}
247
240
  {@const lastRow = data.length - 1 === index}
248
- {#if selectedRecords || tools}
241
+ {#if selectedRecords || tools || preTools}
249
242
  <div
250
- class="
251
- sticky left-0
252
- flex items-center p-2.5 text-xs h-10
253
- bg-card
254
- border-r gap-2
255
- "
243
+ class="sticky left-0 flex items-center p-2.5 text-xs h-10 bg-card border-r gap-2 {onCellClass?.(entry, 0) ?? ''}"
256
244
  >
257
- <!-- collapsable toggle -->
258
- {#if showCollapsible}
259
- <Button
260
- variant="ghost"
261
- class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent transition-transform"
262
- style={expandedRows[index]
263
- ? "transform: rotate(90deg);"
264
- : "transform: rotate(0deg);"}
265
- Icon={ChevronRight}
266
- onclick={() => {
267
- expandedRows[index] = !expandedRows[index];
268
- expandedRows = [...expandedRows];
269
- }}
270
- disabled={isDisabled}
271
- ></Button>
245
+ {#if preTools && !isDisabled}
246
+ {@render preTools(entry, index)}
272
247
  {/if}
273
248
  {#if selectedRecords && showCheckboxes}
274
249
  <Checkbox
@@ -294,12 +269,7 @@
294
269
  onclick={() => {
295
270
  select?.onSelect(entry);
296
271
  }}
297
- class="
298
- flex items-center p-2.5 text-xs h-10 text-nowrap overflow-clip
299
- {select ? 'cursor-pointer hover:bg-accent' : ''}
300
- bg-card
301
- {lastColumn && !showLastColumnBorder ? '' : 'border-r'}
302
- "
272
+ class="flex items-center p-2.5 text-xs h-10 text-nowrap overflow-clip bg-card {select ? 'cursor-pointer hover:bg-accent' : ''} {lastColumn && !showLastColumnBorder ? '' : 'border-r'} {onCellClass?.(entry, index + 1) ?? ''}"
303
273
  >
304
274
  {#if overrideCell}
305
275
  {@render overrideCell(fieldValue, column, entry)}
@@ -310,41 +280,12 @@
310
280
  {/each}
311
281
  {#if rowActions}
312
282
  <div
313
- class="
314
- sticky right-0 z-10
315
- flex items-center p-2.5 text-xs h-10
316
- border-l gap-2
317
- bg-card
318
- "
283
+ class="sticky right-0 z-10 flex items-center p-2.5 text-xs h-10 border-l gap-2 bg-card {onCellClass?.(entry, columns.length + 1) ?? ''}"
319
284
  >
320
285
  {@render rowActions?.(entry, index)}
321
286
  </div>
322
287
  {/if}
323
- <!-- nested data -->
324
- <div
325
- style="grid-column: span {columnsLength + rowActionsExists};"
326
- class="
327
- {!showLastColumnBorder ? '' : 'border-r'}
328
- {lastRow && !showLastRowBorder ? '' : 'border-b'}
329
- "
330
- >
331
- <div
332
- style="
333
- {parentWidth ? `width: ${parentWidth}px` : ''};
334
- max-width: 100vw;
335
- {expandedRows[index] ? '' : 'height: 0px;'}
336
- "
337
- class="
338
- sticky left-0 top-0 overflow-auto bg-muted
339
-
340
- {expandedRows[index] ? 'border-t' : ''}
341
- "
342
- >
343
- {#if collapsible && expandedRows[index]}
344
- {@render collapsible(entry, index)}
345
- {/if}
346
- </div>
347
- </div>
288
+ <div style="grid-column: span {columnsLength + rowActionsExists};" class="{!showLastColumnBorder ? '' : 'border-r'} {lastRow && !showLastRowBorder ? '' : 'border-b'}"></div>
348
289
  {/each}
349
290
  {/if}
350
291
  </div>
@@ -11,7 +11,6 @@ interface Select {
11
11
  export interface TableProps {
12
12
  data: Entry[];
13
13
  columns?: Column[];
14
- showCollapsible?: boolean;
15
14
  sort?: Record<string, "asc" | "desc">;
16
15
  localSorting?: boolean;
17
16
  selectedRecords?: Array<any>;
@@ -21,12 +20,13 @@ export interface TableProps {
21
20
  showLastColumnBorder?: boolean;
22
21
  headerBorderTop?: boolean;
23
22
  overrideCell?: Snippet<[any, Column, Entry]>;
23
+ preTools?: Snippet<[Entry, number]>;
24
24
  tools?: Snippet<[Entry, number]>;
25
25
  rowActions?: Snippet<[Entry, number]>;
26
- collapsible?: Snippet<[Entry, number]>;
27
26
  parentWidth?: number;
28
27
  select?: Select;
29
28
  tableWidth?: number;
29
+ onCellClass?: (entry: Entry, cellIndex: number) => string;
30
30
  }
31
31
  import type { Snippet } from "svelte";
32
32
  declare const Table: import("svelte").Component<TableProps, {}, "sort" | "selectedRecords" | "tableWidth">;
@@ -0,0 +1,7 @@
1
+ import type { Changes } from "./utils";
2
+ export interface TreeNode {
3
+ label: string;
4
+ children: TreeNode[];
5
+ }
6
+ export declare function buildChangeTree(c: Changes, fieldLabel?: string): TreeNode[];
7
+ export declare function renderTree(nodes: TreeNode[], prefix?: string): string[];
@@ -0,0 +1,47 @@
1
+ export function buildChangeTree(c, fieldLabel = 'changed') {
2
+ const nodes = [];
3
+ let fieldCount = 0;
4
+ for (const [fieldName, val] of Object.entries(c.data ?? {})) {
5
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
6
+ const v = val;
7
+ const t = v.collection ?? fieldName;
8
+ if (v.delete === true)
9
+ nodes.push({ label: `${t}: inline delete`, children: [] });
10
+ else if (v.update)
11
+ nodes.push({ label: `${t}: inline edit`, children: [] });
12
+ else if (v.create)
13
+ nodes.push({ label: `${t}: inline create`, children: [] });
14
+ }
15
+ else {
16
+ fieldCount++;
17
+ }
18
+ }
19
+ if (fieldCount > 0)
20
+ nodes.unshift({ label: `${fieldCount} field${fieldCount > 1 ? 's' : ''} ${fieldLabel}`, children: [] });
21
+ for (const [col, ch] of Object.entries(c.children ?? {})) {
22
+ const kids = [];
23
+ for (let i = 0; i < ch.created.length; i++)
24
+ kids.push({ label: 'new record', children: [] });
25
+ for (const r of ch.linked)
26
+ kids.push({ label: `#${r.id} linked`, children: [] });
27
+ for (const u of ch.updated)
28
+ kids.push({ label: `#${u.id}`, children: buildChangeTree(u.changes) });
29
+ for (const r of ch.deleted)
30
+ kids.push({ label: `#${r.id} deleted`, children: [] });
31
+ for (const r of ch.unlinked)
32
+ kids.push({ label: `#${r.id} unlinked`, children: [] });
33
+ if (kids.length)
34
+ nodes.push({ label: col, children: kids });
35
+ }
36
+ return nodes;
37
+ }
38
+ export function renderTree(nodes, prefix = '') {
39
+ const lines = [];
40
+ nodes.forEach((node, i) => {
41
+ const last = i === nodes.length - 1;
42
+ lines.push(`${prefix}${last ? '└── ' : '├── '}${node.label}`);
43
+ if (node.children.length)
44
+ lines.push(...renderTree(node.children, prefix + (last ? ' ' : '│ ')));
45
+ });
46
+ return lines;
47
+ }
@@ -14,6 +14,7 @@
14
14
  submitButton?: SubmitButton;
15
15
  title?: Snippet<[string]>;
16
16
  onSuccessfullSave?: (entry: any) => Promise<void>;
17
+ onCreated?: (record: any) => Promise<void>;
17
18
  onCancel?: () => Promise<void>;
18
19
  onChanges?: (changes: Changes) => void;
19
20
  }
@@ -25,6 +26,8 @@
25
26
  import { getStudioContext } from "../../../context";
26
27
  import { toast } from "svelte-sonner";
27
28
  import { untrack } from "svelte";
29
+ import type { ChildrenChanges } from "../utils";
30
+ import { showDialog } from "../../../actions";
28
31
 
29
32
  const { lobb, ctx } = getStudioContext();
30
33
  import CreateDetailViewChildren from "./createDetailViewChildren.svelte";
@@ -38,6 +41,7 @@
38
41
  showRelatedRecords = true,
39
42
  onCancel,
40
43
  onSuccessfullSave,
44
+ onCreated,
41
45
  title,
42
46
  submitButton,
43
47
  onChanges,
@@ -46,6 +50,27 @@
46
50
  const isRecordingMode = onChanges !== undefined;
47
51
  let changes = $state<Changes>({ data: {}, children: {} });
48
52
 
53
+ const totalChangeCount = $derived.by(() => {
54
+ let count = Object.keys(changes.data).length;
55
+ for (const ch of Object.values(changes.children) as ChildrenChanges[]) {
56
+ count += ch.created.length + ch.updated.length + ch.deleted.length + ch.linked.length + ch.unlinked.length;
57
+ }
58
+ return count;
59
+ });
60
+
61
+ const hasChildChanges = $derived(
62
+ Object.values(changes.children).some((ch: ChildrenChanges) =>
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)
67
+ )
68
+ );
69
+
70
+ import { buildChangeTree, renderTree } from "../changeTreeUtils";
71
+
72
+ const changeSummaryLines = $derived(renderTree(buildChangeTree(changes, 'filled')));
73
+
49
74
  const fieldNames = Object.keys(ctx.meta.collections[collectionName].fields);
50
75
  let values = $state(getDefaultEntry(ctx, fieldNames, collectionName, passedValues));
51
76
  let fieldsErrors: Record<string, any> = $state({});
@@ -63,7 +88,15 @@
63
88
  });
64
89
  });
65
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
+
66
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);
67
100
  const result: Record<string, any> = {};
68
101
  for (const [collection, ops] of Object.entries(changes.children)) {
69
102
  const hasOps = ops.created.length || ops.linked.length;
@@ -74,7 +107,7 @@
74
107
  };
75
108
  }
76
109
  const children = Object.keys(result).length ? result : undefined;
77
- return { data: changes.data, ...(children ? { children } : {}) };
110
+ return { data, ...(children ? { children } : {}) };
78
111
  }
79
112
 
80
113
  function handleCancel() {
@@ -82,17 +115,27 @@
82
115
  }
83
116
 
84
117
  async function handleSave() {
118
+ if (!isRecordingMode && hasChildChanges && changeSummaryLines.length > 0) {
119
+ const confirmed = await showDialog(
120
+ "Confirm changes",
121
+ changeSummaryLines.join('\n')
122
+ );
123
+ if (!confirmed) return;
124
+ }
125
+
85
126
  const snap = $state.snapshot(changes);
86
127
  const response = await lobb.createOne(collectionName, buildPayload(snap), undefined, isRecordingMode);
87
128
 
88
129
  if (response.status === 204) {
89
130
  onChanges?.(snap);
90
131
  if (onSuccessfullSave) await onSuccessfullSave(snap);
91
- toast.success(`The record was successfully created`);
132
+ await onCreated?.(snap.data);
133
+ if (!isRecordingMode) toast.success(`The record was successfully created`);
92
134
  handleCancel();
93
135
  return;
94
136
  }
95
137
 
138
+ let createdRecord: any = null;
96
139
  if (!response.bodyUsed) {
97
140
  const result = await response.json();
98
141
  if (response.status >= 400) {
@@ -104,10 +147,12 @@
104
147
  return;
105
148
  }
106
149
  }
150
+ createdRecord = result.data ?? result;
107
151
  }
108
152
 
109
153
  onChanges?.(snap);
110
154
  if (onSuccessfullSave) await onSuccessfullSave(snap);
155
+ await onCreated?.(createdRecord ?? snap.data);
111
156
  toast.success(`The record was successfully created`);
112
157
  onCancel?.();
113
158
  }
@@ -152,9 +197,10 @@
152
197
  variant="default"
153
198
  size="sm"
154
199
  Icon={submitButton?.icon ? submitButton.icon : Plus}
200
+ aria-label={submitButton?.text ?? "Create record"}
155
201
  onclick={handleSave}
156
202
  >
157
- {submitButton?.text ? submitButton.text : "Create"}
203
+ {submitButton?.text ?? "Create"}{totalChangeCount > 0 ? ` (${totalChangeCount})` : ''}
158
204
  </Button>
159
205
  </div>
160
206
  </div>
@@ -11,6 +11,7 @@ export interface CreateDetailViewProp {
11
11
  submitButton?: SubmitButton;
12
12
  title?: Snippet<[string]>;
13
13
  onSuccessfullSave?: (entry: any) => Promise<void>;
14
+ onCreated?: (record: any) => Promise<void>;
14
15
  onCancel?: () => Promise<void>;
15
16
  onChanges?: (changes: Changes) => void;
16
17
  }
@@ -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}
@@ -2,6 +2,7 @@ interface Props {
2
2
  collectionName: string;
3
3
  entry: Record<string, any>;
4
4
  fieldsErrors?: Record<string, string[]>;
5
+ changedFields?: string[];
5
6
  }
6
7
  declare const DetailView: import("svelte").Component<Props, {}, "entry">;
7
8
  type DetailView = ReturnType<typeof DetailView>;
@@ -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
@@ -4,6 +4,7 @@ interface Props {
4
4
  value: any;
5
5
  errorMessages?: string[];
6
6
  entry?: Record<string, any>;
7
+ changed?: boolean;
7
8
  }
8
9
  declare const FieldInput: import("svelte").Component<Props, {}, "value" | "entry">;
9
10
  type FieldInput = ReturnType<typeof FieldInput>;
@@ -27,6 +27,8 @@
27
27
  import { getStudioContext } from "../../../context";
28
28
  import { toast } from "svelte-sonner";
29
29
  import { untrack } from "svelte";
30
+ import { showDialog } from "../../../actions";
31
+
30
32
 
31
33
  const { lobb, ctx } = getStudioContext();
32
34
  import { getChangedProperties } from "../../../utils";
@@ -69,6 +71,27 @@
69
71
  ),
70
72
  );
71
73
 
74
+ const totalChangeCount = $derived.by(() => {
75
+ let count = Object.keys(localChanges.data).length;
76
+ for (const ch of Object.values(localChanges.children) as ChildrenChanges[]) {
77
+ count += ch.created.length + ch.updated.length + ch.deleted.length + ch.linked.length + ch.unlinked.length;
78
+ }
79
+ return count;
80
+ });
81
+
82
+ const hasChildChanges = $derived(
83
+ Object.values(localChanges.children).some((ch: ChildrenChanges) =>
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)
88
+ )
89
+ );
90
+
91
+ import { buildChangeTree, renderTree } from "../changeTreeUtils";
92
+
93
+ const changeSummaryLines = $derived(renderTree(buildChangeTree(localChanges)));
94
+
72
95
  $effect(() => {
73
96
  const currentEntrySnap = $state.snapshot(values);
74
97
 
@@ -77,19 +100,18 @@
77
100
  });
78
101
  });
79
102
 
80
- $effect(() => {
81
- const snap = $state.snapshot(localChanges);
82
- if (isRecordingMode) {
83
- const hasAny = Object.keys(snap.data).length > 0 ||
84
- Object.values(snap.children).some((c: ChildrenChanges) =>
85
- c.created.length || c.updated.length || c.deleted.length || c.linked.length || c.unlinked.length
86
- );
87
- if (hasAny) untrack(() => onChanges?.(snap));
88
- }
89
- });
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
+ }
90
110
 
91
111
  function buildPayload(changes: Changes): { data: Record<string, any>; children?: Record<string, any> } {
92
- 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);
93
115
  const children = buildChildren(changes.children);
94
116
  return { data, ...(children ? { children } : {}) };
95
117
  }
@@ -119,13 +141,21 @@
119
141
  }
120
142
 
121
143
  async function handleSave() {
144
+ if (!isRecordingMode && hasChildChanges && changeSummaryLines.length > 0) {
145
+ const confirmed = await showDialog(
146
+ "Confirm changes",
147
+ changeSummaryLines.join('\n')
148
+ );
149
+ if (!confirmed) return;
150
+ }
151
+
122
152
  const snap = $state.snapshot(localChanges);
123
153
  const response = await lobb.updateOne(collectionName, recordId, buildPayload(snap), isRecordingMode);
124
154
 
125
155
  if (response.status === 204) {
126
156
  onChanges?.(snap);
127
157
  if (onSuccessfullSave) await onSuccessfullSave(snap);
128
- toast.success(`The record was successfully updated`);
158
+ if (!isRecordingMode) toast.success(`The record was successfully updated`);
129
159
  onCancel?.();
130
160
  return;
131
161
  }
@@ -145,7 +175,7 @@
145
175
 
146
176
  onChanges?.(snap);
147
177
  if (onSuccessfullSave) await onSuccessfullSave(snap);
148
- toast.success(`The record was successfully updated`);
178
+ if (!isRecordingMode) toast.success(`The record was successfully updated`);
149
179
  onCancel?.();
150
180
  }
151
181
  </script>
@@ -170,7 +200,7 @@
170
200
  </div>
171
201
  </div>
172
202
  <div class="flex-1 overflow-y-auto">
173
- <DetailView {collectionName} bind:entry={values} {fieldsErrors} />
203
+ <DetailView {collectionName} bind:entry={values} {fieldsErrors} changedFields={Object.keys(localChanges.data)} />
174
204
  {#if showRelatedRecords}
175
205
  <UpdateDetailViewChildren {collectionName} entry={values} changes={localChanges} onChanges={(children) => { localChanges.children = children; }} />
176
206
  {/if}
@@ -189,10 +219,11 @@
189
219
  variant="default"
190
220
  size="sm"
191
221
  Icon={submitButton?.icon ? submitButton.icon : Pencil}
222
+ aria-label={submitButton?.text ?? "Update"}
192
223
  onclick={handleSave}
193
224
  disabled={!hasChanges}
194
225
  >
195
- {submitButton?.text ? submitButton.text : "Update"}
226
+ {submitButton?.text ?? "Update"}{totalChangeCount > 0 ? ` (${totalChangeCount})` : ''}
196
227
  </Button>
197
228
  </div>
198
229
  </div>