@svadmin/core 0.23.0 → 0.23.3
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/package.json +20 -3
- package/src/adapters/casbin.ts +1 -1
- package/src/adapters/casl.ts +1 -1
- package/src/audit.ts +12 -11
- package/src/auth-hooks.svelte.ts +25 -2
- package/src/chatProvider.svelte.ts +20 -3
- package/src/context.svelte.ts +17 -1
- package/src/data-transfer.svelte.ts +23 -11
- package/src/form-hooks.svelte.ts +71 -27
- package/src/form-hooks.test.svelte.ts +83 -0
- package/src/helpers-pure.ts +13 -6
- package/src/helpers.test.ts +2 -2
- package/src/helpers.ts +4 -0
- package/src/hook-utils.svelte.ts +40 -20
- package/src/hooks.svelte.ts +43 -50
- package/src/i18n.svelte.ts +3 -0
- package/src/index.ts +10 -8
- package/src/inferencer-openapi.ts +5 -3
- package/src/inferencer.ts +6 -4
- package/src/live-sse.ts +20 -17
- package/src/live-websocket.ts +1 -0
- package/src/live.svelte.ts +19 -11
- package/src/mutation-hooks.svelte.ts +39 -25
- package/src/mutation-hooks.test.svelte.ts +114 -0
- package/src/notification.svelte.ts +6 -6
- package/src/options.svelte.ts +1 -1
- package/src/{permissions.ts → permissions.svelte.ts} +1 -1
- package/src/permissions.test.ts +32 -80
- package/src/query-hooks.svelte.ts +18 -9
- package/src/query-hooks.test.svelte.ts +65 -0
- package/src/query-keys.ts +3 -4
- package/src/reactive-hooks.test.svelte.ts +26 -0
- package/src/router-provider.test.ts +3 -0
- package/src/router-provider.ts +13 -9
- package/src/router.ts +8 -0
- package/src/routing-hooks.svelte.ts +16 -13
- package/src/routing-hooks.test.svelte.ts +55 -0
- package/src/table-hooks.svelte.ts +46 -1
- package/src/table-hooks.test.svelte.ts +57 -0
- package/src/table-state.svelte.ts +17 -12
- package/src/theme.svelte.ts +30 -7
- package/src/toast.svelte.ts +8 -0
- package/src/unsaved-changes.svelte.ts +14 -6
- package/src/url-sync.ts +2 -1
- package/src/useCan.ts +2 -2
- package/src/useParsed.svelte.ts +12 -4
- package/src/utility-hooks.svelte.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/core",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.3",
|
|
4
4
|
"author": "zuohuadong",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,7 +9,16 @@
|
|
|
9
9
|
},
|
|
10
10
|
"main": "src/index.ts",
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"@
|
|
12
|
+
"@happy-dom/global-registrator": "^20.8.9",
|
|
13
|
+
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
14
|
+
"@testing-library/dom": "^10.4.1",
|
|
15
|
+
"@testing-library/svelte": "^5.3.1",
|
|
16
|
+
"@testing-library/user-event": "^14.6.1",
|
|
17
|
+
"@types/bun": "^1.3.11",
|
|
18
|
+
"@types/jsdom": "^28.0.1",
|
|
19
|
+
"happy-dom": "^20.8.9",
|
|
20
|
+
"jsdom": "^29.0.2",
|
|
21
|
+
"vitest": "^4.1.4"
|
|
13
22
|
},
|
|
14
23
|
"peerDependencies": {
|
|
15
24
|
"svelte": "^5.0.0",
|
|
@@ -61,5 +70,13 @@
|
|
|
61
70
|
"sideEffects": false,
|
|
62
71
|
"type": "module",
|
|
63
72
|
"types": "src/index.ts",
|
|
64
|
-
"dependencies": {}
|
|
73
|
+
"dependencies": {},
|
|
74
|
+
"peerDependenciesMeta": {
|
|
75
|
+
"svelte": {
|
|
76
|
+
"optional": true
|
|
77
|
+
},
|
|
78
|
+
"@tanstack/svelte-query": {
|
|
79
|
+
"optional": true
|
|
80
|
+
}
|
|
81
|
+
}
|
|
65
82
|
}
|
package/src/adapters/casbin.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* ```
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type { AccessControlProvider, CanParams, CanResult } from '../permissions';
|
|
21
|
+
import type { AccessControlProvider, CanParams, CanResult } from '../permissions.svelte';
|
|
22
22
|
|
|
23
23
|
/** Casbin Enforcer interface (minimal, to avoid hard dependency on casbin) */
|
|
24
24
|
interface CasbinEnforcer {
|
package/src/adapters/casl.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* ```
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type { AccessControlProvider, CanParams, CanResult } from '../permissions';
|
|
22
|
+
import type { AccessControlProvider, CanParams, CanResult } from '../permissions.svelte';
|
|
23
23
|
|
|
24
24
|
/** CASL Ability interface (minimal, to avoid hard dependency on @casl/ability) */
|
|
25
25
|
interface CaslAbility {
|
package/src/audit.ts
CHANGED
|
@@ -53,19 +53,20 @@ export function resetAuditLogProvider(): void {
|
|
|
53
53
|
export function audit(entry: Omit<AuditEntry, 'timestamp'>): void {
|
|
54
54
|
const fullEntry: AuditEntry = { ...entry, timestamp: new Date().toISOString() };
|
|
55
55
|
try {
|
|
56
|
-
handler(fullEntry);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (auditLogProvider) {
|
|
60
|
-
auditLogProvider.create({
|
|
61
|
-
resource: entry.resource ?? '',
|
|
62
|
-
action: entry.action,
|
|
63
|
-
data: entry.data ?? entry.details,
|
|
64
|
-
previousData: entry.previousData,
|
|
65
|
-
meta: entry.meta,
|
|
66
|
-
}).catch(e => console.error('[audit] provider create error:', e));
|
|
56
|
+
const result = handler(fullEntry);
|
|
57
|
+
if (result && typeof result === 'object' && 'then' in result) {
|
|
58
|
+
(result as Promise<void>).catch(e => console.error('[audit] handler error:', e));
|
|
67
59
|
}
|
|
68
60
|
} catch (e) {
|
|
69
61
|
console.error('[audit] handler error:', e);
|
|
70
62
|
}
|
|
63
|
+
if (auditLogProvider) {
|
|
64
|
+
auditLogProvider.create({
|
|
65
|
+
resource: entry.resource ?? '',
|
|
66
|
+
action: entry.action,
|
|
67
|
+
data: entry.data ?? entry.details,
|
|
68
|
+
previousData: entry.previousData,
|
|
69
|
+
meta: entry.meta,
|
|
70
|
+
}).catch(e => console.error('[audit] provider create error:', e));
|
|
71
|
+
}
|
|
71
72
|
}
|
package/src/auth-hooks.svelte.ts
CHANGED
|
@@ -7,6 +7,11 @@ import { navigate } from './router';
|
|
|
7
7
|
import { notify } from './notification.svelte';
|
|
8
8
|
import { t } from './i18n.svelte';
|
|
9
9
|
import type { AuthActionResult, CheckResult, Identity, AuthProvider } from './types';
|
|
10
|
+
import { useQueryClient } from '@tanstack/svelte-query';
|
|
11
|
+
|
|
12
|
+
let _logoutVersion = $state(0);
|
|
13
|
+
export function getLogoutVersion() { return _logoutVersion; }
|
|
14
|
+
export function resetLogoutVersion() { _logoutVersion = 0; }
|
|
10
15
|
|
|
11
16
|
// ─── Mutate Factory ─────────────────────────────────────────────
|
|
12
17
|
|
|
@@ -59,7 +64,7 @@ export function useLogin(opts?: { errorMessage?: string | false }) {
|
|
|
59
64
|
method: 'login',
|
|
60
65
|
successMessage: t('common.operationSuccess'),
|
|
61
66
|
errorMessage: opts?.errorMessage ?? t('common.loginFailed'),
|
|
62
|
-
onSuccess: (result) => {
|
|
67
|
+
onSuccess: (result) => { navigate(result.redirectTo ?? '/'); }
|
|
63
68
|
});
|
|
64
69
|
}
|
|
65
70
|
|
|
@@ -69,7 +74,14 @@ export function useLogout() {
|
|
|
69
74
|
return createAuthMutation({
|
|
70
75
|
method: 'logout',
|
|
71
76
|
successMessage: null,
|
|
72
|
-
onSuccess: (result) => {
|
|
77
|
+
onSuccess: (result) => {
|
|
78
|
+
_logoutVersion++;
|
|
79
|
+
try {
|
|
80
|
+
const queryClient = useQueryClient();
|
|
81
|
+
queryClient.clear();
|
|
82
|
+
} catch {}
|
|
83
|
+
navigate(result.redirectTo ?? '/login');
|
|
84
|
+
}
|
|
73
85
|
});
|
|
74
86
|
}
|
|
75
87
|
|
|
@@ -125,6 +137,10 @@ export function useGetIdentity() {
|
|
|
125
137
|
|
|
126
138
|
if (typeof window !== 'undefined') {
|
|
127
139
|
fetch();
|
|
140
|
+
$effect(() => {
|
|
141
|
+
void _logoutVersion;
|
|
142
|
+
data = null;
|
|
143
|
+
});
|
|
128
144
|
}
|
|
129
145
|
|
|
130
146
|
return {
|
|
@@ -170,6 +186,13 @@ export function useIsAuthenticated() {
|
|
|
170
186
|
// Initial check
|
|
171
187
|
check();
|
|
172
188
|
|
|
189
|
+
if (typeof window !== 'undefined') {
|
|
190
|
+
$effect(() => {
|
|
191
|
+
void _logoutVersion;
|
|
192
|
+
isAuthenticated = false;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
173
196
|
return {
|
|
174
197
|
get isAuthenticated() { return isAuthenticated; },
|
|
175
198
|
get isLoading() { return isLoading; },
|
|
@@ -148,13 +148,12 @@ export interface AgentOptions {
|
|
|
148
148
|
* ```
|
|
149
149
|
*/
|
|
150
150
|
export interface AgentProvider {
|
|
151
|
-
/** Available tools the agent can invoke */
|
|
152
151
|
tools?: AdminTool[];
|
|
153
|
-
/** Send messages and receive a stream of typed agent events */
|
|
154
152
|
chat(
|
|
155
153
|
messages: ChatMessage[],
|
|
156
154
|
options?: AgentOptions,
|
|
157
155
|
): AsyncGenerator<AgentEvent, void, unknown>;
|
|
156
|
+
approveToolCall?(id: string, approved: boolean): void;
|
|
158
157
|
}
|
|
159
158
|
|
|
160
159
|
// ─── Chat Provider singleton ───────────────────────────────────
|
|
@@ -190,14 +189,22 @@ interface ApprovalEntry { id: string; callback: ApprovalCallback }
|
|
|
190
189
|
let pendingApprovals = $state<ApprovalEntry[]>([]);
|
|
191
190
|
|
|
192
191
|
export function registerApproval(id: string, callback: ApprovalCallback): void {
|
|
192
|
+
if (pendingApprovals.some(e => e.id === id)) return;
|
|
193
193
|
pendingApprovals = [...pendingApprovals, { id, callback }];
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
export function resolveApproval(id: string, approved: boolean): boolean {
|
|
197
197
|
const entry = pendingApprovals.find(e => e.id === id);
|
|
198
198
|
if (!entry) return false;
|
|
199
|
-
entry.callback(approved);
|
|
200
199
|
pendingApprovals = pendingApprovals.filter(e => e.id !== id);
|
|
200
|
+
try {
|
|
201
|
+
const result: unknown = entry.callback(approved);
|
|
202
|
+
if (result && typeof result === 'object' && 'then' in result) {
|
|
203
|
+
(result as Promise<unknown>).catch((err) => console.error('[svadmin] Approval callback error:', err));
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.error('[svadmin] Approval callback error:', err);
|
|
207
|
+
}
|
|
201
208
|
return true;
|
|
202
209
|
}
|
|
203
210
|
|
|
@@ -216,3 +223,13 @@ export function setChatContext(ctx: ChatContext): void {
|
|
|
216
223
|
export function getChatContext(): ChatContext {
|
|
217
224
|
return chatContext;
|
|
218
225
|
}
|
|
226
|
+
|
|
227
|
+
export function resetChatProvider(): void {
|
|
228
|
+
chatProvider = null;
|
|
229
|
+
agentProvider = null;
|
|
230
|
+
for (const entry of pendingApprovals) {
|
|
231
|
+
try { entry.callback(false); } catch { /* noop */ }
|
|
232
|
+
}
|
|
233
|
+
pendingApprovals = [];
|
|
234
|
+
chatContext = {};
|
|
235
|
+
}
|
package/src/context.svelte.ts
CHANGED
|
@@ -5,9 +5,17 @@ import type { DataProvider, AuthProvider, ResourceDefinition } from './types';
|
|
|
5
5
|
import type { RouterProvider } from './router-provider';
|
|
6
6
|
import { resetNotificationProvider } from './notification.svelte';
|
|
7
7
|
import { resetAuditLogProvider } from './audit';
|
|
8
|
-
import { resetAccessControlProvider } from './permissions';
|
|
8
|
+
import { resetAccessControlProvider } from './permissions.svelte';
|
|
9
9
|
import { resetAdminOptions } from './options.svelte';
|
|
10
10
|
import { resetI18n } from './i18n.svelte';
|
|
11
|
+
import { resetChatProvider } from './chatProvider.svelte';
|
|
12
|
+
import { resetToast } from './toast.svelte';
|
|
13
|
+
import { resetTheme } from './theme.svelte';
|
|
14
|
+
import { resetRouter } from './router';
|
|
15
|
+
import { resetUnsavedChanges } from './unsaved-changes.svelte';
|
|
16
|
+
import { resetGlobalPath } from './useParsed.svelte';
|
|
17
|
+
import { resetLogoutVersion } from './auth-hooks.svelte';
|
|
18
|
+
import { resetSidebarCollapsed } from './hooks.svelte';
|
|
11
19
|
|
|
12
20
|
// ─── DataProvider (supports single or multiple) ─────────────────
|
|
13
21
|
|
|
@@ -141,4 +149,12 @@ export function resetContext(): void {
|
|
|
141
149
|
resetAccessControlProvider();
|
|
142
150
|
resetAdminOptions();
|
|
143
151
|
resetI18n();
|
|
152
|
+
resetChatProvider();
|
|
153
|
+
resetToast();
|
|
154
|
+
resetTheme();
|
|
155
|
+
resetRouter();
|
|
156
|
+
resetUnsavedChanges();
|
|
157
|
+
resetGlobalPath();
|
|
158
|
+
resetLogoutVersion();
|
|
159
|
+
resetSidebarCollapsed();
|
|
144
160
|
}
|
|
@@ -25,10 +25,11 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
|
|
|
25
25
|
let isLoading = $state(false);
|
|
26
26
|
|
|
27
27
|
async function triggerExport() {
|
|
28
|
-
const
|
|
28
|
+
const exportResource = resource;
|
|
29
29
|
isLoading = true;
|
|
30
30
|
|
|
31
31
|
try {
|
|
32
|
+
const provider = getDataProviderForResource(exportResource, options.dataProviderName);
|
|
32
33
|
const batchSize = options.pageSize ?? 20;
|
|
33
34
|
const maxItems = options.maxItemCount ?? Infinity;
|
|
34
35
|
let allRecords: TData[] = [];
|
|
@@ -36,7 +37,7 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
|
|
|
36
37
|
|
|
37
38
|
while (allRecords.length < maxItems) {
|
|
38
39
|
const result = await provider.getList<TData>({
|
|
39
|
-
resource,
|
|
40
|
+
resource: exportResource,
|
|
40
41
|
pagination: { current: page, pageSize: batchSize },
|
|
41
42
|
sorters: options.sorters,
|
|
42
43
|
filters: options.filters,
|
|
@@ -57,7 +58,11 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
|
|
|
57
58
|
|
|
58
59
|
if (options.download !== false && typeof document !== 'undefined') {
|
|
59
60
|
const fields = Object.keys(mapped[0]);
|
|
60
|
-
const escapeCsvField = (s: string) =>
|
|
61
|
+
const escapeCsvField = (s: string) => {
|
|
62
|
+
let val = s;
|
|
63
|
+
if (/^[=+@\t\r]/.test(val)) val = "'" + val;
|
|
64
|
+
return val.includes(',') || val.includes('"') || val.includes('\n') || val.includes('\r') ? `"${val.replace(/"/g, '""')}"` : val;
|
|
65
|
+
};
|
|
61
66
|
const header = fields.map(escapeCsvField).join(',');
|
|
62
67
|
const rows = mapped.map(record =>
|
|
63
68
|
fields.map(f => {
|
|
@@ -72,9 +77,9 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
|
|
|
72
77
|
const url = URL.createObjectURL(blob);
|
|
73
78
|
const a = document.createElement('a');
|
|
74
79
|
a.href = url;
|
|
75
|
-
a.download = `${
|
|
80
|
+
a.download = `${exportResource}_export.csv`;
|
|
76
81
|
a.click();
|
|
77
|
-
URL.revokeObjectURL(url);
|
|
82
|
+
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
return allRecords;
|
|
@@ -113,12 +118,13 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
|
|
|
113
118
|
let mutationResult = $state<{ succeeded: unknown[]; errored: { request: unknown; error: unknown }[] } | null>(null);
|
|
114
119
|
|
|
115
120
|
async function handleChange(info: { file: File }) {
|
|
116
|
-
const
|
|
121
|
+
const importResource = resource;
|
|
117
122
|
isLoading = true;
|
|
118
123
|
const succeeded: unknown[] = [];
|
|
119
124
|
const errored: { request: unknown; error: unknown }[] = [];
|
|
120
125
|
|
|
121
126
|
try {
|
|
127
|
+
const provider = getDataProviderForResource(importResource, options.dataProviderName);
|
|
122
128
|
let text = await info.file.text();
|
|
123
129
|
// Strip BOM
|
|
124
130
|
if (text.charCodeAt(0) === 0xFEFF) {
|
|
@@ -126,7 +132,11 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
|
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
const rows = parseCSV(text);
|
|
129
|
-
if (rows.length < 2)
|
|
135
|
+
if (rows.length < 2) {
|
|
136
|
+
mutationResult = { succeeded: [], errored: [] };
|
|
137
|
+
options.onFinish?.({ succeeded: [], errored: [] });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
130
140
|
|
|
131
141
|
const headers = rows[0];
|
|
132
142
|
const records: Record<string, unknown>[] = [];
|
|
@@ -144,7 +154,7 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
|
|
|
144
154
|
if (batchSize === 1) {
|
|
145
155
|
for (const record of records) {
|
|
146
156
|
try {
|
|
147
|
-
const res = await provider.create({ resource, variables: record, meta: options.meta });
|
|
157
|
+
const res = await provider.create({ resource: importResource, variables: record, meta: options.meta });
|
|
148
158
|
succeeded.push(res);
|
|
149
159
|
} catch (error) {
|
|
150
160
|
errored.push({ request: record, error });
|
|
@@ -157,16 +167,15 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
|
|
|
157
167
|
const batch = records.slice(i, i + batchSize);
|
|
158
168
|
if (provider.createMany) {
|
|
159
169
|
try {
|
|
160
|
-
const res = await provider.createMany({ resource, variables: batch, meta: options.meta });
|
|
170
|
+
const res = await provider.createMany({ resource: importResource, variables: batch, meta: options.meta });
|
|
161
171
|
succeeded.push(res);
|
|
162
172
|
} catch (error) {
|
|
163
173
|
errored.push({ request: batch, error });
|
|
164
174
|
}
|
|
165
175
|
} else {
|
|
166
|
-
// Fallback: loop internal items inside batch securely instead of breaking whole batch
|
|
167
176
|
for (const record of batch) {
|
|
168
177
|
try {
|
|
169
|
-
const res = await provider.create({ resource, variables: record, meta: options.meta });
|
|
178
|
+
const res = await provider.create({ resource: importResource, variables: record, meta: options.meta });
|
|
170
179
|
succeeded.push(res);
|
|
171
180
|
} catch (error) {
|
|
172
181
|
errored.push({ request: record, error });
|
|
@@ -180,6 +189,9 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
|
|
|
180
189
|
|
|
181
190
|
mutationResult = { succeeded, errored };
|
|
182
191
|
options.onFinish?.({ succeeded, errored });
|
|
192
|
+
} catch (error) {
|
|
193
|
+
mutationResult = { succeeded, errored: [...errored, { request: null, error }] };
|
|
194
|
+
options.onFinish?.({ succeeded, errored: mutationResult.errored });
|
|
183
195
|
} finally {
|
|
184
196
|
isLoading = false;
|
|
185
197
|
}
|
package/src/form-hooks.svelte.ts
CHANGED
|
@@ -246,7 +246,7 @@ export function useForm<
|
|
|
246
246
|
values = { ...initialValues } as TVariables;
|
|
247
247
|
tainted = {};
|
|
248
248
|
clearErrors();
|
|
249
|
-
queryInitializedId =
|
|
249
|
+
queryInitializedId = currentId;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
// ─── Server error handling ──────────────────────────────────────
|
|
@@ -313,17 +313,25 @@ export function useForm<
|
|
|
313
313
|
|
|
314
314
|
const createMut = createMutation(() => ({
|
|
315
315
|
...options.createMutationOptions,
|
|
316
|
-
mutationFn: (variables:
|
|
317
|
-
|
|
318
|
-
|
|
316
|
+
mutationFn: (variables: unknown) => {
|
|
317
|
+
const targetResource = resource;
|
|
318
|
+
const targetMeta = mutationMeta;
|
|
319
|
+
return provider.create<TData, TVariables>({ resource: targetResource, variables: variables as TVariables, meta: targetMeta });
|
|
320
|
+
},
|
|
321
|
+
onMutate: async () => {
|
|
322
|
+
return { targetResource: resource };
|
|
323
|
+
},
|
|
324
|
+
onSuccess: (data: { data: TData }, _vars: unknown, context: unknown) => {
|
|
325
|
+
const ctx = context as { targetResource?: string } | undefined;
|
|
326
|
+
const res = ctx?.targetResource ?? resource;
|
|
327
|
+
invalidateByScopes(queryClient, res, invalidateScopes, ['list', 'many'], undefined, options.dataProviderName);
|
|
319
328
|
if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.createSuccess') });
|
|
320
|
-
const pk = getResource(
|
|
329
|
+
const pk = getResource(res).primaryKey ?? 'id';
|
|
321
330
|
const newId = (data.data as Record<string, unknown>)[pk];
|
|
322
|
-
audit({ action: 'create', resource, recordId: String(newId) });
|
|
323
|
-
// Publish live event
|
|
331
|
+
audit({ action: 'create', resource: res, recordId: String(newId) });
|
|
324
332
|
try {
|
|
325
333
|
const lp = getLiveProvider();
|
|
326
|
-
if (lp?.publish) lp.publish({ type: 'INSERT', resource, payload: { ids: newId != null ? [newId as string | number] : [] } });
|
|
334
|
+
if (lp?.publish) lp.publish({ type: 'INSERT', resource: res, payload: { ids: newId != null ? [newId as string | number] : [] } });
|
|
327
335
|
} catch { /* no live provider */ }
|
|
328
336
|
onMutationSuccess?.(data);
|
|
329
337
|
if (redirectOverride !== false) {
|
|
@@ -337,6 +345,9 @@ export function useForm<
|
|
|
337
345
|
const updateMut = createMutation<{ data: TData }, unknown, TVariables>(() => ({
|
|
338
346
|
...options.updateMutationOptions,
|
|
339
347
|
mutationFn: async (variables: TVariables) => {
|
|
348
|
+
const targetId = currentId!;
|
|
349
|
+
const targetResource = resource;
|
|
350
|
+
const targetMeta = mutationMeta;
|
|
340
351
|
if (mutationMode === 'undoable') {
|
|
341
352
|
await new Promise<void>((resolve, reject) => {
|
|
342
353
|
toast.undoable(t('common.actionCanBeUndone'), undoableTimeout, () => {
|
|
@@ -344,38 +355,48 @@ export function useForm<
|
|
|
344
355
|
}, resolve);
|
|
345
356
|
});
|
|
346
357
|
}
|
|
347
|
-
return provider.update<TData, TVariables>({ resource, id:
|
|
358
|
+
return provider.update<TData, TVariables>({ resource: targetResource, id: targetId, variables, meta: targetMeta });
|
|
348
359
|
},
|
|
349
360
|
onMutate: async (variables: TVariables) => {
|
|
350
|
-
if (mutationMode === 'pessimistic') return;
|
|
351
|
-
|
|
352
|
-
const
|
|
361
|
+
if (mutationMode === 'pessimistic') return { targetId: currentId, targetResource: resource };
|
|
362
|
+
const targetId = currentId;
|
|
363
|
+
const targetResource = resource;
|
|
364
|
+
const dpN = options.dataProviderName;
|
|
365
|
+
const dp = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dpN;
|
|
366
|
+
await queryClient.cancelQueries({ predicate: (q) => dp(q) && q.queryKey[1] === resource });
|
|
367
|
+
const previousQueries = queryClient.getQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resource });
|
|
353
368
|
if (optimisticUpdateMap?.list !== false) {
|
|
354
|
-
queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'list' }, (old: unknown) => {
|
|
369
|
+
queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resource && q.queryKey[2] === 'list' }, (old: unknown) => {
|
|
355
370
|
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
356
371
|
const o = old as { data: Record<string, unknown>[] };
|
|
357
372
|
const pk = getResource(resource).primaryKey ?? 'id';
|
|
358
|
-
return { ...o, data: o.data.map((item) => String(item[pk]) === String(
|
|
373
|
+
return { ...o, data: o.data.map((item) => String(item[pk]) === String(targetId) ? deepMerge(item, variables) : item) };
|
|
359
374
|
});
|
|
360
375
|
}
|
|
361
|
-
if (optimisticUpdateMap?.detail !== false &&
|
|
362
|
-
queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'one' && q.queryKey[3] ===
|
|
376
|
+
if (optimisticUpdateMap?.detail !== false && targetId != null) {
|
|
377
|
+
queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resource && q.queryKey[2] === 'one' && q.queryKey[3] === targetId }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, variables) } : old);
|
|
363
378
|
}
|
|
364
|
-
return { previousQueries };
|
|
379
|
+
return { previousQueries, targetId, targetResource };
|
|
365
380
|
},
|
|
366
|
-
onSuccess: (data: unknown) => {
|
|
381
|
+
onSuccess: (data: unknown, _vars: unknown, context: unknown) => {
|
|
382
|
+
const ctx = context as { targetId?: string | number; targetResource?: string } | undefined;
|
|
383
|
+
const targetId = ctx?.targetId ?? currentId;
|
|
384
|
+
const res = ctx?.targetResource ?? resource;
|
|
367
385
|
if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.updateSuccess') });
|
|
368
|
-
audit({ action: 'update', resource, recordId: String(
|
|
386
|
+
audit({ action: 'update', resource: res, recordId: String(targetId) });
|
|
369
387
|
try {
|
|
370
388
|
const lp = getLiveProvider();
|
|
371
|
-
if (lp?.publish) lp.publish({ type: 'UPDATE', resource, payload: { ids:
|
|
389
|
+
if (lp?.publish) lp.publish({ type: 'UPDATE', resource: res, payload: { ids: targetId != null ? [targetId] : [] } });
|
|
372
390
|
} catch { /* no live provider */ }
|
|
373
391
|
onMutationSuccess?.(data);
|
|
374
392
|
if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
|
|
375
393
|
},
|
|
376
|
-
onSettled: (_data: unknown, error: unknown) => {
|
|
394
|
+
onSettled: (_data: unknown, error: unknown, _vars: unknown, context: unknown) => {
|
|
377
395
|
if (error instanceof UndoError) return;
|
|
378
|
-
|
|
396
|
+
const ctx = context as { targetId?: string | number; targetResource?: string } | undefined;
|
|
397
|
+
const targetId = ctx?.targetId ?? currentId;
|
|
398
|
+
const res = ctx?.targetResource ?? resource;
|
|
399
|
+
invalidateByScopes(queryClient, res, invalidateScopes, ['list', 'many', 'detail'], targetId ?? undefined, options.dataProviderName);
|
|
379
400
|
},
|
|
380
401
|
onError: (error: unknown, _vars: unknown, context: unknown) => {
|
|
381
402
|
const ctx = context as { previousQueries?: [unknown, unknown][] } | undefined;
|
|
@@ -404,9 +425,14 @@ export function useForm<
|
|
|
404
425
|
// ─── Submit ─────────────────────────────────────────────────────
|
|
405
426
|
async function submit(overrides?: { redirect?: 'list' | 'edit' | 'show' | false }) {
|
|
406
427
|
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
|
428
|
+
while (autoSaveInProgress) {
|
|
429
|
+
await new Promise(r => setTimeout(r, 50));
|
|
430
|
+
}
|
|
431
|
+
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
|
432
|
+
autoSaveDirty = false;
|
|
407
433
|
if (onSubmitFn) {
|
|
408
434
|
let cancelled = false;
|
|
409
|
-
const result = onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
|
|
435
|
+
const result = await onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
|
|
410
436
|
if (result === false || cancelled) return;
|
|
411
437
|
}
|
|
412
438
|
|
|
@@ -424,12 +450,16 @@ export function useForm<
|
|
|
424
450
|
// ─── AutoSave ───────────────────────────────────────────────────
|
|
425
451
|
let autoSaveStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
|
426
452
|
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
453
|
+
let autoSaveStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
|
454
|
+
let autoSaveInProgress = $state(false);
|
|
455
|
+
let autoSaveDirty = false;
|
|
427
456
|
let lastAutoSaveData = $state<unknown>(null);
|
|
428
457
|
let lastAutoSaveError = $state<unknown>(null);
|
|
429
458
|
|
|
430
459
|
$effect(() => {
|
|
431
460
|
return () => {
|
|
432
461
|
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
|
462
|
+
if (autoSaveStatusTimer) clearTimeout(autoSaveStatusTimer);
|
|
433
463
|
};
|
|
434
464
|
});
|
|
435
465
|
|
|
@@ -440,28 +470,42 @@ export function useForm<
|
|
|
440
470
|
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
|
441
471
|
|
|
442
472
|
const safeId = currentId;
|
|
473
|
+
const safeResource = resource;
|
|
474
|
+
const safeMeta = mutationMeta;
|
|
443
475
|
autoSaveTimer = setTimeout(async () => {
|
|
476
|
+
if (autoSaveInProgress || createMut.isPending || updateMut.isPending) {
|
|
477
|
+
autoSaveDirty = true;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
444
480
|
const finalValues = autoSaveOpts.onFinish ? autoSaveOpts.onFinish(values) : values;
|
|
445
481
|
autoSaveStatus = 'saving';
|
|
482
|
+
autoSaveInProgress = true;
|
|
483
|
+
autoSaveDirty = false;
|
|
446
484
|
try {
|
|
447
|
-
await provider.update<TData, TVariables>({ resource, id: safeId as string | number, variables: finalValues, meta:
|
|
485
|
+
await provider.update<TData, TVariables>({ resource: safeResource, id: safeId as string | number, variables: finalValues, meta: safeMeta });
|
|
448
486
|
const scopes = autoSaveOpts.invalidates ?? ['resourceAll'];
|
|
449
|
-
invalidateByScopes(queryClient,
|
|
487
|
+
invalidateByScopes(queryClient, safeResource, scopes, ['resourceAll'], safeId ?? undefined, options.dataProviderName);
|
|
450
488
|
autoSaveStatus = 'saved';
|
|
451
489
|
lastAutoSaveData = finalValues;
|
|
452
490
|
lastAutoSaveError = null;
|
|
453
|
-
|
|
491
|
+
initialValues = { ...finalValues } as TVariables;
|
|
492
|
+
tainted = {};
|
|
493
|
+
if (autoSaveStatusTimer) clearTimeout(autoSaveStatusTimer);
|
|
494
|
+
autoSaveStatusTimer = setTimeout(() => { autoSaveStatus = 'idle'; autoSaveStatusTimer = null; }, 2000);
|
|
454
495
|
} catch (e) {
|
|
455
496
|
autoSaveStatus = 'error';
|
|
456
497
|
lastAutoSaveError = e;
|
|
457
498
|
checkError(e);
|
|
499
|
+
} finally {
|
|
500
|
+
autoSaveInProgress = false;
|
|
501
|
+
if (autoSaveDirty) triggerAutoSave();
|
|
458
502
|
}
|
|
459
503
|
}, autoSaveOpts.debounce ?? 1000);
|
|
460
504
|
}
|
|
461
505
|
|
|
462
506
|
if (autoSaveOpts?.invalidateOnUnmount) {
|
|
463
507
|
$effect(() => {
|
|
464
|
-
return () => queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resource });
|
|
508
|
+
return () => queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === options.dataProviderName && q.queryKey[1] === resource });
|
|
465
509
|
});
|
|
466
510
|
}
|
|
467
511
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { useForm } from './form-hooks.svelte';
|
|
3
|
+
import { flushSync } from 'svelte';
|
|
4
|
+
import { QueryClient } from '@tanstack/svelte-query';
|
|
5
|
+
|
|
6
|
+
vi.mock('./context.svelte', () => ({
|
|
7
|
+
useDataProvider: () => ({
|
|
8
|
+
getOne: vi.fn(),
|
|
9
|
+
create: vi.fn().mockResolvedValue({ data: { id: 1 } }),
|
|
10
|
+
update: vi.fn().mockResolvedValue({ data: { id: 1 } })
|
|
11
|
+
}),
|
|
12
|
+
useResource: () => ({ name: 'posts' }),
|
|
13
|
+
getResource: () => ({ name: 'posts', primaryKey: 'id' }),
|
|
14
|
+
useRouterContext: () => ({ navigate: vi.fn() }),
|
|
15
|
+
useTranslate: () => (key: string) => key,
|
|
16
|
+
useNotification: () => ({ open: vi.fn(), close: vi.fn() })
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('@tanstack/svelte-query', async (importOriginal) => {
|
|
20
|
+
const actual = await importOriginal();
|
|
21
|
+
return {
|
|
22
|
+
...actual as any,
|
|
23
|
+
useQueryClient: () => new QueryClient(),
|
|
24
|
+
createQuery: () => ({ data: { data: { id: 1, title: 'Original' } }, isPending: false }),
|
|
25
|
+
createMutation: (opts: any) => ({
|
|
26
|
+
mutate: vi.fn((vars, callbacks) => {
|
|
27
|
+
if (opts.mutationFn) opts.mutationFn(vars);
|
|
28
|
+
if (callbacks?.onSuccess) callbacks.onSuccess({ data: {} });
|
|
29
|
+
}),
|
|
30
|
+
isPending: false
|
|
31
|
+
})
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('useForm - Headless Svelte 5 Compatibility', () => {
|
|
36
|
+
|
|
37
|
+
it('preserves reactive proxy data bindings safely across Svelte boundaries', () => {
|
|
38
|
+
let form: ReturnType<typeof useForm>;
|
|
39
|
+
|
|
40
|
+
const cleanup = $effect.root(() => {
|
|
41
|
+
form = useForm({
|
|
42
|
+
action: 'create',
|
|
43
|
+
resource: 'posts'
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
flushSync();
|
|
48
|
+
|
|
49
|
+
// Properly use setFieldValue which guarantees state sync in svadmin forms
|
|
50
|
+
form!.setFieldValue('title', 'Reactive Test');
|
|
51
|
+
|
|
52
|
+
flushSync();
|
|
53
|
+
|
|
54
|
+
// The internal $state proxy absorbs this mutation
|
|
55
|
+
expect(form!.values.title).toBe('Reactive Test');
|
|
56
|
+
|
|
57
|
+
cleanup();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles autoSave queuing correctly across reactive boundaries', () => {
|
|
61
|
+
let form: ReturnType<typeof useForm>;
|
|
62
|
+
|
|
63
|
+
const cleanup = $effect.root(() => {
|
|
64
|
+
form = useForm({
|
|
65
|
+
action: 'edit',
|
|
66
|
+
resource: 'posts',
|
|
67
|
+
id: 1,
|
|
68
|
+
autoSave: { enabled: true, debounce: 0 }
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
flushSync();
|
|
73
|
+
|
|
74
|
+
form!.setFieldValue('title', 'Saved Title');
|
|
75
|
+
|
|
76
|
+
flushSync();
|
|
77
|
+
|
|
78
|
+
expect(form!.mutation).toBeDefined();
|
|
79
|
+
expect((form!.mutation as any).isPending).toBeDefined();
|
|
80
|
+
|
|
81
|
+
cleanup();
|
|
82
|
+
});
|
|
83
|
+
});
|