@svadmin/core 0.23.1 → 0.23.4

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 +11 -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 +25 -6
  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 +15 -5
  47. package/src/utility-hooks.svelte.ts +5 -2
@@ -138,7 +138,7 @@ export function useTable<
138
138
  if (newPage !== pagination.current) {
139
139
  pagination = { ...pagination, current: newPage };
140
140
  }
141
- const newPageSize = urlState.pageSize ?? initialOpts.pagination?.pageSize ?? getAdminOptions().defaultPageSize ?? 10;
141
+ const newPageSize = urlState.pageSize ?? getOptions().pagination?.pageSize ?? getAdminOptions().defaultPageSize ?? 10;
142
142
  if (newPageSize !== pagination.pageSize) {
143
143
  pagination = { ...pagination, pageSize: newPageSize };
144
144
  }
@@ -220,6 +220,51 @@ export function useTable<
220
220
  return 0;
221
221
  });
222
222
  }
223
+ const activeFilters = filtersMode === 'off' ? effectiveFilters : [];
224
+ if (activeFilters.length > 0) {
225
+ function matchesFilter(item: Record<string, unknown>, f: Filter): boolean {
226
+ if (!('field' in f)) {
227
+ if (f.operator === 'and') return f.value.every(sub => matchesFilter(item, sub));
228
+ if (f.operator === 'or') return f.value.length === 0 || f.value.some(sub => matchesFilter(item, sub));
229
+ return true;
230
+ }
231
+ const val = item[f.field];
232
+ const fv = f.value;
233
+ switch (f.operator) {
234
+ case 'null': return val == null;
235
+ case 'nnull': return val != null;
236
+ default: break;
237
+ }
238
+ if (fv == null || fv === '') return true;
239
+ switch (f.operator) {
240
+ case 'eq': return val == fv;
241
+ case 'ne': return val != fv;
242
+ case 'lt': return typeof val === 'number' && typeof fv === 'number' && val < fv;
243
+ case 'gt': return typeof val === 'number' && typeof fv === 'number' && val > fv;
244
+ case 'lte': return typeof val === 'number' && typeof fv === 'number' && val <= fv;
245
+ case 'gte': return typeof val === 'number' && typeof fv === 'number' && val >= fv;
246
+ case 'in': return !Array.isArray(fv) || fv.length === 0 || fv.includes(val);
247
+ case 'nin': return !Array.isArray(fv) || fv.length === 0 || !fv.includes(val);
248
+ case 'contains': return typeof val === 'string' && typeof fv === 'string' && val.includes(fv);
249
+ case 'ncontains': return typeof val === 'string' && typeof fv === 'string' && !val.includes(fv);
250
+ case 'startswith': return typeof val === 'string' && typeof fv === 'string' && val.startsWith(fv);
251
+ case 'endswith': return typeof val === 'string' && typeof fv === 'string' && val.endsWith(fv);
252
+ case 'between': {
253
+ if (!Array.isArray(fv) || fv.length !== 2) return true;
254
+ return typeof val === 'number' && val >= (fv[0] as number) && val <= (fv[1] as number);
255
+ }
256
+ case 'nbetween': {
257
+ if (!Array.isArray(fv) || fv.length !== 2) return true;
258
+ return typeof val === 'number' && (val < (fv[0] as number) || val > (fv[1] as number));
259
+ }
260
+ default: return true;
261
+ }
262
+ }
263
+ sorted = sorted.filter((item) => {
264
+ const rec = item as Record<string, unknown>;
265
+ return activeFilters.every(f => matchesFilter(rec, f));
266
+ });
267
+ }
223
268
  if (paginationMode === 'off') return sorted;
224
269
  const start = ((pagination.current ?? 1) - 1) * (pagination.pageSize ?? 10);
225
270
  const end = start + (pagination.pageSize ?? 10);
@@ -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
  }
@@ -415,7 +419,22 @@ export function setColorTheme(ct: ColorTheme): void {
415
419
  try { localStorage.setItem(COLOR_STORAGE_KEY, ct); } catch {}
416
420
  }
417
421
  applyColorTheme(ct);
418
- // Apply the preset's CSS variable overrides for the current resolved theme
419
422
  const preset = builtinPresets[ct];
420
423
  if (preset) applyColorPreset(preset);
421
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;
@@ -1,6 +1,6 @@
1
1
  // useParsed — parse current URL hash into structured route info
2
2
 
3
- import { currentPath } from './router';
3
+ import { currentPath, registerRouterSync } from './router';
4
4
  import { getResources } from './context.svelte';
5
5
 
6
6
  interface ParsedRoute {
@@ -12,12 +12,22 @@ 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
+ }
22
+ registerRouterSync(syncGlobalPath);
23
+
15
24
  if (typeof window !== 'undefined') {
16
- // Initialize
17
25
  globalPath = currentPath();
18
- // Listen to navigation events
19
- window.addEventListener('hashchange', () => { globalPath = currentPath(); });
20
- window.addEventListener('popstate', () => { globalPath = currentPath(); });
26
+ if (!(window as any).__svadminParsedInit) {
27
+ (window as any).__svadminParsedInit = true;
28
+ window.addEventListener('hashchange', () => { globalPath = currentPath(); });
29
+ window.addEventListener('popstate', () => { globalPath = currentPath(); });
30
+ }
21
31
  }
22
32
 
23
33
  /**
@@ -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