@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
@@ -14,8 +14,10 @@
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, Network } from "lucide-svelte";
18
18
  import ListViewChildren from "./listViewChildren.svelte";
19
+ import Drawer from "../drawer.svelte";
20
+ import { ArrowLeft } from "lucide-svelte";
19
21
  import FieldCell from "./fieldCell.svelte";
20
22
  import Skeleton from "../ui/skeleton/skeleton.svelte";
21
23
  import Button from "../ui/button/button.svelte";
@@ -76,37 +78,80 @@
76
78
  let localChanges = $state<ChildrenChanges>(
77
79
  untrack(() => changes) ?? { created: [], updated: [], deleted: [], linked: [], unlinked: [] }
78
80
  );
81
+ // Counter for temporary IDs assigned to locally-created pending records so they
82
+ // can be individually targeted by handleDelete before being committed to the DB.
83
+ let nextTempId = -1;
79
84
 
80
85
 
81
86
  // Derives the displayed rows by applying localChanges on top of server data.
87
+ // Deleted/unlinked rows stay visible but carry _recordingState so the table
88
+ // can render them with visual staging indicators.
82
89
  const data = $derived.by(() => {
83
90
  if (!isRecordingMode) return serverData;
84
91
 
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;
92
+ const deletedIds = new Set(localChanges.deleted.map((r: any) => String(r.id)));
93
+ const unlinkedIds = new Set(localChanges.unlinked.map((r: any) => String(r.id)));
94
+
95
+ let result = serverData.map((r: any) => {
96
+ const id = String(r.id);
97
+ const update = localChanges.updated.find((u) => String(u.id) === id);
98
+ const hasUpdate = update && (
99
+ Object.keys(update.changes.data).length > 0 ||
100
+ Object.values(update.changes.children).some((ch: any) =>
101
+ ch.created.length || ch.updated.length || ch.deleted.length || ch.linked.length || ch.unlinked.length
102
+ )
103
+ );
104
+
105
+ let state: string | undefined;
106
+ if (deletedIds.has(id)) state = 'deleted';
107
+ else if (unlinkedIds.has(id)) state = 'unlinked';
108
+ else if (hasUpdate) state = 'updated';
109
+
110
+ return {
111
+ ...(hasUpdate ? { ...r, ...update!.changes.data } : r),
112
+ ...(state ? { _recordingState: state } : {}),
113
+ };
95
114
  });
96
115
 
97
116
  for (const record of localChanges.linked) {
98
117
  if (!result.some((r: any) => String(r.id) === String(record.id))) {
99
- result = [...result, record];
118
+ result = [...result, { ...record, _recordingState: 'linked' }];
100
119
  }
101
120
  }
102
121
 
103
122
  for (const item of localChanges.created) {
104
- result = [...result, { ...item.data, _pending: true }];
123
+ result = [...result, { ...item.data, id: (item as any)._tempId, _recordingState: 'created' }];
105
124
  }
106
125
 
107
126
  return result;
108
127
  });
109
128
 
129
+ // IDs to exclude from the Link picker — records already present in this table
130
+ const excludeIds = $derived([
131
+ ...serverData.map((r: any) => r.id),
132
+ ...localChanges.linked.map((r: any) => r.id),
133
+ ]);
134
+
135
+ function onCellClass(entry: any, cellIndex: number): string {
136
+ if (!isRecordingMode) return '';
137
+ const state = entry._recordingState;
138
+ const border = cellIndex === 0 ? {
139
+ deleted: 'border-l-2 border-l-red-500',
140
+ unlinked: 'border-l-2 border-l-slate-500',
141
+ created: 'border-l-2 border-l-green-500',
142
+ linked: 'border-l-2 border-l-blue-500',
143
+ updated: 'border-l-2 border-l-orange-500',
144
+ }[state as string] ?? '' : '';
145
+ const bg: Record<string, string> = {
146
+ deleted: '!bg-red-500/5',
147
+ unlinked: '!bg-slate-500/5',
148
+ created: '!bg-green-500/5',
149
+ linked: '!bg-blue-500/5',
150
+ updated: '!bg-orange-500/5',
151
+ };
152
+ return `${bg[state as string] ?? ''} ${border}`.trim();
153
+ }
154
+
110
155
  const hasRowActions = $derived(
111
156
  loadExtensionComponents(ctx, "listView.entry.actions", undefined, { collectionName }).length > 0
112
157
  );
@@ -177,6 +222,7 @@
177
222
  (ctx.meta.collections[collectionName]?.children ?? [])
178
223
  .some((c: any) => c.type === "fk" || c.type === "m2m" || c.type === "polymorphic")
179
224
  );
225
+ let childrenDrawerEntry = $state<Record<string, any> | null>(null);
180
226
 
181
227
  // requests the data from the server when the params is changed
182
228
  $effect(() => {
@@ -204,16 +250,24 @@
204
250
  }
205
251
 
206
252
  async function handleDelete(entryId: string) {
207
- const result = await showDialog("Are you sure?", "This will permanently delete the record.");
208
- if (!result) return;
253
+ if (!isRecordingMode) {
254
+ const result = await showDialog("Are you sure?", "This will permanently delete the record.");
255
+ if (!result) return;
256
+ }
209
257
  if (isRecordingMode) {
210
258
  // If the record was locally linked (not yet in DB), just cancel the link instead of marking for deletion.
211
259
  const linkedIdx = localChanges.linked.findIndex((r: any) => String(r.id) === String(entryId));
212
260
  if (linkedIdx !== -1) {
213
261
  localChanges.linked.splice(linkedIdx, 1);
214
262
  } else {
215
- const record = serverData.find((r: any) => String(r.id) === String(entryId));
216
- if (record) localChanges.deleted.push($state.snapshot(record));
263
+ // If it's a locally-created pending record (has a negative tempId), remove it from created.
264
+ const createdIdx = localChanges.created.findIndex((item: any) => String(item._tempId) === String(entryId));
265
+ if (createdIdx !== -1) {
266
+ localChanges.created.splice(createdIdx, 1);
267
+ } else {
268
+ const record = serverData.find((r: any) => String(r.id) === String(entryId));
269
+ if (record) localChanges.deleted.push($state.snapshot(record));
270
+ }
217
271
  }
218
272
  onChanges?.($state.snapshot(localChanges));
219
273
  } else if (parentContext) {
@@ -228,8 +282,10 @@
228
282
  }
229
283
 
230
284
  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;
285
+ if (!isRecordingMode) {
286
+ const result = await showDialog("Are you sure?", "This will unlink the record without deleting it.");
287
+ if (!result) return;
288
+ }
233
289
  if (isRecordingMode) {
234
290
  // If the record was locally linked this session, just cancel the link — net effect is no change.
235
291
  const linkedIdx = localChanges.linked.findIndex((r: any) => String(r.id) === String(entryId));
@@ -265,14 +321,63 @@
265
321
  });
266
322
 
267
323
  function handleCreate(changes: import("../detailView/utils").Changes) {
268
- localChanges.created.push({ data: changes.data });
324
+ (localChanges.created as any[]).push({ data: changes.data, _tempId: nextTempId-- });
325
+ onChanges?.($state.snapshot(localChanges));
326
+ }
327
+
328
+ const hasAnyChanges = $derived(
329
+ localChanges.created.length > 0 ||
330
+ localChanges.updated.length > 0 ||
331
+ localChanges.deleted.length > 0 ||
332
+ localChanges.linked.length > 0 ||
333
+ localChanges.unlinked.length > 0
334
+ );
335
+
336
+ function handleRevertRow(entryId: string) {
337
+ const id = String(entryId);
338
+ const createdIdx = localChanges.created.findIndex((item: any) => String(item._tempId) === id);
339
+ if (createdIdx !== -1) localChanges.created.splice(createdIdx, 1);
340
+ const linkedIdx = localChanges.linked.findIndex((r: any) => String(r.id) === id);
341
+ if (linkedIdx !== -1) localChanges.linked.splice(linkedIdx, 1);
342
+ const deletedIdx = localChanges.deleted.findIndex((r: any) => String(r.id) === id);
343
+ if (deletedIdx !== -1) localChanges.deleted.splice(deletedIdx, 1);
344
+ const unlinkedIdx = localChanges.unlinked.findIndex((r: any) => String(r.id) === id);
345
+ if (unlinkedIdx !== -1) localChanges.unlinked.splice(unlinkedIdx, 1);
346
+ const updatedIdx = localChanges.updated.findIndex((u: any) => String(u.id) === id);
347
+ if (updatedIdx !== -1) localChanges.updated.splice(updatedIdx, 1);
348
+ onChanges?.($state.snapshot(localChanges));
349
+ }
350
+
351
+ function handleRevertAll() {
352
+ localChanges.created = [];
353
+ localChanges.updated = [];
354
+ localChanges.deleted = [];
355
+ localChanges.linked = [];
356
+ localChanges.unlinked = [];
357
+ onChanges?.($state.snapshot(localChanges));
358
+ }
359
+
360
+ function handleEditPending(tempId: string, editChanges: import("../detailView/utils").Changes) {
361
+ const createdIdx = localChanges.created.findIndex((item: any) => String(item._tempId) === tempId);
362
+ if (createdIdx !== -1) {
363
+ localChanges.created[createdIdx] = {
364
+ ...localChanges.created[createdIdx],
365
+ data: { ...localChanges.created[createdIdx].data, ...editChanges.data },
366
+ };
367
+ }
269
368
  onChanges?.($state.snapshot(localChanges));
270
369
  }
271
370
 
272
371
  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;
372
+ const isEmpty = Object.keys(editChanges.data).length === 0 &&
373
+ Object.values(editChanges.children).every((ch: any) =>
374
+ !ch.created.length && !ch.updated.length && !ch.deleted.length && !ch.linked.length && !ch.unlinked.length
375
+ );
376
+ const existingIdx = localChanges.updated.findIndex((u) => String(u.id) === id);
377
+ if (isEmpty) {
378
+ if (existingIdx !== -1) localChanges.updated.splice(existingIdx, 1);
379
+ } else if (existingIdx !== -1) {
380
+ localChanges.updated[existingIdx].changes = editChanges;
276
381
  } else {
277
382
  localChanges.updated.push({ id, changes: editChanges });
278
383
  }
@@ -306,9 +411,22 @@
306
411
  {parentContext}
307
412
  onLink={isRecordingMode ? handleLink : undefined}
308
413
  onCreate={isRecordingMode ? handleCreate : undefined}
414
+ {excludeIds}
309
415
  >
310
416
  {#snippet left()}
311
417
  {@render headerLeft?.()}
418
+ {#if isRecordingMode && hasAnyChanges}
419
+ <Button
420
+ class="h-6 px-2 font-normal text-xs text-muted-foreground"
421
+ variant="ghost"
422
+ size="sm"
423
+ Icon={RotateCcw}
424
+ onclick={handleRevertAll}
425
+ title="Revert all changes"
426
+ >
427
+ Revert all
428
+ </Button>
429
+ {/if}
312
430
  {/snippet}
313
431
  </Header>
314
432
  {/if}
@@ -338,7 +456,6 @@
338
456
  <Table
339
457
  {data}
340
458
  {columns}
341
- showCollapsible={doesCollectionHasChildren}
342
459
  selectByColumn="id"
343
460
  showLastRowBorder={true}
344
461
  showLastColumnBorder={true}
@@ -346,40 +463,70 @@
346
463
  bind:selectedRecords
347
464
  bind:tableWidth={dataTableWidth}
348
465
  {...tableProps}
349
- rowActions={hasRowActions ? rowActionsSnippet : undefined}>
350
- {#snippet tools(entry)}
351
- {#if showEdit}
352
- <CanAccess collection={collectionName} action="update">
353
- <UpdateDetailViewButton
354
- {collectionName}
355
- recordId={entry.id}
356
- 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}
466
+ rowActions={hasRowActions ? rowActionsSnippet : undefined}
467
+ onCellClass={isRecordingMode ? onCellClass : undefined}>
468
+ {#snippet preTools(entry)}
469
+ {#if doesCollectionHasChildren}
366
470
  <Button
367
471
  class="h-6 w-6 text-muted-foreground hover:bg-transparent"
368
472
  variant="ghost"
369
473
  size="icon"
370
- onclick={() => handleUnlink(entry.id)}
371
- Icon={Unlink}
372
- title="Remove from this entry"
474
+ onclick={() => { childrenDrawerEntry = entry; }}
475
+ Icon={Network}
476
+ title="Show children"
477
+ aria-label={`Show children of record ${entry.id}`}
373
478
  ></Button>
374
479
  {/if}
375
- {#if showDelete}
480
+ {/snippet}
481
+ {#snippet tools(entry)}
482
+ {#if entry._recordingState !== 'deleted' && entry._recordingState !== 'unlinked'}
483
+ {#if showEdit}
484
+ {@const isPending = entry._recordingState === 'created'}
485
+ <CanAccess collection={collectionName} action="update">
486
+ <UpdateDetailViewButton
487
+ {collectionName}
488
+ recordId={entry.id}
489
+ variant="ghost"
490
+ class="h-5 w-5 px-0 py-0 text-muted-foreground hover:bg-transparent"
491
+ Icon={Pencil}
492
+ aria-label={`Edit record ${entry.id}`}
493
+ values={isPending ? entry : undefined}
494
+ changes={isRecordingMode && !isPending ? localChanges.updated.find((u) => String(u.id) === String(entry.id))?.changes : undefined}
495
+ onChanges={isPending ? (c) => handleEditPending(String(entry.id), c) : isRecordingMode ? (c) => handleUpdate(String(entry.id), c) : undefined}
496
+ onSuccessfullSave={!isRecordingMode ? async () => { params = { ...params }; } : undefined}
497
+ ></UpdateDetailViewButton>
498
+ </CanAccess>
499
+ {/if}
500
+ {#if parentContext}
501
+ <Button
502
+ class="h-6 w-6 text-muted-foreground hover:bg-transparent"
503
+ variant="ghost"
504
+ size="icon"
505
+ onclick={() => handleUnlink(entry.id)}
506
+ Icon={Unlink}
507
+ title="Remove from this entry"
508
+ ></Button>
509
+ {/if}
510
+ {#if showDelete}
511
+ <Button
512
+ class="h-6 w-6 text-muted-foreground hover:bg-transparent"
513
+ variant="ghost"
514
+ size="icon"
515
+ onclick={() => handleDelete(entry.id)}
516
+ Icon={Trash}
517
+ title="Delete permanently"
518
+ ></Button>
519
+ {/if}
520
+ {/if}
521
+ {#if isRecordingMode && entry._recordingState}
376
522
  <Button
377
523
  class="h-6 w-6 text-muted-foreground hover:bg-transparent"
378
524
  variant="ghost"
379
525
  size="icon"
380
- onclick={() => handleDelete(entry.id)}
381
- Icon={Trash}
382
- title="Delete permanently"
526
+ onclick={() => handleRevertRow(entry.id)}
527
+ Icon={RotateCcw}
528
+ title="Revert changes"
529
+ aria-label={`Revert changes for record ${entry.id}`}
383
530
  ></Button>
384
531
  {/if}
385
532
  {/snippet}
@@ -392,15 +539,6 @@
392
539
  refresh={() => { params = { ...params }; }}
393
540
  />
394
541
  {/snippet}
395
- {#snippet collapsible(entry)}
396
- <ListViewChildren
397
- {collectionName}
398
- recordId={entry.id}
399
- width={dataTableWidth > dataTableContainerWidth
400
- ? dataTableContainerWidth
401
- : dataTableWidth}
402
- />
403
- {/snippet}
404
542
  </Table>
405
543
  {/if}
406
544
  </div>
@@ -414,3 +552,23 @@
414
552
  />
415
553
  {/if}
416
554
  </div>
555
+
556
+ {#if childrenDrawerEntry}
557
+ <Drawer position="bottom" onHide={async () => { childrenDrawerEntry = null; }}>
558
+ <div class="flex h-12 items-center gap-4 border-b px-4 shrink-0">
559
+ <Button
560
+ variant="outline"
561
+ onclick={() => { childrenDrawerEntry = null; }}
562
+ class="h-8 w-8 rounded-full text-xs font-normal"
563
+ Icon={ArrowLeft}
564
+ ></Button>
565
+ <div class="flex items-center gap-2 text-sm">
566
+ <span>Children of</span>
567
+ <span class="rounded-md border bg-muted px-2 py-0.5">{collectionName} #{childrenDrawerEntry.id}</span>
568
+ </div>
569
+ </div>
570
+ <div class="flex-1 overflow-y-auto">
571
+ <ListViewChildren {collectionName} recordId={String(childrenDrawerEntry.id)} />
572
+ </div>
573
+ </Drawer>
574
+ {/if}
@@ -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>
@@ -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>