@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
@@ -27,6 +27,7 @@
27
27
  import { getStudioContext } from "../../../context";
28
28
  import { toast } from "svelte-sonner";
29
29
  import { untrack } from "svelte";
30
+ import { showDialog } from "../../../actions";
30
31
 
31
32
  const { lobb, ctx } = getStudioContext();
32
33
  import { getChangedProperties } from "../../../utils";
@@ -69,6 +70,34 @@
69
70
  ),
70
71
  );
71
72
 
73
+ const totalChangeCount = $derived.by(() => {
74
+ let count = Object.keys(localChanges.data).length;
75
+ for (const ch of Object.values(localChanges.children) as ChildrenChanges[]) {
76
+ count += ch.created.length + ch.updated.length + ch.deleted.length + ch.linked.length + ch.unlinked.length;
77
+ }
78
+ return count;
79
+ });
80
+
81
+ const hasChildChanges = $derived(
82
+ Object.values(localChanges.children).some((ch: ChildrenChanges) =>
83
+ ch.created.length || ch.updated.length || ch.deleted.length || ch.linked.length || ch.unlinked.length
84
+ )
85
+ );
86
+
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
+ });
100
+
72
101
  $effect(() => {
73
102
  const currentEntrySnap = $state.snapshot(values);
74
103
 
@@ -77,17 +106,6 @@
77
106
  });
78
107
  });
79
108
 
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
- });
90
-
91
109
  function buildPayload(changes: Changes): { data: Record<string, any>; children?: Record<string, any> } {
92
110
  const { id: _id, ...data } = changes.data;
93
111
  const children = buildChildren(changes.children);
@@ -119,6 +137,14 @@
119
137
  }
120
138
 
121
139
  async function handleSave() {
140
+ if (!isRecordingMode && hasChildChanges && changeSummaryLines.length > 0) {
141
+ const confirmed = await showDialog(
142
+ "Confirm changes",
143
+ changeSummaryLines.map(l => `• ${l}`).join('\n')
144
+ );
145
+ if (!confirmed) return;
146
+ }
147
+
122
148
  const snap = $state.snapshot(localChanges);
123
149
  const response = await lobb.updateOne(collectionName, recordId, buildPayload(snap), isRecordingMode);
124
150
 
@@ -189,10 +215,11 @@
189
215
  variant="default"
190
216
  size="sm"
191
217
  Icon={submitButton?.icon ? submitButton.icon : Pencil}
218
+ aria-label={submitButton?.text ?? "Update"}
192
219
  onclick={handleSave}
193
220
  disabled={!hasChanges}
194
221
  >
195
- {submitButton?.text ? submitButton.text : "Update"}
222
+ {submitButton?.text ?? "Update"}{totalChangeCount > 0 ? ` (${totalChangeCount})` : ''}
196
223
  </Button>
197
224
  </div>
198
225
  </div>
@@ -11,6 +11,7 @@
11
11
  class?: ButtonProps["class"];
12
12
  Icon?: ButtonProps["Icon"];
13
13
  children?: ButtonProps["children"];
14
+ "aria-label"?: string;
14
15
  }
15
16
 
16
17
  let props: LocalProp = $props();
@@ -20,6 +21,11 @@
20
21
  const { lobb } = getStudioContext();
21
22
 
22
23
  async function openView() {
24
+ if (props.values) {
25
+ values = props.values;
26
+ open = true;
27
+ return;
28
+ }
23
29
  const params = {
24
30
  fields: "*",
25
31
  filter: { id: props.recordId },
@@ -37,6 +43,7 @@
37
43
  size={props.size}
38
44
  class={props.class}
39
45
  Icon={props.Icon}
46
+ aria-label={props["aria-label"]}
40
47
  onclick={openView}
41
48
  >
42
49
  {#if props.children}
@@ -6,6 +6,7 @@ interface LocalProp extends UpdateDetailViewProp {
6
6
  class?: ButtonProps["class"];
7
7
  Icon?: ButtonProps["Icon"];
8
8
  children?: ButtonProps["children"];
9
+ "aria-label"?: string;
9
10
  }
10
11
  declare const UpdateDetailViewButton: import("svelte").Component<LocalProp, {}, "">;
11
12
  type UpdateDetailViewButton = ReturnType<typeof UpdateDetailViewButton>;
@@ -36,6 +36,7 @@
36
36
  ></button>
37
37
 
38
38
  <div
39
+ role="dialog"
39
40
  transition:slide={{ axis: position === "bottom" ? "y" : "x" }}
40
41
  class={position === "bottom"
41
42
  ? "fixed bottom-0 left-0 z-40 flex h-[60vh] w-full flex-col border-t bg-card"
@@ -3,9 +3,11 @@
3
3
  import Input from "./ui/input/input.svelte";
4
4
  import SelectRecord from "./selectRecord.svelte";
5
5
  import UpdateDetailViewButton from "./detailView/update/updateDetailViewButton.svelte";
6
+ import CreateDetailView from "./detailView/create/createDetailView.svelte";
6
7
  import { getCollectionPrimaryField } from "./dataTable/utils";
7
8
  import { getStudioContext } from "../context";
8
- import { ExternalLink } from "lucide-svelte";
9
+ import { ExternalLink, Plus } from "lucide-svelte";
10
+ import Button from "./ui/button/button.svelte";
9
11
 
10
12
  const { lobb, ctx } = getStudioContext();
11
13
 
@@ -28,6 +30,7 @@
28
30
  }: LocalProps = $props();
29
31
 
30
32
  let displayName = $state<string | null>(null);
33
+ let createDrawerOpen = $state(false);
31
34
 
32
35
  onMount(async () => {
33
36
  if (value == null) return;
@@ -51,6 +54,12 @@
51
54
  displayName = primaryFieldName ? String(selectedEntry[primaryFieldName]) : null;
52
55
  }
53
56
 
57
+ async function handleCreated(record: any) {
58
+ const primaryFieldName = getCollectionPrimaryField(ctx, collectionName);
59
+ value = record.id;
60
+ displayName = primaryFieldName ? String(record[primaryFieldName]) : null;
61
+ }
62
+
54
63
  const idIsZero = $derived(value === 0);
55
64
  </script>
56
65
 
@@ -71,6 +80,14 @@
71
80
  {displayName}
72
81
  </div>
73
82
  {/if}
83
+ <Button
84
+ class="h-6 px-2 font-normal text-xs"
85
+ variant="outline"
86
+ Icon={Plus}
87
+ onclick={() => (createDrawerOpen = true)}
88
+ >
89
+ Create
90
+ </Button>
74
91
  <SelectRecord
75
92
  class="h-6 px-2 font-normal text-xs"
76
93
  variant="outline"
@@ -78,6 +95,7 @@
78
95
  {collectionName}
79
96
  {fieldName}
80
97
  onSelect={handleSelect}
98
+ text="Select"
81
99
  {entry}
82
100
  />
83
101
  </div>
@@ -103,3 +121,11 @@
103
121
  />
104
122
  </div>
105
123
  {/if}
124
+
125
+ {#if createDrawerOpen}
126
+ <CreateDetailView
127
+ collectionName={collectionName}
128
+ onCreated={handleCreated}
129
+ onCancel={async () => { createDrawerOpen = false; }}
130
+ />
131
+ {/if}
@@ -6,7 +6,8 @@
6
6
  import * as Popover from "./ui/popover/index";
7
7
  import { getCollectionPrimaryField } from "./dataTable/utils";
8
8
  import { getStudioContext } from "../context";
9
- import { ArrowLeft, Link, ChevronDown } from "lucide-svelte";
9
+ import { ArrowLeft, Link, ChevronDown, Plus } from "lucide-svelte";
10
+ import CreateDetailView from "./detailView/create/createDetailView.svelte";
10
11
 
11
12
  const { ctx, lobb } = getStudioContext();
12
13
 
@@ -32,6 +33,7 @@
32
33
  let displayName = $state<string | null>(null);
33
34
  let collectionPopoverOpen = $state(false);
34
35
  let recordDrawerOpen = $state(false);
36
+ let createDrawerOpen = $state(false);
35
37
 
36
38
  onMount(async () => {
37
39
  if (selectedCollection == null || selectedId == null) return;
@@ -68,6 +70,12 @@
68
70
  displayName = primaryFieldName ? String(record[primaryFieldName]) : null;
69
71
  recordDrawerOpen = false;
70
72
  }
73
+
74
+ async function onPolyCreated(record: any) {
75
+ const primaryFieldName = getCollectionPrimaryField(ctx, selectedCollection!);
76
+ entry = { ...entry, [idField]: record.id };
77
+ displayName = primaryFieldName ? String(record[primaryFieldName]) : null;
78
+ }
71
79
  </script>
72
80
 
73
81
  <div class="flex h-9 w-full items-center gap-1.5 rounded-md border pl-1.5 pr-9 text-xs bg-muted {destructive ? 'border-destructive bg-destructive/10' : ''}">
@@ -115,15 +123,23 @@
115
123
  </div>
116
124
  {/if}
117
125
 
118
- <!-- Select record button -->
126
+ <!-- Create / Select record buttons -->
119
127
  {#if selectedCollection}
128
+ <Button
129
+ class="h-6 shrink-0 px-2 font-normal text-xs"
130
+ variant="outline"
131
+ onclick={() => (createDrawerOpen = true)}
132
+ >
133
+ <Plus size="13" />
134
+ Create
135
+ </Button>
120
136
  <Button
121
137
  class="h-6 shrink-0 px-2 font-normal text-xs"
122
138
  variant="outline"
123
139
  onclick={() => (recordDrawerOpen = true)}
124
140
  >
125
141
  <Link size="13" />
126
- Select Record
142
+ Select
127
143
  </Button>
128
144
  {/if}
129
145
  </div>
@@ -155,3 +171,11 @@
155
171
  </div>
156
172
  </Drawer>
157
173
  {/if}
174
+
175
+ {#if createDrawerOpen && selectedCollection}
176
+ <CreateDetailView
177
+ collectionName={selectedCollection}
178
+ onCreated={onPolyCreated}
179
+ onCancel={async () => { createDrawerOpen = false; }}
180
+ />
181
+ {/if}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lobb-js/studio",
3
3
  "license": "UNLICENSED",
4
- "version": "0.40.0",
4
+ "version": "0.42.0",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -45,7 +45,7 @@
45
45
  "postpublish": "./scripts/postpublish.sh"
46
46
  },
47
47
  "devDependencies": {
48
- "@lobb-js/core": "^0.37.0",
48
+ "@lobb-js/core": "^0.37.1",
49
49
  "@chromatic-com/storybook": "^4.1.2",
50
50
  "@playwright/test": "^1.60.0",
51
51
  "@storybook/addon-a11y": "^10.0.1",
@@ -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>