@lobb-js/studio 0.41.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.
- package/dist/components/confirmationDialog/confirmationDialog.svelte +1 -1
- package/dist/components/dataTable/dataTable.svelte +182 -50
- package/dist/components/dataTable/header.svelte +5 -2
- package/dist/components/dataTable/header.svelte.d.ts +1 -0
- package/dist/components/dataTable/table.svelte +10 -21
- package/dist/components/dataTable/table.svelte.d.ts +1 -0
- package/dist/components/detailView/create/createDetailView.svelte +43 -1
- package/dist/components/detailView/create/createDetailView.svelte.d.ts +1 -0
- package/dist/components/detailView/update/updateDetailView.svelte +39 -12
- package/dist/components/detailView/update/updateDetailViewButton.svelte +7 -0
- package/dist/components/detailView/update/updateDetailViewButton.svelte.d.ts +1 -0
- package/dist/components/drawer.svelte +1 -0
- package/dist/components/foreingKeyInput.svelte +27 -1
- package/dist/components/polymorphicInput.svelte +27 -3
- package/package.json +2 -2
- package/src/lib/components/confirmationDialog/confirmationDialog.svelte +1 -1
- package/src/lib/components/dataTable/dataTable.svelte +182 -50
- package/src/lib/components/dataTable/header.svelte +5 -2
- package/src/lib/components/dataTable/table.svelte +10 -21
- package/src/lib/components/detailView/create/createDetailView.svelte +43 -1
- package/src/lib/components/detailView/update/updateDetailView.svelte +39 -12
- package/src/lib/components/detailView/update/updateDetailViewButton.svelte +7 -0
- package/src/lib/components/drawer.svelte +1 -0
- package/src/lib/components/foreingKeyInput.svelte +27 -1
- 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 ?
|
|
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>;
|
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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,
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
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={() =>
|
|
381
|
-
Icon={
|
|
382
|
-
title="
|
|
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>
|