@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.
Files changed (47) hide show
  1. package/package.json +20 -3
  2. package/src/adapters/casbin.ts +1 -1
  3. package/src/adapters/casl.ts +1 -1
  4. package/src/audit.ts +12 -11
  5. package/src/auth-hooks.svelte.ts +25 -2
  6. package/src/chatProvider.svelte.ts +20 -3
  7. package/src/context.svelte.ts +17 -1
  8. package/src/data-transfer.svelte.ts +23 -11
  9. package/src/form-hooks.svelte.ts +71 -27
  10. package/src/form-hooks.test.svelte.ts +83 -0
  11. package/src/helpers-pure.ts +13 -6
  12. package/src/helpers.test.ts +2 -2
  13. package/src/helpers.ts +4 -0
  14. package/src/hook-utils.svelte.ts +40 -20
  15. package/src/hooks.svelte.ts +43 -50
  16. package/src/i18n.svelte.ts +3 -0
  17. package/src/index.ts +10 -8
  18. package/src/inferencer-openapi.ts +5 -3
  19. package/src/inferencer.ts +6 -4
  20. package/src/live-sse.ts +20 -17
  21. package/src/live-websocket.ts +1 -0
  22. package/src/live.svelte.ts +19 -11
  23. package/src/mutation-hooks.svelte.ts +39 -25
  24. package/src/mutation-hooks.test.svelte.ts +114 -0
  25. package/src/notification.svelte.ts +6 -6
  26. package/src/options.svelte.ts +1 -1
  27. package/src/{permissions.ts → permissions.svelte.ts} +1 -1
  28. package/src/permissions.test.ts +32 -80
  29. package/src/query-hooks.svelte.ts +18 -9
  30. package/src/query-hooks.test.svelte.ts +65 -0
  31. package/src/query-keys.ts +3 -4
  32. package/src/reactive-hooks.test.svelte.ts +26 -0
  33. package/src/router-provider.test.ts +3 -0
  34. package/src/router-provider.ts +13 -9
  35. package/src/router.ts +8 -0
  36. package/src/routing-hooks.svelte.ts +16 -13
  37. package/src/routing-hooks.test.svelte.ts +55 -0
  38. package/src/table-hooks.svelte.ts +46 -1
  39. package/src/table-hooks.test.svelte.ts +57 -0
  40. package/src/table-state.svelte.ts +17 -12
  41. package/src/theme.svelte.ts +30 -7
  42. package/src/toast.svelte.ts +8 -0
  43. package/src/unsaved-changes.svelte.ts +14 -6
  44. package/src/url-sync.ts +2 -1
  45. package/src/useCan.ts +2 -2
  46. package/src/useParsed.svelte.ts +12 -4
  47. package/src/utility-hooks.svelte.ts +5 -2
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { useTable } from './table-hooks.svelte';
3
+ import { flushSync } from 'svelte';
4
+ import { QueryClient } from '@tanstack/svelte-query';
5
+
6
+ vi.mock('./context.svelte', () => ({
7
+ useDataProvider: () => ({
8
+ getList: vi.fn().mockResolvedValue({ data: [], total: 0 })
9
+ }),
10
+ useResource: () => ({ name: 'posts' }),
11
+ getResource: (name: string) => ({ name, primaryKey: 'id' }),
12
+ useRouterContext: () => ({ navigate: vi.fn(), url: '/', parsedParams: { current: 1, pageSize: 10 } }),
13
+ useTranslate: () => (key: string) => key,
14
+ getSyncWithLocation: () => false,
15
+ useNotification: () => ({ open: vi.fn(), close: vi.fn() }),
16
+ getLiveProvider: () => undefined
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: () => ({
25
+ data: { data: [{ id: 1, title: 'Row' }], total: 1 },
26
+ isPending: false,
27
+ isFetching: false,
28
+ error: null
29
+ })
30
+ };
31
+ });
32
+
33
+ describe('useTable - Headless Svelte 5 Compatibility', () => {
34
+
35
+ it('binds useTable logic internally and updates pagination flawlessly via $state', () => {
36
+ let table: ReturnType<typeof useTable>;
37
+
38
+ const cleanup = $effect.root(() => {
39
+ table = useTable({ resource: 'posts' });
40
+ });
41
+
42
+ flushSync();
43
+
44
+ expect(table!.query).toBeDefined();
45
+ expect(table!.query?.data?.data[0].title).toBe('Row');
46
+ expect(table!.current).toBe(1);
47
+
48
+ // Trigger mutable state
49
+ table!.setPage(2);
50
+
51
+ flushSync();
52
+
53
+ expect(table!.current).toBe(2);
54
+
55
+ cleanup();
56
+ });
57
+ });
@@ -182,6 +182,7 @@ export class TableState<TData extends BaseRecord = BaseRecord, TError = HttpErro
182
182
 
183
183
  setSorters(newSorters: Sort[]) {
184
184
  this.currentSorters = newSorters;
185
+ this.pagination = { ...this.pagination, current: 1 };
185
186
  }
186
187
 
187
188
  toggleSort(field: string) {
@@ -193,6 +194,7 @@ export class TableState<TData extends BaseRecord = BaseRecord, TError = HttpErro
193
194
  } else {
194
195
  this.currentSorters = [];
195
196
  }
197
+ this.pagination = { ...this.pagination, current: 1 };
196
198
  }
197
199
 
198
200
  setFilters(newFilters: Filter[], mode?: FilterSetMode) {
@@ -221,25 +223,28 @@ export class TableState<TData extends BaseRecord = BaseRecord, TError = HttpErro
221
223
  this.pagination = { ...this.pagination, current: 1 };
222
224
  }
223
225
 
224
- /** For client-side pagination mode */
225
226
  get clientData(): TData[] {
226
- if (this.paginationMode !== 'client') return [];
227
+ if (this.paginationMode === 'server') return [];
227
228
  const allData = this.data;
228
- const start = (this.current - 1) * this.pageSize;
229
- const end = start + this.pageSize;
230
229
  let sorted = [...allData];
231
- const activeSorters = this.sortersMode === 'off' ? this.effectiveSorters : this.currentSorters;
230
+ const activeSorters = this.sortersMode === 'off' ? this.effectiveSorters : [];
232
231
  if (activeSorters.length > 0) {
233
- const { field, order } = activeSorters[0];
234
232
  sorted.sort((a, b) => {
235
- const va = (a as Record<string, unknown>)[field];
236
- const vb = (b as Record<string, unknown>)[field];
237
- if (va == null) return 1;
238
- if (vb == null) return -1;
239
- const cmp = va < vb ? -1 : va > vb ? 1 : 0;
240
- return order === 'desc' ? -cmp : cmp;
233
+ for (const { field, order } of activeSorters) {
234
+ const va = (a as Record<string, unknown>)[field];
235
+ const vb = (b as Record<string, unknown>)[field];
236
+ if (va == null && vb == null) continue;
237
+ if (va == null) return 1;
238
+ if (vb == null) return -1;
239
+ const cmp = va < vb ? -1 : va > vb ? 1 : 0;
240
+ if (cmp !== 0) return order === 'desc' ? -cmp : cmp;
241
+ }
242
+ return 0;
241
243
  });
242
244
  }
245
+ if (this.paginationMode === 'off') return sorted;
246
+ const start = (this.current - 1) * this.pageSize;
247
+ const end = start + this.pageSize;
243
248
  return sorted.slice(start, end);
244
249
  }
245
250
  }
@@ -248,15 +248,19 @@ let themeConfig: ThemeConfig = {};
248
248
  * automatically via AdminApp's themeConfig prop.
249
249
  */
250
250
  export function configureTheme(config: ThemeConfig): void {
251
- themeConfig = { ...config };
252
- // Re-apply the current theme with new strategy
251
+ themeConfig = { ...themeConfig, ...config };
253
252
  applyTheme(mode);
254
- // Apply color preset first (lower priority)
255
253
  if (config.colorPreset) {
256
254
  const preset = resolvePreset(config.colorPreset);
257
- if (preset) applyColorPreset(preset);
255
+ if (preset) {
256
+ colorTheme = preset.name as ColorTheme;
257
+ if (typeof window !== 'undefined') {
258
+ try { localStorage.setItem(COLOR_STORAGE_KEY, preset.name); } catch {}
259
+ }
260
+ applyColorTheme(preset.name as ColorTheme);
261
+ applyColorPreset(preset);
262
+ }
258
263
  }
259
- // Then apply manual CSS overrides (higher priority)
260
264
  if (config.cssOverrides) {
261
265
  applyCssOverrides(config.cssOverrides);
262
266
  }
@@ -294,12 +298,16 @@ export function clearCssOverrides(keys?: string[]): void {
294
298
  }
295
299
 
296
300
  // ── Color themes (display metadata, derived from presets) ──
297
- export const colorThemes: { id: ColorTheme | string; label: string; color: string }[] = $derived(Object.values(builtinPresets).map(p => ({
301
+ const _colorThemesCache: { id: ColorTheme | string; label: string; color: string }[] = $derived(Object.values(builtinPresets).map(p => ({
298
302
  id: p.name as ColorTheme,
299
303
  label: p.label,
300
304
  color: p.color,
301
305
  })));
302
306
 
307
+ export function getColorThemes() {
308
+ return _colorThemesCache;
309
+ }
310
+
303
311
  // ── Dark/Light mode ──────────────────────────────────────
304
312
 
305
313
  function getStoredTheme(): ThemeMode {
@@ -411,7 +419,22 @@ export function setColorTheme(ct: ColorTheme): void {
411
419
  try { localStorage.setItem(COLOR_STORAGE_KEY, ct); } catch {}
412
420
  }
413
421
  applyColorTheme(ct);
414
- // Apply the preset's CSS variable overrides for the current resolved theme
415
422
  const preset = builtinPresets[ct];
416
423
  if (preset) applyColorPreset(preset);
417
424
  }
425
+
426
+ export function resetTheme(): void {
427
+ mode = getStoredTheme();
428
+ colorTheme = getStoredColorTheme();
429
+ themeConfig = {};
430
+ if (typeof document !== 'undefined') {
431
+ for (const key of activePresetVars) {
432
+ document.documentElement.style.removeProperty(key);
433
+ }
434
+ }
435
+ activePresetVars = [];
436
+ applyTheme(mode);
437
+ applyColorTheme(colorTheme);
438
+ const preset = builtinPresets[colorTheme];
439
+ if (preset) applyColorPreset(preset);
440
+ }
@@ -56,6 +56,14 @@ export function removeToast(id: number): void {
56
56
  toasts = toasts.filter(t => t.id !== id);
57
57
  }
58
58
 
59
+ export function resetToast(): void {
60
+ for (const timer of undoableTimers.values()) clearTimeout(timer);
61
+ undoableTimers.clear();
62
+ queue = [];
63
+ promiseQueue = [];
64
+ toasts = [];
65
+ }
66
+
59
67
  export const toast = {
60
68
  success: (msg: string, duration?: number, opts?: { key?: string }) => addToast('success', msg, duration, opts),
61
69
  error: (msg: string, duration?: number, opts?: { key?: string }) => addToast('error', msg, duration ?? 5000, opts),
@@ -2,14 +2,21 @@
2
2
  // Supports: browser tab close, hash-based SPA routing, and framework router interception
3
3
 
4
4
  import { t } from './i18n.svelte';
5
+ import { syncGlobalPath } from './useParsed.svelte';
5
6
 
6
7
  let dirty = $state(false);
7
8
 
8
- /** Mark whether the current form has unsaved changes */
9
9
  export function setUnsavedChanges(value: boolean) {
10
10
  dirty = value;
11
11
  }
12
12
 
13
+ export function resetUnsavedChanges(): void {
14
+ dirty = false;
15
+ _unsavedChangesCleanup?.();
16
+ _unsavedChangesCleanup = null;
17
+ _navigationGuard = null;
18
+ }
19
+
13
20
  export function getUnsavedChanges(): boolean {
14
21
  return dirty;
15
22
  }
@@ -68,12 +75,14 @@ export function registerNavigationGuard(guard: NavigationGuard): void {
68
75
  * initUnsavedChangesNotifier();
69
76
  * ```
70
77
  */
78
+ let _unsavedChangesCleanup: (() => void) | null = null;
79
+
71
80
  export function initUnsavedChangesNotifier(options?: {
72
81
  beforeNavigate?: (callback: (nav: { cancel: () => void }) => void) => void;
73
82
  }) {
74
83
  if (typeof window === 'undefined') return;
84
+ _unsavedChangesCleanup?.();
75
85
 
76
- // 1. Browser tab close / refresh
77
86
  const onBeforeUnload = (e: BeforeUnloadEvent) => {
78
87
  if (!dirty) return;
79
88
  e.preventDefault();
@@ -81,7 +90,6 @@ export function initUnsavedChangesNotifier(options?: {
81
90
  };
82
91
  window.addEventListener('beforeunload', onBeforeUnload);
83
92
 
84
- // 2. Framework router (SvelteKit, etc.)
85
93
  if (options?.beforeNavigate) {
86
94
  options.beforeNavigate(({ cancel }) => {
87
95
  if (dirty) {
@@ -94,12 +102,12 @@ export function initUnsavedChangesNotifier(options?: {
94
102
  });
95
103
  }
96
104
 
97
- // 3. Hash-based fallback for SPAs without a framework router
98
105
  const onHashChange = (e: HashChangeEvent) => {
99
106
  if (!dirty) return;
100
107
  if (!confirm(t('common.unsavedChanges'))) {
101
108
  if (e.oldURL) {
102
109
  history.replaceState(null, '', new URL(e.oldURL).hash || '#');
110
+ syncGlobalPath();
103
111
  }
104
112
  } else {
105
113
  dirty = false;
@@ -107,9 +115,9 @@ export function initUnsavedChangesNotifier(options?: {
107
115
  };
108
116
  window.addEventListener('hashchange', onHashChange);
109
117
 
110
- // Cleanup
111
- return () => {
118
+ _unsavedChangesCleanup = () => {
112
119
  window.removeEventListener('beforeunload', onBeforeUnload);
113
120
  window.removeEventListener('hashchange', onHashChange);
114
121
  };
122
+ return _unsavedChangesCleanup;
115
123
  }
package/src/url-sync.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Filter } from './types';
2
2
  import { getRouterProvider } from './context.svelte';
3
+ import { getAdminOptions } from './options.svelte';
3
4
 
4
5
  export interface URLState {
5
6
  page?: number;
@@ -57,7 +58,7 @@ export function writeURLState(state: URLState): void {
57
58
  }
58
59
 
59
60
  if ('pageSize' in state) {
60
- if (state.pageSize && state.pageSize !== 10) params['pageSize'] = String(state.pageSize);
61
+ if (state.pageSize && state.pageSize !== (getAdminOptions().defaultPageSize ?? 10)) params['pageSize'] = String(state.pageSize);
61
62
  else delete params['pageSize'];
62
63
  }
63
64
 
package/src/useCan.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // useCan — reactive permission check hook with TanStack Query integration
2
2
 
3
3
  import { createQuery } from '@tanstack/svelte-query';
4
- import { canAccessAsync, getAccessControlProvider } from './permissions';
5
- import type { Action, CanResult } from './permissions';
4
+ import { canAccessAsync, getAccessControlProvider } from './permissions.svelte';
5
+ import type { Action, CanResult } from './permissions.svelte';
6
6
 
7
7
  export interface UseCanOptions {
8
8
  resource: string;
@@ -12,12 +12,20 @@ interface ParsedRoute {
12
12
  }
13
13
 
14
14
  let globalPath = $state('/');
15
+
16
+ export function resetGlobalPath(): void {
17
+ globalPath = typeof window !== 'undefined' ? currentPath() : '/';
18
+ }
19
+ export function syncGlobalPath(): void {
20
+ if (typeof window !== 'undefined') globalPath = currentPath();
21
+ }
15
22
  if (typeof window !== 'undefined') {
16
- // Initialize
17
23
  globalPath = currentPath();
18
- // Listen to navigation events
19
- window.addEventListener('hashchange', () => { globalPath = currentPath(); });
20
- window.addEventListener('popstate', () => { globalPath = currentPath(); });
24
+ if (!(window as any).__svadminParsedInit) {
25
+ (window as any).__svadminParsedInit = true;
26
+ window.addEventListener('hashchange', () => { globalPath = currentPath(); });
27
+ window.addEventListener('popstate', () => { globalPath = currentPath(); });
28
+ }
21
29
  }
22
30
 
23
31
  /**
@@ -56,8 +56,11 @@ export function useModalForm<
56
56
  visible = false;
57
57
  formState.reset();
58
58
  if (options.autoSave?.invalidateOnClose) {
59
- queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === formState.resource && (q.queryKey[2] === 'list' || q.queryKey[2] === 'infiniteList' || q.queryKey[2] === 'select') });
60
- queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === formState.resource && q.queryKey[2] === 'many' });
59
+ const dpN = options.dataProviderName;
60
+ const dp = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dpN;
61
+ queryClient.invalidateQueries({ predicate: (q) => dp(q) && q.queryKey[1] === formState.resource && (q.queryKey[2] === 'list' || q.queryKey[2] === 'infiniteList' || q.queryKey[2] === 'select') });
62
+ queryClient.invalidateQueries({ predicate: (q) => dp(q) && q.queryKey[1] === formState.resource && q.queryKey[2] === 'many' });
63
+ queryClient.invalidateQueries({ predicate: (q) => dp(q) && q.queryKey[1] === formState.resource && q.queryKey[2] === 'one' });
61
64
  }
62
65
  }
63
66