@svadmin/core 0.18.1 → 0.19.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.18.1",
3
+ "version": "0.19.1",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
@@ -266,7 +266,9 @@ export function useOnError() {
266
266
 
267
267
  /**
268
268
  * Fetches permissions from authProvider.getPermissions().
269
- * Returns a reactive integrated object.
269
+ * Returns a reactive object with convenience methods for permission checks.
270
+ *
271
+ * Supports `refetch()` for session-level permission refresh (e.g., after role change).
270
272
  *
271
273
  * @example
272
274
  * ```svelte
@@ -282,9 +284,12 @@ export function usePermissions<T = unknown>() {
282
284
  let permissions = $state<T | null>(null);
283
285
  let isLoading = $state(true);
284
286
  let error = $state<Error | null>(null);
285
- let version = $state(0); // Force reactive invalidation
287
+ let version = $state(0);
286
288
 
287
- if (provider?.getPermissions) {
289
+ function fetch() {
290
+ if (!provider?.getPermissions) { isLoading = false; return; }
291
+ isLoading = true;
292
+ error = null;
288
293
  provider.getPermissions().then(p => {
289
294
  permissions = p as T;
290
295
  version++;
@@ -294,19 +299,18 @@ export function usePermissions<T = unknown>() {
294
299
  isLoading = false;
295
300
  console.warn('[svadmin] usePermissions failed:', err);
296
301
  });
297
- } else {
298
- isLoading = false;
299
302
  }
300
303
 
304
+ fetch();
305
+
301
306
  return {
302
307
  get isLoading() { return isLoading; },
303
308
  get error() { return error; },
304
309
  get version() { return version; },
305
-
306
- /** Get the raw permissions data */
307
310
  get raw() { return permissions; },
311
+ refetch: fetch,
308
312
 
309
- /** Convenience: check if a specific permission exists */
313
+ /** Check if a specific permission string exists */
310
314
  has(perm: string): boolean {
311
315
  if (!permissions) return false;
312
316
  if (Array.isArray(permissions)) return permissions.includes(perm);
@@ -315,12 +319,10 @@ export function usePermissions<T = unknown>() {
315
319
  return false;
316
320
  },
317
321
 
318
- /** Convenience: check resource action (Casbin style representation if applicable) */
322
+ /** Check resource:action style permission */
319
323
  can(resource: string, action: string): boolean {
320
- // Tries to map to a standard permission string format like "resource:action"
321
- // Replace with your own logic if your permissions are structured differently.
322
324
  return this.has(`${resource}:${action}`);
323
- }
325
+ },
324
326
  };
325
327
  }
326
328
 
@@ -326,10 +326,16 @@ export function useForm<
326
326
 
327
327
  if (!runValidation()) { toast.warning(t('validation.required')); return; }
328
328
  redirectOverride = overrides?.redirect;
329
- if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
330
- else await updateMut.mutateAsync(values);
331
- // Untaint on success
332
- tainted = {};
329
+ try {
330
+ if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
331
+ else await updateMut.mutateAsync(values);
332
+ // Untaint on success
333
+ tainted = {};
334
+ } catch (error) {
335
+ // Errors are already handled by the mutation's onError callback.
336
+ // We catch here to prevent unhandled promise rejections.
337
+ handleHttpError(error instanceof Error ? error : new Error(String(error)));
338
+ }
333
339
  }
334
340
 
335
341
  // ─── AutoSave ───────────────────────────────────────────────────
@@ -170,6 +170,11 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
170
170
  debounceTimer = setTimeout(() => { searchText = value; }, debounceMs);
171
171
  }
172
172
 
173
+ // Cleanup debounce timer on unmount
174
+ $effect(() => {
175
+ return () => { if (debounceTimer) clearTimeout(debounceTimer); };
176
+ });
177
+
173
178
  return {
174
179
  query,
175
180
  get options() { return selectOptions; },
@@ -519,13 +519,23 @@ export function addTranslations(locale: string, translations: Record<string, str
519
519
  }
520
520
 
521
521
  /**
522
- * useTranslation — standard i18n hook
523
- * Returns { translate, getLocale, changeLocale }
522
+ * useTranslation — Svelte 5 reactive translation hook
523
+ * Tracks locale changes natively.
524
524
  */
525
525
  export function useTranslation() {
526
- return {
527
- translate: t,
528
- getLocale,
529
- changeLocale: setLocale,
526
+ const translate = $derived.by(() => {
527
+ // Read currentLocale so this $derived depends on it
528
+ const loc = currentLocale;
529
+ return (key: string, params?: Record<string, string | number>): string => {
530
+ // Intentionally passing the key to the main t() function which uses currentLocale
531
+ return t(key, params);
532
+ };
533
+ });
534
+
535
+ return {
536
+ get t() { return translate; },
537
+ get locale() { return currentLocale; },
538
+ setLocale,
539
+ getAvailableLocales
530
540
  };
531
541
  }
@@ -1,6 +1,6 @@
1
1
  // Unit tests for permissions module
2
2
  import { describe, test, expect, beforeEach } from 'bun:test';
3
- import { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync } from './permissions';
3
+ import { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync, type CanParams, type CanResult } from './permissions';
4
4
  import type { Action, AccessControlProvider } from './permissions';
5
5
 
6
6
  describe('permissions', () => {
@@ -13,21 +13,26 @@ describe('permissions', () => {
13
13
 
14
14
  test('canAccessAsync respects deny rule', async () => {
15
15
  setAccessControlProvider({
16
- can: async ({ action }) => {
16
+ can: async (p) => {
17
+ const { action } = p as CanParams;
17
18
  if (action === 'delete') return { can: false, reason: 'Denied' };
18
19
  return { can: true };
19
20
  },
20
21
  });
21
22
  expect(await canAccessAsync('posts', 'delete')).toEqual({ can: false, reason: 'Denied' });
22
- expect(await canAccessAsync('posts', 'list')).toEqual({ can: true });
23
+ expect((await canAccessAsync('posts', 'delete')) as CanResult).toEqual({ can: false, reason: 'Denied' });
24
+ expect((await canAccessAsync('posts', 'list')) as CanResult).toEqual({ can: true });
23
25
  });
24
26
 
25
27
  test('canAccessAsync resolves with allowed status', async () => {
26
28
  setAccessControlProvider({
27
- can: async () => ({ can: true }),
29
+ can: async (params) => {
30
+ expect((params as CanParams).params?.published).toBe(true);
31
+ return { can: true };
32
+ },
28
33
  });
29
- const result = await canAccessAsync('posts', 'edit');
30
- expect((result as any).can).toBe(true);
34
+ const result = await canAccessAsync('posts', 'edit', { published: true });
35
+ expect((result as CanResult).can).toBe(true);
31
36
  });
32
37
 
33
38
  test('canAccessAsync resolves with denied status', async () => {
@@ -35,20 +40,21 @@ describe('permissions', () => {
35
40
  can: async () => ({ can: false, reason: 'No permission' }),
36
41
  });
37
42
  const result = await canAccessAsync('users', 'delete');
38
- expect((result as any).can).toBe(false);
39
- expect((result as any).reason).toBe('No permission');
43
+ expect((result as CanResult).can).toBe(false);
44
+ expect((result as CanResult).reason).toBe('No permission');
40
45
  });
41
46
 
42
47
  test('resource-specific rules', async () => {
43
48
  setAccessControlProvider({
44
- can: async ({ resource, action }) => {
49
+ can: async (p) => {
50
+ const { resource, action } = p as CanParams;
45
51
  if (resource === 'users' && action === 'delete') return { can: false, reason: 'Cannot delete users' };
46
52
  return { can: true };
47
53
  },
48
54
  });
49
- expect((await canAccessAsync('users', 'delete')).can).toBe(false);
50
- expect((await canAccessAsync('users', 'list')).can).toBe(true);
51
- expect((await canAccessAsync('posts', 'delete')).can).toBe(true);
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);
52
58
  });
53
59
  });
54
60
 
@@ -65,45 +71,51 @@ describe('AccessControlProvider', () => {
65
71
  });
66
72
 
67
73
  test('provider can() is called via canAccessAsync', async () => {
74
+ let callCount = 0;
68
75
  setAccessControlProvider({
69
- can: async ({ resource, action }) => {
70
- if (resource === 'posts' && action === 'delete') {
76
+ can: async (params) => {
77
+ callCount++;
78
+ expect((params as CanParams).resource).toBe('posts');
79
+ if ((params as CanParams).action === 'delete') {
71
80
  return { can: false, reason: 'Cannot delete posts' };
72
81
  }
73
82
  return { can: true };
74
83
  },
75
84
  });
76
- const denied = await canAccessAsync('posts', 'delete');
85
+ const denied = await canAccessAsync('posts', 'delete') as CanResult;
77
86
  expect(denied.can).toBe(false);
78
87
  expect(denied.reason).toBe('Cannot delete posts');
79
88
 
80
- const allowed = await canAccessAsync('posts', 'list');
89
+ const allowed = await canAccessAsync('posts', 'list') as CanResult;
81
90
  expect(allowed.can).toBe(true);
91
+ expect(callCount).toBe(2);
82
92
  });
83
93
 
84
94
  test('params.id is passed through', async () => {
85
95
  setAccessControlProvider({
86
- can: async ({ params }) => {
96
+ can: async (p) => {
97
+ const { params } = p as CanParams;
87
98
  if (params?.id === 42) return { can: false, reason: 'Record locked' };
88
99
  return { can: true };
89
100
  },
90
101
  });
91
102
  const result = await canAccessAsync('posts', 'edit', { id: 42 });
92
- expect((result as any).can).toBe(false);
93
- expect((result as any).reason).toBe('Record locked');
103
+ expect((result as CanResult).can).toBe(false);
104
+ expect((result as CanResult).reason).toBe('Record locked');
94
105
  });
95
106
 
96
107
  test('extended action types work', async () => {
97
108
  setAccessControlProvider({
98
- can: async ({ action }) => {
109
+ can: async (p) => {
110
+ const { action } = p as CanParams;
99
111
  if (action === 'show') return { can: false, reason: 'No show' };
100
112
  if (action === 'field') return { can: false, reason: 'No field' };
101
113
  return { can: true };
102
114
  },
103
115
  });
104
- expect((await canAccessAsync('posts', 'show')).can).toBe(false);
105
- expect((await canAccessAsync('posts', 'field')).can).toBe(false);
106
- expect((await canAccessAsync('posts', 'list')).can).toBe(true);
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);
107
119
  });
108
120
 
109
121
  test('getAccessControlOptions returns provider options', () => {
@@ -139,10 +151,10 @@ describe('CASL adapter', () => {
139
151
 
140
152
  const provider = createCaslAccessControl(ability);
141
153
 
142
- const allowed = await provider.can({ resource: 'posts', action: 'edit' });
154
+ const allowed = await provider.can({ resource: 'posts', action: 'edit' }) as CanResult;
143
155
  expect(allowed.can).toBe(true);
144
156
 
145
- const denied = await provider.can({ resource: 'users', action: 'delete' });
157
+ const denied = await provider.can({ resource: 'users', action: 'delete' }) as CanResult;
146
158
  expect(denied.can).toBe(false);
147
159
  expect(denied.reason).toContain('Cannot "delete" on "users"');
148
160
  });
@@ -162,10 +174,10 @@ describe('Casbin adapter', () => {
162
174
  getUser: () => 'alice',
163
175
  });
164
176
 
165
- const allowed = await provider.can({ resource: 'posts', action: 'edit' });
177
+ const allowed = await provider.can({ resource: 'posts', action: 'edit' }) as CanResult;
166
178
  expect(allowed.can).toBe(true);
167
179
 
168
- const denied = await provider.can({ resource: 'users', action: 'delete' });
180
+ const denied = await provider.can({ resource: 'users', action: 'delete' }) as CanResult;
169
181
  expect(denied.can).toBe(false);
170
182
  expect(denied.reason).toContain('alice');
171
183
  });
@@ -336,7 +336,7 @@ function applyTheme(m: ThemeMode): void {
336
336
  }
337
337
 
338
338
  // Apply on init
339
- applyTheme(mode);
339
+ applyTheme(getStoredTheme());
340
340
 
341
341
  // Listen for system preference changes
342
342
  if (typeof window !== 'undefined') {
@@ -383,7 +383,7 @@ function applyColorTheme(ct: ColorTheme): void {
383
383
  }
384
384
 
385
385
  // Apply on init
386
- applyColorTheme(colorTheme);
386
+ applyColorTheme(getStoredColorTheme());
387
387
 
388
388
  export function getColorTheme(): ColorTheme {
389
389
  return colorTheme;
@@ -1,62 +1,114 @@
1
- // UnsavedChangesNotifier — warns user before leaving page with unsaved changes
2
- // Svelte 5 component using runes
1
+ // UnsavedChangesNotifier — guards against accidental navigation with dirty forms
2
+ // Supports: browser tab close, hash-based SPA routing, and framework router interception
3
3
 
4
- // No lifecycle imports needed — uses module-level $state pattern
4
+ import { t } from './i18n.svelte';
5
5
 
6
- let warnWhenUnsavedChanges = $state(false);
6
+ let dirty = $state(false);
7
7
 
8
- /**
9
- * Call this to mark the form as dirty (has unsaved changes).
10
- * When set to true, navigating away or closing the tab will trigger a warning.
11
- */
12
- export function setUnsavedChanges(dirty: boolean) {
13
- warnWhenUnsavedChanges = dirty;
8
+ /** Mark whether the current form has unsaved changes */
9
+ export function setUnsavedChanges(value: boolean) {
10
+ dirty = value;
14
11
  }
15
12
 
16
13
  export function getUnsavedChanges(): boolean {
17
- return warnWhenUnsavedChanges;
14
+ return dirty;
18
15
  }
19
16
 
20
- const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
21
- if (warnWhenUnsavedChanges) {
22
- e.preventDefault();
23
- // Modern browsers ignore custom messages but require returnValue to be set
24
- e.returnValue = '';
25
- return '';
26
- }
27
- };
17
+ /**
18
+ * Navigation interceptor callback type.
19
+ * Frameworks like SvelteKit can register a cancel function here.
20
+ * Return `true` to allow navigation, `false` to block it.
21
+ */
22
+ type NavigationGuard = (opts: { cancel: () => void }) => void;
23
+
24
+ let _navigationGuard: NavigationGuard | null = null;
25
+
26
+ /**
27
+ * Register a framework-level navigation guard.
28
+ *
29
+ * In SvelteKit, call this from your root layout:
30
+ * ```ts
31
+ * import { beforeNavigate } from '$app/navigation';
32
+ * import { registerNavigationGuard } from '@svadmin/core';
33
+ *
34
+ * registerNavigationGuard(({ cancel }) => {
35
+ * beforeNavigate(({ cancel: kitCancel }) => {
36
+ * cancel = kitCancel;
37
+ * });
38
+ * });
39
+ * ```
40
+ *
41
+ * Or more idiomatically, use the built-in `initUnsavedChangesNotifier`
42
+ * and pass a `beforeNavigate` callback.
43
+ */
44
+ export function registerNavigationGuard(guard: NavigationGuard): void {
45
+ _navigationGuard = guard;
46
+ }
28
47
 
29
48
  /**
30
49
  * Initialize the UnsavedChangesNotifier.
31
- * Call this in your root layout or AdminApp component.
32
- * It handles both browser tab close and in-app navigation.
50
+ *
51
+ * @param options.beforeNavigate - Framework-level navigation interceptor.
52
+ * For SvelteKit: pass the `beforeNavigate` function from `$app/navigation`.
53
+ * For hash-based SPAs: omit this, hash navigation is handled automatically.
54
+ *
55
+ * @example SvelteKit
56
+ * ```svelte
57
+ * <script>
58
+ * import { beforeNavigate } from '$app/navigation';
59
+ * import { initUnsavedChangesNotifier } from '@svadmin/core';
60
+ * initUnsavedChangesNotifier({ beforeNavigate });
61
+ * </script>
62
+ * ```
63
+ *
64
+ * @example Hash-based SPA
65
+ * ```ts
66
+ * initUnsavedChangesNotifier();
67
+ * ```
33
68
  */
34
- export function initUnsavedChangesNotifier() {
69
+ export function initUnsavedChangesNotifier(options?: {
70
+ beforeNavigate?: (callback: (nav: { cancel: () => void }) => void) => void;
71
+ }) {
35
72
  if (typeof window === 'undefined') return;
36
73
 
37
- window.addEventListener('beforeunload', beforeUnloadHandler);
38
-
39
- // For hash-based navigation, intercept hashchange
40
- const hashChangeHandler = (e: HashChangeEvent) => {
41
- if (warnWhenUnsavedChanges) {
42
- const confirmed = confirm('You have unsaved changes. Are you sure you want to leave?');
43
- if (!confirmed) {
44
- e.preventDefault();
45
- // Restore original hash
46
- if (e.oldURL) {
47
- const oldHash = new URL(e.oldURL).hash;
48
- history.replaceState(null, '', oldHash || '#');
74
+ // 1. Browser tab close / refresh
75
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
76
+ if (!dirty) return;
77
+ e.preventDefault();
78
+ e.returnValue = '';
79
+ };
80
+ window.addEventListener('beforeunload', onBeforeUnload);
81
+
82
+ // 2. Framework router (SvelteKit, etc.)
83
+ if (options?.beforeNavigate) {
84
+ options.beforeNavigate(({ cancel }) => {
85
+ if (dirty) {
86
+ if (!confirm(t('common.unsavedChanges'))) {
87
+ cancel();
88
+ } else {
89
+ dirty = false;
49
90
  }
50
- } else {
51
- warnWhenUnsavedChanges = false;
52
91
  }
92
+ });
93
+ }
94
+
95
+ // 3. Hash-based fallback for SPAs without a framework router
96
+ const onHashChange = (e: HashChangeEvent) => {
97
+ if (!dirty) return;
98
+ if (!confirm(t('common.unsavedChanges'))) {
99
+ e.preventDefault();
100
+ if (e.oldURL) {
101
+ history.replaceState(null, '', new URL(e.oldURL).hash || '#');
102
+ }
103
+ } else {
104
+ dirty = false;
53
105
  }
54
106
  };
107
+ window.addEventListener('hashchange', onHashChange);
55
108
 
56
- window.addEventListener('hashchange', hashChangeHandler);
57
-
109
+ // Cleanup
58
110
  return () => {
59
- window.removeEventListener('beforeunload', beforeUnloadHandler);
60
- window.removeEventListener('hashchange', hashChangeHandler);
111
+ window.removeEventListener('beforeunload', onBeforeUnload);
112
+ window.removeEventListener('hashchange', onHashChange);
61
113
  };
62
114
  }