@svadmin/core 0.0.2 → 0.0.6
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 +1 -1
- package/src/auth-hooks.svelte.ts +293 -0
- package/src/context.ts +12 -0
- package/src/data-transfer.test.ts +118 -0
- package/src/data-transfer.ts +139 -0
- package/src/hooks.svelte.ts +110 -5
- package/src/i18n.svelte.ts +70 -4
- package/src/i18n.test.ts +90 -0
- package/src/index.ts +15 -0
- package/src/inferencer.test.ts +189 -0
- package/src/inferencer.ts +247 -0
- package/src/notification.svelte.ts +45 -0
- package/src/permissions.test.ts +55 -0
- package/src/router-provider.test.ts +71 -0
- package/src/router-provider.ts +100 -0
- package/src/types.ts +2 -0
- package/src/useCan.ts +37 -0
- package/src/useParsed.ts +74 -0
package/src/hooks.svelte.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { toast } from './toast.svelte';
|
|
|
13
13
|
import { audit } from './audit';
|
|
14
14
|
import { navigate } from './router';
|
|
15
15
|
import { t } from './i18n.svelte';
|
|
16
|
+
import { readURLState, writeURLState } from './url-sync';
|
|
16
17
|
|
|
17
18
|
// ─── useList ────────────────────────────────────────────────────
|
|
18
19
|
|
|
@@ -189,6 +190,7 @@ export function useNavigation() {
|
|
|
189
190
|
create: (resource: string) => navigate(`/${resource}/create`),
|
|
190
191
|
edit: (resource: string, id: string | number) => navigate(`/${resource}/edit/${id}`),
|
|
191
192
|
show: (resource: string, id: string | number) => navigate(`/${resource}/show/${id}`),
|
|
193
|
+
clone: (resource: string, id: string | number) => navigate(`/${resource}/create?clone_id=${id}`),
|
|
192
194
|
goBack: () => history.back(),
|
|
193
195
|
push: (path: string) => navigate(path),
|
|
194
196
|
};
|
|
@@ -391,12 +393,47 @@ interface UseFormOptions {
|
|
|
391
393
|
onMutationSuccess?: (data: unknown) => void;
|
|
392
394
|
onMutationError?: (error: Error) => void;
|
|
393
395
|
meta?: Record<string, unknown>;
|
|
396
|
+
validate?: (values: Record<string, unknown>) => Record<string, string> | null;
|
|
397
|
+
autoSave?: {
|
|
398
|
+
enabled: boolean;
|
|
399
|
+
debounce?: number; // ms, default 1000
|
|
400
|
+
onFinish?: (values: Record<string, unknown>) => Record<string, unknown>; // transform before save
|
|
401
|
+
};
|
|
394
402
|
}
|
|
395
403
|
|
|
396
404
|
export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
|
|
397
405
|
const provider = getDataProvider();
|
|
398
406
|
const queryClient = useQueryClient();
|
|
399
|
-
const { resource, action, id, redirect = 'list', onMutationSuccess, onMutationError, meta } = options;
|
|
407
|
+
const { resource, action, id, redirect = 'list', onMutationSuccess, onMutationError, meta, validate, autoSave } = options;
|
|
408
|
+
|
|
409
|
+
// Validation state
|
|
410
|
+
let errors = $state<Record<string, string>>({});
|
|
411
|
+
|
|
412
|
+
function setFieldError(field: string, message: string) {
|
|
413
|
+
errors = { ...errors, [field]: message };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function clearErrors() {
|
|
417
|
+
errors = {};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function clearFieldError(field: string) {
|
|
421
|
+
const next = { ...errors };
|
|
422
|
+
delete next[field];
|
|
423
|
+
errors = next;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function runValidation(values: Record<string, unknown>): boolean {
|
|
427
|
+
clearErrors();
|
|
428
|
+
if (validate) {
|
|
429
|
+
const result = validate(values);
|
|
430
|
+
if (result && Object.keys(result).length > 0) {
|
|
431
|
+
errors = result;
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
400
437
|
|
|
401
438
|
// Fetch existing data for edit/clone
|
|
402
439
|
const query = (action === 'edit' || action === 'clone') && id != null
|
|
@@ -450,6 +487,12 @@ export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
|
|
|
450
487
|
}
|
|
451
488
|
|
|
452
489
|
async function onFinish(values: Record<string, unknown>) {
|
|
490
|
+
// Run validation before submitting
|
|
491
|
+
if (!runValidation(values)) {
|
|
492
|
+
toast.warning(t('validation.required'));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
453
496
|
if (action === 'create' || action === 'clone') {
|
|
454
497
|
// @ts-expect-error $ rune prefix — Svelte compiler transforms this
|
|
455
498
|
await $createMut.mutateAsync(values);
|
|
@@ -459,7 +502,7 @@ export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
|
|
|
459
502
|
}
|
|
460
503
|
}
|
|
461
504
|
|
|
462
|
-
|
|
505
|
+
const base = {
|
|
463
506
|
query,
|
|
464
507
|
get formLoading() {
|
|
465
508
|
// @ts-expect-error $ rune prefix — Svelte compiler transforms this
|
|
@@ -467,6 +510,39 @@ export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
|
|
|
467
510
|
},
|
|
468
511
|
mutation: action === 'edit' ? updateMut : createMut,
|
|
469
512
|
onFinish,
|
|
513
|
+
get errors() { return errors; },
|
|
514
|
+
setFieldError,
|
|
515
|
+
clearErrors,
|
|
516
|
+
clearFieldError,
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// ─── autoSave ─────────────────────────────────────────────
|
|
520
|
+
let autoSaveStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
|
521
|
+
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
522
|
+
|
|
523
|
+
function triggerAutoSave(values: Record<string, unknown>) {
|
|
524
|
+
if (!autoSave?.enabled || action === 'create') return;
|
|
525
|
+
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
|
526
|
+
|
|
527
|
+
autoSaveTimer = setTimeout(async () => {
|
|
528
|
+
const finalValues = autoSave.onFinish ? autoSave.onFinish(values) : values;
|
|
529
|
+
autoSaveStatus = 'saving';
|
|
530
|
+
try {
|
|
531
|
+
await provider.update<T>({ resource, id: id!, variables: finalValues });
|
|
532
|
+
queryClient.invalidateQueries({ queryKey: [resource] });
|
|
533
|
+
autoSaveStatus = 'saved';
|
|
534
|
+
// Reset to idle after 2s
|
|
535
|
+
setTimeout(() => { autoSaveStatus = 'idle'; }, 2000);
|
|
536
|
+
} catch {
|
|
537
|
+
autoSaveStatus = 'error';
|
|
538
|
+
}
|
|
539
|
+
}, autoSave.debounce ?? 1000);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
...base,
|
|
544
|
+
triggerAutoSave,
|
|
545
|
+
get autoSaveStatus() { return autoSaveStatus; },
|
|
470
546
|
};
|
|
471
547
|
}
|
|
472
548
|
|
|
@@ -483,10 +559,27 @@ interface UseTableOptions {
|
|
|
483
559
|
}
|
|
484
560
|
|
|
485
561
|
export function useTable<T = Record<string, unknown>>(options: UseTableOptions) {
|
|
486
|
-
const { resource, meta } = options;
|
|
562
|
+
const { resource, meta, syncWithLocation = false } = options;
|
|
563
|
+
|
|
564
|
+
// Read initial state from URL if syncWithLocation
|
|
565
|
+
let initialPagination = options.pagination ?? { current: 1, pageSize: 10 };
|
|
566
|
+
let initialSorters = options.sorters ?? [];
|
|
567
|
+
|
|
568
|
+
if (syncWithLocation) {
|
|
569
|
+
const urlState = readURLState();
|
|
570
|
+
if (urlState.page || urlState.pageSize) {
|
|
571
|
+
initialPagination = {
|
|
572
|
+
current: urlState.page ?? initialPagination.current,
|
|
573
|
+
pageSize: urlState.pageSize ?? initialPagination.pageSize,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
if (urlState.sortField) {
|
|
577
|
+
initialSorters = [{ field: urlState.sortField, order: urlState.sortOrder ?? 'asc' }];
|
|
578
|
+
}
|
|
579
|
+
}
|
|
487
580
|
|
|
488
|
-
let pagination = $state<Pagination>(
|
|
489
|
-
let sorters = $state<Sort[]>(
|
|
581
|
+
let pagination = $state<Pagination>(initialPagination);
|
|
582
|
+
let sorters = $state<Sort[]>(initialSorters);
|
|
490
583
|
let filters = $state<Filter[]>(options.filters ?? []);
|
|
491
584
|
|
|
492
585
|
const query = useList<T>({ resource, pagination, sorters, filters, meta });
|
|
@@ -496,6 +589,18 @@ export function useTable<T = Record<string, unknown>>(options: UseTableOptions)
|
|
|
496
589
|
function setPage(page: number) { pagination = { ...pagination, current: page }; }
|
|
497
590
|
function setPageSize(size: number) { pagination = { ...pagination, pageSize: size, current: 1 }; }
|
|
498
591
|
|
|
592
|
+
// Sync state to URL when syncWithLocation is enabled
|
|
593
|
+
if (syncWithLocation) {
|
|
594
|
+
$effect(() => {
|
|
595
|
+
writeURLState({
|
|
596
|
+
page: pagination.current,
|
|
597
|
+
pageSize: pagination.pageSize,
|
|
598
|
+
sortField: sorters[0]?.field,
|
|
599
|
+
sortOrder: sorters[0]?.order,
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
499
604
|
return {
|
|
500
605
|
query,
|
|
501
606
|
pagination,
|
package/src/i18n.svelte.ts
CHANGED
|
@@ -19,6 +19,9 @@ const locales: Record<string, Locale> = {
|
|
|
19
19
|
'common.page': '{current} / {total}',
|
|
20
20
|
'common.export': '导出',
|
|
21
21
|
'common.import': '导入',
|
|
22
|
+
'common.clone': '克隆',
|
|
23
|
+
'common.autoSaving': '自动保存中...',
|
|
24
|
+
'common.autoSaved': '已自动保存',
|
|
22
25
|
'common.selectAll': '全选',
|
|
23
26
|
'common.batchDelete': '批量删除 ({count})',
|
|
24
27
|
'common.unsavedChanges': '有未保存的修改,确定离开吗?',
|
|
@@ -37,8 +40,6 @@ const locales: Record<string, Locale> = {
|
|
|
37
40
|
'common.toggleTheme': '切换主题',
|
|
38
41
|
'common.darkMode': '暗色模式',
|
|
39
42
|
'common.lightMode': '亮色模式',
|
|
40
|
-
'common.noData': '暂无数据',
|
|
41
|
-
'common.search': '搜索...',
|
|
42
43
|
'common.detail': '详情',
|
|
43
44
|
'common.loadFailed': '加载失败: {message}',
|
|
44
45
|
'common.pageNotFound': '页面未找到',
|
|
@@ -56,6 +57,38 @@ const locales: Record<string, Locale> = {
|
|
|
56
57
|
'config.addToEnvFile': '请在 .env 文件中添加以下内容:',
|
|
57
58
|
'config.envFilePath': '文件路径: .env',
|
|
58
59
|
'config.reload': '配置完成后刷新页面',
|
|
60
|
+
// Auth pages
|
|
61
|
+
'auth.login': '登录',
|
|
62
|
+
'auth.register': '注册',
|
|
63
|
+
'auth.forgotPassword': '忘记密码',
|
|
64
|
+
'auth.resetPassword': '重置密码',
|
|
65
|
+
'auth.email': '邮箱',
|
|
66
|
+
'auth.password': '密码',
|
|
67
|
+
'auth.confirmPassword': '确认密码',
|
|
68
|
+
'auth.rememberMe': '记住我',
|
|
69
|
+
'auth.loginButton': '登录',
|
|
70
|
+
'auth.registerButton': '注册',
|
|
71
|
+
'auth.sendResetLink': '发送重置链接',
|
|
72
|
+
'auth.backToLogin': '返回登录',
|
|
73
|
+
'auth.noAccount': '还没有账号?',
|
|
74
|
+
'auth.hasAccount': '已有账号?',
|
|
75
|
+
'auth.forgotPasswordLink': '忘记密码?',
|
|
76
|
+
'auth.forgotPasswordDescription': '输入您的邮箱地址,我们将发送重置密码的链接。',
|
|
77
|
+
'auth.resetLinkSent': '重置链接已发送,请查看您的邮箱。',
|
|
78
|
+
'auth.passwordMismatch': '两次输入的密码不一致',
|
|
79
|
+
'auth.emailRequired': '请输入邮箱',
|
|
80
|
+
'auth.passwordRequired': '请输入密码',
|
|
81
|
+
'auth.registerSuccess': '注册成功',
|
|
82
|
+
'auth.welcomeBack': '欢迎回来',
|
|
83
|
+
'auth.welcomeMessage': '登录以继续使用管理后台',
|
|
84
|
+
'auth.createAccount': '创建账号',
|
|
85
|
+
'auth.createAccountMessage': '填写信息以创建您的账号',
|
|
86
|
+
// Validation
|
|
87
|
+
'validation.required': '此字段为必填项',
|
|
88
|
+
'validation.minLength': '最少 {min} 个字符',
|
|
89
|
+
'validation.maxLength': '最多 {max} 个字符',
|
|
90
|
+
'validation.invalidEmail': '请输入有效的邮箱地址',
|
|
91
|
+
'validation.invalidFormat': '格式不正确',
|
|
59
92
|
},
|
|
60
93
|
'en': {
|
|
61
94
|
// Common
|
|
@@ -73,6 +106,9 @@ const locales: Record<string, Locale> = {
|
|
|
73
106
|
'common.page': '{current} / {total}',
|
|
74
107
|
'common.export': 'Export',
|
|
75
108
|
'common.import': 'Import',
|
|
109
|
+
'common.clone': 'Clone',
|
|
110
|
+
'common.autoSaving': 'Auto-saving...',
|
|
111
|
+
'common.autoSaved': 'Saved',
|
|
76
112
|
'common.selectAll': 'Select All',
|
|
77
113
|
'common.batchDelete': 'Batch Delete ({count})',
|
|
78
114
|
'common.unsavedChanges': 'You have unsaved changes. Leave anyway?',
|
|
@@ -91,8 +127,6 @@ const locales: Record<string, Locale> = {
|
|
|
91
127
|
'common.toggleTheme': 'Toggle theme',
|
|
92
128
|
'common.darkMode': 'Dark mode',
|
|
93
129
|
'common.lightMode': 'Light mode',
|
|
94
|
-
'common.noData': 'No data',
|
|
95
|
-
'common.search': 'Search...',
|
|
96
130
|
'common.detail': 'Detail',
|
|
97
131
|
'common.loadFailed': 'Load failed: {message}',
|
|
98
132
|
'common.pageNotFound': 'Page not found',
|
|
@@ -110,6 +144,38 @@ const locales: Record<string, Locale> = {
|
|
|
110
144
|
'config.addToEnvFile': 'Add the following to your .env file:',
|
|
111
145
|
'config.envFilePath': 'File path: .env',
|
|
112
146
|
'config.reload': 'Reload after configuration',
|
|
147
|
+
// Auth pages
|
|
148
|
+
'auth.login': 'Login',
|
|
149
|
+
'auth.register': 'Register',
|
|
150
|
+
'auth.forgotPassword': 'Forgot Password',
|
|
151
|
+
'auth.resetPassword': 'Reset Password',
|
|
152
|
+
'auth.email': 'Email',
|
|
153
|
+
'auth.password': 'Password',
|
|
154
|
+
'auth.confirmPassword': 'Confirm Password',
|
|
155
|
+
'auth.rememberMe': 'Remember me',
|
|
156
|
+
'auth.loginButton': 'Sign In',
|
|
157
|
+
'auth.registerButton': 'Sign Up',
|
|
158
|
+
'auth.sendResetLink': 'Send Reset Link',
|
|
159
|
+
'auth.backToLogin': 'Back to Login',
|
|
160
|
+
'auth.noAccount': "Don't have an account?",
|
|
161
|
+
'auth.hasAccount': 'Already have an account?',
|
|
162
|
+
'auth.forgotPasswordLink': 'Forgot password?',
|
|
163
|
+
'auth.forgotPasswordDescription': 'Enter your email address and we will send you a reset link.',
|
|
164
|
+
'auth.resetLinkSent': 'Reset link sent! Check your email.',
|
|
165
|
+
'auth.passwordMismatch': 'Passwords do not match',
|
|
166
|
+
'auth.emailRequired': 'Email is required',
|
|
167
|
+
'auth.passwordRequired': 'Password is required',
|
|
168
|
+
'auth.registerSuccess': 'Registration successful',
|
|
169
|
+
'auth.welcomeBack': 'Welcome Back',
|
|
170
|
+
'auth.welcomeMessage': 'Sign in to continue to admin panel',
|
|
171
|
+
'auth.createAccount': 'Create Account',
|
|
172
|
+
'auth.createAccountMessage': 'Fill in your details to create an account',
|
|
173
|
+
// Validation
|
|
174
|
+
'validation.required': 'This field is required',
|
|
175
|
+
'validation.minLength': 'Minimum {min} characters',
|
|
176
|
+
'validation.maxLength': 'Maximum {max} characters',
|
|
177
|
+
'validation.invalidEmail': 'Please enter a valid email address',
|
|
178
|
+
'validation.invalidFormat': 'Invalid format',
|
|
113
179
|
},
|
|
114
180
|
};
|
|
115
181
|
|
package/src/i18n.test.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Unit tests for URL parsing (useParsed logic)
|
|
2
|
+
// We test the pure parsing logic directly since the hook itself uses Svelte runes.
|
|
3
|
+
import { describe, test, expect } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse a hash-based URL into structured route info.
|
|
7
|
+
* This mirrors the logic in useParsed.ts.
|
|
8
|
+
*/
|
|
9
|
+
function parseHash(hash: string) {
|
|
10
|
+
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
11
|
+
const [pathPart, queryPart] = raw.split('?');
|
|
12
|
+
const segments = pathPart.split('/').filter(Boolean);
|
|
13
|
+
|
|
14
|
+
const params: Record<string, string> = {};
|
|
15
|
+
if (queryPart) {
|
|
16
|
+
for (const pair of queryPart.split('&')) {
|
|
17
|
+
const [k, v] = pair.split('=');
|
|
18
|
+
if (k) params[decodeURIComponent(k)] = decodeURIComponent(v ?? '');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
resource: segments[0],
|
|
24
|
+
action: segments[1],
|
|
25
|
+
id: segments[2],
|
|
26
|
+
params,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('parseHash (useParsed logic)', () => {
|
|
31
|
+
test('empty hash', () => {
|
|
32
|
+
const result = parseHash('');
|
|
33
|
+
expect(result.resource).toBeUndefined();
|
|
34
|
+
expect(result.action).toBeUndefined();
|
|
35
|
+
expect(result.id).toBeUndefined();
|
|
36
|
+
expect(result.params).toEqual({});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('root hash', () => {
|
|
40
|
+
const result = parseHash('#/');
|
|
41
|
+
expect(result.resource).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('resource only', () => {
|
|
45
|
+
const result = parseHash('#/posts');
|
|
46
|
+
expect(result.resource).toBe('posts');
|
|
47
|
+
expect(result.action).toBeUndefined();
|
|
48
|
+
expect(result.id).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('resource and action', () => {
|
|
52
|
+
const result = parseHash('#/posts/create');
|
|
53
|
+
expect(result.resource).toBe('posts');
|
|
54
|
+
expect(result.action).toBe('create');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('resource, action, and id', () => {
|
|
58
|
+
const result = parseHash('#/posts/edit/42');
|
|
59
|
+
expect(result.resource).toBe('posts');
|
|
60
|
+
expect(result.action).toBe('edit');
|
|
61
|
+
expect(result.id).toBe('42');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('with query params', () => {
|
|
65
|
+
const result = parseHash('#/posts?page=2&sort=name');
|
|
66
|
+
expect(result.resource).toBe('posts');
|
|
67
|
+
expect(result.params.page).toBe('2');
|
|
68
|
+
expect(result.params.sort).toBe('name');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('resource with action and query', () => {
|
|
72
|
+
const result = parseHash('#/posts/edit/5?tab=details');
|
|
73
|
+
expect(result.resource).toBe('posts');
|
|
74
|
+
expect(result.action).toBe('edit');
|
|
75
|
+
expect(result.id).toBe('5');
|
|
76
|
+
expect(result.params.tab).toBe('details');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('encoded query params', () => {
|
|
80
|
+
const result = parseHash('#/search?q=hello%20world&filter=tag%3Djs');
|
|
81
|
+
expect(result.params.q).toBe('hello world');
|
|
82
|
+
expect(result.params.filter).toBe('tag=js');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('login route', () => {
|
|
86
|
+
const result = parseHash('#/login');
|
|
87
|
+
expect(result.resource).toBe('login');
|
|
88
|
+
expect(result.action).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export {
|
|
|
4
4
|
setDataProvider, getDataProvider,
|
|
5
5
|
setAuthProvider, getAuthProvider,
|
|
6
6
|
setResources, getResources, getResource,
|
|
7
|
+
setRouterProvider, getRouterProvider,
|
|
7
8
|
} from './context';
|
|
8
9
|
export {
|
|
9
10
|
useList, useInfiniteList,
|
|
@@ -22,6 +23,7 @@ export { readURLState, writeURLState } from './url-sync';
|
|
|
22
23
|
export { setAccessControl, canAccess, canAccessAsync } from './permissions';
|
|
23
24
|
export { useLive } from './live';
|
|
24
25
|
export { toast } from './toast.svelte';
|
|
26
|
+
export { notify, closeNotification, setNotificationProvider, getNotificationProvider } from './notification.svelte';
|
|
25
27
|
export { t, setLocale, getLocale, getAvailableLocales, addTranslations } from './i18n.svelte';
|
|
26
28
|
export { audit, setAuditHandler } from './audit';
|
|
27
29
|
export { getTheme, setTheme, toggleTheme, getResolvedTheme, getColorTheme, setColorTheme, colorThemes } from './theme.svelte';
|
|
@@ -46,3 +48,16 @@ export type {
|
|
|
46
48
|
export type { LiveProvider, LiveEvent } from './live';
|
|
47
49
|
export type { Action, AccessControlResult, AccessControlFn } from './permissions';
|
|
48
50
|
export type { AuditEntry, AuditHandler } from './audit';
|
|
51
|
+
export { useCan } from './useCan';
|
|
52
|
+
export { useExport, useImport } from './data-transfer';
|
|
53
|
+
export {
|
|
54
|
+
useLogin, useLogout,
|
|
55
|
+
useRegister, useForgotPassword, useUpdatePassword,
|
|
56
|
+
useGetIdentity, useIsAuthenticated,
|
|
57
|
+
useOnError, usePermissions,
|
|
58
|
+
} from './auth-hooks.svelte';
|
|
59
|
+
export { useParsed } from './useParsed';
|
|
60
|
+
export { createHashRouterProvider, createHistoryRouterProvider } from './router-provider';
|
|
61
|
+
export type { RouterProvider } from './router-provider';
|
|
62
|
+
export { inferFieldType, inferResource } from './inferencer';
|
|
63
|
+
export type { InferResult } from './inferencer';
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Unit tests for Inferencer
|
|
2
|
+
import { describe, test, expect } from 'bun:test';
|
|
3
|
+
import { inferFieldType, inferResource } from './inferencer';
|
|
4
|
+
|
|
5
|
+
describe('inferFieldType', () => {
|
|
6
|
+
test('detects boolean', () => {
|
|
7
|
+
expect(inferFieldType('active', true)).toBe('boolean');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('detects number', () => {
|
|
11
|
+
expect(inferFieldType('age', 25)).toBe('number');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('detects email', () => {
|
|
15
|
+
expect(inferFieldType('email', 'user@example.com')).toBe('email');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('detects email from key name', () => {
|
|
19
|
+
expect(inferFieldType('user_email', 'test')).toBe('email');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('detects URL', () => {
|
|
23
|
+
expect(inferFieldType('website', 'https://example.com')).toBe('url');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('detects image URL', () => {
|
|
27
|
+
expect(inferFieldType('avatar', 'https://example.com/photo.jpg')).toBe('image');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('detects image from key name', () => {
|
|
31
|
+
expect(inferFieldType('avatar', '/uploads/user.png')).toBe('image');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('detects date ISO string', () => {
|
|
35
|
+
expect(inferFieldType('created', '2024-01-15T10:30:00Z')).toBe('date');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('detects date from key name', () => {
|
|
39
|
+
expect(inferFieldType('created_at', 'some value')).toBe('date');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('detects phone', () => {
|
|
43
|
+
expect(inferFieldType('phone', '+1 (555) 123-4567')).toBe('phone');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('detects color hex', () => {
|
|
47
|
+
expect(inferFieldType('color', '#ff5533')).toBe('color');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('detects textarea for long strings', () => {
|
|
51
|
+
expect(inferFieldType('content', 'a'.repeat(250))).toBe('textarea');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('detects textarea from key name', () => {
|
|
55
|
+
expect(inferFieldType('description', 'short')).toBe('textarea');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('detects tags array', () => {
|
|
59
|
+
expect(inferFieldType('tags', ['svelte', 'admin'])).toBe('tags');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('detects images array', () => {
|
|
63
|
+
expect(inferFieldType('photos', ['https://x.com/a.png', 'https://x.com/b.jpg'])).toBe('images');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('detects json object', () => {
|
|
67
|
+
expect(inferFieldType('meta', { foo: 'bar' })).toBe('json');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('null defaults to text', () => {
|
|
71
|
+
expect(inferFieldType('unknown', null)).toBe('text');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('plain string defaults to text', () => {
|
|
75
|
+
expect(inferFieldType('title', 'Hello World')).toBe('text');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('inferResource', () => {
|
|
80
|
+
const sampleData = [
|
|
81
|
+
{
|
|
82
|
+
id: 1,
|
|
83
|
+
title: 'First Post',
|
|
84
|
+
content: 'A very long description that is more than two hundred characters. '.repeat(4),
|
|
85
|
+
author_id: 5,
|
|
86
|
+
status: 'published',
|
|
87
|
+
views: 100,
|
|
88
|
+
is_featured: true,
|
|
89
|
+
created_at: '2024-01-15T10:00:00Z',
|
|
90
|
+
email: 'author@blog.com',
|
|
91
|
+
tags: ['svelte', 'admin'],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 2,
|
|
95
|
+
title: 'Second Post',
|
|
96
|
+
content: 'Another long body text that exceeds the 200 char limit easily. '.repeat(5),
|
|
97
|
+
author_id: 3,
|
|
98
|
+
status: 'draft',
|
|
99
|
+
views: 42,
|
|
100
|
+
is_featured: false,
|
|
101
|
+
created_at: '2024-02-20T14:30:00Z',
|
|
102
|
+
email: 'editor@blog.com',
|
|
103
|
+
tags: ['react'],
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
test('returns correct resource name and label', () => {
|
|
108
|
+
const result = inferResource('posts', sampleData);
|
|
109
|
+
expect(result.resource.name).toBe('posts');
|
|
110
|
+
expect(result.resource.label).toBe('Posts');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('infers correct number of fields', () => {
|
|
114
|
+
const result = inferResource('posts', sampleData);
|
|
115
|
+
expect(result.fields.length).toBe(10); // id, title, content, author_id, status, views, is_featured, created_at, email, tags
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('detects id as primary key', () => {
|
|
119
|
+
const result = inferResource('posts', sampleData);
|
|
120
|
+
const idField = result.fields.find(f => f.key === 'id');
|
|
121
|
+
expect(idField).toBeDefined();
|
|
122
|
+
expect(idField!.showInForm).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('detects number type for views', () => {
|
|
126
|
+
const result = inferResource('posts', sampleData);
|
|
127
|
+
const viewsField = result.fields.find(f => f.key === 'views');
|
|
128
|
+
expect(viewsField!.type).toBe('number');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('detects boolean type for is_featured', () => {
|
|
132
|
+
const result = inferResource('posts', sampleData);
|
|
133
|
+
const field = result.fields.find(f => f.key === 'is_featured');
|
|
134
|
+
expect(field!.type).toBe('boolean');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('detects date for created_at', () => {
|
|
138
|
+
const result = inferResource('posts', sampleData);
|
|
139
|
+
const field = result.fields.find(f => f.key === 'created_at');
|
|
140
|
+
expect(field!.type).toBe('date');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('detects relation for author_id', () => {
|
|
144
|
+
const result = inferResource('posts', sampleData);
|
|
145
|
+
const field = result.fields.find(f => f.key === 'author_id');
|
|
146
|
+
expect(field!.type).toBe('relation');
|
|
147
|
+
expect(field!.resource).toBe('authors');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('detects email type', () => {
|
|
151
|
+
const result = inferResource('posts', sampleData);
|
|
152
|
+
const field = result.fields.find(f => f.key === 'email');
|
|
153
|
+
expect(field!.type).toBe('email');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('detects tags array', () => {
|
|
157
|
+
const result = inferResource('posts', sampleData);
|
|
158
|
+
const field = result.fields.find(f => f.key === 'tags');
|
|
159
|
+
expect(field!.type).toBe('tags');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('detects textarea for content', () => {
|
|
163
|
+
const result = inferResource('posts', sampleData);
|
|
164
|
+
const field = result.fields.find(f => f.key === 'content');
|
|
165
|
+
expect(field!.type).toBe('textarea');
|
|
166
|
+
expect(field!.showInList).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('generates valid TypeScript code', () => {
|
|
170
|
+
const result = inferResource('posts', sampleData);
|
|
171
|
+
expect(result.code).toContain("name: 'posts'");
|
|
172
|
+
expect(result.code).toContain("import type { ResourceDefinition }");
|
|
173
|
+
expect(result.code).toContain("export const postsResource");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('handles empty data gracefully', () => {
|
|
177
|
+
const result = inferResource('empty', []);
|
|
178
|
+
expect(result.fields.length).toBe(0);
|
|
179
|
+
expect(result.code).toContain('No data available');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('custom primaryKey', () => {
|
|
183
|
+
const data = [{ _id: 'abc', name: 'Test' }];
|
|
184
|
+
const result = inferResource('items', data, { primaryKey: '_id' });
|
|
185
|
+
expect(result.resource.primaryKey).toBe('_id');
|
|
186
|
+
const pkField = result.fields.find(f => f.key === '_id');
|
|
187
|
+
expect(pkField!.showInForm).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
});
|