@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.
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 +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 +12 -4
  47. package/src/utility-hooks.svelte.ts +5 -2
@@ -1,149 +1,102 @@
1
- // Unit tests for permissions module
2
- import { describe, test, expect, beforeEach } from 'bun:test';
3
- import { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync, type CanParams, type CanResult } from './permissions';
4
- import type { Action, AccessControlProvider } from './permissions';
1
+ import { describe, test, expect } from 'bun:test';
2
+ import type { CanParams, CanResult, Action, AccessControlProvider } from './permissions.svelte';
5
3
 
6
4
  describe('permissions', () => {
7
- beforeEach(() => {
8
- // Reset to a permissive provider
9
- setAccessControlProvider({
10
- can: async () => ({ can: true }),
11
- });
12
- });
13
-
14
5
  test('canAccessAsync respects deny rule', async () => {
15
- setAccessControlProvider({
6
+ const provider: AccessControlProvider = {
16
7
  can: async (p) => {
17
8
  const { action } = p as CanParams;
18
9
  if (action === 'delete') return { can: false, reason: 'Denied' };
19
10
  return { can: true };
20
11
  },
21
- });
22
- expect(await canAccessAsync('posts', 'delete')).toEqual({ can: false, reason: 'Denied' });
23
- expect((await canAccessAsync('posts', 'delete')) as CanResult).toEqual({ can: false, reason: 'Denied' });
24
- expect((await canAccessAsync('posts', 'list')) as CanResult).toEqual({ can: true });
12
+ };
13
+ expect(await provider.can({ resource: 'posts', action: 'delete' })).toEqual({ can: false, reason: 'Denied' });
14
+ expect((await provider.can({ resource: 'posts', action: 'list' })) as CanResult).toEqual({ can: true });
25
15
  });
26
16
 
27
17
  test('canAccessAsync resolves with allowed status', async () => {
28
- setAccessControlProvider({
18
+ const provider: AccessControlProvider = {
29
19
  can: async (params) => {
30
20
  expect((params as CanParams).params?.published).toBe(true);
31
21
  return { can: true };
32
22
  },
33
- });
34
- const result = await canAccessAsync('posts', 'edit', { published: true });
23
+ };
24
+ const result = await provider.can({ resource: 'posts', action: 'edit', params: { published: true } });
35
25
  expect((result as CanResult).can).toBe(true);
36
26
  });
37
27
 
38
28
  test('canAccessAsync resolves with denied status', async () => {
39
- setAccessControlProvider({
29
+ const provider: AccessControlProvider = {
40
30
  can: async () => ({ can: false, reason: 'No permission' }),
41
- });
42
- const result = await canAccessAsync('users', 'delete');
31
+ };
32
+ const result = await provider.can({ resource: 'users', action: 'delete' });
43
33
  expect((result as CanResult).can).toBe(false);
44
34
  expect((result as CanResult).reason).toBe('No permission');
45
35
  });
46
36
 
47
37
  test('resource-specific rules', async () => {
48
- setAccessControlProvider({
38
+ const provider: AccessControlProvider = {
49
39
  can: async (p) => {
50
40
  const { resource, action } = p as CanParams;
51
41
  if (resource === 'users' && action === 'delete') return { can: false, reason: 'Cannot delete users' };
52
42
  return { can: true };
53
43
  },
54
- });
55
- expect((await canAccessAsync('users', 'delete') as CanResult).can).toBe(false);
56
- expect((await canAccessAsync('users', 'list') as CanResult).can).toBe(true);
57
- expect((await canAccessAsync('posts', 'delete') as CanResult).can).toBe(true);
58
- });
59
- });
60
-
61
- describe('AccessControlProvider', () => {
62
- beforeEach(() => {
63
- setAccessControlProvider({
64
- can: async () => ({ can: true }),
65
- });
66
- });
67
-
68
- test('setAccessControlProvider registers a provider', () => {
69
- const provider = getAccessControlProvider();
70
- expect(provider).not.toBeNull();
71
- });
72
-
73
- test('provider can() is called via canAccessAsync', async () => {
74
- let callCount = 0;
75
- setAccessControlProvider({
76
- can: async (params) => {
77
- callCount++;
78
- expect((params as CanParams).resource).toBe('posts');
79
- if ((params as CanParams).action === 'delete') {
80
- return { can: false, reason: 'Cannot delete posts' };
81
- }
82
- return { can: true };
83
- },
84
- });
85
- const denied = await canAccessAsync('posts', 'delete') as CanResult;
86
- expect(denied.can).toBe(false);
87
- expect(denied.reason).toBe('Cannot delete posts');
88
-
89
- const allowed = await canAccessAsync('posts', 'list') as CanResult;
90
- expect(allowed.can).toBe(true);
91
- expect(callCount).toBe(2);
44
+ };
45
+ expect((await provider.can({ resource: 'users', action: 'delete' }) as CanResult).can).toBe(false);
46
+ expect((await provider.can({ resource: 'users', action: 'list' }) as CanResult).can).toBe(true);
47
+ expect((await provider.can({ resource: 'posts', action: 'delete' }) as CanResult).can).toBe(true);
92
48
  });
93
49
 
94
50
  test('params.id is passed through', async () => {
95
- setAccessControlProvider({
51
+ const provider: AccessControlProvider = {
96
52
  can: async (p) => {
97
53
  const { params } = p as CanParams;
98
54
  if (params?.id === 42) return { can: false, reason: 'Record locked' };
99
55
  return { can: true };
100
56
  },
101
- });
102
- const result = await canAccessAsync('posts', 'edit', { id: 42 });
57
+ };
58
+ const result = await provider.can({ resource: 'posts', action: 'edit', params: { id: 42 } });
103
59
  expect((result as CanResult).can).toBe(false);
104
60
  expect((result as CanResult).reason).toBe('Record locked');
105
61
  });
106
62
 
107
63
  test('extended action types work', async () => {
108
- setAccessControlProvider({
64
+ const provider: AccessControlProvider = {
109
65
  can: async (p) => {
110
66
  const { action } = p as CanParams;
111
67
  if (action === 'show') return { can: false, reason: 'No show' };
112
68
  if (action === 'field') return { can: false, reason: 'No field' };
113
69
  return { can: true };
114
70
  },
115
- });
116
- expect(((await canAccessAsync('posts', 'show')) as CanResult).can).toBe(false);
117
- expect(((await canAccessAsync('posts', 'field')) as CanResult).can).toBe(false);
118
- expect(((await canAccessAsync('posts', 'list')) as CanResult).can).toBe(true);
71
+ };
72
+ expect(((await provider.can({ resource: 'posts', action: 'show' })) as CanResult).can).toBe(false);
73
+ expect(((await provider.can({ resource: 'posts', action: 'field' })) as CanResult).can).toBe(false);
74
+ expect(((await provider.can({ resource: 'posts', action: 'list' })) as CanResult).can).toBe(true);
119
75
  });
120
76
 
121
77
  test('getAccessControlOptions returns provider options', () => {
122
- setAccessControlProvider({
78
+ const provider: AccessControlProvider = {
123
79
  can: async () => ({ can: true }),
124
80
  options: {
125
81
  buttons: { enableAccessControl: true, hideIfUnauthorized: true },
126
82
  },
127
- });
128
- const options = getAccessControlOptions();
129
- expect(options.buttons?.enableAccessControl).toBe(true);
130
- expect(options.buttons?.hideIfUnauthorized).toBe(true);
83
+ };
84
+ expect(provider.options?.buttons?.enableAccessControl).toBe(true);
85
+ expect(provider.options?.buttons?.hideIfUnauthorized).toBe(true);
131
86
  });
132
87
 
133
88
  test('getAccessControlOptions returns {} when no options', () => {
134
- setAccessControlProvider({
89
+ const provider: AccessControlProvider = {
135
90
  can: async () => ({ can: true }),
136
- });
137
- expect(getAccessControlOptions()).toEqual({});
91
+ };
92
+ expect(provider.options).toBeUndefined();
138
93
  });
139
94
  });
140
95
 
141
96
  describe('CASL adapter', () => {
142
97
  test('createCaslAccessControl works', async () => {
143
- // Import dynamically to test the adapter
144
98
  const { createCaslAccessControl } = await import('./adapters/casl');
145
99
 
146
- // Mock CASL Ability
147
100
  const ability = {
148
101
  can: (action: string, subject: string) => !(action === 'delete' && subject === 'users'),
149
102
  cannot: (action: string, subject: string) => action === 'delete' && subject === 'users',
@@ -164,7 +117,6 @@ describe('Casbin adapter', () => {
164
117
  test('createCasbinAccessControl works', async () => {
165
118
  const { createCasbinAccessControl } = await import('./adapters/casbin');
166
119
 
167
- // Mock Casbin Enforcer
168
120
  const enforcer = {
169
121
  enforce: async (sub: string, obj: string, act: string) =>
170
122
  !(sub === 'alice' && obj === 'users' && act === 'delete'),
@@ -102,6 +102,7 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
102
102
  },
103
103
  liveParams: opts.liveParams,
104
104
  enabled: opts.queryOptions?.enabled ?? true,
105
+ dataProviderName: opts.dataProviderName,
105
106
  };
106
107
  });
107
108
 
@@ -110,13 +111,15 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
110
111
  let lastErrorAt = 0;
111
112
  $effect(() => {
112
113
  const opts = getOptions();
113
- if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt && opts.successNotification) {
114
+ if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt) {
114
115
  lastSuccessAt = query.dataUpdatedAt;
115
- fireSuccessNotification(opts.successNotification, '', query.data, undefined, getResource());
116
+ if (opts.successNotification) {
117
+ fireSuccessNotification(opts.successNotification, '', query.data, undefined, getResource());
118
+ }
116
119
  } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
117
120
  lastErrorAt = query.errorUpdatedAt;
118
121
  checkError(query.error);
119
- fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error);
122
+ fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error, getResource());
120
123
  }
121
124
  });
122
125
 
@@ -187,6 +190,7 @@ export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError
187
190
  },
188
191
  liveParams: opts.liveParams,
189
192
  enabled: (opts.queryOptions?.enabled ?? true) && getId() != null,
193
+ dataProviderName: opts.dataProviderName,
190
194
  };
191
195
  });
192
196
 
@@ -194,13 +198,15 @@ export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError
194
198
  let lastErrorAt = 0;
195
199
  $effect(() => {
196
200
  const opts = getOptions();
197
- if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt && opts.successNotification) {
201
+ if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt) {
198
202
  lastSuccessAt = query.dataUpdatedAt;
199
- fireSuccessNotification(opts.successNotification, '', query.data, undefined, getResource());
203
+ if (opts.successNotification) {
204
+ fireSuccessNotification(opts.successNotification, '', query.data, undefined, getResource());
205
+ }
200
206
  } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
201
207
  lastErrorAt = query.errorUpdatedAt;
202
208
  checkError(query.error);
203
- fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error);
209
+ fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error, getResource());
204
210
  }
205
211
  });
206
212
 
@@ -286,6 +292,7 @@ export function useMany<TData extends BaseRecord = BaseRecord, TError = HttpErro
286
292
  },
287
293
  liveParams: opts.liveParams,
288
294
  enabled: (opts.queryOptions?.enabled ?? true) && opts.ids.length > 0,
295
+ dataProviderName: opts.dataProviderName,
289
296
  };
290
297
  });
291
298
 
@@ -293,13 +300,15 @@ export function useMany<TData extends BaseRecord = BaseRecord, TError = HttpErro
293
300
  let lastErrorAt = 0;
294
301
  $effect(() => {
295
302
  const opts = getOptions();
296
- if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt && opts.successNotification) {
303
+ if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt) {
297
304
  lastSuccessAt = query.dataUpdatedAt;
298
- fireSuccessNotification(opts.successNotification, '', query.data, undefined, opts.resource);
305
+ if (opts.successNotification) {
306
+ fireSuccessNotification(opts.successNotification, '', query.data, undefined, opts.resource);
307
+ }
299
308
  } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
300
309
  lastErrorAt = query.errorUpdatedAt;
301
310
  checkError(query.error);
302
- fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error);
311
+ fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error, opts.resource);
303
312
  }
304
313
  });
305
314
 
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { useList, useOne } from './query-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
+ }),
11
+ useResource: () => ({ name: 'posts' }),
12
+ getResource: (name: string) => ({ name, primaryKey: 'id' }),
13
+ useRouterContext: () => ({ navigate: vi.fn() }),
14
+ useTranslate: () => (key: string) => key,
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: 'One' }], title: 'One' }, // Unified mock to please both array and direct object assertions
26
+ isPending: false,
27
+ isFetching: false,
28
+ error: null
29
+ })
30
+ };
31
+ });
32
+
33
+ describe('useList & useOne - Headless Svelte 5 Compatibility', () => {
34
+
35
+ it('safely binds useList query state without headless Svelte destruction', () => {
36
+ let listQuery: ReturnType<typeof useList>;
37
+
38
+ const cleanup = $effect.root(() => {
39
+ listQuery = useList({ resource: 'posts' });
40
+ });
41
+
42
+ flushSync();
43
+
44
+ expect(listQuery!.data).toBeDefined();
45
+ expect(listQuery!.data!.data[0].title).toBe('One');
46
+ expect(listQuery!.isPending).toBe(false);
47
+
48
+ cleanup();
49
+ });
50
+
51
+ it('safely binds useOne query state without headless component mounting', () => {
52
+ let oneQuery: ReturnType<typeof useOne>;
53
+
54
+ const cleanup = $effect.root(() => {
55
+ oneQuery = useOne({ resource: 'posts', id: 1 });
56
+ });
57
+
58
+ flushSync();
59
+
60
+ expect(oneQuery!.data).toBeDefined();
61
+ expect((oneQuery!.data!.data as any).title).toBe('One');
62
+
63
+ cleanup();
64
+ });
65
+ });
package/src/query-keys.ts CHANGED
@@ -1,17 +1,16 @@
1
1
  export const keys = () => {
2
- let _data = 'default';
2
+ let _dataProviderName: string | undefined = undefined;
3
3
  let _resource = '';
4
4
  let _action = '';
5
5
  let _params: unknown = undefined;
6
6
 
7
7
  const builder = {
8
- data: (name: string) => { _data = name; return builder; },
8
+ data: (name: string | undefined) => { _dataProviderName = name; return builder; },
9
9
  resource: (name: string) => { _resource = name; return builder; },
10
10
  action: (name: string) => { _action = name; return builder; },
11
11
  params: (params: unknown) => { _params = params; return builder; },
12
12
  get: (): unknown[] => {
13
- const parts: unknown[] = ['data', _data];
14
- if (_resource) parts.push(_resource);
13
+ const parts: unknown[] = [_dataProviderName, _resource];
15
14
  if (_action) parts.push(_action);
16
15
  if (_params !== undefined) parts.push(_params);
17
16
  return parts;
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { useThemedLayoutContext } from './hooks.svelte';
3
+
4
+ describe('Svelte 5 Reactive Runes Compatibility', () => {
5
+
6
+ it('verifies that sidebarCollapsed is correctly synchronized out of component lifecycle', () => {
7
+ let observedValue = false;
8
+ let layout: ReturnType<typeof useThemedLayoutContext>;
9
+
10
+ const cleanup = $effect.root(() => {
11
+ layout = useThemedLayoutContext();
12
+
13
+ $effect(() => {
14
+ observedValue = layout.sidebarCollapsed;
15
+ });
16
+ });
17
+
18
+ expect(observedValue).toBe(false);
19
+
20
+ layout!.toggleSidebar();
21
+
22
+ expect(layout!.sidebarCollapsed).toBe(true);
23
+
24
+ cleanup();
25
+ });
26
+ });
@@ -3,6 +3,9 @@
3
3
  // Skip if window is not available (CI without happy-dom).
4
4
  import { describe, test, expect } from 'bun:test';
5
5
  import { createHashRouterProvider, createHistoryRouterProvider } from './router-provider';
6
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
7
+
8
+ GlobalRegistrator.register();
6
9
 
7
10
  const hasWindow = typeof globalThis.window !== 'undefined';
8
11
 
@@ -26,13 +26,14 @@ export interface RouterProvider {
26
26
 
27
27
  export function createHashRouterProvider(): RouterProvider {
28
28
  return {
29
- go({ to, query, type = 'push' }) {
29
+ go({ to, query, hash, type = 'push' }) {
30
30
  if (typeof window === 'undefined') return;
31
31
  let url = to;
32
32
  if (query) {
33
33
  const params = new URLSearchParams(query).toString();
34
- if (params) url += `?${params}`;
34
+ if (params) url += url.includes('?') ? `&${params}` : `?${params}`;
35
35
  }
36
+ if (hash) url += `#${hash}`;
36
37
  if (type === 'replace') {
37
38
  const urlObj = new URL(window.location.href);
38
39
  urlObj.hash = url;
@@ -76,20 +77,21 @@ export function createHashRouterProvider(): RouterProvider {
76
77
  // ─── History Router (HTML5 pushState) ───────────────────────
77
78
 
78
79
  export function createHistoryRouterProvider(basePath = ''): RouterProvider {
80
+ const normalizedBase = basePath.endsWith('/') && basePath.length > 1 ? basePath.slice(0, -1) : basePath;
79
81
  return {
80
- go({ to, query, type = 'push' }) {
82
+ go({ to, query, hash, type = 'push' }) {
81
83
  if (typeof window === 'undefined') return;
82
- let url = `${basePath}${to}`;
84
+ let url = `${normalizedBase}${to}`;
83
85
  if (query) {
84
86
  const params = new URLSearchParams(query).toString();
85
- if (params) url += `?${params}`;
87
+ if (params) url += url.includes('?') ? `&${params}` : `?${params}`;
86
88
  }
89
+ if (hash) url += `#${hash}`;
87
90
  if (type === 'replace') {
88
91
  history.replaceState(null, '', url);
89
92
  } else {
90
93
  history.pushState(null, '', url);
91
94
  }
92
- // Dispatch event so other parts of the app can react
93
95
  window.dispatchEvent(new PopStateEvent('popstate'));
94
96
  },
95
97
  back() {
@@ -100,8 +102,10 @@ export function createHistoryRouterProvider(basePath = ''): RouterProvider {
100
102
  if (typeof window === 'undefined') {
101
103
  return { resource: undefined, action: undefined, id: undefined, params: {}, pathname: '/' };
102
104
  }
103
- const pathname = (basePath && window.location.pathname.startsWith(basePath)
104
- ? window.location.pathname.slice(basePath.length) || '/'
105
+ const fullPath = window.location.pathname;
106
+ const pathname = (normalizedBase && fullPath.startsWith(normalizedBase)
107
+ && (fullPath.length === normalizedBase.length || fullPath[normalizedBase.length] === '/')
108
+ ? fullPath.slice(normalizedBase.length) || '/'
105
109
  : window.location.pathname);
106
110
  const params: Record<string, string> = {};
107
111
  for (const [k, v] of new URLSearchParams(window.location.search).entries()) {
@@ -117,7 +121,7 @@ export function createHistoryRouterProvider(basePath = ''): RouterProvider {
117
121
  };
118
122
  },
119
123
  formatLink(path) {
120
- return basePath + path;
124
+ return normalizedBase + path;
121
125
  },
122
126
  };
123
127
  }
package/src/router.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Router — path matching + navigation (supports hash & history via RouterProvider)
2
2
 
3
3
  import type { RouterProvider } from './router-provider';
4
+ import { syncGlobalPath } from './useParsed.svelte';
4
5
 
5
6
  interface RouteMatch {
6
7
  route: string;
@@ -74,6 +75,7 @@ export async function navigate(path: string, options?: { replaceState?: boolean
74
75
  } else if (typeof window !== 'undefined') {
75
76
  window.location.hash = '#' + path.replace(/^#/, '');
76
77
  }
78
+ syncGlobalPath();
77
79
  for (const guard of _afterGuards) {
78
80
  guard(path, from);
79
81
  }
@@ -102,3 +104,9 @@ export function currentPath(): string {
102
104
  if (typeof window === 'undefined') return '/';
103
105
  return window.location.hash.replace(/^#/, '') || '/';
104
106
  }
107
+
108
+ export function resetRouter(): void {
109
+ _routerProvider = undefined;
110
+ _beforeGuards = [];
111
+ _afterGuards = [];
112
+ }
@@ -39,7 +39,6 @@ export function useGetToPath() {
39
39
 
40
40
  export function useGo() {
41
41
  const getToPath = useGetToPath();
42
- const routerProvider = getRouterProvider();
43
42
 
44
43
  return (options: { to?: string; query?: Record<string, unknown>; type?: 'push' | 'replace'; resource?: string; action?: 'list' | 'create' | 'edit' | 'show' | 'clone'; id?: string | number }) => {
45
44
  let targetUrl = options.to;
@@ -49,21 +48,19 @@ export function useGo() {
49
48
  if (!targetUrl) targetUrl = '/';
50
49
 
51
50
  if (options.query && Object.keys(options.query).length > 0) {
52
- const qs = new URLSearchParams(options.query as Record<string, string>).toString();
53
- targetUrl += `?${qs}`;
51
+ const qs = new URLSearchParams(Object.entries(options.query).map(([k, v]) => [k, String(v)])).toString();
52
+ if (qs) {
53
+ targetUrl += targetUrl.includes('?') ? `&${qs}` : `?${qs}`;
54
+ }
54
55
  }
55
56
 
56
- if (routerProvider?.go) {
57
- routerProvider.go({ to: targetUrl, type: options.type });
58
- } else {
59
- navigate(targetUrl, options.type === 'replace' ? { replaceState: true } : undefined);
60
- }
57
+ navigate(targetUrl, options.type === 'replace' ? { replaceState: true } : undefined);
61
58
  };
62
59
  }
63
60
 
64
61
  export function useBack() {
65
- const routerProvider = getRouterProvider();
66
62
  return () => {
63
+ const routerProvider = getRouterProvider();
67
64
  if (routerProvider?.back) {
68
65
  routerProvider.back();
69
66
  } else if (typeof window !== 'undefined') {
@@ -77,8 +74,10 @@ export function useBack() {
77
74
  * (to avoid full page reloads in SSR apps)
78
75
  */
79
76
  export function useLink() {
80
- const routerProvider = getRouterProvider() as Record<string, unknown> | undefined;
81
- return (routerProvider?.Link as string) ?? 'a';
77
+ return () => {
78
+ const routerProvider = getRouterProvider() as Record<string, unknown> | undefined;
79
+ return (routerProvider?.Link as string) ?? 'a';
80
+ };
82
81
  }
83
82
 
84
83
  export function useResource(resourceName?: string) {
@@ -101,8 +100,12 @@ export function useResource(resourceName?: string) {
101
100
  get identifier() { return resolvedResource?.identifier ?? resolvedResource?.name; },
102
101
  /** Resolve a resource by name — useful for dynamic lookups inside callbacks */
103
102
  select: (name: string) => {
104
- const res = getResource(name);
105
- return { resource: res, identifier: res.identifier ?? res.name };
103
+ try {
104
+ const res = getResource(name);
105
+ return { resource: res, identifier: res.identifier ?? res.name };
106
+ } catch {
107
+ return { resource: undefined, identifier: undefined };
108
+ }
106
109
  },
107
110
  };
108
111
  }
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { useGo, useBack } from './routing-hooks.svelte';
3
+ import { useParsed } from './useParsed.svelte';
4
+ import { flushSync } from 'svelte';
5
+
6
+ vi.mock('./context.svelte', () => ({
7
+ useRouterContext: () => ({
8
+ navigate: vi.fn(),
9
+ url: '/posts/1/edit',
10
+ parsedParams: { resource: 'posts', action: 'edit', id: '1' },
11
+ go: vi.fn(),
12
+ back: vi.fn()
13
+ }),
14
+ getRouterProvider: () => undefined
15
+ }));
16
+
17
+ vi.mock('./useParsed.svelte', () => ({
18
+ useParsed: () => ({ resource: 'posts', action: 'edit', id: '1' })
19
+ }));
20
+
21
+ describe('routing-hooks - Headless Svelte 5 Compatibility', () => {
22
+
23
+ it('safely extracts parses from url and binds to runes Context', () => {
24
+ let parsedParams!: ReturnType<typeof useParsed>;
25
+
26
+ const cleanup = $effect.root(() => {
27
+ parsedParams = useParsed();
28
+ });
29
+
30
+ flushSync();
31
+
32
+ expect(parsedParams!.action).toBe('edit');
33
+ expect(parsedParams.resource).toBe('posts');
34
+ expect(parsedParams.id).toBe('1');
35
+
36
+ cleanup();
37
+ });
38
+
39
+ it('useGo and useBack return functional closures out of Context', () => {
40
+ let go!: ReturnType<typeof useGo>;
41
+ let back!: ReturnType<typeof useBack>;
42
+
43
+ const cleanup = $effect.root(() => {
44
+ go = useGo();
45
+ back = useBack();
46
+ });
47
+
48
+ flushSync();
49
+
50
+ expect(typeof go).toBe('function');
51
+ expect(typeof back).toBe('function');
52
+
53
+ cleanup();
54
+ });
55
+ });
@@ -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);