@svadmin/core 0.23.0 → 0.23.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +20 -3
- package/src/adapters/casbin.ts +1 -1
- package/src/adapters/casl.ts +1 -1
- package/src/audit.ts +12 -11
- package/src/auth-hooks.svelte.ts +25 -2
- package/src/chatProvider.svelte.ts +20 -3
- package/src/context.svelte.ts +17 -1
- package/src/data-transfer.svelte.ts +23 -11
- package/src/form-hooks.svelte.ts +71 -27
- package/src/form-hooks.test.svelte.ts +83 -0
- package/src/helpers-pure.ts +13 -6
- package/src/helpers.test.ts +2 -2
- package/src/helpers.ts +4 -0
- package/src/hook-utils.svelte.ts +40 -20
- package/src/hooks.svelte.ts +43 -50
- package/src/i18n.svelte.ts +3 -0
- package/src/index.ts +10 -8
- package/src/inferencer-openapi.ts +5 -3
- package/src/inferencer.ts +6 -4
- package/src/live-sse.ts +20 -17
- package/src/live-websocket.ts +1 -0
- package/src/live.svelte.ts +19 -11
- package/src/mutation-hooks.svelte.ts +39 -25
- package/src/mutation-hooks.test.svelte.ts +114 -0
- package/src/notification.svelte.ts +6 -6
- package/src/options.svelte.ts +1 -1
- package/src/{permissions.ts → permissions.svelte.ts} +1 -1
- package/src/permissions.test.ts +32 -80
- package/src/query-hooks.svelte.ts +18 -9
- package/src/query-hooks.test.svelte.ts +65 -0
- package/src/query-keys.ts +3 -4
- package/src/reactive-hooks.test.svelte.ts +26 -0
- package/src/router-provider.test.ts +3 -0
- package/src/router-provider.ts +13 -9
- package/src/router.ts +8 -0
- package/src/routing-hooks.svelte.ts +16 -13
- package/src/routing-hooks.test.svelte.ts +55 -0
- package/src/table-hooks.svelte.ts +46 -1
- package/src/table-hooks.test.svelte.ts +57 -0
- package/src/table-state.svelte.ts +17 -12
- package/src/theme.svelte.ts +30 -7
- package/src/toast.svelte.ts +8 -0
- package/src/unsaved-changes.svelte.ts +14 -6
- package/src/url-sync.ts +2 -1
- package/src/useCan.ts +2 -2
- package/src/useParsed.svelte.ts +12 -4
- package/src/utility-hooks.svelte.ts +5 -2
package/src/permissions.test.ts
CHANGED
|
@@ -1,149 +1,102 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
23
|
-
expect((await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
29
|
+
const provider: AccessControlProvider = {
|
|
40
30
|
can: async () => ({ can: false, reason: 'No permission' }),
|
|
41
|
-
}
|
|
42
|
-
const result = await
|
|
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
|
-
|
|
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
|
|
56
|
-
expect((await
|
|
57
|
-
expect((await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
117
|
-
expect(((await
|
|
118
|
-
expect(((await
|
|
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
|
-
|
|
78
|
+
const provider: AccessControlProvider = {
|
|
123
79
|
can: async () => ({ can: true }),
|
|
124
80
|
options: {
|
|
125
81
|
buttons: { enableAccessControl: true, hideIfUnauthorized: true },
|
|
126
82
|
},
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
expect(options
|
|
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
|
-
|
|
89
|
+
const provider: AccessControlProvider = {
|
|
135
90
|
can: async () => ({ can: true }),
|
|
136
|
-
}
|
|
137
|
-
expect(
|
|
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
|
|
114
|
+
if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt) {
|
|
114
115
|
lastSuccessAt = query.dataUpdatedAt;
|
|
115
|
-
|
|
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
|
|
201
|
+
if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt) {
|
|
198
202
|
lastSuccessAt = query.dataUpdatedAt;
|
|
199
|
-
|
|
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
|
|
303
|
+
if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt) {
|
|
297
304
|
lastSuccessAt = query.dataUpdatedAt;
|
|
298
|
-
|
|
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
|
|
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) => {
|
|
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[] = [
|
|
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
|
|
package/src/router-provider.ts
CHANGED
|
@@ -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 = `${
|
|
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
|
|
104
|
-
|
|
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
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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 ??
|
|
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);
|