@fidt/dynamic-form 0.0.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/README.md +458 -0
- package/package.json +59 -0
- package/src/components/DynamicForm.vue +229 -0
- package/src/index.ts +16 -0
- package/src/interfaces/index.ts +64 -0
- package/src/models/form.model.ts +195 -0
- package/src/utils/validator/index.test.ts +199 -0
- package/src/utils/validator/index.ts +42 -0
- package/src/utils/validator/libs/arktype.ts +45 -0
- package/src/utils/validator/libs/zod.ts +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
# @fidt/dynamic-form
|
|
2
|
+
|
|
3
|
+
Thư viện dynamic form cho Vue 3 với hỗ trợ validation tích hợp (Zod, ArkType).
|
|
4
|
+
|
|
5
|
+
## ✨ Tính năng
|
|
6
|
+
|
|
7
|
+
- 🎯 **Dynamic Form Generation** - Tạo form từ schema JSON
|
|
8
|
+
- 📐 **Flexible Layout** - Hỗ trợ grid layout đa cột và nested fields
|
|
9
|
+
- ✅ **Built-in Validation** - Validation tích hợp với Zod/ArkType
|
|
10
|
+
- 🎨 **UI Component Agnostic** - Tương thích với bất kỳ UI component nào
|
|
11
|
+
- 📦 **Type-safe** - Fully typed với TypeScript
|
|
12
|
+
- 🔄 **Reactive** - Tích hợp Vue reactivity system
|
|
13
|
+
|
|
14
|
+
## 📦 Cài đặt
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @fidt/dynamic-form
|
|
18
|
+
# hoặc
|
|
19
|
+
pnpm add @fidt/dynamic-form
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 🚀 Sử dụng cơ bản
|
|
23
|
+
|
|
24
|
+
### 1. Import và đăng ký
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import DymikForm from '@fidt/dynamic-form'
|
|
28
|
+
import '@fidt/dynamic-form/dist/dynamic-form.css'
|
|
29
|
+
|
|
30
|
+
// Đăng ký global (optional)
|
|
31
|
+
app.use(DymikForm)
|
|
32
|
+
|
|
33
|
+
// Hoặc import trực tiếp trong component
|
|
34
|
+
import { DynamicForm, FormModel } from '@fidt/dynamic-form'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2. Tạo form đơn giản
|
|
38
|
+
|
|
39
|
+
```vue
|
|
40
|
+
<script setup lang="ts">
|
|
41
|
+
import { reactive } from 'vue'
|
|
42
|
+
import { DynamicForm, FormModel } from '@fidt/dynamic-form'
|
|
43
|
+
|
|
44
|
+
const form = reactive(
|
|
45
|
+
new FormModel({
|
|
46
|
+
name: 'contact-form',
|
|
47
|
+
description: 'Form liên hệ đơn giản',
|
|
48
|
+
fields: [
|
|
49
|
+
{
|
|
50
|
+
name: 'fullName',
|
|
51
|
+
label: 'Họ và tên',
|
|
52
|
+
type: 'FInput',
|
|
53
|
+
required: true,
|
|
54
|
+
required_text: 'Vui lòng nhập họ tên',
|
|
55
|
+
props: { placeholder: 'Nhập họ và tên' },
|
|
56
|
+
validation_rules: [
|
|
57
|
+
{ type: 'minLength', value: 2, message: 'Tên phải có ít nhất 2 ký tự' }
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'email',
|
|
62
|
+
label: 'Email',
|
|
63
|
+
type: 'FInput',
|
|
64
|
+
required: true,
|
|
65
|
+
props: { placeholder: 'your@email.com' },
|
|
66
|
+
validation_rules: [
|
|
67
|
+
{ type: 'email', message: 'Email không hợp lệ' }
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'submit',
|
|
72
|
+
type: 'FButton',
|
|
73
|
+
role: 'submit',
|
|
74
|
+
props: { label: 'Gửi', order: 'primary' }
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
})
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
function onSubmit(values: Record<string, unknown>) {
|
|
81
|
+
console.log('Form data:', values)
|
|
82
|
+
}
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<template>
|
|
86
|
+
<DynamicForm :form="form" @submit="onSubmit" />
|
|
87
|
+
</template>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## 📐 Layout nâng cao
|
|
91
|
+
|
|
92
|
+
### Grid Layout với nhiều cột
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
const form = reactive(
|
|
96
|
+
new FormModel({
|
|
97
|
+
name: 'registration-form',
|
|
98
|
+
columns: 3, // Số cột trong grid
|
|
99
|
+
layout: [
|
|
100
|
+
['fullName', 'email', 'phone'], // Hàng 1: 3 fields
|
|
101
|
+
[['idCard', 'issueDate'], 'address', null], // Hàng 2: nested fields, 1 field, 1 ô trống
|
|
102
|
+
[null, null, 'submit'], // Hàng 3: button căn phải
|
|
103
|
+
],
|
|
104
|
+
fields: {
|
|
105
|
+
fullName: {
|
|
106
|
+
label: 'Họ và tên',
|
|
107
|
+
type: 'FInput',
|
|
108
|
+
required: true,
|
|
109
|
+
props: { placeholder: 'Nhập họ tên' }
|
|
110
|
+
},
|
|
111
|
+
email: {
|
|
112
|
+
label: 'Email',
|
|
113
|
+
type: 'FInput',
|
|
114
|
+
required: true,
|
|
115
|
+
validation_rules: [{ type: 'email', message: 'Email không hợp lệ' }]
|
|
116
|
+
},
|
|
117
|
+
phone: {
|
|
118
|
+
label: 'Số điện thoại',
|
|
119
|
+
type: 'FInput',
|
|
120
|
+
props: { placeholder: '0123456789' }
|
|
121
|
+
},
|
|
122
|
+
idCard: {
|
|
123
|
+
label: 'CCCD',
|
|
124
|
+
type: 'FInput',
|
|
125
|
+
required: true,
|
|
126
|
+
props: { placeholder: 'Số CCCD' }
|
|
127
|
+
},
|
|
128
|
+
issueDate: {
|
|
129
|
+
label: 'Ngày cấp',
|
|
130
|
+
type: 'FInput',
|
|
131
|
+
props: { type: 'date' }
|
|
132
|
+
},
|
|
133
|
+
address: {
|
|
134
|
+
label: 'Địa chỉ',
|
|
135
|
+
type: 'FInput',
|
|
136
|
+
props: { placeholder: 'Địa chỉ hiện tại' }
|
|
137
|
+
},
|
|
138
|
+
submit: {
|
|
139
|
+
type: 'FButton',
|
|
140
|
+
role: 'submit',
|
|
141
|
+
props: { label: 'Đăng ký', order: 'primary' }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Giải thích Layout
|
|
149
|
+
|
|
150
|
+
- **`columns`**: Số cột trong grid (mặc định: auto)
|
|
151
|
+
- **`layout`**: Mảng 2D định nghĩa vị trí các field
|
|
152
|
+
- `'fieldName'` - Field đơn chiếm 1 ô
|
|
153
|
+
- `['field1', 'field2']` - Nhiều field nested trong 1 ô
|
|
154
|
+
- `null` - Ô trống (dùng để căn chỉnh)
|
|
155
|
+
|
|
156
|
+
### Format Fields
|
|
157
|
+
|
|
158
|
+
Có 2 cách định nghĩa fields:
|
|
159
|
+
|
|
160
|
+
**1. Array format** (truyền thống):
|
|
161
|
+
```typescript
|
|
162
|
+
fields: [
|
|
163
|
+
{
|
|
164
|
+
name: 'email',
|
|
165
|
+
label: 'Email',
|
|
166
|
+
type: 'FInput',
|
|
167
|
+
// ...
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**2. Object format** (recommended với layout):
|
|
173
|
+
```typescript
|
|
174
|
+
fields: {
|
|
175
|
+
email: {
|
|
176
|
+
label: 'Email',
|
|
177
|
+
type: 'FInput',
|
|
178
|
+
// ...
|
|
179
|
+
}
|
|
180
|
+
// name tự động lấy từ key
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## ✅ Validation
|
|
185
|
+
|
|
186
|
+
### Các loại validation được hỗ trợ
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
validation_rules: [
|
|
190
|
+
// String validation
|
|
191
|
+
{ type: 'string', message: 'Phải là chuỗi' },
|
|
192
|
+
{ type: 'minLength', value: 3, message: 'Tối thiểu 3 ký tự' },
|
|
193
|
+
{ type: 'maxLength', value: 50, message: 'Tối đa 50 ký tự' },
|
|
194
|
+
|
|
195
|
+
// Number validation
|
|
196
|
+
{ type: 'number', message: 'Phải là số' },
|
|
197
|
+
{ type: 'min', value: 18, message: 'Tối thiểu 18' },
|
|
198
|
+
{ type: 'max', value: 100, message: 'Tối đa 100' },
|
|
199
|
+
|
|
200
|
+
// Special formats
|
|
201
|
+
{ type: 'email', message: 'Email không hợp lệ' },
|
|
202
|
+
{ type: 'url', message: 'URL không hợp lệ' },
|
|
203
|
+
{ type: 'regex', value: /^\d{9,12}$/, message: 'CCCD không đúng định dạng' },
|
|
204
|
+
|
|
205
|
+
// Custom validation
|
|
206
|
+
{
|
|
207
|
+
type: 'custom',
|
|
208
|
+
value: (value, formValues) => value !== formValues.password,
|
|
209
|
+
message: 'Mật khẩu không khớp'
|
|
210
|
+
}
|
|
211
|
+
]
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Custom Validator
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
import { ValidatorUtils } from '@fidt/dynamic-form'
|
|
218
|
+
|
|
219
|
+
ValidatorUtils.addCustomValidator('isAdult', (rule, value) => {
|
|
220
|
+
const age = parseInt(value)
|
|
221
|
+
return age >= 18
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// Sử dụng
|
|
225
|
+
validation_rules: [
|
|
226
|
+
{ type: 'isAdult', message: 'Phải từ 18 tuổi trở lên' }
|
|
227
|
+
]
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## 🎯 FormModel API
|
|
231
|
+
|
|
232
|
+
### Properties
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
interface FormItem {
|
|
236
|
+
name: string // Tên form
|
|
237
|
+
description?: string // Mô tả
|
|
238
|
+
id?: string // ID duy nhất
|
|
239
|
+
fields: FormField[] // Danh sách fields
|
|
240
|
+
columns?: number // Số cột grid
|
|
241
|
+
layout?: (string | null | string[])[][] // Layout grid
|
|
242
|
+
css_classes?: string // CSS classes tùy chỉnh
|
|
243
|
+
disabled?: boolean // Disable toàn bộ form
|
|
244
|
+
invalid?: boolean // Trạng thái invalid (readonly)
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Methods
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
// Validation
|
|
252
|
+
form.validate(): boolean // Validate toàn bộ form
|
|
253
|
+
form.validateField(name, value): boolean // Validate 1 field
|
|
254
|
+
|
|
255
|
+
// Get/Set values
|
|
256
|
+
form.getFormValue(): Record<string, any> // Lấy tất cả giá trị
|
|
257
|
+
form.setFormValue(values: Record<string, any>): void // Set nhiều giá trị
|
|
258
|
+
form.setFieldValue(name: string, value: any): void // Set 1 giá trị
|
|
259
|
+
|
|
260
|
+
// Errors
|
|
261
|
+
form.getFormErrors(): Record<string, string> // Lấy tất cả lỗi
|
|
262
|
+
form.getFormError(name: string): string | undefined // Lấy lỗi của 1 field
|
|
263
|
+
form.setFormError(name: string, error: string): void // Set lỗi cho 1 field
|
|
264
|
+
|
|
265
|
+
// Reset
|
|
266
|
+
form.reset(): void // Reset form về trạng thái ban đầu
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## 🎨 Events
|
|
270
|
+
|
|
271
|
+
```vue
|
|
272
|
+
<DynamicForm
|
|
273
|
+
:form="form"
|
|
274
|
+
@submit="onSubmit"
|
|
275
|
+
@value-change="onValueChange"
|
|
276
|
+
@loading="onLoading"
|
|
277
|
+
@submit-result="onSubmitResult"
|
|
278
|
+
/>
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Event handlers
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
function onSubmit(values: Record<string, unknown>) {
|
|
285
|
+
// Được gọi khi form submit và validation pass
|
|
286
|
+
console.log('Submit data:', values)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function onValueChange(values: Record<string, unknown>) {
|
|
290
|
+
// Được gọi mỗi khi có field thay đổi
|
|
291
|
+
console.log('Current form values:', values)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function onLoading(isLoading: boolean) {
|
|
295
|
+
// Trạng thái loading khi submit
|
|
296
|
+
console.log('Loading:', isLoading)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function onSubmitResult(result: any) {
|
|
300
|
+
// Kết quả sau khi submit (nếu có saulFunctionName)
|
|
301
|
+
console.log('Result:', result)
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## 🎛️ Field Configuration
|
|
306
|
+
|
|
307
|
+
### FormField Interface
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
interface FormField {
|
|
311
|
+
name: string // Tên field (unique)
|
|
312
|
+
label?: string // Label hiển thị
|
|
313
|
+
type: string // Component type (VD: 'FInput', 'FButton')
|
|
314
|
+
role?: 'submit' | 'reset' | 'field' // Vai trò của field
|
|
315
|
+
required?: boolean // Bắt buộc
|
|
316
|
+
disabled?: boolean // Disable
|
|
317
|
+
required_text?: string // Thông báo lỗi khi required
|
|
318
|
+
props: any // Props truyền cho component
|
|
319
|
+
error?: string // Error message (readonly)
|
|
320
|
+
classes?: string // CSS classes
|
|
321
|
+
value?: any // Giá trị hiện tại
|
|
322
|
+
validation_rules?: ValidationRule[] // Quy tắc validation
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Ví dụ Field types
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
// Input
|
|
330
|
+
{
|
|
331
|
+
name: 'username',
|
|
332
|
+
type: 'FInput',
|
|
333
|
+
props: { placeholder: 'Enter username', type: 'text' }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Button
|
|
337
|
+
{
|
|
338
|
+
name: 'submit',
|
|
339
|
+
type: 'FButton',
|
|
340
|
+
role: 'submit',
|
|
341
|
+
props: { label: 'Submit', order: 'primary', size: 'large' }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Select (tùy component library)
|
|
345
|
+
{
|
|
346
|
+
name: 'country',
|
|
347
|
+
type: 'FSelect',
|
|
348
|
+
props: {
|
|
349
|
+
options: [
|
|
350
|
+
{ label: 'Vietnam', value: 'vn' },
|
|
351
|
+
{ label: 'USA', value: 'us' }
|
|
352
|
+
]
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## 🎨 Custom Styling
|
|
358
|
+
|
|
359
|
+
### CSS Classes
|
|
360
|
+
|
|
361
|
+
```scss
|
|
362
|
+
.dymik-form {
|
|
363
|
+
// Form container
|
|
364
|
+
|
|
365
|
+
&.layout-mode {
|
|
366
|
+
// Grid layout mode
|
|
367
|
+
display: grid;
|
|
368
|
+
grid-template-columns: repeat(var(--columns), 1fr);
|
|
369
|
+
gap: 1rem;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.field {
|
|
373
|
+
// Field wrapper
|
|
374
|
+
|
|
375
|
+
label {
|
|
376
|
+
.required {
|
|
377
|
+
color: red;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.error {
|
|
382
|
+
color: red;
|
|
383
|
+
font-size: 0.875rem;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.field-group {
|
|
388
|
+
// Nested fields container
|
|
389
|
+
|
|
390
|
+
.nested-fields {
|
|
391
|
+
display: flex;
|
|
392
|
+
gap: 0.5rem;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Custom CSS Classes
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
const form = new FormModel({
|
|
402
|
+
name: 'my-form',
|
|
403
|
+
css_classes: 'custom-form theme-dark', // Form-level classes
|
|
404
|
+
fields: [
|
|
405
|
+
{
|
|
406
|
+
name: 'email',
|
|
407
|
+
classes: 'highlighted-field', // Field-level classes
|
|
408
|
+
// ...
|
|
409
|
+
}
|
|
410
|
+
]
|
|
411
|
+
})
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
## 🔌 Tích hợp với UI Components
|
|
415
|
+
|
|
416
|
+
Dynamic Form hoàn toàn component-agnostic. Bạn có thể sử dụng với bất kỳ UI library nào:
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
// Với FIDT UI Components
|
|
420
|
+
{
|
|
421
|
+
type: 'FInput',
|
|
422
|
+
props: { placeholder: 'Enter text' }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Với Element Plus
|
|
426
|
+
{
|
|
427
|
+
type: 'el-input',
|
|
428
|
+
props: { placeholder: 'Enter text' }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Với Ant Design Vue
|
|
432
|
+
{
|
|
433
|
+
type: 'a-input',
|
|
434
|
+
props: { placeholder: 'Enter text' }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Hoặc custom component của bạn
|
|
438
|
+
{
|
|
439
|
+
type: 'MyCustomInput',
|
|
440
|
+
props: { /* custom props */ }
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
**Lưu ý**: Component phải hỗ trợ `v-model` và emit `value-change` event.
|
|
445
|
+
|
|
446
|
+
## 📚 Ví dụ hoàn chỉnh
|
|
447
|
+
|
|
448
|
+
Xem thêm các ví dụ trong thư mục `examples/`:
|
|
449
|
+
- [ContactForm.vue](./examples/with-fidt-ui-component/src/pages/ContactForm.vue) - Form liên hệ đơn giản
|
|
450
|
+
- [ThreeColumnForm.vue](./examples/with-fidt-ui-component/src/pages/ThreeColumnForm.vue) - Form đăng ký 3 cột với nested fields
|
|
451
|
+
|
|
452
|
+
## 🤝 Contributing
|
|
453
|
+
|
|
454
|
+
Contributions, issues và feature requests đều được chào đón!
|
|
455
|
+
|
|
456
|
+
## 📝 License
|
|
457
|
+
|
|
458
|
+
MIT © fidt
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fidt/dynamic-form",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "A dynamic form library for Vue 3 with built-in validation support (Zod, ArkType)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vue",
|
|
7
|
+
"vue3",
|
|
8
|
+
"form",
|
|
9
|
+
"dynamic-form",
|
|
10
|
+
"validation",
|
|
11
|
+
"zod",
|
|
12
|
+
"arktype"
|
|
13
|
+
],
|
|
14
|
+
"author": "fidt",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": ""
|
|
19
|
+
},
|
|
20
|
+
"homepage": "",
|
|
21
|
+
"module": "dist/index.es.js",
|
|
22
|
+
"types": "dist/index.d.ts",
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"src",
|
|
26
|
+
"package.json",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.es.js"
|
|
33
|
+
},
|
|
34
|
+
"./dist/dynamic-form.css": "./dist/dynamic-form.css"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "vite build"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"vue": "^3.5.13"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@standard-schema/spec": "^1.0.0",
|
|
44
|
+
"arktype": "^2.1.20",
|
|
45
|
+
"zod": "^3.24.2"
|
|
46
|
+
},
|
|
47
|
+
"sideEffects": [
|
|
48
|
+
"*.vue",
|
|
49
|
+
"*.scss"
|
|
50
|
+
],
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@vitejs/plugin-vue": "^5.2.1",
|
|
53
|
+
"sass-embedded": "^1.98.0",
|
|
54
|
+
"typescript": "^5.0.0",
|
|
55
|
+
"vite": "^6.2.0",
|
|
56
|
+
"vite-plugin-dts": "^4.5.3",
|
|
57
|
+
"vitest": "^1.0.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="dymik-form" :class="[props.form.css_classes, { 'layout-mode': hasLayout }]"
|
|
3
|
+
:style="hasLayout && props.form.columns ? { '--columns': props.form.columns } : {}">
|
|
4
|
+
|
|
5
|
+
<!-- Layout-based rendering -->
|
|
6
|
+
<template v-if="hasLayout">
|
|
7
|
+
<template v-for="(row, rowIndex) in props.form.layout" :key="`row-${rowIndex}`">
|
|
8
|
+
<div v-for="(cell, colIndex) in row" :key="`${rowIndex}-${colIndex}`"
|
|
9
|
+
class="field" :class="Array.isArray(cell) ? 'field-group' : getFieldByName(cell as string | null)?.classes">
|
|
10
|
+
|
|
11
|
+
<!-- Nested fields (array of field names) -->
|
|
12
|
+
<template v-if="Array.isArray(cell)">
|
|
13
|
+
<div class="nested-fields">
|
|
14
|
+
<div v-for="fieldName in cell" :key="fieldName"
|
|
15
|
+
class="nested-field" :class="getFieldByName(fieldName)?.classes">
|
|
16
|
+
<template v-if="getFieldByName(fieldName)">
|
|
17
|
+
<label v-if="getFieldByName(fieldName)!.label" :for="getFieldByName(fieldName)!.name">
|
|
18
|
+
{{ getFieldByName(fieldName)!.label }}
|
|
19
|
+
<span v-if="getFieldByName(fieldName)!.required" class="required">*</span>
|
|
20
|
+
</label>
|
|
21
|
+
<component
|
|
22
|
+
v-model="getFieldByName(fieldName)!.value"
|
|
23
|
+
:is="getFieldByName(fieldName)!.type"
|
|
24
|
+
v-bind="getFieldByName(fieldName)!.props"
|
|
25
|
+
:key="getFieldByName(fieldName)!.name"
|
|
26
|
+
:invalid="!!getFieldByName(fieldName)!.error"
|
|
27
|
+
@value-change="(value: any) => onValueChanged(getFieldByName(fieldName)!.name, value)"
|
|
28
|
+
@input="(event: any) => onNativeInput(getFieldByName(fieldName)!.name, event)"
|
|
29
|
+
:disabled="form.disabled || getFieldByName(fieldName)!.disabled"
|
|
30
|
+
@click="(event: any) => onFieldClick(getFieldByName(fieldName)!, event)" />
|
|
31
|
+
<span v-if="!!getFieldByName(fieldName)!.error" class="error">
|
|
32
|
+
{{ getFieldByName(fieldName)!.error }}
|
|
33
|
+
</span>
|
|
34
|
+
</template>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<!-- Single field -->
|
|
40
|
+
<template v-else-if="getFieldByName(cell as string | null)">
|
|
41
|
+
<label v-if="getFieldByName(cell as string | null)!.label" :for="getFieldByName(cell as string | null)!.name">
|
|
42
|
+
{{ getFieldByName(cell as string | null)!.label }}
|
|
43
|
+
<span v-if="getFieldByName(cell as string | null)!.required" class="required">*</span>
|
|
44
|
+
</label>
|
|
45
|
+
<component
|
|
46
|
+
v-model="getFieldByName(cell as string | null)!.value"
|
|
47
|
+
:is="getFieldByName(cell as string | null)!.type"
|
|
48
|
+
v-bind="getFieldByName(cell as string | null)!.props"
|
|
49
|
+
:key="getFieldByName(cell as string | null)!.name"
|
|
50
|
+
:invalid="!!getFieldByName(cell as string | null)!.error"
|
|
51
|
+
@value-change="(value: any) => onValueChanged(getFieldByName(cell as string | null)!.name, value)"
|
|
52
|
+
@input="(event: any) => onNativeInput(getFieldByName(cell as string | null)!.name, event)"
|
|
53
|
+
:disabled="form.disabled || getFieldByName(cell as string | null)!.disabled"
|
|
54
|
+
@click="(event: any) => onFieldClick(getFieldByName(cell as string | null)!, event)" />
|
|
55
|
+
<span v-if="!!getFieldByName(cell as string | null)!.error" class="error">
|
|
56
|
+
{{ getFieldByName(cell as string | null)!.error }}
|
|
57
|
+
</span>
|
|
58
|
+
</template>
|
|
59
|
+
</div>
|
|
60
|
+
</template>
|
|
61
|
+
</template>
|
|
62
|
+
|
|
63
|
+
<!-- Legacy array-based rendering -->
|
|
64
|
+
<template v-else>
|
|
65
|
+
<div class="field" :class="field.classes" v-for="field of props.form.fields" :key="field.name">
|
|
66
|
+
<label v-if="field.label" :for="field.name">
|
|
67
|
+
{{ field.label }}
|
|
68
|
+
<span v-if="field.required" class="required">*</span>
|
|
69
|
+
</label>
|
|
70
|
+
<component v-model="field.value" :is="field.type" v-bind="field.props" :key="field.name"
|
|
71
|
+
:invalid="!!field.error" @value-change="(value: any) => onValueChanged(field.name, value)"
|
|
72
|
+
@input="(event: any) => onNativeInput(field.name, event)"
|
|
73
|
+
:disabled="form.disabled || field.disabled" @click="(event: any) => onFieldClick(field, event)" />
|
|
74
|
+
<span v-if="!!field.error" class="error">{{ field.error }}</span>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
77
|
+
</div>
|
|
78
|
+
</template>
|
|
79
|
+
<script setup lang="ts">
|
|
80
|
+
import { ref, computed } from 'vue';
|
|
81
|
+
import FormModel from '../models/form.model';
|
|
82
|
+
import type { FormField } from '../interfaces';
|
|
83
|
+
|
|
84
|
+
const props = defineProps<{ form: FormModel }>();
|
|
85
|
+
const emit = defineEmits(['submit', 'value-change', 'loading', 'submit-result']);
|
|
86
|
+
const loading = ref(false);
|
|
87
|
+
|
|
88
|
+
const hasLayout = computed(() => {
|
|
89
|
+
return !!(props.form.layout && props.form.layout.length > 0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function getFieldByName(fieldName: string | null): FormField | undefined {
|
|
93
|
+
if (!fieldName) return undefined;
|
|
94
|
+
return props.form.fields.find((f: FormField) => f.name === fieldName);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function onNativeInput(fieldName: string, event: any) {
|
|
98
|
+
if (event instanceof Event && event.target) {
|
|
99
|
+
onValueChanged(fieldName, (event.target as HTMLInputElement).value);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function onValueChanged(fieldName: string, value: any) {
|
|
104
|
+
const field = props.form.fields.find((f: FormField) => f.name === fieldName);
|
|
105
|
+
|
|
106
|
+
if (field) {
|
|
107
|
+
field.value = value;
|
|
108
|
+
|
|
109
|
+
emit('value-change', props.form.getFormValue());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
props.form.validateField(fieldName, value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function onFieldClick(field: FormField, event: Event) {
|
|
116
|
+
if (field.role === 'submit') {
|
|
117
|
+
const isValid = props.form.validate();
|
|
118
|
+
|
|
119
|
+
if (!isValid) {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
|
|
126
|
+
if (props.form.saulFunctionName) {
|
|
127
|
+
loading.value = true;
|
|
128
|
+
emit('loading', true);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await props.form.submitToEndpoint();
|
|
132
|
+
emit('submit-result', { message: 'Form submitted successfully!', type: 'success' });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error('Error:', error);
|
|
135
|
+
emit('submit-result', { message: 'Failed to submit form.', type: 'error' });
|
|
136
|
+
} finally {
|
|
137
|
+
loading.value = false;
|
|
138
|
+
emit('loading', false);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
emit('submit', props.form.getFormValue());
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
</script>
|
|
146
|
+
<style scoped lang="scss">
|
|
147
|
+
.dymik-form {
|
|
148
|
+
display: flex;
|
|
149
|
+
gap: 16px;
|
|
150
|
+
flex-wrap: wrap;
|
|
151
|
+
padding: 1rem;
|
|
152
|
+
|
|
153
|
+
&.layout-mode {
|
|
154
|
+
display: grid;
|
|
155
|
+
grid-template-columns: repeat(var(--columns, 1), 1fr);
|
|
156
|
+
gap: 16px;
|
|
157
|
+
|
|
158
|
+
@media (max-width: 1023px) {
|
|
159
|
+
grid-template-columns: repeat(2, 1fr);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@media (max-width: 767px) {
|
|
163
|
+
grid-template-columns: 1fr;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.field {
|
|
168
|
+
display: flex;
|
|
169
|
+
flex-direction: column;
|
|
170
|
+
gap: 0.5rem;
|
|
171
|
+
|
|
172
|
+
&.field-group {
|
|
173
|
+
gap: 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.nested-fields {
|
|
177
|
+
display: flex;
|
|
178
|
+
gap: 8px;
|
|
179
|
+
width: 100%;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.nested-field {
|
|
183
|
+
display: flex;
|
|
184
|
+
flex-direction: column;
|
|
185
|
+
gap: 0.5rem;
|
|
186
|
+
flex: 1;
|
|
187
|
+
min-width: 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.required {
|
|
191
|
+
color: red;
|
|
192
|
+
margin-left: 0.25rem;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.error {
|
|
196
|
+
color: red;
|
|
197
|
+
font-size: 0.875rem;
|
|
198
|
+
margin-top: 0.25rem;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.loading-content {
|
|
204
|
+
display: flex;
|
|
205
|
+
justify-content: center;
|
|
206
|
+
align-items: center;
|
|
207
|
+
font-size: 1.5rem;
|
|
208
|
+
color: white;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
</style>
|
|
212
|
+
|
|
213
|
+
<style lang="scss">
|
|
214
|
+
.dymik-form {
|
|
215
|
+
.field {
|
|
216
|
+
&.full_width {
|
|
217
|
+
width: 100%;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
&.half_width {
|
|
221
|
+
width: calc(50% - 8px);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
&.third_width {
|
|
225
|
+
width: calc(33.333% - 11px);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
</style>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './interfaces';
|
|
2
|
+
export * from './models/form.model';
|
|
3
|
+
export * from './utils/validator';
|
|
4
|
+
|
|
5
|
+
export { default as FormModel } from './models/form.model';
|
|
6
|
+
export { default as ValidatorUtils } from './utils/validator';
|
|
7
|
+
|
|
8
|
+
import DynamicForm from './components/DynamicForm.vue';
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
DynamicForm,
|
|
13
|
+
install(app: any) {
|
|
14
|
+
app.component('DynamicForm', DynamicForm);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { StandardSchemaV1 as StandardSchema } from "@standard-schema/spec";
|
|
2
|
+
|
|
3
|
+
export interface FormListItem {
|
|
4
|
+
name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
id: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FormItem {
|
|
10
|
+
name: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
id?: string;
|
|
13
|
+
fields: FormField[] | Record<string, Omit<FormField, 'name'>>;
|
|
14
|
+
columns?: number;
|
|
15
|
+
layout?: (string | null | string[])[][];
|
|
16
|
+
css_classes?: string;
|
|
17
|
+
saulFunctionName?: string;
|
|
18
|
+
invalid?: boolean;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FormField {
|
|
23
|
+
label?: string;
|
|
24
|
+
name: string;
|
|
25
|
+
type: string;
|
|
26
|
+
role?: 'submit' | 'reset' | 'field';
|
|
27
|
+
required?: boolean;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
required_text?: string;
|
|
30
|
+
props: any;
|
|
31
|
+
error?: string;
|
|
32
|
+
classes?: string;
|
|
33
|
+
value?: any;
|
|
34
|
+
validation_rules?: ValidationRule[];
|
|
35
|
+
// Ensure ValidationRule is defined or imported
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ValidationRule {
|
|
39
|
+
type:
|
|
40
|
+
| 'string'
|
|
41
|
+
| 'number'
|
|
42
|
+
| 'boolean'
|
|
43
|
+
| 'date'
|
|
44
|
+
| 'regex'
|
|
45
|
+
| 'min'
|
|
46
|
+
| 'max'
|
|
47
|
+
| 'minLength'
|
|
48
|
+
| 'maxLength'
|
|
49
|
+
| 'email'
|
|
50
|
+
| 'url'
|
|
51
|
+
| 'custom';
|
|
52
|
+
message?: string;
|
|
53
|
+
value?: string | number | boolean | ((value: any, formValue: any) => boolean);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
export interface IValidatorLib {
|
|
58
|
+
|
|
59
|
+
schemas: Record<ValidationRule['type'], () => StandardSchema> | {};
|
|
60
|
+
|
|
61
|
+
validate(rule: ValidationRule, value: any): boolean;
|
|
62
|
+
|
|
63
|
+
schemaFactory: (type: ValidationRule['type'], ruleValue: any) => StandardSchema;
|
|
64
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { FormField, FormItem } from "../interfaces";
|
|
2
|
+
import ValidatorUtils from "../utils/validator";
|
|
3
|
+
|
|
4
|
+
export default class FormModel implements FormItem {
|
|
5
|
+
name: string;
|
|
6
|
+
description?: string | undefined;
|
|
7
|
+
id?: string;
|
|
8
|
+
fields: FormField[];
|
|
9
|
+
columns?: number;
|
|
10
|
+
layout?: (string | null | string[])[][];
|
|
11
|
+
css_classes?: string | undefined;
|
|
12
|
+
saulFunctionName?: string | undefined;
|
|
13
|
+
invalid: boolean;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
|
|
16
|
+
constructor(form: FormItem) {
|
|
17
|
+
this.name = form.name;
|
|
18
|
+
this.id = form.id;
|
|
19
|
+
this.description = form.description;
|
|
20
|
+
|
|
21
|
+
// Normalize fields from object to array if needed
|
|
22
|
+
if (Array.isArray(form.fields)) {
|
|
23
|
+
this.fields = form.fields;
|
|
24
|
+
} else {
|
|
25
|
+
// Convert object format to array format
|
|
26
|
+
this.fields = Object.entries(form.fields).map(([name, field]) => ({
|
|
27
|
+
...field,
|
|
28
|
+
name,
|
|
29
|
+
})) as FormField[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Initialize field.value from props.modelValue if value is not explicitly set
|
|
33
|
+
for (const field of this.fields) {
|
|
34
|
+
if (field.value === undefined && field.props?.modelValue !== undefined) {
|
|
35
|
+
field.value = field.props.modelValue;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.columns = form.columns;
|
|
40
|
+
this.layout = form.layout;
|
|
41
|
+
this.css_classes = form.css_classes;
|
|
42
|
+
this.saulFunctionName = form.saulFunctionName;
|
|
43
|
+
this.invalid = false;
|
|
44
|
+
this.disabled = form.disabled || false;
|
|
45
|
+
|
|
46
|
+
// Validate layout if provided
|
|
47
|
+
if (this.layout) {
|
|
48
|
+
this.validateLayout();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public validateField(name: string, value: any): boolean {
|
|
53
|
+
const field = this.fields.find((field) => field.name === name);
|
|
54
|
+
if (field) {
|
|
55
|
+
field.error = ''; // Reset error message
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if (field.required && !value) {
|
|
59
|
+
field.error = field.required_text || `${field.label} is required`;
|
|
60
|
+
this.invalid = true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (field.validation_rules) {
|
|
65
|
+
|
|
66
|
+
if (!field.required && !value) {
|
|
67
|
+
field.error = '';
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const rule of field.validation_rules) {
|
|
72
|
+
const fieldValid = ValidatorUtils.validate(rule, value, this.getFormValue());
|
|
73
|
+
|
|
74
|
+
if (!fieldValid) {
|
|
75
|
+
field.error = rule.message || `Invalid value for ${field.label}`;
|
|
76
|
+
this.invalid = true;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return field?.error ? false : true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public validate(): boolean {
|
|
86
|
+
this.invalid = false;
|
|
87
|
+
for (const field of this.fields) {
|
|
88
|
+
if (!this.validateField(field.name, field.value)) {
|
|
89
|
+
this.invalid = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return !this.invalid;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public getFormValue(): Record<string, any> {
|
|
96
|
+
const formValue: Record<string, any> = {};
|
|
97
|
+
for (const field of this.fields) {
|
|
98
|
+
if (field.role === 'submit' || field.role === 'reset' || field.value === undefined) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
formValue[field.name] = field.value;
|
|
102
|
+
}
|
|
103
|
+
return formValue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public setFormValue(value: Record<string, any>): void {
|
|
107
|
+
for (const field of this.fields) {
|
|
108
|
+
if (value.hasOwnProperty(field.name)) {
|
|
109
|
+
field.value = value[field.name];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public setFieldValue(name: string, value: any): void {
|
|
115
|
+
const field = this.fields.find((field) => field.name === name);
|
|
116
|
+
if (field) {
|
|
117
|
+
field.value = value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
public reset(): void {
|
|
122
|
+
for (const field of this.fields) {
|
|
123
|
+
field.value = undefined;
|
|
124
|
+
field.error = '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.invalid = false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public async submitToEndpoint(): Promise<any> {
|
|
131
|
+
if (this.saulFunctionName) {
|
|
132
|
+
|
|
133
|
+
const formData = this.getFormValue();
|
|
134
|
+
|
|
135
|
+
console.log(`Submitting form to ${this.saulFunctionName} with data:`, formData);
|
|
136
|
+
// TODO: SaulFunctionCall Here
|
|
137
|
+
|
|
138
|
+
this.reset();
|
|
139
|
+
|
|
140
|
+
// if (!response.ok) {
|
|
141
|
+
// throw new Error('Failed to submit form');
|
|
142
|
+
// }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
public getFormErrors(): Record<string, string> {
|
|
147
|
+
const errors: Record<string, string> = {};
|
|
148
|
+
for (const field of this.fields) {
|
|
149
|
+
if (field.error) {
|
|
150
|
+
errors[field.name] = field.error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return errors;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
public getFormError(name: string): string | undefined {
|
|
157
|
+
const field = this.fields.find((field) => field.name === name);
|
|
158
|
+
return field?.error;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
public setFormError(name: string, error: string): void {
|
|
162
|
+
const field = this.fields.find((field) => field.name === name);
|
|
163
|
+
if (field) {
|
|
164
|
+
field.error = error;
|
|
165
|
+
this.invalid = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
public validateLayout(): void {
|
|
170
|
+
if (!this.layout) return;
|
|
171
|
+
|
|
172
|
+
const fieldNames = new Set(this.fields.map(f => f.name));
|
|
173
|
+
const missingFields: string[] = [];
|
|
174
|
+
|
|
175
|
+
for (const row of this.layout) {
|
|
176
|
+
for (const cell of row) {
|
|
177
|
+
// Handle nested arrays (multiple fields in one cell)
|
|
178
|
+
if (Array.isArray(cell)) {
|
|
179
|
+
for (const fieldName of cell) {
|
|
180
|
+
if (fieldName && !fieldNames.has(fieldName)) {
|
|
181
|
+
missingFields.push(fieldName);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} else if (cell && !fieldNames.has(cell)) {
|
|
185
|
+
missingFields.push(cell);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (missingFields.length > 0) {
|
|
191
|
+
console.warn(`[FormModel] Layout references missing fields: ${missingFields.join(', ')}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import ValidatorUtils from "./index";
|
|
2
|
+
import type { ValidationRule } from "@/interfaces";
|
|
3
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe("ValidatorUtils", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
ValidatorUtils.setLib("zod");
|
|
9
|
+
ValidatorUtils.customValidators = {};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should validate using the default library (zod)", () => {
|
|
13
|
+
const rule: ValidationRule = { type: "string" };
|
|
14
|
+
const value = "test";
|
|
15
|
+
expect(ValidatorUtils.validate(rule, value, {})).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it.skip("should switch to a different library and validate", () => {
|
|
19
|
+
ValidatorUtils.setLib("ark_type");
|
|
20
|
+
const rule: ValidationRule = { type: "string" };
|
|
21
|
+
const value = "test";
|
|
22
|
+
expect(ValidatorUtils.validate(rule, value, {})).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should throw an error when switching to an unsupported library", () => {
|
|
26
|
+
expect(() => ValidatorUtils.setLib("unsupported_lib")).toThrow(
|
|
27
|
+
'Validator library "unsupported_lib" is not supported.'
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should add a custom validator and validate using it", () => {
|
|
32
|
+
ValidatorUtils.customValidators["isEven"] = (value) => value % 2 === 0;
|
|
33
|
+
|
|
34
|
+
const rule: ValidationRule = { type: "custom", value: "isEven" };
|
|
35
|
+
expect(ValidatorUtils.validate(rule, 4, {})).toBe(true);
|
|
36
|
+
expect(ValidatorUtils.validate(rule, 3, {})).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should throw an error if a custom validator is not found", () => {
|
|
40
|
+
const rule: ValidationRule = { type: "custom", value: "nonExistentValidator" };
|
|
41
|
+
expect(() => ValidatorUtils.validate(rule, 4, {})).toThrow(
|
|
42
|
+
'Custom validation function "nonExistentValidator" not found.'
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should validate string schema", () => {
|
|
47
|
+
const rule: ValidationRule = { type: "string" };
|
|
48
|
+
expect(ValidatorUtils.validate(rule, "test", {})).toBe(true);
|
|
49
|
+
expect(ValidatorUtils.validate(rule, 123, {})).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should validate number schema", () => {
|
|
53
|
+
const rule: ValidationRule = { type: "number" };
|
|
54
|
+
expect(ValidatorUtils.validate(rule, 123, {})).toBe(true);
|
|
55
|
+
expect(ValidatorUtils.validate(rule, "test", {})).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should validate boolean schema", () => {
|
|
59
|
+
const rule: ValidationRule = { type: "boolean" };
|
|
60
|
+
expect(ValidatorUtils.validate(rule, true, {})).toBe(true);
|
|
61
|
+
expect(ValidatorUtils.validate(rule, "true", {})).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should validate email schema", () => {
|
|
65
|
+
const rule: ValidationRule = { type: "email" };
|
|
66
|
+
expect(ValidatorUtils.validate(rule, "test@example.com", {})).toBe(true);
|
|
67
|
+
expect(ValidatorUtils.validate(rule, "invalid-email", {})).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should validate url schema", () => {
|
|
71
|
+
const rule: ValidationRule = { type: "url" };
|
|
72
|
+
expect(ValidatorUtils.validate(rule, "https://example.com", {})).toBe(true);
|
|
73
|
+
expect(ValidatorUtils.validate(rule, "invalid-url", {})).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should validate minLength schema", () => {
|
|
77
|
+
const rule: ValidationRule = { type: "minLength", value: 5 };
|
|
78
|
+
expect(ValidatorUtils.validate(rule, "12345", {})).toBe(true);
|
|
79
|
+
expect(ValidatorUtils.validate(rule, "123", {})).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should validate maxLength schema", () => {
|
|
83
|
+
const rule: ValidationRule = { type: "maxLength", value: 5 };
|
|
84
|
+
expect(ValidatorUtils.validate(rule, "12345", {})).toBe(true);
|
|
85
|
+
expect(ValidatorUtils.validate(rule, "123456", {})).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should validate min schema", () => {
|
|
89
|
+
const rule: ValidationRule = { type: "min", value: 10 };
|
|
90
|
+
expect(ValidatorUtils.validate(rule, 15, {})).toBe(true);
|
|
91
|
+
expect(ValidatorUtils.validate(rule, 5, {})).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should validate max schema", () => {
|
|
95
|
+
const rule: ValidationRule = { type: "max", value: 10 };
|
|
96
|
+
expect(ValidatorUtils.validate(rule, 5, {})).toBe(true);
|
|
97
|
+
expect(ValidatorUtils.validate(rule, 15, {})).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should validate regex schema", () => {
|
|
101
|
+
const rule: ValidationRule = { type: "regex", value: "^test$" };
|
|
102
|
+
expect(ValidatorUtils.validate(rule, "test", {})).toBe(true);
|
|
103
|
+
expect(ValidatorUtils.validate(rule, "not-test", {})).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should validate date schema", () => {
|
|
107
|
+
const rule: ValidationRule = { type: "date" };
|
|
108
|
+
expect(ValidatorUtils.validate(rule, new Date().toISOString(), {})).toBe(true);
|
|
109
|
+
expect(ValidatorUtils.validate(rule, "invalid-date", {})).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should validate custom schema", () => {
|
|
113
|
+
ValidatorUtils.customValidators["isPositive"] = (value) => value > 0;
|
|
114
|
+
const rule: ValidationRule = { type: "custom", value: "isPositive" };
|
|
115
|
+
expect(ValidatorUtils.validate(rule, 5, {})).toBe(true);
|
|
116
|
+
expect(ValidatorUtils.validate(rule, -5, {})).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("ValidatorUtils with ArkType library", () => {
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
ValidatorUtils.setLib("ark_type");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should validate string schema", () => {
|
|
126
|
+
const rule: ValidationRule = { type: "string" };
|
|
127
|
+
|
|
128
|
+
expect(ValidatorUtils.validate(rule, "test", {})).toBe(true);
|
|
129
|
+
expect(ValidatorUtils.validate(rule, 123, {})).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should validate number schema", () => {
|
|
133
|
+
const rule: ValidationRule = { type: "number" };
|
|
134
|
+
expect(ValidatorUtils.validate(rule, 123, {})).toBe(true);
|
|
135
|
+
expect(ValidatorUtils.validate(rule, "test", {})).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should validate boolean schema", () => {
|
|
139
|
+
const rule: ValidationRule = { type: "boolean" };
|
|
140
|
+
expect(ValidatorUtils.validate(rule, true, {})).toBe(true);
|
|
141
|
+
expect(ValidatorUtils.validate(rule, "true", {})).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should validate email schema", () => {
|
|
145
|
+
const rule: ValidationRule = { type: "email" };
|
|
146
|
+
expect(ValidatorUtils.validate(rule, "test@example.com", {})).toBe(true);
|
|
147
|
+
expect(ValidatorUtils.validate(rule, "invalid-email", {})).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should validate url schema", () => {
|
|
151
|
+
const rule: ValidationRule = { type: "url" };
|
|
152
|
+
expect(ValidatorUtils.validate(rule, "https://example.com", {})).toBe(true);
|
|
153
|
+
expect(ValidatorUtils.validate(rule, "invalid-url", {})).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should validate minLength schema", () => {
|
|
157
|
+
const rule: ValidationRule = { type: "minLength", value: 5 };
|
|
158
|
+
expect(ValidatorUtils.validate(rule, "12345", {})).toBe(true);
|
|
159
|
+
expect(ValidatorUtils.validate(rule, "123", {})).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("should validate maxLength schema", () => {
|
|
163
|
+
const rule: ValidationRule = { type: "maxLength", value: 5 };
|
|
164
|
+
expect(ValidatorUtils.validate(rule, "12345", {})).toBe(true);
|
|
165
|
+
expect(ValidatorUtils.validate(rule, "123456", {})).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("should validate min schema", () => {
|
|
169
|
+
const rule: ValidationRule = { type: "min", value: 10 };
|
|
170
|
+
expect(ValidatorUtils.validate(rule, 15, {})).toBe(true);
|
|
171
|
+
expect(ValidatorUtils.validate(rule, 5, {})).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("should validate max schema", () => {
|
|
175
|
+
const rule: ValidationRule = { type: "max", value: 10 };
|
|
176
|
+
expect(ValidatorUtils.validate(rule, 5, {})).toBe(true);
|
|
177
|
+
expect(ValidatorUtils.validate(rule, 15, {})).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should validate regex schema", () => {
|
|
181
|
+
const rule: ValidationRule = { type: "regex", value: "^test$" };
|
|
182
|
+
expect(ValidatorUtils.validate(rule, "test", {})).toBe(true);
|
|
183
|
+
expect(ValidatorUtils.validate(rule, "not-test", {})).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("should validate date schema", () => {
|
|
187
|
+
const rule: ValidationRule = { type: "date" };
|
|
188
|
+
expect(ValidatorUtils.validate(rule, new Date().toISOString(), {})).toBe(true);
|
|
189
|
+
expect(ValidatorUtils.validate(rule, "invalid-date", {})).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
it("should validate custom schema", () => {
|
|
194
|
+
ValidatorUtils.customValidators["isPositive"] = (value) => value > 0;
|
|
195
|
+
const rule: ValidationRule = { type: "custom", value: "isPositive" };
|
|
196
|
+
expect(ValidatorUtils.validate(rule, 5, {})).toBe(true);
|
|
197
|
+
expect(ValidatorUtils.validate(rule, -5, {})).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { IValidatorLib, ValidationRule } from "../../interfaces";
|
|
2
|
+
import ArkTypeValidatorLib from "./libs/arktype";
|
|
3
|
+
import ZodValidatorLib from "./libs/zod";
|
|
4
|
+
|
|
5
|
+
export default class ValidatorUtils {
|
|
6
|
+
private static lib: string = 'ark_type';
|
|
7
|
+
|
|
8
|
+
public static customValidators: Record<string, (value: any, formValue: any) => boolean> = {};
|
|
9
|
+
|
|
10
|
+
private static validatorLibs: { [key: string]: IValidatorLib } = {
|
|
11
|
+
zod: new ZodValidatorLib(),
|
|
12
|
+
ark_type: new ArkTypeValidatorLib(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
public static addLib(name: string, lib: IValidatorLib) {
|
|
16
|
+
this.validatorLibs[name] = lib;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public static setLib(lib: string) {
|
|
20
|
+
if (this.validatorLibs[lib]) {
|
|
21
|
+
this.lib = lib;
|
|
22
|
+
} else {
|
|
23
|
+
throw new Error(`Validator library "${lib}" is not supported.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static validate(rule: ValidationRule, value: any, formValue: any): boolean {
|
|
28
|
+
|
|
29
|
+
if (rule.type === 'custom') {
|
|
30
|
+
// Resolve the custom validation function from the map
|
|
31
|
+
const customValidation = this.customValidators[rule.value as string];
|
|
32
|
+
|
|
33
|
+
if (customValidation) {
|
|
34
|
+
return customValidation(value, formValue);
|
|
35
|
+
} else {
|
|
36
|
+
throw new Error(`Custom validation function "${rule.value}" not found.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return this.validatorLibs[this.lib].validate(rule, value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ArkErrors, type, Type } from "arktype";
|
|
2
|
+
import type { IValidatorLib, ValidationRule } from "@/interfaces";
|
|
3
|
+
|
|
4
|
+
export default class ArktypeValidatorLib implements IValidatorLib {
|
|
5
|
+
schemas: Record<ValidationRule['type'], (ruleValue?: any) => Type<any>> = {} as Record<ValidationRule['type'], () => Type>;
|
|
6
|
+
|
|
7
|
+
validate({ type: ruleType, value: ruleValue }: ValidationRule, value: any): boolean {
|
|
8
|
+
const schema = this.schemaFactory(ruleType, ruleValue);
|
|
9
|
+
const result = schema(value);
|
|
10
|
+
|
|
11
|
+
return !(result instanceof ArkErrors);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
schemaFactory(validationType: ValidationRule['type'], ruleValue: any): Type<any> {
|
|
15
|
+
this.schemas = {
|
|
16
|
+
string: () => type("string"),
|
|
17
|
+
number: () => type("number"),
|
|
18
|
+
boolean: () => type("boolean"),
|
|
19
|
+
email: () => type("string.email"),
|
|
20
|
+
url: () => type("string.url"),
|
|
21
|
+
minLength: () => type(`string >= ${Number(ruleValue)}`),
|
|
22
|
+
maxLength: () => type(`string <= ${Number(ruleValue)}`),
|
|
23
|
+
min: () => type(`number >= ${Number(ruleValue)}`),
|
|
24
|
+
max: () => type(`number <= ${Number(ruleValue)}`),
|
|
25
|
+
regex: () => type(`string & /${ruleValue}/`),
|
|
26
|
+
date: () => type("string.date"),
|
|
27
|
+
custom: () => {
|
|
28
|
+
if (typeof ruleValue === "function") {
|
|
29
|
+
return type((val: any) => ({
|
|
30
|
+
data: val,
|
|
31
|
+
problems: ruleValue(val) ? undefined : [{ path: [], message: "Custom validation failed" }],
|
|
32
|
+
}));
|
|
33
|
+
} else {
|
|
34
|
+
throw new Error("Invalid rule value for custom validation. Expected a function.");
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const schemaGenerator = this.schemas[validationType];
|
|
40
|
+
if (!schemaGenerator) {
|
|
41
|
+
throw new Error(`Unsupported rule type: ${type}`);
|
|
42
|
+
}
|
|
43
|
+
return schemaGenerator(ruleValue);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { IValidatorLib, ValidationRule } from "@/interfaces";
|
|
3
|
+
|
|
4
|
+
export default class ZodValidatorLib implements IValidatorLib {
|
|
5
|
+
|
|
6
|
+
schemas: Record<ValidationRule['type'], () => z.ZodTypeAny> = {} as Record<ValidationRule['type'], () => z.ZodTypeAny>;
|
|
7
|
+
|
|
8
|
+
validate({ type, value: ruleValue }: ValidationRule, value: any): boolean {
|
|
9
|
+
try {
|
|
10
|
+
const schema = this.schemaFactory(type, ruleValue);
|
|
11
|
+
// Zod does not support date validation directly, so we need to convert the value to a Date object if the type is "date"
|
|
12
|
+
schema.parse(type === "date" ? new Date(value) : value);
|
|
13
|
+
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
schemaFactory(type: ValidationRule['type'], ruleValue: any) {
|
|
21
|
+
this.schemas = {
|
|
22
|
+
string: () => z.string(),
|
|
23
|
+
number: () => z.number(),
|
|
24
|
+
boolean: () => z.boolean(),
|
|
25
|
+
email: () => z.string().email(),
|
|
26
|
+
url: () => z.string().url(),
|
|
27
|
+
minLength: () => z.string().min(Number(ruleValue)),
|
|
28
|
+
maxLength: () => z.string().max(Number(ruleValue)),
|
|
29
|
+
min: () => z.number().min(Number(ruleValue)),
|
|
30
|
+
max: () => z.number().max(Number(ruleValue)),
|
|
31
|
+
regex: () => {
|
|
32
|
+
if (typeof ruleValue === "string") {
|
|
33
|
+
return z.string().regex(new RegExp(ruleValue));
|
|
34
|
+
} else {
|
|
35
|
+
throw new Error("Invalid rule value for regex validation. Expected a string.");
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
date: () => z.date(),
|
|
39
|
+
custom: () => {
|
|
40
|
+
if (typeof ruleValue === "function") {
|
|
41
|
+
return z.custom(ruleValue);
|
|
42
|
+
} else {
|
|
43
|
+
throw new Error("Invalid rule value for custom validation. Expected a function.");
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const schema = this.schemas[type]();
|
|
49
|
+
if (!schema) throw new Error(`Unsupported rule type: ${type}`);
|
|
50
|
+
|
|
51
|
+
return schema;
|
|
52
|
+
}
|
|
53
|
+
}
|