@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.
- package/package.json +6 -2
- package/src/adapters/casbin.ts +61 -0
- package/src/adapters/casl.ts +51 -0
- package/src/auth-hooks.svelte.ts +2 -3
- package/src/{context.ts → context.svelte.ts} +35 -23
- package/src/data-transfer.ts +1 -1
- package/src/form-hooks.svelte.ts +6 -6
- package/src/hooks.svelte.ts +3 -3
- package/src/i18n.svelte.ts +69 -1
- package/src/index.ts +10 -4
- package/src/mutation-hooks.svelte.ts +1 -1
- package/src/permissions.test.ts +121 -2
- package/src/permissions.ts +108 -21
- package/src/query-hooks.svelte.ts +2 -2
- package/src/routing-hooks.svelte.ts +1 -1
- package/src/table-hooks.svelte.ts +4 -4
- package/src/table-state.svelte.ts +243 -0
- package/src/toast.svelte.ts +22 -6
- package/src/types.ts +5 -0
- package/src/unsaved-changes.svelte.ts +1 -1
- package/src/useCan.ts +42 -25
- package/src/useParsed.ts +1 -1
- package/src/useStepsForm.svelte.ts +1 -1
- package/src/utility-hooks.svelte.ts +1 -1
package/src/permissions.ts
CHANGED
|
@@ -1,42 +1,129 @@
|
|
|
1
1
|
// Permission / Access Control
|
|
2
2
|
|
|
3
|
+
// ─── Types ────────────────────────────────────────────────────
|
|
3
4
|
|
|
4
|
-
|
|
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
|
|
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
|
-
) =>
|
|
58
|
+
) => CanResult | Promise<CanResult>;
|
|
59
|
+
|
|
60
|
+
// ─── State ────────────────────────────────────────────────────
|
|
16
61
|
|
|
17
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
94
|
+
/** Get global button options from the AccessControlProvider */
|
|
95
|
+
export function getAccessControlOptions() {
|
|
96
|
+
return provider?.options ?? {};
|
|
36
97
|
}
|
|
37
98
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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: (
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/toast.svelte.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
// Toast notification system —
|
|
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
|
-
|
|
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,
|
|
5
|
+
import type { Action, CanResult } from './permissions';
|
|
5
6
|
|
|
6
|
-
interface
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
})
|
|
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
|
|
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
|
@@ -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
|
|
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';
|