@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.
@@ -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
- return {
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>(options.pagination ?? { current: 1, pageSize: 10 });
489
- let sorters = $state<Sort[]>(options.sorters ?? []);
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,
@@ -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
 
@@ -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
+ });