@svadmin/core 0.0.3 → 0.3.0
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/audit.ts +37 -0
- package/src/auth-hooks.svelte.ts +293 -0
- package/src/auth-hooks.test.ts +205 -0
- package/src/context.ts +77 -15
- package/src/data-transfer.test.ts +118 -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 +278 -496
- package/src/http-error.test.ts +85 -0
- package/src/i18n.svelte.ts +24 -4
- package/src/i18n.test.ts +90 -0
- package/src/index.ts +51 -8
- package/src/inferencer-openapi.test.ts +192 -0
- package/src/inferencer-openapi.ts +290 -0
- package/src/inferencer.test.ts +189 -0
- package/src/inferencer.ts +247 -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/permissions.test.ts +55 -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-provider.test.ts +71 -0
- package/src/router-provider.ts +100 -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/toast.svelte.ts +8 -4
- package/src/types.ts +124 -47
- package/src/unsaved-changes.svelte.ts +62 -0
- package/src/url-sync.ts +15 -0
- package/src/useParsed.ts +110 -0
- package/src/useStepsForm.svelte.ts +71 -0
- package/src/utility-hooks.svelte.ts +161 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI Schema → ResourceDefinition converter
|
|
3
|
+
*
|
|
4
|
+
* Parses an OpenAPI 3.x JSON schema and generates ResourceDefinition[]
|
|
5
|
+
* by analyzing the schema's paths and components.
|
|
6
|
+
*/
|
|
7
|
+
import type { FieldDefinition, ResourceDefinition } from './types';
|
|
8
|
+
|
|
9
|
+
// ─── OpenAPI Types (subset) ──────────────────────────────────
|
|
10
|
+
|
|
11
|
+
interface OpenAPISchema {
|
|
12
|
+
openapi?: string;
|
|
13
|
+
info?: { title?: string; version?: string };
|
|
14
|
+
paths?: Record<string, OpenAPIPathItem>;
|
|
15
|
+
components?: {
|
|
16
|
+
schemas?: Record<string, OpenAPISchemaObject>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface OpenAPIPathItem {
|
|
21
|
+
get?: OpenAPIOperation;
|
|
22
|
+
post?: OpenAPIOperation;
|
|
23
|
+
put?: OpenAPIOperation;
|
|
24
|
+
patch?: OpenAPIOperation;
|
|
25
|
+
delete?: OpenAPIOperation;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface OpenAPIOperation {
|
|
29
|
+
operationId?: string;
|
|
30
|
+
tags?: string[];
|
|
31
|
+
parameters?: OpenAPIParameter[];
|
|
32
|
+
requestBody?: {
|
|
33
|
+
content?: Record<string, { schema?: OpenAPISchemaObject | OpenAPIRef }>;
|
|
34
|
+
};
|
|
35
|
+
responses?: Record<string, {
|
|
36
|
+
content?: Record<string, { schema?: OpenAPISchemaObject | OpenAPIRef }>;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface OpenAPIParameter {
|
|
41
|
+
name: string;
|
|
42
|
+
in: string;
|
|
43
|
+
required?: boolean;
|
|
44
|
+
schema?: OpenAPISchemaObject;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface OpenAPISchemaObject {
|
|
48
|
+
type?: string;
|
|
49
|
+
format?: string;
|
|
50
|
+
properties?: Record<string, OpenAPISchemaObject | OpenAPIRef>;
|
|
51
|
+
items?: OpenAPISchemaObject | OpenAPIRef;
|
|
52
|
+
required?: string[];
|
|
53
|
+
enum?: (string | number)[];
|
|
54
|
+
$ref?: string;
|
|
55
|
+
description?: string;
|
|
56
|
+
nullable?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface OpenAPIRef {
|
|
60
|
+
$ref: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isRef(obj: unknown): obj is OpenAPIRef {
|
|
64
|
+
return obj !== null && typeof obj === 'object' && '$ref' in obj && typeof (obj as OpenAPIRef).$ref === 'string';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Schema Resolution ───────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function resolveRef(ref: string, root: OpenAPISchema): OpenAPISchemaObject | null {
|
|
70
|
+
// $ref format: "#/components/schemas/Post"
|
|
71
|
+
const parts = ref.replace('#/', '').split('/');
|
|
72
|
+
let current: unknown = root;
|
|
73
|
+
for (const part of parts) {
|
|
74
|
+
if (current === null || typeof current !== 'object') return null;
|
|
75
|
+
current = (current as Record<string, unknown>)[part];
|
|
76
|
+
if (!current) return null;
|
|
77
|
+
}
|
|
78
|
+
return current as OpenAPISchemaObject;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveSchema(schemaOrRef: OpenAPISchemaObject | OpenAPIRef, root: OpenAPISchema): OpenAPISchemaObject | null {
|
|
82
|
+
if (isRef(schemaOrRef)) {
|
|
83
|
+
return resolveRef(schemaOrRef.$ref, root);
|
|
84
|
+
}
|
|
85
|
+
return schemaOrRef;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Type Mapping ────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function mapOpenAPITypeToFieldType(
|
|
91
|
+
key: string,
|
|
92
|
+
schema: OpenAPISchemaObject,
|
|
93
|
+
root: OpenAPISchema
|
|
94
|
+
): FieldDefinition['type'] {
|
|
95
|
+
const { type, format } = schema;
|
|
96
|
+
|
|
97
|
+
// Enum → select
|
|
98
|
+
if (schema.enum && schema.enum.length > 0) return 'select';
|
|
99
|
+
|
|
100
|
+
// Array types
|
|
101
|
+
if (type === 'array') {
|
|
102
|
+
if (schema.items) {
|
|
103
|
+
const itemSchema = resolveSchema(schema.items, root);
|
|
104
|
+
if (itemSchema?.type === 'string') {
|
|
105
|
+
const lk = key.toLowerCase();
|
|
106
|
+
if (lk.includes('image') || lk.includes('photo')) return 'images';
|
|
107
|
+
return 'tags';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return 'tags';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Object → json
|
|
114
|
+
if (type === 'object') return 'json';
|
|
115
|
+
|
|
116
|
+
// Boolean
|
|
117
|
+
if (type === 'boolean') return 'boolean';
|
|
118
|
+
|
|
119
|
+
// Number/Integer
|
|
120
|
+
if (type === 'number' || type === 'integer') return 'number';
|
|
121
|
+
|
|
122
|
+
// String with format
|
|
123
|
+
if (type === 'string') {
|
|
124
|
+
if (format === 'date' || format === 'date-time') return 'date';
|
|
125
|
+
if (format === 'email') return 'email';
|
|
126
|
+
if (format === 'uri' || format === 'url') return 'url';
|
|
127
|
+
if (format === 'binary' || format === 'byte') return 'image';
|
|
128
|
+
|
|
129
|
+
// Heuristic from key name
|
|
130
|
+
const lk = key.toLowerCase();
|
|
131
|
+
if (lk.includes('email')) return 'email';
|
|
132
|
+
if (lk.includes('phone') || lk.includes('tel')) return 'phone';
|
|
133
|
+
if (lk.includes('url') || lk.includes('link') || lk.includes('website')) return 'url';
|
|
134
|
+
if (lk.includes('avatar') || lk.includes('image') || lk.includes('photo') || lk.includes('logo')) return 'image';
|
|
135
|
+
if (lk.includes('color') || lk.includes('colour')) return 'color';
|
|
136
|
+
if (lk.includes('description') || lk.includes('content') || lk.includes('body') || lk.includes('bio')) return 'textarea';
|
|
137
|
+
if (lk === 'created_at' || lk === 'updated_at' || lk.endsWith('_at') || lk.endsWith('_date')) return 'date';
|
|
138
|
+
|
|
139
|
+
return 'text';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return 'text';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Core: Parse OpenAPI Schema ──────────────────────────────
|
|
146
|
+
|
|
147
|
+
export interface InferFromOpenAPIOptions {
|
|
148
|
+
/** Only infer resources matching these schema names */
|
|
149
|
+
include?: string[];
|
|
150
|
+
/** Exclude these schema names */
|
|
151
|
+
exclude?: string[];
|
|
152
|
+
/** Custom primary key (default: 'id') */
|
|
153
|
+
primaryKey?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse an OpenAPI 3.x JSON schema and produce ResourceDefinition[].
|
|
158
|
+
*/
|
|
159
|
+
export function inferFromOpenAPI(
|
|
160
|
+
spec: OpenAPISchema,
|
|
161
|
+
options: InferFromOpenAPIOptions = {}
|
|
162
|
+
): ResourceDefinition[] {
|
|
163
|
+
const { primaryKey = 'id', include, exclude } = options;
|
|
164
|
+
const schemas = spec.components?.schemas ?? {};
|
|
165
|
+
const resources: ResourceDefinition[] = [];
|
|
166
|
+
|
|
167
|
+
// Detect which schemas have CRUD paths
|
|
168
|
+
const resourcePaths = detectResourcePaths(spec);
|
|
169
|
+
|
|
170
|
+
for (const [schemaName, schemaObj] of Object.entries(schemas)) {
|
|
171
|
+
// Apply include/exclude filters
|
|
172
|
+
if (include && !include.includes(schemaName)) continue;
|
|
173
|
+
if (exclude && exclude.includes(schemaName)) continue;
|
|
174
|
+
|
|
175
|
+
const resolved = resolveSchema(schemaObj as OpenAPISchemaObject | OpenAPIRef, spec);
|
|
176
|
+
if (!resolved || resolved.type !== 'object' || !resolved.properties) continue;
|
|
177
|
+
|
|
178
|
+
const fields: FieldDefinition[] = [];
|
|
179
|
+
const requiredFields = new Set(resolved.required ?? []);
|
|
180
|
+
|
|
181
|
+
for (const [propName, propSchemaOrRef] of Object.entries(resolved.properties)) {
|
|
182
|
+
const propSchema = resolveSchema(propSchemaOrRef, spec);
|
|
183
|
+
if (!propSchema) continue;
|
|
184
|
+
|
|
185
|
+
const fieldType = mapOpenAPITypeToFieldType(propName, propSchema, spec);
|
|
186
|
+
|
|
187
|
+
// Detect relation from $ref or _id suffix
|
|
188
|
+
let relatedResource: string | null = null;
|
|
189
|
+
if (isRef(propSchemaOrRef)) {
|
|
190
|
+
const refName = propSchemaOrRef.$ref.split('/').pop() ?? '';
|
|
191
|
+
relatedResource = refName.toLowerCase() + 's';
|
|
192
|
+
} else if (propName.endsWith('_id') || propName.endsWith('Id')) {
|
|
193
|
+
const base = propName.replace(/_id$|Id$/, '');
|
|
194
|
+
relatedResource = base.endsWith('s') ? base : base + 's';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const field: FieldDefinition = {
|
|
198
|
+
key: propName,
|
|
199
|
+
label: humanize(propName),
|
|
200
|
+
type: relatedResource ? 'relation' : fieldType,
|
|
201
|
+
required: requiredFields.has(propName),
|
|
202
|
+
sortable: ['text', 'number', 'date'].includes(fieldType),
|
|
203
|
+
searchable: ['text', 'email'].includes(fieldType),
|
|
204
|
+
showInList: propName !== primaryKey && fieldType !== 'textarea' && fieldType !== 'json',
|
|
205
|
+
showInForm: propName !== primaryKey,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (relatedResource) {
|
|
209
|
+
field.resource = relatedResource;
|
|
210
|
+
field.optionLabel = 'name';
|
|
211
|
+
field.optionValue = 'id';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (propSchema.enum) {
|
|
215
|
+
field.options = propSchema.enum.map(v => ({
|
|
216
|
+
label: String(v).charAt(0).toUpperCase() + String(v).slice(1),
|
|
217
|
+
value: v as string | number,
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
fields.push(field);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Sort: primaryKey first
|
|
225
|
+
fields.sort((a, b) => {
|
|
226
|
+
if (a.key === primaryKey) return -1;
|
|
227
|
+
if (b.key === primaryKey) return 1;
|
|
228
|
+
return 0;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Check path-based CRUD capabilities
|
|
232
|
+
const pathInfo = resourcePaths.get(schemaName.toLowerCase()) ??
|
|
233
|
+
resourcePaths.get(schemaName.toLowerCase() + 's');
|
|
234
|
+
|
|
235
|
+
resources.push({
|
|
236
|
+
name: schemaName.toLowerCase() + 's',
|
|
237
|
+
label: schemaName,
|
|
238
|
+
primaryKey,
|
|
239
|
+
fields,
|
|
240
|
+
canCreate: pathInfo?.hasPost ?? true,
|
|
241
|
+
canEdit: pathInfo?.hasPut ?? true,
|
|
242
|
+
canDelete: pathInfo?.hasDelete ?? true,
|
|
243
|
+
canShow: pathInfo?.hasGet ?? true,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return resources;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── Path Detection ──────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
interface PathInfo {
|
|
253
|
+
hasGet: boolean;
|
|
254
|
+
hasPost: boolean;
|
|
255
|
+
hasPut: boolean;
|
|
256
|
+
hasDelete: boolean;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function detectResourcePaths(spec: OpenAPISchema): Map<string, PathInfo> {
|
|
260
|
+
const result = new Map<string, PathInfo>();
|
|
261
|
+
if (!spec.paths) return result;
|
|
262
|
+
|
|
263
|
+
for (const [path, methods] of Object.entries(spec.paths)) {
|
|
264
|
+
// Extract resource name from path like /api/posts or /posts/{id}
|
|
265
|
+
const segments = path.split('/').filter(Boolean);
|
|
266
|
+
const resourceSegment = segments.find(s => !s.startsWith('{') && s !== 'api' && s !== 'v1' && s !== 'v2');
|
|
267
|
+
if (!resourceSegment) continue;
|
|
268
|
+
|
|
269
|
+
const name = resourceSegment.toLowerCase();
|
|
270
|
+
const existing = result.get(name) ?? { hasGet: false, hasPost: false, hasPut: false, hasDelete: false };
|
|
271
|
+
|
|
272
|
+
if (methods.get) existing.hasGet = true;
|
|
273
|
+
if (methods.post) existing.hasPost = true;
|
|
274
|
+
if (methods.put || methods.patch) existing.hasPut = true;
|
|
275
|
+
if (methods.delete) existing.hasDelete = true;
|
|
276
|
+
|
|
277
|
+
result.set(name, existing);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── Utilities ───────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
function humanize(key: string): string {
|
|
286
|
+
return key
|
|
287
|
+
.replace(/_/g, ' ')
|
|
288
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
289
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
290
|
+
}
|
|
@@ -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
|
+
});
|