@svadmin/core 0.3.0 → 0.5.0

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.
@@ -1,42 +1,129 @@
1
1
  // Permission / Access Control
2
2
 
3
+ // ─── Types ────────────────────────────────────────────────────
3
4
 
4
- export type Action = 'list' | 'create' | 'edit' | 'delete' | 'export';
5
+ /** Actions that can be checked for access control. Extensible via string literal union. */
6
+ export type Action = 'list' | 'show' | 'create' | 'edit' | 'delete' | 'export' | 'field' | (string & {});
5
7
 
6
- export interface AccessControlResult {
8
+ export interface CanParams {
9
+ resource: string;
10
+ action: Action;
11
+ params?: { id?: string | number; [key: string]: unknown };
12
+ }
13
+
14
+ export interface CanResult {
7
15
  can: boolean;
8
16
  reason?: string;
9
17
  }
10
18
 
19
+ /** @deprecated Use `CanResult` instead */
20
+ export type AccessControlResult = CanResult;
21
+
22
+ /**
23
+ * Formal Access Control Provider interface.
24
+ * Implement this to define your authorization logic.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const accessControlProvider: AccessControlProvider = {
29
+ * can: async ({ resource, action, params }) => {
30
+ * if (resource === 'users' && action === 'delete') {
31
+ * return { can: false, reason: 'Cannot delete users' };
32
+ * }
33
+ * return { can: true };
34
+ * },
35
+ * options: {
36
+ * buttons: { enableAccessControl: true, hideIfUnauthorized: false },
37
+ * },
38
+ * };
39
+ * ```
40
+ */
41
+ export interface AccessControlProvider {
42
+ can: (params: CanParams) => Promise<CanResult>;
43
+ options?: {
44
+ buttons?: {
45
+ /** Enable access control checks on CRUD buttons globally. Default: false */
46
+ enableAccessControl?: boolean;
47
+ /** Hide buttons when unauthorized instead of disabling. Default: false */
48
+ hideIfUnauthorized?: boolean;
49
+ };
50
+ };
51
+ }
52
+
53
+ /** @deprecated Use `AccessControlProvider['can']` instead */
11
54
  export type AccessControlFn = (
12
55
  resource: string,
13
56
  action: Action,
14
57
  params?: Record<string, unknown>,
15
- ) => AccessControlResult | Promise<AccessControlResult>;
58
+ ) => CanResult | Promise<CanResult>;
59
+
60
+ // ─── State ────────────────────────────────────────────────────
16
61
 
17
- let accessControlFn: AccessControlFn | null = null;
62
+ let provider: AccessControlProvider | null = null;
63
+ let legacyFn: AccessControlFn | null = null;
18
64
 
65
+ // ─── API ──────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Register an AccessControlProvider.
69
+ */
70
+ export function setAccessControlProvider(p: AccessControlProvider): void {
71
+ provider = p;
72
+ legacyFn = null;
73
+ }
74
+
75
+ /**
76
+ * @deprecated Use `setAccessControlProvider()` with full `AccessControlProvider` interface.
77
+ * Legacy API preserved for backward compatibility.
78
+ */
19
79
  export function setAccessControl(fn: AccessControlFn): void {
20
- accessControlFn = fn;
80
+ legacyFn = fn;
81
+ provider = {
82
+ can: async ({ resource, action, params }) => {
83
+ const result = fn(resource, action, params);
84
+ return result instanceof Promise ? result : Promise.resolve(result);
85
+ },
86
+ };
21
87
  }
22
88
 
23
- export function canAccess(resource: string, action: Action, params?: Record<string, unknown>): AccessControlResult {
24
- if (!accessControlFn) return { can: true };
25
- const result = accessControlFn(resource, action, params);
26
- if (result instanceof Promise) {
27
- console.warn('[permissions] canAccess called synchronously but accessControlFn returned a Promise. Defaulting to { can: true }. Use canAccessAsync() instead.');
28
- return { can: true };
29
- }
30
- return result;
89
+ /** Get the current AccessControlProvider (or null) */
90
+ export function getAccessControlProvider(): AccessControlProvider | null {
91
+ return provider;
31
92
  }
32
93
 
33
- export async function canAccessAsync(resource: string, action: Action, params?: Record<string, unknown>): Promise<AccessControlResult> {
34
- if (!accessControlFn) return { can: true };
35
- return accessControlFn(resource, action, params);
94
+ /** Get global button options from the AccessControlProvider */
95
+ export function getAccessControlOptions() {
96
+ return provider?.options ?? {};
36
97
  }
37
98
 
38
- // Example usage:
39
- // setAccessControl((resource, action) => {
40
- // if (resource === 'users' && action === 'delete') return { can: false, reason: '无权删除用户' };
41
- // return { can: true };
42
- // });
99
+ /**
100
+ * Synchronous access check. Returns `{ can: true }` if no provider/fn is set.
101
+ * When a legacy sync fn is registered via `setAccessControl()`, this calls it synchronously.
102
+ * When only a provider is registered, this warns if provider.can() returns a Promise.
103
+ */
104
+ export function canAccess(resource: string, action: Action, params?: Record<string, unknown>): CanResult {
105
+ // Fast path: use legacy sync fn if available
106
+ if (legacyFn) {
107
+ const result = legacyFn(resource, action, params);
108
+ if (result instanceof Promise) {
109
+ console.warn('[permissions] canAccess called synchronously but accessControlFn returned a Promise. Defaulting to { can: true }. Use canAccessAsync() instead.');
110
+ return { can: true };
111
+ }
112
+ return result;
113
+ }
114
+ if (!provider) return { can: true };
115
+ // Provider path (always async) — warn
116
+ console.warn('[permissions] canAccess called synchronously but provider.can() returned a Promise. Defaulting to { can: true }. Use canAccessAsync() instead.');
117
+ return { can: true };
118
+ }
119
+
120
+ /**
121
+ * Async access check. Preferred over `canAccess()`.
122
+ */
123
+ export async function canAccessAsync(resource: string, action: Action, params?: Record<string, unknown>): Promise<CanResult> {
124
+ if (legacyFn) {
125
+ return legacyFn(resource, action, params);
126
+ }
127
+ if (!provider) return { can: true };
128
+ return provider.can({ resource, action, params });
129
+ }
@@ -1,6 +1,6 @@
1
1
  import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
2
2
  import { getAdminOptions } from './options';
3
- import { getDataProviderForResource, getDataProvider } from './context';
3
+ import { getDataProviderForResource, getDataProvider, getLiveProvider } from './context.svelte';
4
4
  import { useParsed } from './useParsed';
5
5
  import {
6
6
  createOvertimeTracker,
@@ -64,7 +64,7 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
64
64
 
65
65
  createLiveSubscription({
66
66
  resource,
67
- liveProvider: (globalThis as any).__svadminLiveProvider, // Handle via context ideally, or skip if not needed. Wait, in live.ts we pass liveProvider. Let's fix this in hook-utils.
67
+ liveProvider: getLiveProvider(),
68
68
  liveMode: options.liveMode ?? adminOptions.liveMode,
69
69
  onLiveEvent: (e) => {
70
70
  options.onLiveEvent?.(e);
@@ -1,5 +1,5 @@
1
1
  import { currentPath, navigate } from './router';
2
- import { getResource, getResources, getRouterProvider } from './context';
2
+ import { getResource, getResources, getRouterProvider } from './context.svelte';
3
3
  import { useParsed } from './useParsed';
4
4
 
5
5
  // ─── routing-hooks.svelte.ts ───────────────────────────────────
@@ -1,7 +1,7 @@
1
1
  import { useParsed } from './useParsed';
2
2
  import { useList } from './query-hooks.svelte';
3
3
  import { readURLState, writeURLState } from './url-sync';
4
- import type { Pagination, Sort, Filter, BaseRecord, HttpError, KnownResources } from './types';
4
+ import type { Pagination, Sort, Filter, BaseRecord, HttpError, KnownResources, GetListResult } from './types';
5
5
  import type { UseListOptions } from './query-hooks.svelte';
6
6
 
7
7
  export type FilterSetMode = 'merge' | 'replace';
@@ -148,15 +148,15 @@ export function useTable<
148
148
  createLinkForSyncWithLocation,
149
149
  get totalPages() {
150
150
  // ts-expect-error reactive access
151
- const total = (query as any).data?.total ?? 0;
151
+ const total = (query.data as GetListResult<TQueryFnData> | undefined)?.total ?? 0;
152
152
  return Math.ceil(total / (pagination.pageSize ?? 10));
153
153
  },
154
154
  get current() { return pagination.current ?? 1; },
155
155
  get pageSize() { return pagination.pageSize ?? 10; },
156
- get pageCount() { return Math.ceil(((query as any).data?.total ?? 0) / (pagination.pageSize ?? 10)); },
156
+ get pageCount() { return Math.ceil(((query.data as GetListResult<TQueryFnData> | undefined)?.total ?? 0) / (pagination.pageSize ?? 10)); },
157
157
  get clientData(): TQueryFnData[] {
158
158
  if (paginationMode !== 'client') return [];
159
- const allData = ((query as any).data?.data ?? []) as TQueryFnData[];
159
+ const allData = ((query.data as GetListResult<TQueryFnData> | undefined)?.data ?? []) as TQueryFnData[];
160
160
  const start = ((pagination.current ?? 1) - 1) * (pagination.pageSize ?? 10);
161
161
  const end = start + (pagination.pageSize ?? 10);
162
162
  let sorted = [...allData];
@@ -0,0 +1,243 @@
1
+ // TableState — Svelte 5 Class State Pattern for table data management
2
+ // Reactive class that encapsulates all table state with fine-grained $state/$derived
3
+
4
+ import { useParsed } from './useParsed';
5
+ import { useList } from './query-hooks.svelte';
6
+ import { readURLState, writeURLState } from './url-sync';
7
+ import type { Pagination, Sort, Filter, BaseRecord, HttpError, KnownResources, GetListResult } from './types';
8
+ import type { UseListOptions } from './query-hooks.svelte';
9
+
10
+ export type FilterSetMode = 'merge' | 'replace';
11
+
12
+ export interface TableStateOptions<TData extends BaseRecord = BaseRecord, TError = HttpError>
13
+ extends Omit<UseListOptions<TData, TError>, 'pagination' | 'sorters' | 'filters'> {
14
+ resource?: KnownResources;
15
+ pagination?: Pagination;
16
+ sorters?: {
17
+ initial?: Sort[];
18
+ permanent?: Sort[];
19
+ mode?: 'off' | 'server';
20
+ };
21
+ filters?: {
22
+ initial?: Filter[];
23
+ permanent?: Filter[];
24
+ mode?: 'off' | 'server';
25
+ defaultBehavior?: FilterSetMode;
26
+ };
27
+ syncWithLocation?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Svelte 5 Class-based reactive table state.
32
+ *
33
+ * Usage:
34
+ * ```ts
35
+ * const table = new TableState({ resource: 'posts' });
36
+ * table.setPage(2);
37
+ * table.toggleSort('title');
38
+ * console.log(table.data); // reactive
39
+ * ```
40
+ */
41
+ export class TableState<TData extends BaseRecord = BaseRecord, TError = HttpError> {
42
+ // ─── Reactive state ───────────────────────────────────────────
43
+ pagination = $state<Pagination>({ current: 1, pageSize: 10 });
44
+ currentSorters = $state<Sort[]>([]);
45
+ currentFilters = $state<Filter[]>([]);
46
+
47
+ // ─── Private config ───────────────────────────────────────────
48
+ private permanentSorters: Sort[];
49
+ private permanentFilters: Filter[];
50
+ private sortersMode: 'off' | 'server';
51
+ private filtersMode: 'off' | 'server';
52
+ private filterDefaultBehavior: FilterSetMode;
53
+ private syncWithLocation: boolean;
54
+ private paginationMode: string;
55
+
56
+ // ─── Query result ─────────────────────────────────────────────
57
+ private _queryResult;
58
+
59
+ constructor(options: TableStateOptions<TData, TError> = {}) {
60
+ const parsed = useParsed();
61
+ const resource = options.resource ?? parsed.resource ?? '';
62
+
63
+ this.permanentSorters = options.sorters?.permanent ?? [];
64
+ this.permanentFilters = options.filters?.permanent ?? [];
65
+ this.sortersMode = options.sorters?.mode ?? 'server';
66
+ this.filtersMode = options.filters?.mode ?? 'server';
67
+ this.filterDefaultBehavior = options.filters?.defaultBehavior ?? 'replace';
68
+ this.syncWithLocation = options.syncWithLocation ?? false;
69
+ this.paginationMode = options.pagination?.mode ?? 'server';
70
+
71
+ // Init from URL or defaults
72
+ let initPagination = options.pagination ?? { current: 1, pageSize: 10 };
73
+ let initSorters = options.sorters?.initial ?? [];
74
+ let initFilters = options.filters?.initial ?? [];
75
+
76
+ if (this.syncWithLocation && typeof window !== 'undefined') {
77
+ const urlState = readURLState();
78
+ if (urlState.page || urlState.pageSize) {
79
+ initPagination = {
80
+ current: urlState.page ?? initPagination.current,
81
+ pageSize: urlState.pageSize ?? initPagination.pageSize,
82
+ };
83
+ }
84
+ if (urlState.sortField) {
85
+ initSorters = [{ field: urlState.sortField, order: (urlState.sortOrder as 'asc' | 'desc') ?? 'asc' }];
86
+ }
87
+ if (urlState.filters) {
88
+ initFilters = urlState.filters;
89
+ }
90
+ }
91
+
92
+ this.pagination = initPagination;
93
+ this.currentSorters = initSorters;
94
+ this.currentFilters = initFilters;
95
+
96
+ // Create query
97
+ const { meta, syncWithLocation: _s, dataProviderName, pagination: _p, sorters: _sr, filters: _f, ...restOptions } = options;
98
+ this._queryResult = useList<TData, TError>({
99
+ resource,
100
+ pagination: this.queryPagination,
101
+ sorters: this.effectiveSorters,
102
+ filters: this.effectiveFilters,
103
+ meta,
104
+ dataProviderName,
105
+ ...restOptions,
106
+ });
107
+
108
+ // URL sync effect
109
+ if (this.syncWithLocation && typeof window !== 'undefined') {
110
+ $effect(() => {
111
+ writeURLState({
112
+ page: this.pagination.current,
113
+ pageSize: this.pagination.pageSize,
114
+ sortField: this.effectiveSorters[0]?.field,
115
+ sortOrder: this.effectiveSorters[0]?.order,
116
+ filters: this.effectiveFilters,
117
+ });
118
+ });
119
+ }
120
+ }
121
+
122
+ // ─── Derived state ────────────────────────────────────────────
123
+ get effectiveSorters() { return [...this.currentSorters, ...this.permanentSorters]; }
124
+ get effectiveFilters() { return [...this.currentFilters, ...this.permanentFilters]; }
125
+
126
+ private get queryPagination() {
127
+ return this.paginationMode === 'server'
128
+ ? this.pagination
129
+ : { current: 1, pageSize: 999999, mode: this.paginationMode as 'client' | 'off' };
130
+ }
131
+
132
+ /** Underlying TanStack Query result */
133
+ get query() { return this._queryResult.query; }
134
+
135
+ /** Table data rows */
136
+ get data(): TData[] { return (this.query.data as GetListResult<TData> | undefined)?.data ?? []; }
137
+
138
+ /** Total record count */
139
+ get total(): number { return (this.query.data as GetListResult<TData> | undefined)?.total ?? 0; }
140
+
141
+ /** Is data loading */
142
+ get isLoading(): boolean { return this.query.isLoading; }
143
+
144
+ /** Is data error */
145
+ get isError(): boolean { return this.query.isError; }
146
+
147
+ /** Error object */
148
+ get error(): TError | null { return this.query.error; }
149
+
150
+ /** Current page number */
151
+ get current(): number { return this.pagination.current ?? 1; }
152
+
153
+ /** Page size */
154
+ get pageSize(): number { return this.pagination.pageSize ?? 10; }
155
+
156
+ /** Total number of pages */
157
+ get pageCount(): number { return Math.ceil(this.total / this.pageSize); }
158
+
159
+ /** Has next page */
160
+ get hasNextPage(): boolean { return this.current < this.pageCount; }
161
+
162
+ /** Has previous page */
163
+ get hasPreviousPage(): boolean { return this.current > 1; }
164
+
165
+ // ─── Mutators ─────────────────────────────────────────────────
166
+ setPage(page: number) {
167
+ this.pagination = { ...this.pagination, current: page };
168
+ }
169
+
170
+ setPageSize(size: number) {
171
+ this.pagination = { ...this.pagination, pageSize: size, current: 1 };
172
+ }
173
+
174
+ nextPage() {
175
+ if (this.hasNextPage) this.setPage(this.current + 1);
176
+ }
177
+
178
+ previousPage() {
179
+ if (this.hasPreviousPage) this.setPage(this.current - 1);
180
+ }
181
+
182
+ setSorters(newSorters: Sort[]) {
183
+ this.currentSorters = newSorters;
184
+ }
185
+
186
+ toggleSort(field: string) {
187
+ const existing = this.currentSorters.find(s => s.field === field);
188
+ if (!existing) {
189
+ this.currentSorters = [{ field, order: 'asc' }];
190
+ } else if (existing.order === 'asc') {
191
+ this.currentSorters = [{ field, order: 'desc' }];
192
+ } else {
193
+ this.currentSorters = [];
194
+ }
195
+ }
196
+
197
+ setFilters(newFilters: Filter[], mode?: FilterSetMode) {
198
+ const behavior = mode ?? this.filterDefaultBehavior;
199
+ if (behavior === 'merge') {
200
+ const merged = [...this.currentFilters];
201
+ for (const nf of newFilters) {
202
+ const idx = merged.findIndex(f => f.field === nf.field && f.operator === nf.operator);
203
+ if (idx >= 0) {
204
+ if (nf.value === undefined || nf.value === null || nf.value === '') merged.splice(idx, 1);
205
+ else merged[idx] = nf;
206
+ } else if (nf.value !== undefined && nf.value !== null && nf.value !== '') {
207
+ merged.push(nf);
208
+ }
209
+ }
210
+ this.currentFilters = merged;
211
+ } else {
212
+ this.currentFilters = newFilters;
213
+ }
214
+ this.pagination = { ...this.pagination, current: 1 };
215
+ }
216
+
217
+ resetFilters() {
218
+ this.currentFilters = [];
219
+ this.pagination = { ...this.pagination, current: 1 };
220
+ }
221
+
222
+ /** For client-side pagination mode */
223
+ get clientData(): TData[] {
224
+ if (this.paginationMode !== 'client') return [];
225
+ const allData = this.data;
226
+ const start = (this.current - 1) * this.pageSize;
227
+ const end = start + this.pageSize;
228
+ let sorted = [...allData];
229
+ const activeSorters = this.sortersMode === 'off' ? this.effectiveSorters : this.currentSorters;
230
+ if (activeSorters.length > 0) {
231
+ const { field, order } = activeSorters[0];
232
+ sorted.sort((a, b) => {
233
+ const va = (a as Record<string, unknown>)[field];
234
+ const vb = (b as Record<string, unknown>)[field];
235
+ if (va == null) return 1;
236
+ if (vb == null) return -1;
237
+ const cmp = va < vb ? -1 : va > vb ? 1 : 0;
238
+ return order === 'desc' ? -cmp : cmp;
239
+ });
240
+ }
241
+ return sorted.slice(start, end);
242
+ }
243
+ }
@@ -1,4 +1,7 @@
1
- // Toast notification system — Svelte 5 runes-based
1
+ // Toast notification system — wraps svelte-sonner for consistent API
2
+ // Maintains backward-compatible `toast.success()` / `toast.error()` etc.
3
+
4
+ import { toast as sonner } from 'svelte-sonner';
2
5
 
3
6
  export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'undoable';
4
7
 
@@ -11,6 +14,7 @@ export interface Toast {
11
14
  onTimeout?: () => void;
12
15
  }
13
16
 
17
+ // Legacy compatibility — kept for existing consumers
14
18
  let nextId = 0;
15
19
  let toasts = $state<Toast[]>([]);
16
20
 
@@ -19,23 +23,35 @@ export function getToasts(): Toast[] {
19
23
  }
20
24
 
21
25
  export function addToast(type: ToastType, message: string, duration = 3000, options?: { onUndo?: () => void, onTimeout?: () => void }): void {
26
+ // Use svelte-sonner for regular toasts
27
+ if (type !== 'undoable') {
28
+ switch (type) {
29
+ case 'success': sonner.success(message, { duration }); break;
30
+ case 'error': sonner.error(message, { duration }); break;
31
+ case 'warning': sonner.warning(message, { duration }); break;
32
+ case 'info': sonner.info(message, { duration }); break;
33
+ }
34
+ return;
35
+ }
36
+
37
+ // Undoable toasts still use custom system
22
38
  const id = nextId++;
23
39
  toasts = [...toasts, { id, type, message, duration, ...options }];
24
- if (duration > 0 && type !== 'undoable') {
25
- setTimeout(() => removeToast(id), duration);
26
- }
27
40
  }
28
41
 
29
42
  export function removeToast(id: number): void {
30
43
  toasts = toasts.filter(t => t.id !== id);
31
44
  }
32
45
 
33
- // Convenience methods
46
+ // Convenience methods — backward compatible
34
47
  export const toast = {
35
48
  success: (msg: string, duration?: number) => addToast('success', msg, duration),
36
49
  error: (msg: string, duration?: number) => addToast('error', msg, duration ?? 5000),
37
50
  info: (msg: string, duration?: number) => addToast('info', msg, duration),
38
51
  warning: (msg: string, duration?: number) => addToast('warning', msg, duration ?? 4000),
39
- undoable: (msg: string, duration: number, onUndo: () => void, onTimeout: () => void) =>
52
+ undoable: (msg: string, duration: number, onUndo: () => void, onTimeout: () => void) =>
40
53
  addToast('undoable', msg, duration, { onUndo, onTimeout }),
54
+ /** Promise toast — shows loading → success/error automatically */
55
+ promise: <T>(promise: Promise<T>, opts: { loading: string; success: string; error: string }) =>
56
+ sonner.promise(promise, opts),
41
57
  };
package/src/types.ts CHANGED
@@ -255,6 +255,8 @@ export interface ResourceDefinition {
255
255
  showInMenu?: boolean;
256
256
  parentName?: string;
257
257
  menuOrder?: number;
258
+ /** Navigation group name — resources with the same group are displayed in a collapsible section */
259
+ group?: string;
258
260
  meta?: Record<string, unknown> & { dataProviderName?: string; parent?: string };
259
261
  }
260
262
 
@@ -266,6 +268,7 @@ export interface FieldDefinition {
266
268
  required?: boolean;
267
269
  searchable?: boolean;
268
270
  sortable?: boolean;
271
+ filterable?: boolean;
269
272
  width?: string;
270
273
  showInList?: boolean;
271
274
  showInForm?: boolean;
@@ -280,6 +283,8 @@ export interface FieldDefinition {
280
283
  optionValue?: string; // field to use as value
281
284
  // Validation
282
285
  validate?: (value: unknown) => string | null;
286
+ // Grouping
287
+ group?: string;
283
288
  }
284
289
 
285
290
  // ─── Resource Type Registry ───────────────────────────────────
@@ -1,7 +1,7 @@
1
1
  // UnsavedChangesNotifier — warns user before leaving page with unsaved changes
2
2
  // Svelte 5 component using runes
3
3
 
4
- import { onDestroy, onMount } from 'svelte';
4
+ // No lifecycle imports needed — uses module-level $state pattern
5
5
 
6
6
  let warnWhenUnsavedChanges = $state(false);
7
7
 
package/src/useCan.ts CHANGED
@@ -1,37 +1,54 @@
1
- // useCan — reactive permission check hook
1
+ // useCan — reactive permission check hook with TanStack Query integration
2
2
 
3
+ import { createQuery } from '@tanstack/svelte-query';
3
4
  import { canAccessAsync } from './permissions';
4
- import type { Action, AccessControlResult } from './permissions';
5
+ import type { Action, CanResult } from './permissions';
5
6
 
6
- interface UseCanResult {
7
- allowed: boolean;
8
- reason?: string;
9
- isLoading: boolean;
7
+ export interface UseCanOptions {
8
+ resource: string;
9
+ action: Action;
10
+ params?: Record<string, unknown>;
11
+ queryOptions?: {
12
+ enabled?: boolean;
13
+ staleTime?: number;
14
+ };
15
+ }
16
+
17
+ export interface UseCanResult {
18
+ readonly allowed: boolean;
19
+ readonly reason: string | undefined;
20
+ readonly isLoading: boolean;
10
21
  }
11
22
 
12
23
  /**
13
- * Reactive permission check. Calls canAccessAsync() and returns reactive state.
14
- * Usage: const can = useCan('posts', 'delete');
15
- * if (can.allowed) { ... }
24
+ * Reactive permission check backed by TanStack Query for caching and deduplication.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * // Simple usage (backward compatible)
29
+ * const can = useCan('posts', 'delete');
30
+ * if (can.allowed) { ... }
31
+ *
32
+ * // With options object
33
+ * const can = useCan({ resource: 'posts', action: 'delete', params: { id: 1 } });
34
+ * ```
16
35
  */
17
- export function useCan(resource: string, action: Action, params?: Record<string, unknown>): UseCanResult {
18
- let allowed = $state(true);
19
- let reason = $state<string | undefined>(undefined);
20
- let isLoading = $state(true);
36
+ export function useCan(resourceOrOptions: string | UseCanOptions, action?: Action, params?: Record<string, unknown>): UseCanResult {
37
+ // Normalize arguments: support both (resource, action, params) and (options) signatures
38
+ const options: UseCanOptions = typeof resourceOrOptions === 'string'
39
+ ? { resource: resourceOrOptions, action: action ?? 'list', params }
40
+ : resourceOrOptions;
21
41
 
22
- // Run the async check
23
- canAccessAsync(resource, action, params).then((result: AccessControlResult) => {
24
- allowed = result.can;
25
- reason = result.reason;
26
- isLoading = false;
27
- }).catch(() => {
28
- allowed = true; // default to allowed on error
29
- isLoading = false;
30
- });
42
+ const query = createQuery<CanResult>(() => ({
43
+ queryKey: ['useCan', options.resource, options.action, options.params],
44
+ queryFn: () => canAccessAsync(options.resource, options.action, options.params),
45
+ enabled: options.queryOptions?.enabled ?? true,
46
+ staleTime: options.queryOptions?.staleTime ?? 5 * 60 * 1000, // 5 min default cache
47
+ }));
31
48
 
32
49
  return {
33
- get allowed() { return allowed; },
34
- get reason() { return reason; },
35
- get isLoading() { return isLoading; },
50
+ get allowed() { return query.data?.can ?? true; },
51
+ get reason() { return query.data?.reason; },
52
+ get isLoading() { return query.isLoading; },
36
53
  };
37
54
  }
package/src/useParsed.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // useParsed — parse current URL hash into structured route info
2
2
 
3
3
  import { currentPath } from './router';
4
- import { getResources } from './context';
4
+ import { getResources } from './context.svelte';
5
5
 
6
6
  interface ParsedRoute {
7
7
  resource?: string;
@@ -35,7 +35,7 @@ export function useStepsForm<
35
35
  TError = HttpError,
36
36
  TVariables = Record<string, unknown>,
37
37
  TData extends BaseRecord = TQueryFnData
38
- >(options: UseStepsFormOptions<TQueryFnData, TError, TVariables, TData> = {} as any): UseStepsFormResult<TQueryFnData, TError, TVariables, TData> {
38
+ >(options: UseStepsFormOptions<TQueryFnData, TError, TVariables, TData> = {} as UseStepsFormOptions<TQueryFnData, TError, TVariables, TData>): UseStepsFormResult<TQueryFnData, TError, TVariables, TData> {
39
39
  const form = useForm<TQueryFnData, TError, TVariables, TData>(options);
40
40
 
41
41
  let currentStep = $state(options.defaultStep ?? 0);
@@ -1,7 +1,7 @@
1
1
  import { useForm, type UseFormOptions } from './form-hooks.svelte';
2
2
  import { useQueryClient } from '@tanstack/svelte-query';
3
3
  import { useParsed } from './useParsed';
4
- import { getResource, getResources } from './context';
4
+ import { getResource, getResources } from './context.svelte';
5
5
  import { getAdminOptions } from './options';
6
6
  import { navigate } from './router';
7
7
  import { t } from './i18n.svelte';