@svadmin/core 0.0.6 → 0.4.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 +7 -1
- package/src/audit.ts +37 -0
- package/src/auth-hooks.svelte.ts +2 -3
- package/src/auth-hooks.test.ts +205 -0
- package/src/context.svelte.ts +123 -0
- package/src/data-transfer.ts +148 -70
- package/src/form-hooks.svelte.ts +266 -0
- package/src/helpers.ts +157 -0
- package/src/hook-utils.svelte.ts +121 -0
- package/src/hooks.svelte.ts +277 -530
- package/src/http-error.test.ts +85 -0
- package/src/i18n.svelte.ts +87 -1
- package/src/index.ts +42 -9
- package/src/inferencer-openapi.test.ts +192 -0
- package/src/inferencer-openapi.ts +290 -0
- package/src/live-sse.ts +146 -0
- package/src/live-websocket.ts +141 -0
- package/src/live.test.ts +134 -0
- package/src/live.ts +60 -8
- package/src/mutation-hooks.svelte.ts +301 -0
- package/src/options.ts +95 -0
- package/src/query-hooks.svelte.ts +213 -0
- package/src/query-keys.ts +22 -0
- package/src/resource-types.test.ts +79 -0
- package/src/router.ts +28 -4
- package/src/routing-hooks.svelte.ts +99 -0
- package/src/table-hooks.svelte.ts +178 -0
- package/src/table-state.svelte.ts +243 -0
- package/src/toast.svelte.ts +28 -8
- package/src/types.ts +129 -47
- package/src/unsaved-changes.svelte.ts +62 -0
- package/src/url-sync.ts +15 -0
- package/src/useParsed.ts +40 -4
- package/src/useStepsForm.svelte.ts +71 -0
- package/src/utility-hooks.svelte.ts +161 -0
- package/src/context.ts +0 -61
|
@@ -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,14 +1,20 @@
|
|
|
1
|
-
// Toast notification system —
|
|
1
|
+
// Toast notification system — wraps svelte-sonner for consistent API
|
|
2
|
+
// Maintains backward-compatible `toast.success()` / `toast.error()` etc.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
import { toast as sonner } from 'svelte-sonner';
|
|
5
|
+
|
|
6
|
+
export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'undoable';
|
|
4
7
|
|
|
5
8
|
export interface Toast {
|
|
6
9
|
id: number;
|
|
7
10
|
type: ToastType;
|
|
8
11
|
message: string;
|
|
9
12
|
duration: number;
|
|
13
|
+
onUndo?: () => void;
|
|
14
|
+
onTimeout?: () => void;
|
|
10
15
|
}
|
|
11
16
|
|
|
17
|
+
// Legacy compatibility — kept for existing consumers
|
|
12
18
|
let nextId = 0;
|
|
13
19
|
let toasts = $state<Toast[]>([]);
|
|
14
20
|
|
|
@@ -16,22 +22,36 @@ export function getToasts(): Toast[] {
|
|
|
16
22
|
return toasts;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
|
-
export function addToast(type: ToastType, message: string, duration = 3000): void {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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;
|
|
24
35
|
}
|
|
36
|
+
|
|
37
|
+
// Undoable toasts still use custom system
|
|
38
|
+
const id = nextId++;
|
|
39
|
+
toasts = [...toasts, { id, type, message, duration, ...options }];
|
|
25
40
|
}
|
|
26
41
|
|
|
27
42
|
export function removeToast(id: number): void {
|
|
28
43
|
toasts = toasts.filter(t => t.id !== id);
|
|
29
44
|
}
|
|
30
45
|
|
|
31
|
-
// Convenience methods
|
|
46
|
+
// Convenience methods — backward compatible
|
|
32
47
|
export const toast = {
|
|
33
48
|
success: (msg: string, duration?: number) => addToast('success', msg, duration),
|
|
34
49
|
error: (msg: string, duration?: number) => addToast('error', msg, duration ?? 5000),
|
|
35
50
|
info: (msg: string, duration?: number) => addToast('info', msg, duration),
|
|
36
51
|
warning: (msg: string, duration?: number) => addToast('warning', msg, duration ?? 4000),
|
|
52
|
+
undoable: (msg: string, duration: number, onUndo: () => void, onTimeout: () => void) =>
|
|
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),
|
|
37
57
|
};
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,31 @@
|
|
|
1
|
-
// Core type definitions —
|
|
1
|
+
// Core type definitions — DataProvider + AuthProvider + Providers
|
|
2
|
+
|
|
3
|
+
// ─── HttpError ─────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export type ValidationErrors = Record<string, string | string[]>;
|
|
6
|
+
|
|
7
|
+
export class HttpError extends Error {
|
|
8
|
+
statusCode: number;
|
|
9
|
+
errors?: ValidationErrors;
|
|
10
|
+
|
|
11
|
+
constructor(message: string, statusCode: number, errors?: ValidationErrors) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'HttpError';
|
|
14
|
+
this.statusCode = statusCode;
|
|
15
|
+
this.errors = errors;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class UndoError extends Error {
|
|
20
|
+
constructor() {
|
|
21
|
+
super('Mutation undone');
|
|
22
|
+
this.name = 'UndoError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── Base Types ───────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export type BaseRecord = Record<string, unknown>;
|
|
2
29
|
|
|
3
30
|
// ─── DataProvider ─────────────────────────────────────────────
|
|
4
31
|
|
|
@@ -13,12 +40,25 @@ export interface Sort {
|
|
|
13
40
|
order: 'asc' | 'desc';
|
|
14
41
|
}
|
|
15
42
|
|
|
43
|
+
export type CrudOperator =
|
|
44
|
+
| 'eq' | 'ne' | 'lt' | 'gt' | 'lte' | 'gte'
|
|
45
|
+
| 'contains' | 'ncontains'
|
|
46
|
+
| 'startswith' | 'endswith'
|
|
47
|
+
| 'in' | 'nin'
|
|
48
|
+
| 'null' | 'nnull'
|
|
49
|
+
| 'between' | 'nbetween';
|
|
50
|
+
|
|
16
51
|
export interface Filter {
|
|
17
52
|
field: string;
|
|
18
|
-
operator:
|
|
53
|
+
operator: CrudOperator;
|
|
19
54
|
value: unknown;
|
|
20
55
|
}
|
|
21
56
|
|
|
57
|
+
export interface LogicalFilter {
|
|
58
|
+
operator: 'or' | 'and';
|
|
59
|
+
value: Filter[];
|
|
60
|
+
}
|
|
61
|
+
|
|
22
62
|
export interface GetListParams {
|
|
23
63
|
resource: string;
|
|
24
64
|
pagination?: Pagination;
|
|
@@ -27,8 +67,8 @@ export interface GetListParams {
|
|
|
27
67
|
meta?: Record<string, unknown>;
|
|
28
68
|
}
|
|
29
69
|
|
|
30
|
-
export interface GetListResult<
|
|
31
|
-
data:
|
|
70
|
+
export interface GetListResult<TData extends BaseRecord = BaseRecord> {
|
|
71
|
+
data: TData[];
|
|
32
72
|
total: number;
|
|
33
73
|
}
|
|
34
74
|
|
|
@@ -38,8 +78,8 @@ export interface GetOneParams {
|
|
|
38
78
|
meta?: Record<string, unknown>;
|
|
39
79
|
}
|
|
40
80
|
|
|
41
|
-
export interface GetOneResult<
|
|
42
|
-
data:
|
|
81
|
+
export interface GetOneResult<TData extends BaseRecord = BaseRecord> {
|
|
82
|
+
data: TData;
|
|
43
83
|
}
|
|
44
84
|
|
|
45
85
|
export interface GetManyParams {
|
|
@@ -48,78 +88,78 @@ export interface GetManyParams {
|
|
|
48
88
|
meta?: Record<string, unknown>;
|
|
49
89
|
}
|
|
50
90
|
|
|
51
|
-
export interface GetManyResult<
|
|
52
|
-
data:
|
|
91
|
+
export interface GetManyResult<TData extends BaseRecord = BaseRecord> {
|
|
92
|
+
data: TData[];
|
|
53
93
|
}
|
|
54
94
|
|
|
55
|
-
export interface CreateParams {
|
|
95
|
+
export interface CreateParams<TVariables = unknown> {
|
|
56
96
|
resource: string;
|
|
57
|
-
variables:
|
|
97
|
+
variables: TVariables;
|
|
58
98
|
meta?: Record<string, unknown>;
|
|
59
99
|
}
|
|
60
100
|
|
|
61
|
-
export interface CreateResult<
|
|
62
|
-
data:
|
|
101
|
+
export interface CreateResult<TData extends BaseRecord = BaseRecord> {
|
|
102
|
+
data: TData;
|
|
63
103
|
}
|
|
64
104
|
|
|
65
|
-
export interface CreateManyParams {
|
|
105
|
+
export interface CreateManyParams<TVariables = unknown> {
|
|
66
106
|
resource: string;
|
|
67
|
-
variables:
|
|
107
|
+
variables: TVariables[];
|
|
68
108
|
meta?: Record<string, unknown>;
|
|
69
109
|
}
|
|
70
110
|
|
|
71
|
-
export interface CreateManyResult<
|
|
72
|
-
data:
|
|
111
|
+
export interface CreateManyResult<TData extends BaseRecord = BaseRecord> {
|
|
112
|
+
data: TData[];
|
|
73
113
|
}
|
|
74
114
|
|
|
75
|
-
export interface UpdateParams {
|
|
115
|
+
export interface UpdateParams<TVariables = unknown> {
|
|
76
116
|
resource: string;
|
|
77
117
|
id: string | number;
|
|
78
|
-
variables:
|
|
118
|
+
variables: TVariables;
|
|
79
119
|
meta?: Record<string, unknown>;
|
|
80
120
|
}
|
|
81
121
|
|
|
82
|
-
export interface UpdateResult<
|
|
83
|
-
data:
|
|
122
|
+
export interface UpdateResult<TData extends BaseRecord = BaseRecord> {
|
|
123
|
+
data: TData;
|
|
84
124
|
}
|
|
85
125
|
|
|
86
|
-
export interface UpdateManyParams {
|
|
126
|
+
export interface UpdateManyParams<TVariables = unknown> {
|
|
87
127
|
resource: string;
|
|
88
128
|
ids: (string | number)[];
|
|
89
|
-
variables:
|
|
129
|
+
variables: TVariables;
|
|
90
130
|
meta?: Record<string, unknown>;
|
|
91
131
|
}
|
|
92
132
|
|
|
93
|
-
export interface UpdateManyResult<
|
|
94
|
-
data:
|
|
133
|
+
export interface UpdateManyResult<TData extends BaseRecord = BaseRecord> {
|
|
134
|
+
data: TData[];
|
|
95
135
|
}
|
|
96
136
|
|
|
97
|
-
export interface DeleteParams {
|
|
137
|
+
export interface DeleteParams<TVariables = unknown> {
|
|
98
138
|
resource: string;
|
|
99
139
|
id: string | number;
|
|
100
|
-
variables?:
|
|
140
|
+
variables?: TVariables;
|
|
101
141
|
meta?: Record<string, unknown>;
|
|
102
142
|
}
|
|
103
143
|
|
|
104
|
-
export interface DeleteResult<
|
|
105
|
-
data:
|
|
144
|
+
export interface DeleteResult<TData extends BaseRecord = BaseRecord> {
|
|
145
|
+
data: TData;
|
|
106
146
|
}
|
|
107
147
|
|
|
108
|
-
export interface DeleteManyParams {
|
|
148
|
+
export interface DeleteManyParams<TVariables = unknown> {
|
|
109
149
|
resource: string;
|
|
110
150
|
ids: (string | number)[];
|
|
111
|
-
variables?:
|
|
151
|
+
variables?: TVariables;
|
|
112
152
|
meta?: Record<string, unknown>;
|
|
113
153
|
}
|
|
114
154
|
|
|
115
|
-
export interface DeleteManyResult<
|
|
116
|
-
data:
|
|
155
|
+
export interface DeleteManyResult<TData extends BaseRecord = BaseRecord> {
|
|
156
|
+
data: TData[];
|
|
117
157
|
}
|
|
118
158
|
|
|
119
|
-
export interface CustomParams {
|
|
159
|
+
export interface CustomParams<TVariables = unknown> {
|
|
120
160
|
url: string;
|
|
121
161
|
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
122
|
-
payload?:
|
|
162
|
+
payload?: TVariables;
|
|
123
163
|
query?: Record<string, unknown>;
|
|
124
164
|
headers?: Record<string, string>;
|
|
125
165
|
sorters?: Sort[];
|
|
@@ -127,27 +167,27 @@ export interface CustomParams {
|
|
|
127
167
|
meta?: Record<string, unknown>;
|
|
128
168
|
}
|
|
129
169
|
|
|
130
|
-
export interface CustomResult<
|
|
131
|
-
data:
|
|
170
|
+
export interface CustomResult<TData = unknown> {
|
|
171
|
+
data: TData;
|
|
132
172
|
}
|
|
133
173
|
|
|
134
174
|
export interface DataProvider {
|
|
135
175
|
// Required methods
|
|
136
|
-
getList: <
|
|
137
|
-
getOne: <
|
|
138
|
-
create: <
|
|
139
|
-
update: <
|
|
140
|
-
deleteOne: <
|
|
176
|
+
getList: <TData extends BaseRecord = BaseRecord>(params: GetListParams) => Promise<GetListResult<TData>>;
|
|
177
|
+
getOne: <TData extends BaseRecord = BaseRecord>(params: GetOneParams) => Promise<GetOneResult<TData>>;
|
|
178
|
+
create: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: CreateParams<TVariables>) => Promise<CreateResult<TData>>;
|
|
179
|
+
update: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: UpdateParams<TVariables>) => Promise<UpdateResult<TData>>;
|
|
180
|
+
deleteOne: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: DeleteParams<TVariables>) => Promise<DeleteResult<TData>>;
|
|
141
181
|
getApiUrl: () => string;
|
|
142
182
|
|
|
143
183
|
// Optional bulk methods
|
|
144
|
-
getMany?: <
|
|
145
|
-
createMany?: <
|
|
146
|
-
updateMany?: <
|
|
147
|
-
deleteMany?: <
|
|
184
|
+
getMany?: <TData extends BaseRecord = BaseRecord>(params: GetManyParams) => Promise<GetManyResult<TData>>;
|
|
185
|
+
createMany?: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: CreateManyParams<TVariables>) => Promise<CreateManyResult<TData>>;
|
|
186
|
+
updateMany?: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: UpdateManyParams<TVariables>) => Promise<UpdateManyResult<TData>>;
|
|
187
|
+
deleteMany?: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: DeleteManyParams<TVariables>) => Promise<DeleteManyResult<TData>>;
|
|
148
188
|
|
|
149
189
|
// Optional custom method
|
|
150
|
-
custom?: <
|
|
190
|
+
custom?: <TData = unknown, TVariables = unknown>(params: CustomParams<TVariables>) => Promise<CustomResult<TData>>;
|
|
151
191
|
}
|
|
152
192
|
|
|
153
193
|
// ─── AuthProvider ─────────────────────────────────────────────
|
|
@@ -200,6 +240,8 @@ export type MutationMode = 'pessimistic' | 'optimistic' | 'undoable';
|
|
|
200
240
|
|
|
201
241
|
export interface ResourceDefinition {
|
|
202
242
|
name: string;
|
|
243
|
+
/** Unique identifier — use when multiple resources share the same `name` but target different DataProviders */
|
|
244
|
+
identifier?: string;
|
|
203
245
|
label: string;
|
|
204
246
|
icon?: string;
|
|
205
247
|
primaryKey?: string;
|
|
@@ -210,7 +252,12 @@ export interface ResourceDefinition {
|
|
|
210
252
|
canEdit?: boolean;
|
|
211
253
|
canDelete?: boolean;
|
|
212
254
|
canShow?: boolean;
|
|
213
|
-
|
|
255
|
+
showInMenu?: boolean;
|
|
256
|
+
parentName?: string;
|
|
257
|
+
menuOrder?: number;
|
|
258
|
+
/** Navigation group name — resources with the same group are displayed in a collapsible section */
|
|
259
|
+
group?: string;
|
|
260
|
+
meta?: Record<string, unknown> & { dataProviderName?: string; parent?: string };
|
|
214
261
|
}
|
|
215
262
|
|
|
216
263
|
export interface FieldDefinition {
|
|
@@ -221,6 +268,7 @@ export interface FieldDefinition {
|
|
|
221
268
|
required?: boolean;
|
|
222
269
|
searchable?: boolean;
|
|
223
270
|
sortable?: boolean;
|
|
271
|
+
filterable?: boolean;
|
|
224
272
|
width?: string;
|
|
225
273
|
showInList?: boolean;
|
|
226
274
|
showInForm?: boolean;
|
|
@@ -235,4 +283,38 @@ export interface FieldDefinition {
|
|
|
235
283
|
optionValue?: string; // field to use as value
|
|
236
284
|
// Validation
|
|
237
285
|
validate?: (value: unknown) => string | null;
|
|
286
|
+
// Grouping
|
|
287
|
+
group?: string;
|
|
238
288
|
}
|
|
289
|
+
|
|
290
|
+
// ─── Resource Type Registry ───────────────────────────────────
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Extend this interface via declaration merging to register resource types.
|
|
294
|
+
* When registered, all hooks automatically infer data types from resource names.
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* ```ts
|
|
298
|
+
* declare module '@svadmin/core' {
|
|
299
|
+
* interface ResourceTypeMap {
|
|
300
|
+
* users: { id: string; name: string; email: string }
|
|
301
|
+
* posts: { id: string; title: string; content: string }
|
|
302
|
+
* }
|
|
303
|
+
* }
|
|
304
|
+
*
|
|
305
|
+
* // Now hooks auto-infer:
|
|
306
|
+
* const list = useList({ resource: 'users' })
|
|
307
|
+
* // list.data → { id: string; name: string; email: string }[]
|
|
308
|
+
* ```
|
|
309
|
+
*/
|
|
310
|
+
export interface ResourceTypeMap {}
|
|
311
|
+
|
|
312
|
+
/** When ResourceTypeMap is empty → string (backward compatible); otherwise → registered keys */
|
|
313
|
+
export type KnownResources = keyof ResourceTypeMap extends never
|
|
314
|
+
? string
|
|
315
|
+
: Extract<keyof ResourceTypeMap, string>
|
|
316
|
+
|
|
317
|
+
/** Infer data type from resource name. Falls back to Record<string, unknown> for unregistered resources */
|
|
318
|
+
export type InferData<R extends string> = R extends keyof ResourceTypeMap
|
|
319
|
+
? ResourceTypeMap[R]
|
|
320
|
+
: Record<string, unknown>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// UnsavedChangesNotifier — warns user before leaving page with unsaved changes
|
|
2
|
+
// Svelte 5 component using runes
|
|
3
|
+
|
|
4
|
+
// No lifecycle imports needed — uses module-level $state pattern
|
|
5
|
+
|
|
6
|
+
let warnWhenUnsavedChanges = $state(false);
|
|
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;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getUnsavedChanges(): boolean {
|
|
17
|
+
return warnWhenUnsavedChanges;
|
|
18
|
+
}
|
|
19
|
+
|
|
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
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize the UnsavedChangesNotifier.
|
|
31
|
+
* Call this in your root layout or AdminApp component.
|
|
32
|
+
* It handles both browser tab close and in-app navigation.
|
|
33
|
+
*/
|
|
34
|
+
export function initUnsavedChangesNotifier() {
|
|
35
|
+
if (typeof window === 'undefined') return;
|
|
36
|
+
|
|
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 || '#');
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
warnWhenUnsavedChanges = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
window.addEventListener('hashchange', hashChangeHandler);
|
|
57
|
+
|
|
58
|
+
return () => {
|
|
59
|
+
window.removeEventListener('beforeunload', beforeUnloadHandler);
|
|
60
|
+
window.removeEventListener('hashchange', hashChangeHandler);
|
|
61
|
+
};
|
|
62
|
+
}
|