@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,85 @@
|
|
|
1
|
+
// HttpError + CrudOperator + LogicalFilter tests
|
|
2
|
+
import { describe, test, expect } from 'bun:test';
|
|
3
|
+
import { HttpError } from './types';
|
|
4
|
+
import type { CrudOperator, Filter, LogicalFilter } from './types';
|
|
5
|
+
|
|
6
|
+
describe('HttpError', () => {
|
|
7
|
+
test('creates with message and statusCode', () => {
|
|
8
|
+
const err = new HttpError('Not Found', 404);
|
|
9
|
+
expect(err.message).toBe('Not Found');
|
|
10
|
+
expect(err.statusCode).toBe(404);
|
|
11
|
+
expect(err.name).toBe('HttpError');
|
|
12
|
+
expect(err.errors).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('creates with validation errors', () => {
|
|
16
|
+
const err = new HttpError('Validation Failed', 422, {
|
|
17
|
+
email: ['Email is required', 'Email must be valid'],
|
|
18
|
+
name: 'Name is required',
|
|
19
|
+
});
|
|
20
|
+
expect(err.statusCode).toBe(422);
|
|
21
|
+
expect(err.errors!.email).toEqual(['Email is required', 'Email must be valid']);
|
|
22
|
+
expect(err.errors!.name).toBe('Name is required');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('is instanceof Error', () => {
|
|
26
|
+
const err = new HttpError('Server Error', 500);
|
|
27
|
+
expect(err instanceof Error).toBe(true);
|
|
28
|
+
expect(err instanceof HttpError).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('CrudOperator types', () => {
|
|
33
|
+
test('all operators are valid', () => {
|
|
34
|
+
const operators: CrudOperator[] = [
|
|
35
|
+
'eq', 'ne', 'lt', 'gt', 'lte', 'gte',
|
|
36
|
+
'contains', 'ncontains',
|
|
37
|
+
'startswith', 'endswith',
|
|
38
|
+
'in', 'nin',
|
|
39
|
+
'null', 'nnull',
|
|
40
|
+
'between', 'nbetween',
|
|
41
|
+
];
|
|
42
|
+
expect(operators).toHaveLength(16);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('Filter uses CrudOperator', () => {
|
|
46
|
+
const filter: Filter = { field: 'name', operator: 'startswith', value: 'A' };
|
|
47
|
+
expect(filter.operator).toBe('startswith');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('negation operators', () => {
|
|
51
|
+
const filters: Filter[] = [
|
|
52
|
+
{ field: 'status', operator: 'ncontains', value: 'draft' },
|
|
53
|
+
{ field: 'deleted_at', operator: 'nnull', value: null },
|
|
54
|
+
{ field: 'category', operator: 'nin', value: [1, 2] },
|
|
55
|
+
{ field: 'price', operator: 'nbetween', value: [100, 200] },
|
|
56
|
+
];
|
|
57
|
+
expect(filters).toHaveLength(4);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('LogicalFilter', () => {
|
|
62
|
+
test('or combination', () => {
|
|
63
|
+
const logical: LogicalFilter = {
|
|
64
|
+
operator: 'or',
|
|
65
|
+
value: [
|
|
66
|
+
{ field: 'status', operator: 'eq', value: 'published' },
|
|
67
|
+
{ field: 'status', operator: 'eq', value: 'draft' },
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
expect(logical.operator).toBe('or');
|
|
71
|
+
expect(logical.value).toHaveLength(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('and combination', () => {
|
|
75
|
+
const logical: LogicalFilter = {
|
|
76
|
+
operator: 'and',
|
|
77
|
+
value: [
|
|
78
|
+
{ field: 'price', operator: 'gte', value: 10 },
|
|
79
|
+
{ field: 'price', operator: 'lte', value: 100 },
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
expect(logical.operator).toBe('and');
|
|
83
|
+
expect(logical.value).toHaveLength(2);
|
|
84
|
+
});
|
|
85
|
+
});
|
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,16 +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': '重置',
|
|
56
|
+
'common.welcome': '欢迎使用 {title}',
|
|
57
|
+
'common.dashboardHint': '从侧边栏选择一个资源开始。',
|
|
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': '收起',
|
|
50
73
|
// Field
|
|
51
74
|
'field.enterValue': '输入{label}',
|
|
52
75
|
'field.selectPlaceholder': '请选择',
|
|
53
76
|
'field.tagsPlaceholder': '输入标签后按回车...',
|
|
77
|
+
'field.noOptions': '无可用选项',
|
|
78
|
+
'field.addImage': '添加图片',
|
|
54
79
|
// Config errors
|
|
55
80
|
'config.missingEnvTitle': '环境配置缺失',
|
|
56
81
|
'config.missingEnvDescription': '应用无法启动,因为缺少必要的环境变量。',
|
|
57
82
|
'config.addToEnvFile': '请在 .env 文件中添加以下内容:',
|
|
58
83
|
'config.envFilePath': '文件路径: .env',
|
|
59
84
|
'config.reload': '配置完成后刷新页面',
|
|
85
|
+
'config.reloadButton': '重新加载页面',
|
|
60
86
|
// Auth pages
|
|
61
87
|
'auth.login': '登录',
|
|
62
88
|
'auth.register': '注册',
|
|
@@ -83,6 +109,17 @@ const locales: Record<string, Locale> = {
|
|
|
83
109
|
'auth.welcomeMessage': '登录以继续使用管理后台',
|
|
84
110
|
'auth.createAccount': '创建账号',
|
|
85
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)',
|
|
86
123
|
// Validation
|
|
87
124
|
'validation.required': '此字段为必填项',
|
|
88
125
|
'validation.minLength': '最少 {min} 个字符',
|
|
@@ -125,6 +162,9 @@ const locales: Record<string, Locale> = {
|
|
|
125
162
|
'common.home': 'Home',
|
|
126
163
|
'common.logout': 'Log out',
|
|
127
164
|
'common.toggleTheme': 'Toggle theme',
|
|
165
|
+
'common.switchLanguage': 'Switch language',
|
|
166
|
+
'common.clear': 'Clear',
|
|
167
|
+
'common.menu': 'Menu',
|
|
128
168
|
'common.darkMode': 'Dark mode',
|
|
129
169
|
'common.lightMode': 'Light mode',
|
|
130
170
|
'common.detail': 'Detail',
|
|
@@ -134,16 +174,39 @@ const locales: Record<string, Locale> = {
|
|
|
134
174
|
'common.retry': 'Retry',
|
|
135
175
|
'common.yes': 'Yes',
|
|
136
176
|
'common.no': 'No',
|
|
177
|
+
'common.undo': 'Undo',
|
|
178
|
+
'common.filter': 'Filter',
|
|
179
|
+
'common.reset': 'Reset',
|
|
180
|
+
'common.welcome': 'Welcome to {title}',
|
|
181
|
+
'common.dashboardHint': 'Select a resource from the sidebar to get started.',
|
|
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',
|
|
137
197
|
// Field
|
|
138
198
|
'field.enterValue': 'Enter {label}',
|
|
139
199
|
'field.selectPlaceholder': 'Select...',
|
|
140
200
|
'field.tagsPlaceholder': 'Type a tag and press Enter...',
|
|
201
|
+
'field.noOptions': 'No options available',
|
|
202
|
+
'field.addImage': 'Add image',
|
|
141
203
|
// Config errors
|
|
142
204
|
'config.missingEnvTitle': 'Missing Configuration',
|
|
143
205
|
'config.missingEnvDescription': 'The app cannot start because required environment variables are missing.',
|
|
144
206
|
'config.addToEnvFile': 'Add the following to your .env file:',
|
|
145
207
|
'config.envFilePath': 'File path: .env',
|
|
146
|
-
'config.reload': '
|
|
208
|
+
'config.reload': 'Refresh page after configuration',
|
|
209
|
+
'config.reloadButton': 'Reload Page',
|
|
147
210
|
// Auth pages
|
|
148
211
|
'auth.login': 'Login',
|
|
149
212
|
'auth.register': 'Register',
|
|
@@ -170,6 +233,17 @@ const locales: Record<string, Locale> = {
|
|
|
170
233
|
'auth.welcomeMessage': 'Sign in to continue to admin panel',
|
|
171
234
|
'auth.createAccount': 'Create Account',
|
|
172
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)',
|
|
173
247
|
// Validation
|
|
174
248
|
'validation.required': 'This field is required',
|
|
175
249
|
'validation.minLength': 'Minimum {min} characters',
|
|
@@ -221,3 +295,15 @@ export function t(key: string, params?: Record<string, string | number>): string
|
|
|
221
295
|
export function addTranslations(locale: string, translations: Record<string, string>): void {
|
|
222
296
|
locales[locale] = { ...locales[locale], ...translations };
|
|
223
297
|
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* useTranslation — standard i18n hook
|
|
301
|
+
* Returns { translate, getLocale, changeLocale }
|
|
302
|
+
*/
|
|
303
|
+
export function useTranslation() {
|
|
304
|
+
return {
|
|
305
|
+
translate: t,
|
|
306
|
+
getLocale,
|
|
307
|
+
changeLocale: setLocale,
|
|
308
|
+
};
|
|
309
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,36 +1,52 @@
|
|
|
1
1
|
// Core barrel exports
|
|
2
2
|
|
|
3
3
|
export {
|
|
4
|
-
setDataProvider, getDataProvider,
|
|
4
|
+
setDataProvider, getDataProvider, getDataProviderForResource, getDataProviderNames,
|
|
5
5
|
setAuthProvider, getAuthProvider,
|
|
6
6
|
setResources, getResources, getResource,
|
|
7
7
|
setRouterProvider, getRouterProvider,
|
|
8
|
-
} from './context';
|
|
8
|
+
} from './context.svelte';
|
|
9
|
+
export type { DataProviderInput } from './context.svelte';
|
|
9
10
|
export {
|
|
10
11
|
useList, useInfiniteList,
|
|
11
12
|
useOne, useShow,
|
|
12
13
|
useSelect, useMany,
|
|
13
14
|
useCustom, useApiUrl,
|
|
15
|
+
useCustomMutation, useInvalidate,
|
|
14
16
|
useCreate, useCreateMany,
|
|
15
17
|
useUpdate, useUpdateMany,
|
|
16
18
|
useDelete, useDeleteMany,
|
|
17
19
|
useForm, useTable,
|
|
18
|
-
useNavigation,
|
|
20
|
+
useNavigation, useGo, useBack,
|
|
21
|
+
useGetToPath, useLink,
|
|
19
22
|
useResource,
|
|
23
|
+
useLog, useLogList,
|
|
24
|
+
useModalForm, useDrawerForm, useModal,
|
|
25
|
+
useOvertime, useRelation,
|
|
26
|
+
useNotification, useDataProvider,
|
|
27
|
+
useMenu, useBreadcrumb, useThemedLayoutContext,
|
|
20
28
|
} from './hooks.svelte';
|
|
21
|
-
export { matchRoute, navigate, currentPath } from './router';
|
|
29
|
+
export { matchRoute, navigate, currentPath, setActiveRouterProvider } from './router';
|
|
22
30
|
export { readURLState, writeURLState } from './url-sync';
|
|
23
31
|
export { setAccessControl, canAccess, canAccessAsync } from './permissions';
|
|
24
|
-
export { useLive } from './live';
|
|
32
|
+
export { useLive, useSubscription, usePublish } from './live';
|
|
25
33
|
export { toast } from './toast.svelte';
|
|
26
34
|
export { notify, closeNotification, setNotificationProvider, getNotificationProvider } from './notification.svelte';
|
|
27
|
-
export { t, setLocale, getLocale, getAvailableLocales, addTranslations } from './i18n.svelte';
|
|
28
|
-
export { audit, setAuditHandler } from './audit';
|
|
35
|
+
export { t, setLocale, getLocale, getAvailableLocales, addTranslations, useTranslation } from './i18n.svelte';
|
|
36
|
+
export { audit, setAuditHandler, setAuditLogProvider, getAuditLogProvider } from './audit';
|
|
37
|
+
export type { AuditLogProvider } from './audit';
|
|
29
38
|
export { getTheme, setTheme, toggleTheme, getResolvedTheme, getColorTheme, setColorTheme, colorThemes } from './theme.svelte';
|
|
30
39
|
export type { ThemeMode, ColorTheme } from './theme.svelte';
|
|
40
|
+
export { setUnsavedChanges, getUnsavedChanges, initUnsavedChangesNotifier } from './unsaved-changes.svelte';
|
|
41
|
+
export { setAdminOptions, getAdminOptions, getTextTransformers } from './options';
|
|
42
|
+
export type { AdminOptions, TextTransformers, OvertimeConfig } from './options';
|
|
31
43
|
|
|
44
|
+
export * from './query-keys';
|
|
45
|
+
export { HttpError } from './types';
|
|
32
46
|
export type {
|
|
33
47
|
DataProvider, AuthProvider, NotificationProvider, MutationMode,
|
|
48
|
+
ValidationErrors, CrudOperator, LogicalFilter,
|
|
49
|
+
CustomParams, CustomResult,
|
|
34
50
|
GetListParams, GetListResult,
|
|
35
51
|
GetOneParams, GetOneResult,
|
|
36
52
|
GetManyParams, GetManyResult,
|
|
@@ -40,16 +56,19 @@ export type {
|
|
|
40
56
|
UpdateManyParams, UpdateManyResult,
|
|
41
57
|
DeleteParams, DeleteResult,
|
|
42
58
|
DeleteManyParams, DeleteManyResult,
|
|
43
|
-
CustomParams, CustomResult,
|
|
44
59
|
Pagination, Sort, Filter, Identity,
|
|
45
60
|
ResourceDefinition, FieldDefinition,
|
|
46
61
|
AuthActionResult, CheckResult,
|
|
62
|
+
ResourceTypeMap, KnownResources, InferData,
|
|
63
|
+
BaseRecord,
|
|
47
64
|
} from './types';
|
|
48
|
-
export type {
|
|
65
|
+
export type { InvalidateScope } from './options';
|
|
66
|
+
export type { LiveProvider, LiveEvent, LiveMode } from './live';
|
|
49
67
|
export type { Action, AccessControlResult, AccessControlFn } from './permissions';
|
|
50
68
|
export type { AuditEntry, AuditHandler } from './audit';
|
|
51
69
|
export { useCan } from './useCan';
|
|
52
70
|
export { useExport, useImport } from './data-transfer';
|
|
71
|
+
export type { UseExportOptions, UseImportOptions } from './data-transfer';
|
|
53
72
|
export {
|
|
54
73
|
useLogin, useLogout,
|
|
55
74
|
useRegister, useForgotPassword, useUpdatePassword,
|
|
@@ -57,7 +76,21 @@ export {
|
|
|
57
76
|
useOnError, usePermissions,
|
|
58
77
|
} from './auth-hooks.svelte';
|
|
59
78
|
export { useParsed } from './useParsed';
|
|
79
|
+
export * from './useStepsForm.svelte';
|
|
60
80
|
export { createHashRouterProvider, createHistoryRouterProvider } from './router-provider';
|
|
61
81
|
export type { RouterProvider } from './router-provider';
|
|
62
82
|
export { inferFieldType, inferResource } from './inferencer';
|
|
63
83
|
export type { InferResult } from './inferencer';
|
|
84
|
+
export { createWebSocketLiveProvider } from './live-websocket';
|
|
85
|
+
export type { WebSocketLiveProviderOptions } from './live-websocket';
|
|
86
|
+
export { createSSELiveProvider } from './live-sse';
|
|
87
|
+
export type { SSELiveProviderOptions } from './live-sse';
|
|
88
|
+
export { inferFromOpenAPI } from './inferencer-openapi';
|
|
89
|
+
export type { InferFromOpenAPIOptions } from './inferencer-openapi';
|
|
90
|
+
export {
|
|
91
|
+
getDefaultFilter, getDefaultSortOrder,
|
|
92
|
+
unionFilters, unionSorters,
|
|
93
|
+
file2Base64, generateDefaultDocumentTitle,
|
|
94
|
+
} from './helpers';
|
|
95
|
+
export { TableState } from './table-state.svelte';
|
|
96
|
+
export type { TableStateOptions } from './table-state.svelte';
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for OpenAPI → ResourceDefinition inference
|
|
3
|
+
*/
|
|
4
|
+
import { describe, test, expect } from 'bun:test';
|
|
5
|
+
import { inferFromOpenAPI } from './inferencer-openapi';
|
|
6
|
+
|
|
7
|
+
const sampleOpenAPI = {
|
|
8
|
+
openapi: '3.0.0',
|
|
9
|
+
info: { title: 'Blog API', version: '1.0.0' },
|
|
10
|
+
paths: {
|
|
11
|
+
'/api/posts': {
|
|
12
|
+
get: { operationId: 'getPosts' },
|
|
13
|
+
post: { operationId: 'createPost' },
|
|
14
|
+
},
|
|
15
|
+
'/api/posts/{id}': {
|
|
16
|
+
get: { operationId: 'getPost' },
|
|
17
|
+
put: { operationId: 'updatePost' },
|
|
18
|
+
delete: { operationId: 'deletePost' },
|
|
19
|
+
},
|
|
20
|
+
'/api/users': {
|
|
21
|
+
get: { operationId: 'getUsers' },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
components: {
|
|
25
|
+
schemas: {
|
|
26
|
+
Post: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
required: ['title', 'content'],
|
|
29
|
+
properties: {
|
|
30
|
+
id: { type: 'integer' },
|
|
31
|
+
title: { type: 'string' },
|
|
32
|
+
content: { type: 'string', description: 'Post body content' },
|
|
33
|
+
status: { type: 'string', enum: ['draft', 'published', 'archived'] },
|
|
34
|
+
author_id: { type: 'integer' },
|
|
35
|
+
email: { type: 'string', format: 'email' },
|
|
36
|
+
created_at: { type: 'string', format: 'date-time' },
|
|
37
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
38
|
+
is_featured: { type: 'boolean' },
|
|
39
|
+
views: { type: 'integer' },
|
|
40
|
+
website: { type: 'string', format: 'uri' },
|
|
41
|
+
meta: { type: 'object' },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
User: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
id: { type: 'integer' },
|
|
48
|
+
name: { type: 'string' },
|
|
49
|
+
avatar: { type: 'string', format: 'uri' },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe('inferFromOpenAPI', () => {
|
|
57
|
+
test('parses both schemas', () => {
|
|
58
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
59
|
+
expect(resources.length).toBe(2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('resource name is pluralized and lowercase', () => {
|
|
63
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
64
|
+
const post = resources.find(r => r.name === 'posts');
|
|
65
|
+
expect(post).toBeDefined();
|
|
66
|
+
expect(post!.label).toBe('Post');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('detects required fields', () => {
|
|
70
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
71
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
72
|
+
const titleField = post.fields.find(f => f.key === 'title')!;
|
|
73
|
+
const viewsField = post.fields.find(f => f.key === 'views')!;
|
|
74
|
+
expect(titleField.required).toBe(true);
|
|
75
|
+
expect(viewsField.required).toBeFalsy();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('maps integer to number type', () => {
|
|
79
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
80
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
81
|
+
const viewsField = post.fields.find(f => f.key === 'views')!;
|
|
82
|
+
expect(viewsField.type).toBe('number');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('maps boolean type', () => {
|
|
86
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
87
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
88
|
+
const field = post.fields.find(f => f.key === 'is_featured')!;
|
|
89
|
+
expect(field.type).toBe('boolean');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('maps date-time format to date', () => {
|
|
93
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
94
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
95
|
+
const field = post.fields.find(f => f.key === 'created_at')!;
|
|
96
|
+
expect(field.type).toBe('date');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('maps email format', () => {
|
|
100
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
101
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
102
|
+
const field = post.fields.find(f => f.key === 'email')!;
|
|
103
|
+
expect(field.type).toBe('email');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('maps uri format to url', () => {
|
|
107
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
108
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
109
|
+
const field = post.fields.find(f => f.key === 'website')!;
|
|
110
|
+
expect(field.type).toBe('url');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('maps enum to select with options', () => {
|
|
114
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
115
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
116
|
+
const field = post.fields.find(f => f.key === 'status')!;
|
|
117
|
+
expect(field.type).toBe('select');
|
|
118
|
+
expect(field.options!.length).toBe(3);
|
|
119
|
+
expect(field.options![0].value).toBe('draft');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('maps array of strings to tags', () => {
|
|
123
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
124
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
125
|
+
const field = post.fields.find(f => f.key === 'tags')!;
|
|
126
|
+
expect(field.type).toBe('tags');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('maps object to json', () => {
|
|
130
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
131
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
132
|
+
const field = post.fields.find(f => f.key === 'meta')!;
|
|
133
|
+
expect(field.type).toBe('json');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('detects relation from _id suffix', () => {
|
|
137
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
138
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
139
|
+
const field = post.fields.find(f => f.key === 'author_id')!;
|
|
140
|
+
expect(field.type).toBe('relation');
|
|
141
|
+
expect(field.resource).toBe('authors');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('detects CRUD capabilities from paths', () => {
|
|
145
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
146
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
147
|
+
expect(post.canCreate).toBe(true);
|
|
148
|
+
expect(post.canEdit).toBe(true);
|
|
149
|
+
expect(post.canDelete).toBe(true);
|
|
150
|
+
expect(post.canShow).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('read-only resource has limited capabilities', () => {
|
|
154
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
155
|
+
const user = resources.find(r => r.name === 'users')!;
|
|
156
|
+
expect(user.canShow).toBe(true);
|
|
157
|
+
expect(user.canCreate).toBe(false);
|
|
158
|
+
expect(user.canDelete).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('include filter works', () => {
|
|
162
|
+
const resources = inferFromOpenAPI(sampleOpenAPI, { include: ['Post'] });
|
|
163
|
+
expect(resources.length).toBe(1);
|
|
164
|
+
expect(resources[0].name).toBe('posts');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('exclude filter works', () => {
|
|
168
|
+
const resources = inferFromOpenAPI(sampleOpenAPI, { exclude: ['User'] });
|
|
169
|
+
expect(resources.length).toBe(1);
|
|
170
|
+
expect(resources[0].name).toBe('posts');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('content field is detected as textarea from key name hint', () => {
|
|
174
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
175
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
176
|
+
const field = post.fields.find(f => f.key === 'content')!;
|
|
177
|
+
expect(field.type).toBe('textarea');
|
|
178
|
+
expect(field.showInList).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('id field excluded from form', () => {
|
|
182
|
+
const resources = inferFromOpenAPI(sampleOpenAPI);
|
|
183
|
+
const post = resources.find(r => r.name === 'posts')!;
|
|
184
|
+
const idField = post.fields.find(f => f.key === 'id')!;
|
|
185
|
+
expect(idField.showInForm).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('empty spec returns empty array', () => {
|
|
189
|
+
const resources = inferFromOpenAPI({});
|
|
190
|
+
expect(resources.length).toBe(0);
|
|
191
|
+
});
|
|
192
|
+
});
|