@svadmin/core 0.3.0 → 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 +4 -1
- 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 +4 -2
- package/src/mutation-hooks.svelte.ts +1 -1
- 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/useParsed.ts +1 -1
- package/src/useStepsForm.svelte.ts +1 -1
- package/src/utility-hooks.svelte.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Core SDK — hooks, types, context, i18n, permissions, router",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -59,5 +59,8 @@
|
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/bun": "^1.3.11"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"svelte-sonner": "^1.1.0"
|
|
62
65
|
}
|
|
63
66
|
}
|
package/src/auth-hooks.svelte.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// Auth Hooks — reactive wrappers around AuthProvider methods
|
|
2
2
|
// Each hook encapsulates the auth call + loading state + error handling + redirect
|
|
3
|
-
//
|
|
4
|
-
// not inside mutate(), because Svelte's getContext() only works during init.
|
|
3
|
+
// Uses module-level $state — no component init-time constraints.
|
|
5
4
|
|
|
6
|
-
import { getAuthProvider } from './context';
|
|
5
|
+
import { getAuthProvider } from './context.svelte';
|
|
7
6
|
import { navigate } from './router';
|
|
8
7
|
import { toast } from './toast.svelte';
|
|
9
8
|
import { t } from './i18n.svelte';
|
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
// Svelte
|
|
1
|
+
// Svelte 5 Context — module-level $state singletons (no setContext/getContext required)
|
|
2
|
+
// This removes the constraint that hooks must be called at component init time.
|
|
2
3
|
|
|
3
|
-
import { getContext, setContext } from 'svelte';
|
|
4
4
|
import type { DataProvider, AuthProvider, ResourceDefinition } from './types';
|
|
5
5
|
import type { RouterProvider } from './router-provider';
|
|
6
6
|
|
|
7
|
-
const DATA_PROVIDER_KEY = Symbol('data-provider');
|
|
8
|
-
const AUTH_PROVIDER_KEY = Symbol('auth-provider');
|
|
9
|
-
const RESOURCES_KEY = Symbol('resources');
|
|
10
|
-
const ROUTER_PROVIDER_KEY = Symbol('router-provider');
|
|
11
|
-
|
|
12
7
|
// ─── DataProvider (supports single or multiple) ─────────────────
|
|
13
8
|
|
|
14
9
|
/**
|
|
@@ -18,16 +13,17 @@ const ROUTER_PROVIDER_KEY = Symbol('router-provider');
|
|
|
18
13
|
*/
|
|
19
14
|
export type DataProviderInput = DataProvider | Record<string, DataProvider>;
|
|
20
15
|
|
|
16
|
+
let providers = $state<Record<string, DataProvider> | null>(null);
|
|
17
|
+
|
|
21
18
|
export function setDataProvider(provider: DataProviderInput): void {
|
|
22
19
|
if (isDataProvider(provider)) {
|
|
23
|
-
|
|
20
|
+
providers = { default: provider } as Record<string, DataProvider>;
|
|
24
21
|
} else {
|
|
25
|
-
|
|
22
|
+
providers = provider as Record<string, DataProvider>;
|
|
26
23
|
}
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
export function getDataProvider(name?: string): DataProvider {
|
|
30
|
-
const providers = getContext<Record<string, DataProvider>>(DATA_PROVIDER_KEY);
|
|
31
27
|
if (!providers) throw new Error('DataProvider not found. Did you call setDataProvider in App.svelte?');
|
|
32
28
|
const key = name ?? 'default';
|
|
33
29
|
const provider = providers[key];
|
|
@@ -38,7 +34,6 @@ export function getDataProvider(name?: string): DataProvider {
|
|
|
38
34
|
}
|
|
39
35
|
|
|
40
36
|
export function getDataProviderNames(): string[] {
|
|
41
|
-
const providers = getContext<Record<string, DataProvider>>(DATA_PROVIDER_KEY);
|
|
42
37
|
return providers ? Object.keys(providers) : [];
|
|
43
38
|
}
|
|
44
39
|
|
|
@@ -71,41 +66,58 @@ function isDataProvider(value: unknown): value is DataProvider {
|
|
|
71
66
|
|
|
72
67
|
// ─── Auth Provider ──────────────────────────────────────────────
|
|
73
68
|
|
|
69
|
+
let authProvider = $state<AuthProvider | null>(null);
|
|
70
|
+
|
|
74
71
|
export function setAuthProvider(provider: AuthProvider): void {
|
|
75
|
-
|
|
72
|
+
authProvider = provider;
|
|
76
73
|
}
|
|
77
74
|
|
|
78
75
|
export function getAuthProvider(): AuthProvider {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return provider;
|
|
76
|
+
if (!authProvider) throw new Error('AuthProvider not found. Did you call setAuthProvider in App.svelte?');
|
|
77
|
+
return authProvider;
|
|
82
78
|
}
|
|
83
79
|
|
|
84
80
|
// ─── Resources ──────────────────────────────────────────────────
|
|
85
81
|
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
let resources = $state.raw<ResourceDefinition[]>([]);
|
|
83
|
+
|
|
84
|
+
export function setResources(newResources: ResourceDefinition[]): void {
|
|
85
|
+
resources = newResources;
|
|
88
86
|
}
|
|
89
87
|
|
|
90
88
|
export function getResources(): ResourceDefinition[] {
|
|
91
|
-
|
|
92
|
-
if (!resources) throw new Error('Resources not found. Did you call setResources in App.svelte?');
|
|
89
|
+
if (!resources || resources.length === 0) throw new Error('Resources not found. Did you call setResources in App.svelte?');
|
|
93
90
|
return resources;
|
|
94
91
|
}
|
|
95
92
|
|
|
96
93
|
export function getResource(nameOrIdentifier: string): ResourceDefinition {
|
|
97
|
-
const
|
|
98
|
-
const resource =
|
|
94
|
+
const rList = getResources();
|
|
95
|
+
const resource = rList.find(r => r.identifier === nameOrIdentifier || r.name === nameOrIdentifier);
|
|
99
96
|
if (!resource) throw new Error(`Resource "${nameOrIdentifier}" not found in resource definitions.`);
|
|
100
97
|
return resource;
|
|
101
98
|
}
|
|
102
99
|
|
|
103
100
|
// ─── Router Provider ────────────────────────────────────────────
|
|
104
101
|
|
|
102
|
+
let routerProvider = $state<RouterProvider | undefined>(undefined);
|
|
103
|
+
|
|
105
104
|
export function setRouterProvider(provider: RouterProvider): void {
|
|
106
|
-
|
|
105
|
+
routerProvider = provider;
|
|
107
106
|
}
|
|
108
107
|
|
|
109
108
|
export function getRouterProvider(): RouterProvider | undefined {
|
|
110
|
-
return
|
|
109
|
+
return routerProvider;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
// ─── Live Provider ──────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
let liveProviderState: import('./live').LiveProvider | undefined = undefined;
|
|
116
|
+
|
|
117
|
+
export function setLiveProvider(provider: import('./live').LiveProvider): void {
|
|
118
|
+
liveProviderState = provider;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function getLiveProvider(): import('./live').LiveProvider | undefined {
|
|
122
|
+
return liveProviderState;
|
|
111
123
|
}
|
package/src/data-transfer.ts
CHANGED
package/src/form-hooks.svelte.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
2
2
|
import { useParsed } from './useParsed';
|
|
3
3
|
import { getAdminOptions } from './options';
|
|
4
|
-
import { getDataProviderForResource } from './context';
|
|
4
|
+
import { getDataProviderForResource } from './context.svelte';
|
|
5
5
|
import { createQuery, createMutation } from '@tanstack/svelte-query';
|
|
6
6
|
import { toast } from './toast.svelte';
|
|
7
7
|
import { t } from './i18n.svelte';
|
|
@@ -103,7 +103,7 @@ export function useForm<
|
|
|
103
103
|
let errors = $state<Record<string, string>>({});
|
|
104
104
|
function setFieldError(field: string, message: string) { errors = { ...errors, [field]: message }; }
|
|
105
105
|
function clearErrors() { errors = {}; }
|
|
106
|
-
function clearFieldError(field: string) {
|
|
106
|
+
function clearFieldError(field: string) { delete errors[field]; }
|
|
107
107
|
|
|
108
108
|
function runValidation(values: TVariables): boolean {
|
|
109
109
|
clearErrors();
|
|
@@ -178,14 +178,14 @@ export function useForm<
|
|
|
178
178
|
async function onFinish(values: TVariables, finishOptions?: { redirect?: 'list' | 'edit' | 'show' | false }) {
|
|
179
179
|
if (!runValidation(values)) { toast.warning(t('validation.required')); return; }
|
|
180
180
|
redirectOverride = finishOptions?.redirect;
|
|
181
|
-
if (action === 'create' || action === 'clone') await
|
|
182
|
-
else await
|
|
181
|
+
if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
|
|
182
|
+
else await updateMut.mutateAsync(values);
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
async function onFinishAutoSave(values: TVariables) {
|
|
186
186
|
if (action === 'create' || action === 'clone') return; // Only autosave on edit
|
|
187
187
|
redirectOverride = false; // Never redirect on autoSave
|
|
188
|
-
await
|
|
188
|
+
await updateMut.mutateAsync(values);
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
let autoSaveStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
|
@@ -244,7 +244,7 @@ export function useForm<
|
|
|
244
244
|
|
|
245
245
|
return {
|
|
246
246
|
query,
|
|
247
|
-
get formLoading() { return (
|
|
247
|
+
get formLoading() { return (query?.isLoading ?? false) || createMut.isPending || updateMut.isPending; },
|
|
248
248
|
mutation: action === 'edit' ? updateMut : createMut,
|
|
249
249
|
onFinish,
|
|
250
250
|
onFinishAutoSave,
|
package/src/hooks.svelte.ts
CHANGED
|
@@ -23,7 +23,7 @@ export type { OvertimeResult, OvertimeOptions, NotificationConfig } from './hook
|
|
|
23
23
|
|
|
24
24
|
import { createQuery, createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';
|
|
25
25
|
import { getAdminOptions } from './options';
|
|
26
|
-
import { getDataProviderForResource, getDataProvider } from './context';
|
|
26
|
+
import { getDataProviderForResource, getDataProvider } from './context.svelte';
|
|
27
27
|
import { useParsed } from './useParsed';
|
|
28
28
|
import { createOvertimeTracker, fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
|
|
29
29
|
import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
|
|
@@ -203,7 +203,7 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
|
|
|
203
203
|
return provider.custom<TData>({
|
|
204
204
|
url: options.url,
|
|
205
205
|
method: options.method,
|
|
206
|
-
payload: options.config?.payload
|
|
206
|
+
payload: options.config?.payload,
|
|
207
207
|
query: options.config?.query,
|
|
208
208
|
headers: options.config?.headers,
|
|
209
209
|
sorters: options.config?.sorters,
|
|
@@ -229,7 +229,7 @@ export function useCustomMutation<TData = unknown, TError = HttpError, TVariable
|
|
|
229
229
|
const mutation = createMutation<{ data: TData }, TError, { url: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; values?: TVariables; meta?: Record<string, unknown> }>(() => ({
|
|
230
230
|
mutationFn: async (params) => {
|
|
231
231
|
if (!provider.custom) throw new Error('DataProvider does not support custom method');
|
|
232
|
-
return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values
|
|
232
|
+
return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values, meta: params.meta });
|
|
233
233
|
},
|
|
234
234
|
}));
|
|
235
235
|
|
package/src/i18n.svelte.ts
CHANGED
|
@@ -38,6 +38,9 @@ const locales: Record<string, Locale> = {
|
|
|
38
38
|
'common.home': '首页',
|
|
39
39
|
'common.logout': '退出登录',
|
|
40
40
|
'common.toggleTheme': '切换主题',
|
|
41
|
+
'common.switchLanguage': '切换语言',
|
|
42
|
+
'common.clear': '清除',
|
|
43
|
+
'common.menu': '菜单',
|
|
41
44
|
'common.darkMode': '暗色模式',
|
|
42
45
|
'common.lightMode': '亮色模式',
|
|
43
46
|
'common.detail': '详情',
|
|
@@ -47,19 +50,39 @@ const locales: Record<string, Locale> = {
|
|
|
47
50
|
'common.retry': '重试',
|
|
48
51
|
'common.yes': '是',
|
|
49
52
|
'common.no': '否',
|
|
53
|
+
'common.undo': '撤销',
|
|
54
|
+
'common.filter': '筛选',
|
|
55
|
+
'common.reset': '重置',
|
|
50
56
|
'common.welcome': '欢迎使用 {title}',
|
|
51
57
|
'common.dashboardHint': '从侧边栏选择一个资源开始。',
|
|
52
58
|
'common.redirecting': '正在跳转到登录页...',
|
|
59
|
+
'common.expand': '展开',
|
|
60
|
+
'common.collapse': '收起',
|
|
61
|
+
'common.close': '关闭',
|
|
62
|
+
'common.copy': '复制',
|
|
63
|
+
'common.copyAll': '复制全部',
|
|
64
|
+
'common.copied': '已复制',
|
|
65
|
+
'common.next': '下一步',
|
|
66
|
+
'common.back': '返回',
|
|
67
|
+
'common.step': '步骤',
|
|
68
|
+
'common.columns': '列',
|
|
69
|
+
'common.perPage': '每页',
|
|
70
|
+
'common.copyId': '复制 ID',
|
|
71
|
+
'common.showMore': '展开更多',
|
|
72
|
+
'common.showLess': '收起',
|
|
53
73
|
// Field
|
|
54
74
|
'field.enterValue': '输入{label}',
|
|
55
75
|
'field.selectPlaceholder': '请选择',
|
|
56
76
|
'field.tagsPlaceholder': '输入标签后按回车...',
|
|
77
|
+
'field.noOptions': '无可用选项',
|
|
78
|
+
'field.addImage': '添加图片',
|
|
57
79
|
// Config errors
|
|
58
80
|
'config.missingEnvTitle': '环境配置缺失',
|
|
59
81
|
'config.missingEnvDescription': '应用无法启动,因为缺少必要的环境变量。',
|
|
60
82
|
'config.addToEnvFile': '请在 .env 文件中添加以下内容:',
|
|
61
83
|
'config.envFilePath': '文件路径: .env',
|
|
62
84
|
'config.reload': '配置完成后刷新页面',
|
|
85
|
+
'config.reloadButton': '重新加载页面',
|
|
63
86
|
// Auth pages
|
|
64
87
|
'auth.login': '登录',
|
|
65
88
|
'auth.register': '注册',
|
|
@@ -86,6 +109,17 @@ const locales: Record<string, Locale> = {
|
|
|
86
109
|
'auth.welcomeMessage': '登录以继续使用管理后台',
|
|
87
110
|
'auth.createAccount': '创建账号',
|
|
88
111
|
'auth.createAccountMessage': '填写信息以创建您的账号',
|
|
112
|
+
// AutoSave
|
|
113
|
+
'autoSave.idle': '已保存',
|
|
114
|
+
'autoSave.saving': '保存中...',
|
|
115
|
+
'autoSave.saved': '已保存',
|
|
116
|
+
'autoSave.error': '保存失败',
|
|
117
|
+
// Empty states
|
|
118
|
+
'empty.title': '暂无数据',
|
|
119
|
+
'empty.description': '没有找到符合条件的记录。',
|
|
120
|
+
'empty.noMore': '没有更多了',
|
|
121
|
+
// DevTools
|
|
122
|
+
'devtools.title': '开发者工具 (Ctrl+Shift+D)',
|
|
89
123
|
// Validation
|
|
90
124
|
'validation.required': '此字段为必填项',
|
|
91
125
|
'validation.minLength': '最少 {min} 个字符',
|
|
@@ -128,6 +162,9 @@ const locales: Record<string, Locale> = {
|
|
|
128
162
|
'common.home': 'Home',
|
|
129
163
|
'common.logout': 'Log out',
|
|
130
164
|
'common.toggleTheme': 'Toggle theme',
|
|
165
|
+
'common.switchLanguage': 'Switch language',
|
|
166
|
+
'common.clear': 'Clear',
|
|
167
|
+
'common.menu': 'Menu',
|
|
131
168
|
'common.darkMode': 'Dark mode',
|
|
132
169
|
'common.lightMode': 'Light mode',
|
|
133
170
|
'common.detail': 'Detail',
|
|
@@ -137,19 +174,39 @@ const locales: Record<string, Locale> = {
|
|
|
137
174
|
'common.retry': 'Retry',
|
|
138
175
|
'common.yes': 'Yes',
|
|
139
176
|
'common.no': 'No',
|
|
177
|
+
'common.undo': 'Undo',
|
|
178
|
+
'common.filter': 'Filter',
|
|
179
|
+
'common.reset': 'Reset',
|
|
140
180
|
'common.welcome': 'Welcome to {title}',
|
|
141
181
|
'common.dashboardHint': 'Select a resource from the sidebar to get started.',
|
|
142
182
|
'common.redirecting': 'Redirecting to login...',
|
|
183
|
+
'common.expand': 'Expand',
|
|
184
|
+
'common.collapse': 'Collapse',
|
|
185
|
+
'common.close': 'Close',
|
|
186
|
+
'common.copy': 'Copy',
|
|
187
|
+
'common.copyAll': 'Copy All',
|
|
188
|
+
'common.copied': 'Copied!',
|
|
189
|
+
'common.next': 'Next',
|
|
190
|
+
'common.back': 'Back',
|
|
191
|
+
'common.step': 'Step',
|
|
192
|
+
'common.columns': 'Columns',
|
|
193
|
+
'common.perPage': 'per page',
|
|
194
|
+
'common.copyId': 'Copy ID',
|
|
195
|
+
'common.showMore': 'Show more',
|
|
196
|
+
'common.showLess': 'Show less',
|
|
143
197
|
// Field
|
|
144
198
|
'field.enterValue': 'Enter {label}',
|
|
145
199
|
'field.selectPlaceholder': 'Select...',
|
|
146
200
|
'field.tagsPlaceholder': 'Type a tag and press Enter...',
|
|
201
|
+
'field.noOptions': 'No options available',
|
|
202
|
+
'field.addImage': 'Add image',
|
|
147
203
|
// Config errors
|
|
148
204
|
'config.missingEnvTitle': 'Missing Configuration',
|
|
149
205
|
'config.missingEnvDescription': 'The app cannot start because required environment variables are missing.',
|
|
150
206
|
'config.addToEnvFile': 'Add the following to your .env file:',
|
|
151
207
|
'config.envFilePath': 'File path: .env',
|
|
152
|
-
'config.reload': '
|
|
208
|
+
'config.reload': 'Refresh page after configuration',
|
|
209
|
+
'config.reloadButton': 'Reload Page',
|
|
153
210
|
// Auth pages
|
|
154
211
|
'auth.login': 'Login',
|
|
155
212
|
'auth.register': 'Register',
|
|
@@ -176,6 +233,17 @@ const locales: Record<string, Locale> = {
|
|
|
176
233
|
'auth.welcomeMessage': 'Sign in to continue to admin panel',
|
|
177
234
|
'auth.createAccount': 'Create Account',
|
|
178
235
|
'auth.createAccountMessage': 'Fill in your details to create an account',
|
|
236
|
+
// AutoSave
|
|
237
|
+
'autoSave.idle': 'Saved',
|
|
238
|
+
'autoSave.saving': 'Saving...',
|
|
239
|
+
'autoSave.saved': 'Saved',
|
|
240
|
+
'autoSave.error': 'Save failed',
|
|
241
|
+
// Empty states
|
|
242
|
+
'empty.title': 'No data',
|
|
243
|
+
'empty.description': 'No records found matching your criteria.',
|
|
244
|
+
'empty.noMore': 'No more items',
|
|
245
|
+
// DevTools
|
|
246
|
+
'devtools.title': 'DevTools (Ctrl+Shift+D)',
|
|
179
247
|
// Validation
|
|
180
248
|
'validation.required': 'This field is required',
|
|
181
249
|
'validation.minLength': 'Minimum {min} characters',
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,8 @@ export {
|
|
|
5
5
|
setAuthProvider, getAuthProvider,
|
|
6
6
|
setResources, getResources, getResource,
|
|
7
7
|
setRouterProvider, getRouterProvider,
|
|
8
|
-
} from './context';
|
|
9
|
-
export type { DataProviderInput } from './context';
|
|
8
|
+
} from './context.svelte';
|
|
9
|
+
export type { DataProviderInput } from './context.svelte';
|
|
10
10
|
export {
|
|
11
11
|
useList, useInfiniteList,
|
|
12
12
|
useOne, useShow,
|
|
@@ -92,3 +92,5 @@ export {
|
|
|
92
92
|
unionFilters, unionSorters,
|
|
93
93
|
file2Base64, generateDefaultDocumentTitle,
|
|
94
94
|
} from './helpers';
|
|
95
|
+
export { TableState } from './table-state.svelte';
|
|
96
|
+
export type { TableStateOptions } from './table-state.svelte';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
|
|
2
2
|
import { getAdminOptions } from './options';
|
|
3
|
-
import { getDataProviderForResource } from './context';
|
|
3
|
+
import { getDataProviderForResource } from './context.svelte';
|
|
4
4
|
import { useParsed } from './useParsed';
|
|
5
5
|
import { audit } from './audit';
|
|
6
6
|
import { UndoError } from './types';
|
|
@@ -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/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';
|