@lobb-js/studio 0.40.0 → 0.42.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 (29) hide show
  1. package/dist/components/confirmationDialog/confirmationDialog.svelte +1 -1
  2. package/dist/components/dataTable/dataTable.svelte +182 -50
  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/sort.svelte +5 -9
  6. package/dist/components/dataTable/sortButton.svelte +0 -1
  7. package/dist/components/dataTable/table.svelte +10 -21
  8. package/dist/components/dataTable/table.svelte.d.ts +1 -0
  9. package/dist/components/detailView/create/createDetailView.svelte +43 -1
  10. package/dist/components/detailView/create/createDetailView.svelte.d.ts +1 -0
  11. package/dist/components/detailView/update/updateDetailView.svelte +39 -12
  12. package/dist/components/detailView/update/updateDetailViewButton.svelte +7 -0
  13. package/dist/components/detailView/update/updateDetailViewButton.svelte.d.ts +1 -0
  14. package/dist/components/drawer.svelte +1 -0
  15. package/dist/components/foreingKeyInput.svelte +27 -1
  16. package/dist/components/polymorphicInput.svelte +27 -3
  17. package/package.json +2 -2
  18. package/src/lib/components/confirmationDialog/confirmationDialog.svelte +1 -1
  19. package/src/lib/components/dataTable/dataTable.svelte +182 -50
  20. package/src/lib/components/dataTable/header.svelte +5 -2
  21. package/src/lib/components/dataTable/sort.svelte +5 -9
  22. package/src/lib/components/dataTable/sortButton.svelte +0 -1
  23. package/src/lib/components/dataTable/table.svelte +10 -21
  24. package/src/lib/components/detailView/create/createDetailView.svelte +43 -1
  25. package/src/lib/components/detailView/update/updateDetailView.svelte +39 -12
  26. package/src/lib/components/detailView/update/updateDetailViewButton.svelte +7 -0
  27. package/src/lib/components/drawer.svelte +1 -0
  28. package/src/lib/components/foreingKeyInput.svelte +27 -1
  29. package/src/lib/components/polymorphicInput.svelte +27 -3
@@ -17,7 +17,7 @@
17
17
  <AlertDialog.Content>
18
18
  <AlertDialog.Header>
19
19
  <AlertDialog.Title>{title}</AlertDialog.Title>
20
- <AlertDialog.Description>
20
+ <AlertDialog.Description class="whitespace-pre-line">
21
21
  {description}
22
22
  </AlertDialog.Description>
23
23
  </AlertDialog.Header>
@@ -14,7 +14,7 @@
14
14
  import Table, { type TableProps } from "./table.svelte";
15
15
  import { getCollectionColumns, getCollectionParamsFields } from "./utils";
16
16
  import CanAccess from "../canAccess.svelte";
17
- import { Pencil, Trash, Unlink } from "lucide-svelte";
17
+ import { Pencil, Trash, Unlink, RotateCcw } from "lucide-svelte";
18
18
  import ListViewChildren from "./listViewChildren.svelte";
19
19
  import FieldCell from "./fieldCell.svelte";
20
20
  import Skeleton from "../ui/skeleton/skeleton.svelte";
@@ -76,37 +76,80 @@
76
76
  let localChanges = $state<ChildrenChanges>(
77
77
  untrack(() => changes) ?? { created: [], updated: [], deleted: [], linked: [], unlinked: [] }
78
78
  );
79
+ // Counter for temporary IDs assigned to locally-created pending records so they
80
+ // can be individually targeted by handleDelete before being committed to the DB.
81
+ let nextTempId = -1;
79
82
 
80
83
 
81
84
  // Derives the displayed rows by applying localChanges on top of server data.
85
+ // Deleted/unlinked rows stay visible but carry _recordingState so the table
86
+ // can render them with visual staging indicators.
82
87
  const data = $derived.by(() => {
83
88
  if (!isRecordingMode) return serverData;
84
89
 
85
- const removedIds = new Set([
86
- ...localChanges.deleted.map((r: any) => String(r.id)),
87
- ...localChanges.unlinked.map((r: any) => String(r.id)),
88
- ]);
89
-
90
- let result = serverData.filter((r: any) => !removedIds.has(String(r.id)));
91
-
92
- result = result.map((r: any) => {
93
- const update = localChanges.updated.find((u) => String(u.id) === String(r.id));
94
- return update && Object.keys(update.changes.data).length ? { ...r, ...update.changes.data } : r;
90
+ const deletedIds = new Set(localChanges.deleted.map((r: any) => String(r.id)));
91
+ const unlinkedIds = new Set(localChanges.unlinked.map((r: any) => String(r.id)));
92
+
93
+ let result = serverData.map((r: any) => {
94
+ const id = String(r.id);
95
+ const update = localChanges.updated.find((u) => String(u.id) === id);
96
+ const hasUpdate = update && (
97
+ Object.keys(update.changes.data).length > 0 ||
98
+ Object.values(update.changes.children).some((ch: any) =>
99
+ ch.created.length || ch.updated.length || ch.deleted.length || ch.linked.length || ch.unlinked.length
100
+ )
101
+ );
102
+
103
+ let state: string | undefined;
104
+ if (deletedIds.has(id)) state = 'deleted';
105
+ else if (unlinkedIds.has(id)) state = 'unlinked';
106
+ else if (hasUpdate) state = 'updated';
107
+
108
+ return {
109
+ ...(hasUpdate ? { ...r, ...update!.changes.data } : r),
110
+ ...(state ? { _recordingState: state } : {}),
111
+ };
95
112
  });
96
113
 
97
114
  for (const record of localChanges.linked) {
98
115
  if (!result.some((r: any) => String(r.id) === String(record.id))) {
99
- result = [...result, record];
116
+ result = [...result, { ...record, _recordingState: 'linked' }];
100
117
  }
101
118
  }
102
119
 
103
120
  for (const item of localChanges.created) {
104
- result = [...result, { ...item.data, _pending: true }];
121
+ result = [...result, { ...item.data, id: (item as any)._tempId, _recordingState: 'created' }];
105
122
  }
106
123
 
107
124
  return result;
108
125
  });
109
126
 
127
+ // IDs to exclude from the Link picker — records already present in this table
128
+ const excludeIds = $derived([
129
+ ...serverData.map((r: any) => r.id),
130
+ ...localChanges.linked.map((r: any) => r.id),
131
+ ]);
132
+
133
+ function onCellClass(entry: any, cellIndex: number): string {
134
+ if (!isRecordingMode) return '';
135
+ const state = entry._recordingState;
136
+ const border = cellIndex === 0 ? {
137
+ deleted: 'border-l-2 border-l-red-500',
138
+ unlinked: 'border-l-2 border-l-orange-500',
139
+ created: 'border-l-2 border-l-green-500',
140
+ linked: 'border-l-2 border-l-blue-500',
141
+ updated: 'border-l-2 border-l-amber-500',
142
+ }[state as string] ?? '' : '';
143
+ const bg: Record<string, string> = {
144
+ deleted: '!bg-red-500/5 opacity-50',
145
+ unlinked: '!bg-orange-500/5 opacity-50',
146
+ created: '!bg-green-500/5',
147
+ linked: '!bg-blue-500/5',
148
+ updated: '!bg-amber-500/5',
149
+ };
150
+ return `${bg[state as string] ?? ''} ${border}`.trim();
151
+ }
152
+
110
153
  const hasRowActions = $derived(
111
154
  loadExtensionComponents(ctx, "listView.entry.actions", undefined, { collectionName }).length > 0
112
155
  );
@@ -204,16 +247,24 @@
204
247
  }
205
248
 
206
249
  async function handleDelete(entryId: string) {
207
- const result = await showDialog("Are you sure?", "This will permanently delete the record.");
208
- if (!result) return;
250
+ if (!isRecordingMode) {
251
+ const result = await showDialog("Are you sure?", "This will permanently delete the record.");
252
+ if (!result) return;
253
+ }
209
254
  if (isRecordingMode) {
210
255
  // If the record was locally linked (not yet in DB), just cancel the link instead of marking for deletion.
211
256
  const linkedIdx = localChanges.linked.findIndex((r: any) => String(r.id) === String(entryId));
212
257
  if (linkedIdx !== -1) {
213
258
  localChanges.linked.splice(linkedIdx, 1);
214
259
  } else {
215
- const record = serverData.find((r: any) => String(r.id) === String(entryId));
216
- if (record) localChanges.deleted.push($state.snapshot(record));
260
+ // If it's a locally-created pending record (has a negative tempId), remove it from created.
261
+ const createdIdx = localChanges.created.findIndex((item: any) => String(item._tempId) === String(entryId));
262
+ if (createdIdx !== -1) {
263
+ localChanges.created.splice(createdIdx, 1);
264
+ } else {
265
+ const record = serverData.find((r: any) => String(r.id) === String(entryId));
266
+ if (record) localChanges.deleted.push($state.snapshot(record));
267
+ }
217
268
  }
218
269
  onChanges?.($state.snapshot(localChanges));
219
270
  } else if (parentContext) {
@@ -228,8 +279,10 @@
228
279
  }
229
280
 
230
281
  async function handleUnlink(entryId: string) {
231
- const result = await showDialog("Are you sure?", "This will unlink the record without deleting it.");
232
- if (!result) return;
282
+ if (!isRecordingMode) {
283
+ const result = await showDialog("Are you sure?", "This will unlink the record without deleting it.");
284
+ if (!result) return;
285
+ }
233
286
  if (isRecordingMode) {
234
287
  // If the record was locally linked this session, just cancel the link — net effect is no change.
235
288
  const linkedIdx = localChanges.linked.findIndex((r: any) => String(r.id) === String(entryId));
@@ -265,14 +318,63 @@
265
318
  });
266
319
 
267
320
  function handleCreate(changes: import("../detailView/utils").Changes) {
268
- localChanges.created.push({ data: changes.data });
321
+ (localChanges.created as any[]).push({ data: changes.data, _tempId: nextTempId-- });
322
+ onChanges?.($state.snapshot(localChanges));
323
+ }
324
+
325
+ const hasAnyChanges = $derived(
326
+ localChanges.created.length > 0 ||
327
+ localChanges.updated.length > 0 ||
328
+ localChanges.deleted.length > 0 ||
329
+ localChanges.linked.length > 0 ||
330
+ localChanges.unlinked.length > 0
331
+ );
332
+
333
+ function handleRevertRow(entryId: string) {
334
+ const id = String(entryId);
335
+ const createdIdx = localChanges.created.findIndex((item: any) => String(item._tempId) === id);
336
+ if (createdIdx !== -1) localChanges.created.splice(createdIdx, 1);
337
+ const linkedIdx = localChanges.linked.findIndex((r: any) => String(r.id) === id);
338
+ if (linkedIdx !== -1) localChanges.linked.splice(linkedIdx, 1);
339
+ const deletedIdx = localChanges.deleted.findIndex((r: any) => String(r.id) === id);
340
+ if (deletedIdx !== -1) localChanges.deleted.splice(deletedIdx, 1);
341
+ const unlinkedIdx = localChanges.unlinked.findIndex((r: any) => String(r.id) === id);
342
+ if (unlinkedIdx !== -1) localChanges.unlinked.splice(unlinkedIdx, 1);
343
+ const updatedIdx = localChanges.updated.findIndex((u: any) => String(u.id) === id);
344
+ if (updatedIdx !== -1) localChanges.updated.splice(updatedIdx, 1);
345
+ onChanges?.($state.snapshot(localChanges));
346
+ }
347
+
348
+ function handleRevertAll() {
349
+ localChanges.created = [];
350
+ localChanges.updated = [];
351
+ localChanges.deleted = [];
352
+ localChanges.linked = [];
353
+ localChanges.unlinked = [];
354
+ onChanges?.($state.snapshot(localChanges));
355
+ }
356
+
357
+ function handleEditPending(tempId: string, editChanges: import("../detailView/utils").Changes) {
358
+ const createdIdx = localChanges.created.findIndex((item: any) => String(item._tempId) === tempId);
359
+ if (createdIdx !== -1) {
360
+ localChanges.created[createdIdx] = {
361
+ ...localChanges.created[createdIdx],
362
+ data: { ...localChanges.created[createdIdx].data, ...editChanges.data },
363
+ };
364
+ }
269
365
  onChanges?.($state.snapshot(localChanges));
270
366
  }
271
367
 
272
368
  function handleUpdate(id: string, editChanges: import("../detailView/utils").Changes) {
273
- const existing = localChanges.updated.find((u) => String(u.id) === id);
274
- if (existing) {
275
- existing.changes = editChanges;
369
+ const isEmpty = Object.keys(editChanges.data).length === 0 &&
370
+ Object.values(editChanges.children).every((ch: any) =>
371
+ !ch.created.length && !ch.updated.length && !ch.deleted.length && !ch.linked.length && !ch.unlinked.length
372
+ );
373
+ const existingIdx = localChanges.updated.findIndex((u) => String(u.id) === id);
374
+ if (isEmpty) {
375
+ if (existingIdx !== -1) localChanges.updated.splice(existingIdx, 1);
376
+ } else if (existingIdx !== -1) {
377
+ localChanges.updated[existingIdx].changes = editChanges;
276
378
  } else {
277
379
  localChanges.updated.push({ id, changes: editChanges });
278
380
  }
@@ -306,9 +408,22 @@
306
408
  {parentContext}
307
409
  onLink={isRecordingMode ? handleLink : undefined}
308
410
  onCreate={isRecordingMode ? handleCreate : undefined}
411
+ {excludeIds}
309
412
  >
310
413
  {#snippet left()}
311
414
  {@render headerLeft?.()}
415
+ {#if isRecordingMode && hasAnyChanges}
416
+ <Button
417
+ class="h-6 px-2 font-normal text-xs text-muted-foreground"
418
+ variant="ghost"
419
+ size="sm"
420
+ Icon={RotateCcw}
421
+ onclick={handleRevertAll}
422
+ title="Revert all changes"
423
+ >
424
+ Revert all
425
+ </Button>
426
+ {/if}
312
427
  {/snippet}
313
428
  </Header>
314
429
  {/if}
@@ -346,40 +461,57 @@
346
461
  bind:selectedRecords
347
462
  bind:tableWidth={dataTableWidth}
348
463
  {...tableProps}
349
- rowActions={hasRowActions ? rowActionsSnippet : undefined}>
464
+ rowActions={hasRowActions ? rowActionsSnippet : undefined}
465
+ onCellClass={isRecordingMode ? onCellClass : undefined}>
350
466
  {#snippet tools(entry)}
351
- {#if showEdit}
352
- <CanAccess collection={collectionName} action="update">
353
- <UpdateDetailViewButton
354
- {collectionName}
355
- recordId={entry.id}
467
+ {#if entry._recordingState !== 'deleted' && entry._recordingState !== 'unlinked'}
468
+ {#if showEdit}
469
+ {@const isPending = entry._recordingState === 'created'}
470
+ <CanAccess collection={collectionName} action="update">
471
+ <UpdateDetailViewButton
472
+ {collectionName}
473
+ recordId={entry.id}
474
+ variant="ghost"
475
+ class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent"
476
+ Icon={Pencil}
477
+ aria-label={`Edit record ${entry.id}`}
478
+ values={isPending ? entry : undefined}
479
+ changes={isRecordingMode && !isPending ? localChanges.updated.find((u) => String(u.id) === String(entry.id))?.changes : undefined}
480
+ onChanges={isPending ? (c) => handleEditPending(String(entry.id), c) : isRecordingMode ? (c) => handleUpdate(String(entry.id), c) : undefined}
481
+ onSuccessfullSave={!isRecordingMode ? async () => { params = { ...params }; } : undefined}
482
+ ></UpdateDetailViewButton>
483
+ </CanAccess>
484
+ {/if}
485
+ {#if parentContext}
486
+ <Button
487
+ class="h-6 w-6 text-muted-foreground hover:bg-transparent"
356
488
  variant="ghost"
357
- class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent"
358
- Icon={Pencil}
359
- changes={isRecordingMode ? localChanges.updated.find((u) => String(u.id) === String(entry.id))?.changes : undefined}
360
- onChanges={isRecordingMode ? (c) => handleUpdate(String(entry.id), c) : undefined}
361
- onSuccessfullSave={!isRecordingMode ? async () => { params = { ...params }; } : undefined}
362
- ></UpdateDetailViewButton>
363
- </CanAccess>
364
- {/if}
365
- {#if parentContext}
366
- <Button
367
- class="h-6 w-6 text-muted-foreground hover:bg-transparent"
368
- variant="ghost"
369
- size="icon"
370
- onclick={() => handleUnlink(entry.id)}
371
- Icon={Unlink}
372
- title="Remove from this entry"
373
- ></Button>
489
+ size="icon"
490
+ onclick={() => handleUnlink(entry.id)}
491
+ Icon={Unlink}
492
+ title="Remove from this entry"
493
+ ></Button>
494
+ {/if}
495
+ {#if showDelete}
496
+ <Button
497
+ class="h-6 w-6 text-muted-foreground hover:bg-transparent"
498
+ variant="ghost"
499
+ size="icon"
500
+ onclick={() => handleDelete(entry.id)}
501
+ Icon={Trash}
502
+ title="Delete permanently"
503
+ ></Button>
504
+ {/if}
374
505
  {/if}
375
- {#if showDelete}
506
+ {#if isRecordingMode && entry._recordingState}
376
507
  <Button
377
508
  class="h-6 w-6 text-muted-foreground hover:bg-transparent"
378
509
  variant="ghost"
379
510
  size="icon"
380
- onclick={() => handleDelete(entry.id)}
381
- Icon={Trash}
382
- title="Delete permanently"
511
+ onclick={() => handleRevertRow(entry.id)}
512
+ Icon={RotateCcw}
513
+ title="Revert changes"
514
+ aria-label={`Revert changes for record ${entry.id}`}
383
515
  ></Button>
384
516
  {/if}
385
517
  {/snippet}
@@ -28,6 +28,7 @@
28
28
  showFilter?: boolean;
29
29
  loading?: boolean;
30
30
  left?: Snippet<[]>;
31
+ excludeIds?: (string | number)[];
31
32
  }
32
33
 
33
34
  let {
@@ -40,7 +41,8 @@
40
41
  showImport = true,
41
42
  showFilter = true,
42
43
  loading = false,
43
- left
44
+ left,
45
+ excludeIds = [],
44
46
  }: Props = $props();
45
47
 
46
48
  function handleLink(record: any) {
@@ -169,13 +171,14 @@
169
171
  {collectionName}
170
172
  refresh={() => { params = { ...params }; }}
171
173
  />
172
- {#if parentContext}
174
+ {#if parentContext || onLink}
173
175
  <SelectRecord
174
176
  {collectionName}
175
177
  variant="outline"
176
178
  size="sm"
177
179
  Icon={Link}
178
180
  onSelect={handleLink}
181
+ additionalFilter={excludeIds.length > 0 ? { $and: excludeIds.map(id => ({ id: { $ne: id } })) } : {}}
179
182
  >
180
183
  {headerIsSmall ? "" : "Link"}
181
184
  </SelectRecord>
@@ -12,6 +12,7 @@ interface Props {
12
12
  showFilter?: boolean;
13
13
  loading?: boolean;
14
14
  left?: Snippet<[]>;
15
+ excludeIds?: (string | number)[];
15
16
  }
16
17
  declare const Header: import("svelte").Component<Props, {}, "selectedRecords" | "params">;
17
18
  type Header = ReturnType<typeof Header>;
@@ -4,7 +4,7 @@
4
4
  import * as Popover from "../ui/popover/index.js";
5
5
  import * as Select from "../ui/select/index.js";
6
6
  import { GripVertical, Plus, X } from "lucide-svelte";
7
- import Button, { buttonVariants } from "../ui/button/button.svelte";
7
+ import Button from "../ui/button/button.svelte";
8
8
  import { getStudioContext } from "../../context";
9
9
  import { getFieldIcon } from "./utils";
10
10
  import { dndzone } from "svelte-dnd-action";
@@ -205,17 +205,13 @@
205
205
  </div>
206
206
  {/if}
207
207
  </div>
208
- <div class="flex justify-between border-t p-2">
208
+ <div class="flex justify-between px-3 pb-3">
209
209
  {#if getFieldNames().length}
210
210
  <Popover.Root bind:open={popoverOpen}>
211
211
  <Popover.Trigger
212
- class={buttonVariants({
213
- variant: "ghost",
214
- size: "sm",
215
- class: "text-muted-foreground",
216
- })}
212
+ class="inline-flex w-fit items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
217
213
  >
218
- <Plus />
214
+ <Plus size="14" />
219
215
  Add a sort rule
220
216
  </Popover.Trigger>
221
217
  <Popover.Content class="w-64 p-2">
@@ -231,7 +227,7 @@
231
227
  </Popover.Content>
232
228
  </Popover.Root>
233
229
  {:else}
234
- <div class="flex items-center text-xs text-muted-foreground">
230
+ <div class="px-2 py-1 text-xs text-muted-foreground">
235
231
  All columns have been added
236
232
  </div>
237
233
  {/if}
@@ -22,7 +22,6 @@
22
22
 
23
23
  function pick(fieldName: string) {
24
24
  sort = { ...sort, [fieldName]: "asc" };
25
- popoverOpen = false;
26
25
  }
27
26
  </script>
28
27
 
@@ -41,6 +41,9 @@
41
41
  parentWidth?: number;
42
42
  select?: Select;
43
43
  tableWidth?: number;
44
+
45
+ // recording mode row visuals — cellIndex 0 = tools cell, 1+ = data/action cells
46
+ onCellClass?: (entry: Entry, cellIndex: number) => string;
44
47
  }
45
48
  </script>
46
49
 
@@ -79,6 +82,7 @@
79
82
  collapsible,
80
83
  select,
81
84
  tableWidth = $bindable(),
85
+ onCellClass,
82
86
  }: TableProps = $props();
83
87
 
84
88
  let expandedRows: boolean[] = $state(new Array(data.length).fill(false));
@@ -183,7 +187,7 @@
183
187
  flex items-center p-2.5 text-xs h-10
184
188
  border-r border-b gap-2
185
189
  {headerBorderTop ? 'border-t' : ''}
186
- bg-muted
190
+ bg-muted/50
187
191
  "
188
192
  >
189
193
  <!-- collapsable toggle -->
@@ -208,7 +212,7 @@
208
212
  class="
209
213
  sticky top-0 z-10
210
214
  flex items-center p-2.5 text-xs h-10
211
- bg-muted
215
+ bg-muted/50
212
216
  {lastColumn && !showLastColumnBorder ? '' : 'border-r'}
213
217
  border-b gap-2
214
218
  {headerBorderTop ? 'border-t' : ''}
@@ -235,7 +239,7 @@
235
239
  class="
236
240
  sticky top-0 right-0 z-20
237
241
  flex items-center p-2.5 h-10
238
- bg-muted
242
+ bg-muted/50
239
243
  border-l border-b
240
244
  {headerBorderTop ? 'border-t' : ''}
241
245
  "
@@ -247,12 +251,7 @@
247
251
  {@const lastRow = data.length - 1 === index}
248
252
  {#if selectedRecords || tools}
249
253
  <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
- "
254
+ class="sticky left-0 flex items-center p-2.5 text-xs h-10 bg-card border-r gap-2 {onCellClass?.(entry, 0) ?? ''}"
256
255
  >
257
256
  <!-- collapsable toggle -->
258
257
  {#if showCollapsible}
@@ -294,12 +293,7 @@
294
293
  onclick={() => {
295
294
  select?.onSelect(entry);
296
295
  }}
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
- "
296
+ 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
297
  >
304
298
  {#if overrideCell}
305
299
  {@render overrideCell(fieldValue, column, entry)}
@@ -310,12 +304,7 @@
310
304
  {/each}
311
305
  {#if rowActions}
312
306
  <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
- "
307
+ 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
308
  >
320
309
  {@render rowActions?.(entry, index)}
321
310
  </div>
@@ -27,6 +27,7 @@ export interface TableProps {
27
27
  parentWidth?: number;
28
28
  select?: Select;
29
29
  tableWidth?: number;
30
+ onCellClass?: (entry: Entry, cellIndex: number) => string;
30
31
  }
31
32
  import type { Snippet } from "svelte";
32
33
  declare const Table: import("svelte").Component<TableProps, {}, "sort" | "selectedRecords" | "tableWidth">;
@@ -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,31 @@
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
+ );
66
+
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
+ });
77
+
49
78
  const fieldNames = Object.keys(ctx.meta.collections[collectionName].fields);
50
79
  let values = $state(getDefaultEntry(ctx, fieldNames, collectionName, passedValues));
51
80
  let fieldsErrors: Record<string, any> = $state({});
@@ -82,17 +111,27 @@
82
111
  }
83
112
 
84
113
  async function handleSave() {
114
+ if (!isRecordingMode && hasChildChanges && changeSummaryLines.length > 0) {
115
+ const confirmed = await showDialog(
116
+ "Confirm changes",
117
+ changeSummaryLines.map(l => `• ${l}`).join('\n')
118
+ );
119
+ if (!confirmed) return;
120
+ }
121
+
85
122
  const snap = $state.snapshot(changes);
86
123
  const response = await lobb.createOne(collectionName, buildPayload(snap), undefined, isRecordingMode);
87
124
 
88
125
  if (response.status === 204) {
89
126
  onChanges?.(snap);
90
127
  if (onSuccessfullSave) await onSuccessfullSave(snap);
128
+ await onCreated?.(snap.data);
91
129
  toast.success(`The record was successfully created`);
92
130
  handleCancel();
93
131
  return;
94
132
  }
95
133
 
134
+ let createdRecord: any = null;
96
135
  if (!response.bodyUsed) {
97
136
  const result = await response.json();
98
137
  if (response.status >= 400) {
@@ -104,10 +143,12 @@
104
143
  return;
105
144
  }
106
145
  }
146
+ createdRecord = result.data ?? result;
107
147
  }
108
148
 
109
149
  onChanges?.(snap);
110
150
  if (onSuccessfullSave) await onSuccessfullSave(snap);
151
+ await onCreated?.(createdRecord ?? snap.data);
111
152
  toast.success(`The record was successfully created`);
112
153
  onCancel?.();
113
154
  }
@@ -152,9 +193,10 @@
152
193
  variant="default"
153
194
  size="sm"
154
195
  Icon={submitButton?.icon ? submitButton.icon : Plus}
196
+ aria-label={submitButton?.text ?? "Create record"}
155
197
  onclick={handleSave}
156
198
  >
157
- {submitButton?.text ? submitButton.text : "Create"}
199
+ {submitButton?.text ?? "Create"}{totalChangeCount > 0 ? ` (${totalChangeCount})` : ''}
158
200
  </Button>
159
201
  </div>
160
202
  </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
  }