@svadmin/core 0.23.1 → 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 +25 -6
- 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/src/live-sse.ts
CHANGED
|
@@ -12,13 +12,9 @@
|
|
|
12
12
|
import type { LiveProvider, LiveEvent } from './live.svelte';
|
|
13
13
|
|
|
14
14
|
export interface SSELiveProviderOptions {
|
|
15
|
-
/** SSE endpoint URL, e.g. "https://api.example.com/events" */
|
|
16
15
|
url: string;
|
|
17
|
-
/** Optional EventSource init options (e.g. withCredentials) */
|
|
18
16
|
eventSourceInit?: EventSourceInit;
|
|
19
|
-
/** Optional callback when connection opens */
|
|
20
17
|
onOpen?: () => void;
|
|
21
|
-
/** Optional callback for connection errors */
|
|
22
18
|
onError?: (event: Event) => void;
|
|
23
19
|
}
|
|
24
20
|
|
|
@@ -29,7 +25,7 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
|
|
|
29
25
|
const subscribers = new Map<string, Set<Callback>>();
|
|
30
26
|
let eventSource: EventSource | null = null;
|
|
31
27
|
let status: 'connecting' | 'connected' | 'disconnected' = 'disconnected';
|
|
32
|
-
const
|
|
28
|
+
const namedListeners = new Map<string, EventListener>();
|
|
33
29
|
|
|
34
30
|
function connect() {
|
|
35
31
|
if (eventSource) return;
|
|
@@ -43,20 +39,17 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
|
|
|
43
39
|
options.onOpen?.();
|
|
44
40
|
};
|
|
45
41
|
|
|
46
|
-
// Listen for generic "message" events
|
|
47
42
|
eventSource.onmessage = (msgEvent) => {
|
|
48
43
|
dispatchEvent(msgEvent.data);
|
|
49
44
|
};
|
|
50
45
|
|
|
51
46
|
eventSource.onerror = (event) => {
|
|
52
|
-
// EventSource auto-reconnects by default
|
|
53
47
|
if (eventSource?.readyState === EventSource.CLOSED) {
|
|
54
48
|
status = 'disconnected';
|
|
55
49
|
}
|
|
56
50
|
options.onError?.(event);
|
|
57
51
|
};
|
|
58
52
|
|
|
59
|
-
// Also listen for resource-specific named events
|
|
60
53
|
for (const resource of subscribers.keys()) {
|
|
61
54
|
if (resource !== '*') {
|
|
62
55
|
addNamedListener(resource);
|
|
@@ -65,9 +58,8 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
|
|
|
65
58
|
}
|
|
66
59
|
|
|
67
60
|
function addNamedListener(resource: string) {
|
|
68
|
-
if (!eventSource ||
|
|
69
|
-
|
|
70
|
-
eventSource.addEventListener(resource, ((event: MessageEvent) => {
|
|
61
|
+
if (!eventSource || namedListeners.has(resource)) return;
|
|
62
|
+
const listener = ((event: MessageEvent) => {
|
|
71
63
|
try {
|
|
72
64
|
const parsed = JSON.parse(event.data);
|
|
73
65
|
const liveEvent: LiveEvent = {
|
|
@@ -79,7 +71,18 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
|
|
|
79
71
|
} catch {
|
|
80
72
|
// ignore non-JSON
|
|
81
73
|
}
|
|
82
|
-
}) as EventListener
|
|
74
|
+
}) as EventListener;
|
|
75
|
+
namedListeners.set(resource, listener);
|
|
76
|
+
eventSource.addEventListener(resource, listener);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function removeNamedListener(resource: string) {
|
|
80
|
+
const listener = namedListeners.get(resource);
|
|
81
|
+
if (!listener) return;
|
|
82
|
+
if (eventSource) {
|
|
83
|
+
eventSource.removeEventListener(resource, listener);
|
|
84
|
+
}
|
|
85
|
+
namedListeners.delete(resource);
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
function dispatchEvent(data: string) {
|
|
@@ -98,7 +101,6 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
|
|
|
98
101
|
if (callbacks) {
|
|
99
102
|
for (const cb of callbacks) cb(event);
|
|
100
103
|
}
|
|
101
|
-
// Wildcard subscribers
|
|
102
104
|
const wildcardCallbacks = subscribers.get('*');
|
|
103
105
|
if (wildcardCallbacks) {
|
|
104
106
|
for (const cb of wildcardCallbacks) cb(event);
|
|
@@ -111,7 +113,7 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
|
|
|
111
113
|
eventSource = null;
|
|
112
114
|
}
|
|
113
115
|
status = 'disconnected';
|
|
114
|
-
|
|
116
|
+
namedListeners.clear();
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
return {
|
|
@@ -123,11 +125,9 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
|
|
|
123
125
|
}
|
|
124
126
|
subscribers.get(resource)!.add(callback);
|
|
125
127
|
|
|
126
|
-
// Connect if not already
|
|
127
128
|
if (!eventSource && typeof EventSource !== 'undefined') {
|
|
128
129
|
connect();
|
|
129
130
|
}
|
|
130
|
-
// Add named listener for this resource if connected
|
|
131
131
|
if (eventSource && resource !== '*' && isNewResource) {
|
|
132
132
|
addNamedListener(resource);
|
|
133
133
|
}
|
|
@@ -136,7 +136,10 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
|
|
|
136
136
|
const callbacks = subscribers.get(resource);
|
|
137
137
|
if (callbacks) {
|
|
138
138
|
callbacks.delete(callback);
|
|
139
|
-
if (callbacks.size === 0)
|
|
139
|
+
if (callbacks.size === 0) {
|
|
140
|
+
subscribers.delete(resource);
|
|
141
|
+
removeNamedListener(resource);
|
|
142
|
+
}
|
|
140
143
|
}
|
|
141
144
|
if (subscribers.size === 0) disconnect();
|
|
142
145
|
};
|
package/src/live-websocket.ts
CHANGED
|
@@ -102,6 +102,7 @@ export function createWebSocketLiveProvider(options: WebSocketLiveProviderOption
|
|
|
102
102
|
|
|
103
103
|
function disconnect() {
|
|
104
104
|
intentionalClose = true;
|
|
105
|
+
reconnectAttempts = 0;
|
|
105
106
|
if (reconnectTimer) {
|
|
106
107
|
clearTimeout(reconnectTimer);
|
|
107
108
|
reconnectTimer = null;
|
package/src/live.svelte.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface LiveProviderReconnectOptions {
|
|
|
31
31
|
export function useLive(
|
|
32
32
|
liveProvider: LiveProvider | (() => LiveProvider),
|
|
33
33
|
resource: string | (() => string),
|
|
34
|
-
options?: { liveMode?: LiveMode | (() => LiveMode); onLiveEvent?: (event: LiveEvent) => void; liveParams?: Record<string, unknown> | (() => Record<string, unknown>) }
|
|
34
|
+
options?: { liveMode?: LiveMode | (() => LiveMode); onLiveEvent?: (event: LiveEvent) => void; liveParams?: Record<string, unknown> | (() => Record<string, unknown>); dataProviderName?: string }
|
|
35
35
|
): void {
|
|
36
36
|
const queryClient = useQueryClient();
|
|
37
37
|
|
|
@@ -42,16 +42,24 @@ export function useLive(
|
|
|
42
42
|
const liveParams = typeof options?.liveParams === 'function' ? options.liveParams() : options?.liveParams;
|
|
43
43
|
if (liveMode === 'off') return;
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
45
|
+
let unsubscribe: (() => void) | undefined;
|
|
46
|
+
try {
|
|
47
|
+
unsubscribe = lp.subscribe({
|
|
48
|
+
resource: res,
|
|
49
|
+
liveParams,
|
|
50
|
+
callback: (event) => {
|
|
51
|
+
options?.onLiveEvent?.(event);
|
|
52
|
+
if (liveMode === 'auto') {
|
|
53
|
+
const dpName = options?.dataProviderName;
|
|
54
|
+
const dpMatch = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dpName;
|
|
55
|
+
queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === res });
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.warn('[svadmin] LiveProvider.subscribe failed:', e);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
55
63
|
return unsubscribe;
|
|
56
64
|
});
|
|
57
65
|
}
|
|
@@ -11,7 +11,7 @@ import { toast } from './toast.svelte';
|
|
|
11
11
|
|
|
12
12
|
// ─── Helper: publish live events after mutations ────────────────
|
|
13
13
|
|
|
14
|
-
function publishLiveEvent(resource: string, type: 'created' | 'updated' | 'deleted', ids?: (string | number)[]) {
|
|
14
|
+
export function publishLiveEvent(resource: string, type: 'created' | 'updated' | 'deleted', ids?: (string | number)[]) {
|
|
15
15
|
try {
|
|
16
16
|
const liveProvider = getLiveProvider();
|
|
17
17
|
if (liveProvider?.publish) {
|
|
@@ -36,21 +36,24 @@ export function invalidateByScopes(
|
|
|
36
36
|
resource: string,
|
|
37
37
|
scopes: string[] | false | undefined,
|
|
38
38
|
defaults: string[],
|
|
39
|
-
id?: string | number
|
|
39
|
+
id?: string | number,
|
|
40
|
+
dataProviderName?: string
|
|
40
41
|
): void {
|
|
41
42
|
if (scopes === false) return;
|
|
42
43
|
const effectiveScopes = (scopes && scopes.length > 0) ? scopes : defaults;
|
|
44
|
+
const dpMatch = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dataProviderName;
|
|
43
45
|
for (const scope of effectiveScopes) {
|
|
44
|
-
if (scope === 'list') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resource && (q.queryKey[2] === 'list' || q.queryKey[2] === 'infiniteList' || q.queryKey[2] === 'select') });
|
|
45
|
-
else if (scope === 'many') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'many' });
|
|
46
|
-
else if ((scope === 'detail' || scope === 'one') && id != null) queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'one' && q.queryKey[3] === id });
|
|
47
|
-
else if (scope === '
|
|
46
|
+
if (scope === 'list') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === resource && (q.queryKey[2] === 'list' || q.queryKey[2] === 'infiniteList' || q.queryKey[2] === 'select' || q.queryKey[2] === 'select-defaults') });
|
|
47
|
+
else if (scope === 'many') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === resource && q.queryKey[2] === 'many' });
|
|
48
|
+
else if ((scope === 'detail' || scope === 'one') && id != null) queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === resource && q.queryKey[2] === 'one' && q.queryKey[3] === id });
|
|
49
|
+
else if (scope === 'detail' || scope === 'one') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === resource && q.queryKey[2] === 'one' });
|
|
50
|
+
else if (scope === 'resourceAll') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === resource });
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
export function deepMerge(target: any, source: any): any {
|
|
52
55
|
if (!target || typeof target !== 'object') return source;
|
|
53
|
-
if (!source || typeof source !== 'object') return
|
|
56
|
+
if (!source || typeof source !== 'object') return target;
|
|
54
57
|
if (Array.isArray(source) || source instanceof Date) return source;
|
|
55
58
|
const result = { ...target };
|
|
56
59
|
for (const key in source) {
|
|
@@ -127,14 +130,14 @@ export function useCreate<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
127
130
|
audit({ action: 'create', resource: resName, recordId: String(newId) });
|
|
128
131
|
publishLiveEvent(resName, 'created', newId != null ? [newId as string | number] : undefined);
|
|
129
132
|
// Invalidation in onSuccess for create (refine pattern — no optimistic data to reconcile on error)
|
|
130
|
-
invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many']);
|
|
133
|
+
invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many'], undefined, params.dataProviderName);
|
|
131
134
|
if (typeof options.mutationOptions?.onSuccess === 'function') {
|
|
132
135
|
(options.mutationOptions.onSuccess as Function)(data, params, context);
|
|
133
136
|
}
|
|
134
137
|
},
|
|
135
138
|
onError: (error, params, context) => {
|
|
136
139
|
checkError(error);
|
|
137
|
-
fireErrorNotification(params.errorNotification, 'Create failed', error);
|
|
140
|
+
fireErrorNotification(params.errorNotification, 'Create failed', error, params.resource ?? defaultResource);
|
|
138
141
|
if (typeof options.mutationOptions?.onError === 'function') {
|
|
139
142
|
(options.mutationOptions.onError as Function)(error, params, context);
|
|
140
143
|
}
|
|
@@ -218,10 +221,12 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
218
221
|
if (mutationMode === 'pessimistic') return { _svadmin_ctx: true, userContext };
|
|
219
222
|
const resName = params.resource ?? defaultResource;
|
|
220
223
|
const targetId = params.id ?? defaultId;
|
|
224
|
+
const dpN = params.dataProviderName;
|
|
225
|
+
const dp = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dpN;
|
|
221
226
|
|
|
222
|
-
await queryClient.cancelQueries({ predicate: (q) => q.queryKey[1] === resName });
|
|
227
|
+
await queryClient.cancelQueries({ predicate: (q) => dp(q) && q.queryKey[1] === resName });
|
|
223
228
|
|
|
224
|
-
const previousQueries = queryClient.getQueriesData({ predicate: (q) => q.queryKey[1] === resName });
|
|
229
|
+
const previousQueries = queryClient.getQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName });
|
|
225
230
|
|
|
226
231
|
const pk = getResource(resName).primaryKey ?? 'id';
|
|
227
232
|
const updater = params.optimisticUpdateMap ?? { list: true, many: true, detail: true };
|
|
@@ -229,14 +234,14 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
229
234
|
if (updater.detail !== false) {
|
|
230
235
|
const detailFn = updater.detail;
|
|
231
236
|
if (typeof detailFn === 'function') {
|
|
232
|
-
queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId }, (old: unknown) => detailFn(old, params.variables, targetId));
|
|
237
|
+
queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId }, (old: unknown) => detailFn(old, params.variables, targetId));
|
|
233
238
|
} else {
|
|
234
|
-
queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, params.variables) } : old);
|
|
239
|
+
queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, params.variables) } : old);
|
|
235
240
|
}
|
|
236
241
|
}
|
|
237
242
|
|
|
238
243
|
if (updater.list !== false) {
|
|
239
|
-
queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'list' }, (old: unknown) => {
|
|
244
|
+
queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'list' }, (old: unknown) => {
|
|
240
245
|
if (typeof updater.list === 'function') return updater.list!(old, params.variables, targetId);
|
|
241
246
|
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
242
247
|
const o = old as { data: Record<string, unknown>[] };
|
|
@@ -245,7 +250,7 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
245
250
|
}
|
|
246
251
|
|
|
247
252
|
if (updater.many !== false) {
|
|
248
|
-
queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'many' }, (old: unknown) => {
|
|
253
|
+
queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'many' }, (old: unknown) => {
|
|
249
254
|
if (typeof updater.many === 'function') return updater.many!(old, params.variables, targetId);
|
|
250
255
|
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
251
256
|
const o = old as { data: Record<string, unknown>[] };
|
|
@@ -270,7 +275,10 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
270
275
|
if (error instanceof UndoError) return;
|
|
271
276
|
const resName = params.resource ?? defaultResource;
|
|
272
277
|
const targetId = params.id ?? defaultId;
|
|
273
|
-
invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many', 'detail'], targetId != null ? targetId : undefined);
|
|
278
|
+
invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many', 'detail'], targetId != null ? targetId : undefined, params.dataProviderName);
|
|
279
|
+
if (typeof options.mutationOptions?.onSettled === 'function') {
|
|
280
|
+
(options.mutationOptions.onSettled as Function)(_data, error, params, undefined);
|
|
281
|
+
}
|
|
274
282
|
},
|
|
275
283
|
onError: (error, params, context: unknown) => {
|
|
276
284
|
const ctx = context as MutationContext | undefined;
|
|
@@ -282,7 +290,7 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
282
290
|
if (error instanceof UndoError) return;
|
|
283
291
|
checkError(error);
|
|
284
292
|
const extractedCtx = ctx?._svadmin_ctx ? ctx.userContext : context;
|
|
285
|
-
fireErrorNotification(params.errorNotification, 'Update failed', error);
|
|
293
|
+
fireErrorNotification(params.errorNotification, 'Update failed', error, params.resource ?? defaultResource);
|
|
286
294
|
if (typeof options.mutationOptions?.onError === 'function') {
|
|
287
295
|
(options.mutationOptions.onError as Function)(error, params, extractedCtx);
|
|
288
296
|
}
|
|
@@ -361,23 +369,25 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
361
369
|
if (mutationMode === 'pessimistic') return { _svadmin_ctx: true, userContext };
|
|
362
370
|
const resName = params.resource ?? defaultResource;
|
|
363
371
|
const targetId = params.id ?? defaultId;
|
|
372
|
+
const dpN = params.dataProviderName;
|
|
373
|
+
const dp = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dpN;
|
|
364
374
|
|
|
365
|
-
await queryClient.cancelQueries({ predicate: (q) => q.queryKey[1] === resName });
|
|
375
|
+
await queryClient.cancelQueries({ predicate: (q) => dp(q) && q.queryKey[1] === resName });
|
|
366
376
|
|
|
367
|
-
const previousQueries = queryClient.getQueriesData({ predicate: (q) => q.queryKey[1] === resName });
|
|
377
|
+
const previousQueries = queryClient.getQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName });
|
|
368
378
|
|
|
369
379
|
const pk = getResource(resName).primaryKey ?? 'id';
|
|
370
380
|
|
|
371
|
-
if (targetId != null) queryClient.removeQueries({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId });
|
|
381
|
+
if (targetId != null) queryClient.removeQueries({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId });
|
|
372
382
|
|
|
373
|
-
queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'list' }, (old: unknown) => {
|
|
383
|
+
queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'list' }, (old: unknown) => {
|
|
374
384
|
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
375
385
|
const o = old as { data: Record<string, unknown>[]; total?: number };
|
|
376
386
|
const filtered = o.data.filter((item) => String(item[pk]) !== String(targetId));
|
|
377
387
|
return { ...o, data: filtered, total: (o.total ?? o.data.length) - (o.data.length - filtered.length) };
|
|
378
388
|
});
|
|
379
389
|
|
|
380
|
-
queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'many' }, (old: unknown) => {
|
|
390
|
+
queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'many' }, (old: unknown) => {
|
|
381
391
|
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
382
392
|
const o = old as { data: Record<string, unknown>[] };
|
|
383
393
|
return { ...o, data: o.data.filter((item) => String(item[pk]) !== String(targetId)) };
|
|
@@ -392,7 +402,7 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
392
402
|
|
|
393
403
|
// Remove detail cache entry (refine pattern — no stale show page)
|
|
394
404
|
if (targetId != null) {
|
|
395
|
-
queryClient.removeQueries({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId });
|
|
405
|
+
queryClient.removeQueries({ predicate: (q) => q.queryKey[0] === params.dataProviderName && q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId });
|
|
396
406
|
}
|
|
397
407
|
|
|
398
408
|
fireSuccessNotification(params.successNotification, 'Deleted successfully', data.data, params.variables, resName);
|
|
@@ -405,7 +415,11 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
405
415
|
onSettled: (_data, error, params) => {
|
|
406
416
|
if (error instanceof UndoError) return;
|
|
407
417
|
const resName = params.resource ?? defaultResource;
|
|
408
|
-
|
|
418
|
+
const targetId = params.id ?? defaultId;
|
|
419
|
+
invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many'], targetId != null ? targetId : undefined, params.dataProviderName);
|
|
420
|
+
if (typeof options.mutationOptions?.onSettled === 'function') {
|
|
421
|
+
(options.mutationOptions.onSettled as Function)(_data, error, params, undefined);
|
|
422
|
+
}
|
|
409
423
|
},
|
|
410
424
|
onError: (error, params, context: unknown) => {
|
|
411
425
|
const ctx = context as MutationContext | undefined;
|
|
@@ -417,7 +431,7 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
417
431
|
if (error instanceof UndoError) return;
|
|
418
432
|
checkError(error);
|
|
419
433
|
const extractedCtx = ctx?._svadmin_ctx ? ctx.userContext : context;
|
|
420
|
-
fireErrorNotification(params.errorNotification, 'Delete failed', error);
|
|
434
|
+
fireErrorNotification(params.errorNotification, 'Delete failed', error, params.resource ?? defaultResource);
|
|
421
435
|
if (typeof options.mutationOptions?.onError === 'function') {
|
|
422
436
|
(options.mutationOptions.onError as Function)(error, params, extractedCtx);
|
|
423
437
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { useCreate, useUpdate, invalidateByScopes } from './mutation-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
|
+
getList: vi.fn(),
|
|
10
|
+
create: vi.fn().mockResolvedValue({ data: { id: 1 } }),
|
|
11
|
+
update: vi.fn().mockResolvedValue({ data: { id: 1 } })
|
|
12
|
+
}),
|
|
13
|
+
useResource: () => ({ name: 'posts' }),
|
|
14
|
+
getResource: (name: string) => ({ name, primaryKey: 'id' }),
|
|
15
|
+
useRouterContext: () => ({ navigate: vi.fn() }),
|
|
16
|
+
useTranslate: () => (key: string) => key,
|
|
17
|
+
useNotification: () => ({ open: vi.fn(), close: vi.fn() }),
|
|
18
|
+
getLiveProvider: () => undefined
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('@tanstack/svelte-query', async (importOriginal) => {
|
|
22
|
+
const actual = await importOriginal();
|
|
23
|
+
return {
|
|
24
|
+
...actual as any,
|
|
25
|
+
useQueryClient: () => new QueryClient(),
|
|
26
|
+
createMutation: (opts: any) => ({
|
|
27
|
+
mutate: vi.fn((vars, callbacks) => {
|
|
28
|
+
if (opts.mutationFn) opts.mutationFn(vars);
|
|
29
|
+
if (callbacks?.onSuccess) callbacks.onSuccess({ data: {} });
|
|
30
|
+
}),
|
|
31
|
+
isPending: false
|
|
32
|
+
})
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('useCreate & useUpdate - Headless Svelte 5 Compatibility', () => {
|
|
37
|
+
|
|
38
|
+
it('safely binds useCreate and allows mutations over proxies', () => {
|
|
39
|
+
let createMutation: ReturnType<typeof useCreate>;
|
|
40
|
+
|
|
41
|
+
const cleanup = $effect.root(() => {
|
|
42
|
+
createMutation = useCreate();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
flushSync();
|
|
46
|
+
|
|
47
|
+
expect(createMutation!.mutation.mutate).toBeDefined();
|
|
48
|
+
expect(createMutation!.mutation.isPending).toBe(false);
|
|
49
|
+
|
|
50
|
+
cleanup();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('safely binds useUpdate and evaluates data updates', () => {
|
|
54
|
+
let updateMutation: ReturnType<typeof useUpdate>;
|
|
55
|
+
|
|
56
|
+
const cleanup = $effect.root(() => {
|
|
57
|
+
updateMutation = useUpdate();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
flushSync();
|
|
61
|
+
|
|
62
|
+
expect(updateMutation!.mutation.mutate).toBeDefined();
|
|
63
|
+
expect(updateMutation!.mutation.isPending).toBe(false);
|
|
64
|
+
|
|
65
|
+
cleanup();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('mutation-hooks', () => {
|
|
69
|
+
|
|
70
|
+
describe('invalidateByScopes - Multi-Provider Cache Isolation', () => {
|
|
71
|
+
it('filters perfectly by dataProviderName when passed', () => {
|
|
72
|
+
const queryClient = new QueryClient();
|
|
73
|
+
queryClient.invalidateQueries = vi.fn(async () => {});
|
|
74
|
+
|
|
75
|
+
const resource = 'posts';
|
|
76
|
+
const providerName = 'secondaryProvider';
|
|
77
|
+
|
|
78
|
+
// Act
|
|
79
|
+
invalidateByScopes(queryClient, resource, ['list'], ['list'], undefined, providerName);
|
|
80
|
+
|
|
81
|
+
expect(queryClient.invalidateQueries).toHaveBeenCalled();
|
|
82
|
+
|
|
83
|
+
// Assert predicate captures DataProvider scopes
|
|
84
|
+
const calls = (queryClient.invalidateQueries as ReturnType<typeof vi.fn>).mock.calls;
|
|
85
|
+
|
|
86
|
+
// Using arbitrary mock objects simulating Query keys
|
|
87
|
+
const predicateFn = calls[0][0].predicate;
|
|
88
|
+
|
|
89
|
+
const shouldPass = predicateFn({ queryKey: ['secondaryProvider', 'posts', 'list'] });
|
|
90
|
+
const shouldFail = predicateFn({ queryKey: ['default', 'posts', 'list'] });
|
|
91
|
+
const shouldFailResource = predicateFn({ queryKey: ['secondaryProvider', 'users', 'list'] });
|
|
92
|
+
|
|
93
|
+
expect(shouldPass).toBe(true);
|
|
94
|
+
expect(shouldFail).toBe(false);
|
|
95
|
+
expect(shouldFailResource).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('falls back to all providers when NO provider is specified', () => {
|
|
99
|
+
const queryClient = new QueryClient();
|
|
100
|
+
queryClient.invalidateQueries = vi.fn(async () => {});
|
|
101
|
+
|
|
102
|
+
// Act without providerName
|
|
103
|
+
invalidateByScopes(queryClient, 'posts', ['list'], ['list']);
|
|
104
|
+
|
|
105
|
+
const predicateFn = (queryClient.invalidateQueries as ReturnType<typeof vi.fn>).mock.calls[0][0].predicate;
|
|
106
|
+
|
|
107
|
+
const pass1 = predicateFn({ queryKey: ['default', 'posts', 'list'] });
|
|
108
|
+
const pass2 = predicateFn({ queryKey: ['custom', 'posts', 'list'] });
|
|
109
|
+
expect(pass1).toBe(true);
|
|
110
|
+
expect(pass2).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
});
|
|
@@ -26,13 +26,13 @@ export function notify(params: {
|
|
|
26
26
|
if (notificationProvider) {
|
|
27
27
|
notificationProvider.open(params);
|
|
28
28
|
} else {
|
|
29
|
-
|
|
30
|
-
const
|
|
29
|
+
const { type, message, description } = params;
|
|
30
|
+
const fullMessage = description ? `${message}: ${description}` : message;
|
|
31
31
|
switch (type) {
|
|
32
|
-
case 'success': toast.success(
|
|
33
|
-
case 'error': toast.error(
|
|
34
|
-
case 'warning': toast.warning(
|
|
35
|
-
case 'info': toast.info(
|
|
32
|
+
case 'success': toast.success(fullMessage, undefined, { key: params.key }); break;
|
|
33
|
+
case 'error': toast.error(fullMessage, undefined, { key: params.key }); break;
|
|
34
|
+
case 'warning': toast.warning(fullMessage, undefined, { key: params.key }); break;
|
|
35
|
+
case 'info': toast.info(fullMessage, undefined, { key: params.key }); break;
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/options.svelte.ts
CHANGED
|
@@ -62,7 +62,7 @@ function createDefaultOptions(): AdminOptions {
|
|
|
62
62
|
liveMode: 'auto',
|
|
63
63
|
disableServerSideValidation: false,
|
|
64
64
|
undoableTimeout: 5000,
|
|
65
|
-
textTransformers: defaultTextTransformers,
|
|
65
|
+
textTransformers: { ...defaultTextTransformers },
|
|
66
66
|
redirect: { afterCreate: 'list', afterEdit: 'list', afterClone: 'list' },
|
|
67
67
|
breadcrumb: 'default',
|
|
68
68
|
disableRouteChangeHandler: false,
|
|
@@ -51,7 +51,7 @@ export interface AccessControlProvider {
|
|
|
51
51
|
|
|
52
52
|
// ─── State ────────────────────────────────────────────────────
|
|
53
53
|
|
|
54
|
-
let provider: AccessControlProvider | null = null;
|
|
54
|
+
let provider: AccessControlProvider | null = $state(null);
|
|
55
55
|
|
|
56
56
|
// ─── API ──────────────────────────────────────────────────────
|
|
57
57
|
|