@teamnovu/kit-vue-forms 0.0.1 → 0.0.2
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/PLAN.md +3 -1
- package/package.json +1 -1
- package/src/composables/useField.ts +4 -4
- package/src/composables/useFieldRegistry.ts +1 -1
- package/src/composables/useForm.ts +5 -5
- package/src/composables/useFormData.ts +4 -4
- package/src/composables/useFormState.ts +2 -3
- package/src/composables/useSubform.ts +4 -4
- package/src/composables/useValidation.ts +5 -8
- package/src/index.ts +1 -1
- package/src/types/form.ts +4 -4
- package/src/utils/path.ts +3 -3
- package/tests/formState.test.ts +36 -65
- package/tests/integration.test.ts +7 -7
- package/tests/nestedPath.test.ts +70 -70
- package/tests/subform.test.ts +64 -64
- package/tests/useField.test.ts +10 -10
- package/tests/useForm.test.ts +3 -3
- package/tests/useValidation.test.ts +9 -9
package/PLAN.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# DEPRECATED
|
|
2
|
+
|
|
1
3
|
# Vue Forms Library - MVP Plan
|
|
2
4
|
|
|
3
5
|
## Overview
|
|
@@ -206,4 +208,4 @@ src/
|
|
|
206
208
|
- Direct form instance passing maintains type safety
|
|
207
209
|
- Subform extraction enables component reusability
|
|
208
210
|
- Validation strategies can be overridden per field
|
|
209
|
-
- Backend validation seamlessly integrates with Zod
|
|
211
|
+
- Backend validation seamlessly integrates with Zod
|
package/package.json
CHANGED
|
@@ -29,8 +29,8 @@ export function useField<T, K extends string>(options: UseFieldOptions<T, K>): F
|
|
|
29
29
|
return JSON.stringify(state.value) !== JSON.stringify(state.initialValue)
|
|
30
30
|
})
|
|
31
31
|
|
|
32
|
-
const
|
|
33
|
-
state.value =
|
|
32
|
+
const setData = (newData: T): void => {
|
|
33
|
+
state.value = newData
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const onBlur = (): void => {
|
|
@@ -58,13 +58,13 @@ export function useField<T, K extends string>(options: UseFieldOptions<T, K>): F
|
|
|
58
58
|
const refs = toRefs(state)
|
|
59
59
|
|
|
60
60
|
return {
|
|
61
|
-
|
|
61
|
+
data: refs.value as FormField<T, K>['data'],
|
|
62
62
|
path: refs.path as FormField<T, K>['path'],
|
|
63
63
|
initialValue: refs.initialValue as FormField<T, K>['initialValue'],
|
|
64
64
|
errors: refs.errors as FormField<T, K>['errors'],
|
|
65
65
|
touched: refs.touched as FormField<T, K>['touched'],
|
|
66
66
|
dirty,
|
|
67
|
-
|
|
67
|
+
setData,
|
|
68
68
|
onBlur,
|
|
69
69
|
onFocus,
|
|
70
70
|
reset,
|
|
@@ -32,7 +32,7 @@ export function useFieldRegistry<T extends FormDataDefault>(
|
|
|
32
32
|
const defineField = <K extends Paths<T>>(options: DefineFieldOptions<PickProps<T, K>, K>) => {
|
|
33
33
|
const field = useField({
|
|
34
34
|
...options,
|
|
35
|
-
value: getLens(toRef(formState, '
|
|
35
|
+
value: getLens(toRef(formState, 'data'), options.path),
|
|
36
36
|
initialValue: computed(() => getNestedValue(formState.initialData, unref(options.path))),
|
|
37
37
|
})
|
|
38
38
|
|
|
@@ -17,19 +17,19 @@ export interface UseFormOptions<T extends FormDataDefault> extends ValidationOpt
|
|
|
17
17
|
export function useForm<T extends FormDataDefault>(options: UseFormOptions<T>) {
|
|
18
18
|
const initialData = computed(() => Object.freeze(cloneRefValue(options.initialData)))
|
|
19
19
|
|
|
20
|
-
const
|
|
20
|
+
const data = ref<T>(cloneRefValue(initialData)) as Ref<T>
|
|
21
21
|
|
|
22
22
|
const state = reactive({
|
|
23
23
|
initialData: initialData,
|
|
24
|
-
|
|
24
|
+
data,
|
|
25
25
|
})
|
|
26
26
|
|
|
27
27
|
const fields = useFieldRegistry(state)
|
|
28
28
|
const validationState = useValidation(state, options)
|
|
29
|
-
const formState = useFormState(
|
|
29
|
+
const formState = useFormState(fields)
|
|
30
30
|
|
|
31
31
|
const reset = () => {
|
|
32
|
-
|
|
32
|
+
data.value = cloneRefValue(initialData)
|
|
33
33
|
fields.getFields().forEach(field => field.reset())
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -47,7 +47,7 @@ export function useForm<T extends FormDataDefault>(options: UseFormOptions<T>) {
|
|
|
47
47
|
reset,
|
|
48
48
|
getSubForm,
|
|
49
49
|
initialData: toRef(state, 'initialData') as Form<T>['initialData'],
|
|
50
|
-
|
|
50
|
+
data: toRef(state, 'data') as Form<T>['data'],
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
return formInterface
|
|
@@ -4,13 +4,13 @@ import type { FormDataDefault } from '../types/form'
|
|
|
4
4
|
export function useFormData<T extends FormDataDefault>(
|
|
5
5
|
initialData: Ref<T>,
|
|
6
6
|
) {
|
|
7
|
-
const
|
|
7
|
+
const data = ref(unref(initialData))
|
|
8
8
|
|
|
9
9
|
watch(initialData, (newData) => {
|
|
10
|
-
if (newData !==
|
|
11
|
-
|
|
10
|
+
if (newData !== data.value) {
|
|
11
|
+
data.value = newData
|
|
12
12
|
}
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
-
return {
|
|
15
|
+
return { data }
|
|
16
16
|
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { computed, unref } from 'vue'
|
|
2
|
-
import type { FormDataDefault
|
|
2
|
+
import type { FormDataDefault } from '../types/form'
|
|
3
3
|
import type { FieldRegistry } from './useFieldRegistry'
|
|
4
4
|
|
|
5
5
|
export function useFormState<T extends FormDataDefault>(
|
|
6
|
-
formState: FormState<T>,
|
|
7
6
|
formFieldRegistry: FieldRegistry<T>,
|
|
8
7
|
) {
|
|
9
8
|
const isDirty = computed(() => {
|
|
10
|
-
return
|
|
9
|
+
return formFieldRegistry.getFields().some(field => unref(field.dirty))
|
|
11
10
|
})
|
|
12
11
|
|
|
13
12
|
const isTouched = computed(() => {
|
|
@@ -57,7 +57,7 @@ export function createSubformInterface<
|
|
|
57
57
|
type ScopedMainPaths = Paths<T> & MP<SP>
|
|
58
58
|
|
|
59
59
|
// Create reactive data scoped to subform path
|
|
60
|
-
const
|
|
60
|
+
const data = getLens(mainForm.data, path) as Ref<ST>
|
|
61
61
|
|
|
62
62
|
const initialData = computed(() => {
|
|
63
63
|
return getNestedValue(mainForm.initialData.value, path) as ST
|
|
@@ -70,8 +70,8 @@ export function createSubformInterface<
|
|
|
70
70
|
return {
|
|
71
71
|
...field,
|
|
72
72
|
path: computed(() => unref(field.path).replace(path + '.', '')),
|
|
73
|
-
|
|
74
|
-
field.
|
|
73
|
+
setData: (newData: PickProps<ST, S>) => {
|
|
74
|
+
field.setData(newData as PickProps<T, ScopedMainPaths>)
|
|
75
75
|
},
|
|
76
76
|
} as unknown as FormField<PickProps<ST, S>, S>
|
|
77
77
|
}
|
|
@@ -155,7 +155,7 @@ export function createSubformInterface<
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
return {
|
|
158
|
-
|
|
158
|
+
data: data,
|
|
159
159
|
initialData,
|
|
160
160
|
defineField,
|
|
161
161
|
getField,
|
|
@@ -11,7 +11,7 @@ export interface ValidatorOptions<T> {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export interface ValidationOptions<T> extends ValidatorOptions<T> {
|
|
14
|
-
errors?: MaybeRef<ErrorBag>
|
|
14
|
+
errors?: MaybeRef<ErrorBag | undefined>
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export const SuccessValidationResult: ValidationResult = {
|
|
@@ -113,16 +113,13 @@ export function createValidator<T extends FormDataDefault>(
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
export function useValidation<T extends FormDataDefault>(
|
|
116
|
-
formState: {
|
|
116
|
+
formState: { data: T },
|
|
117
117
|
options: ValidationOptions<T>,
|
|
118
118
|
) {
|
|
119
119
|
const validationState = reactive({
|
|
120
120
|
validators: ref<Ref<Validator<T> | undefined>[]>([createValidator(options)]),
|
|
121
121
|
isValidated: false,
|
|
122
|
-
errors: unref(options.errors) ??
|
|
123
|
-
general: [],
|
|
124
|
-
propertyErrors: {},
|
|
125
|
-
},
|
|
122
|
+
errors: unref(options.errors) ?? SuccessValidationResult.errors,
|
|
126
123
|
})
|
|
127
124
|
|
|
128
125
|
// Watch for changes in the error bag and update validation state
|
|
@@ -152,7 +149,7 @@ export function useValidation<T extends FormDataDefault>(
|
|
|
152
149
|
)
|
|
153
150
|
|
|
154
151
|
// Watch for changes in form data to trigger validation
|
|
155
|
-
watch(() => formState.
|
|
152
|
+
watch(() => formState.data, () => {
|
|
156
153
|
if (validationState.isValidated) {
|
|
157
154
|
validateForm()
|
|
158
155
|
}
|
|
@@ -178,7 +175,7 @@ export function useValidation<T extends FormDataDefault>(
|
|
|
178
175
|
const validationResults = await Promise.all(
|
|
179
176
|
validationState.validators
|
|
180
177
|
.filter(validator => unref(validator) !== undefined)
|
|
181
|
-
.map(validator => unref(validator)!.validate(formState.
|
|
178
|
+
.map(validator => unref(validator)!.validate(formState.data)),
|
|
182
179
|
)
|
|
183
180
|
|
|
184
181
|
const isValid = validationResults.every(result => result.isValid)
|
package/src/index.ts
CHANGED
|
@@ -8,4 +8,4 @@ export type { UseFieldOptions } from './composables/useField'
|
|
|
8
8
|
|
|
9
9
|
// Types
|
|
10
10
|
export type { ValidationStrategy, ValidationErrorMessage as ErrorMessage, ValidationResult, ErrorBag } from './types/validation'
|
|
11
|
-
export type { DeepPartial
|
|
11
|
+
export type { DeepPartial } from './utils/type-helpers'
|
package/src/types/form.ts
CHANGED
|
@@ -8,18 +8,18 @@ import type { ValidatorOptions } from '../composables/useValidation'
|
|
|
8
8
|
export type FormDataDefault = object
|
|
9
9
|
|
|
10
10
|
export interface FormState<T extends FormDataDefault, TIn extends FormDataDefault = T> {
|
|
11
|
-
|
|
11
|
+
data: T
|
|
12
12
|
initialData: TIn
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface FormField<T, P extends string> {
|
|
16
|
-
|
|
16
|
+
data: Ref<T>
|
|
17
17
|
path: Ref<P>
|
|
18
18
|
initialValue: Readonly<Ref<T>>
|
|
19
19
|
errors: Ref<ValidationErrors>
|
|
20
20
|
touched: Ref<boolean>
|
|
21
21
|
dirty: Ref<boolean>
|
|
22
|
-
|
|
22
|
+
setData: (newData: T) => void
|
|
23
23
|
onBlur: () => void
|
|
24
24
|
onFocus: () => void
|
|
25
25
|
reset: () => void
|
|
@@ -29,7 +29,7 @@ export interface FormField<T, P extends string> {
|
|
|
29
29
|
|
|
30
30
|
export interface Form<T extends FormDataDefault> {
|
|
31
31
|
// Data properties
|
|
32
|
-
|
|
32
|
+
data: Ref<T>
|
|
33
33
|
initialData: Readonly<Ref<T>>
|
|
34
34
|
|
|
35
35
|
// Field operations
|
package/src/utils/path.ts
CHANGED
|
@@ -35,13 +35,13 @@ export function setNestedValue<T, K extends Paths<T>>(obj: T, path: K | SplitPat
|
|
|
35
35
|
target[lastKey] = value
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export const getLens = <T, K extends Paths<T>>(
|
|
38
|
+
export const getLens = <T, K extends Paths<T>>(data: MaybeRef<T>, key: MaybeRef<K | SplitPath<K>>) => {
|
|
39
39
|
return computed({
|
|
40
40
|
get() {
|
|
41
|
-
return getNestedValue(unref(
|
|
41
|
+
return getNestedValue(unref(data), unref(key))
|
|
42
42
|
},
|
|
43
43
|
set(value: PickProps<T, K>) {
|
|
44
|
-
setNestedValue(unref(
|
|
44
|
+
setNestedValue(unref(data), unref(key), value)
|
|
45
45
|
},
|
|
46
46
|
})
|
|
47
47
|
}
|
package/tests/formState.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { reactive } from 'vue'
|
|
3
|
-
import {
|
|
3
|
+
import { useFieldRegistry } from '../src/composables/useFieldRegistry'
|
|
4
4
|
import { useFormState } from '../src/composables/useFormState'
|
|
5
|
+
import { useForm } from '../src'
|
|
5
6
|
|
|
6
7
|
describe('useFormState', () => {
|
|
7
8
|
it('should detect dirty state when form data changes', () => {
|
|
@@ -9,18 +10,15 @@ describe('useFormState', () => {
|
|
|
9
10
|
name: 'John',
|
|
10
11
|
age: 30,
|
|
11
12
|
}
|
|
12
|
-
const formData = reactive({ ...initialData })
|
|
13
|
-
const fields = {} as FieldRegistry<typeof initialData>
|
|
14
13
|
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}, fields)
|
|
14
|
+
const form = useForm({ initialData: initialData })
|
|
15
|
+
form.defineField({ path: 'name' })
|
|
16
|
+
form.defineField({ path: 'age' })
|
|
19
17
|
|
|
20
|
-
expect(
|
|
18
|
+
expect(form.isDirty.value).toBe(false)
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
expect(
|
|
20
|
+
form.data.value.name = 'Jane'
|
|
21
|
+
expect(form.isDirty.value).toBe(true)
|
|
24
22
|
})
|
|
25
23
|
|
|
26
24
|
it('should not be dirty when data equals initial data', () => {
|
|
@@ -28,44 +26,34 @@ describe('useFormState', () => {
|
|
|
28
26
|
name: 'John',
|
|
29
27
|
age: 30,
|
|
30
28
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const formState = useFormState({
|
|
35
|
-
formData,
|
|
36
|
-
initialData,
|
|
37
|
-
}, fields)
|
|
29
|
+
const form = useForm({ initialData: initialData })
|
|
30
|
+
form.defineField({ path: 'name' })
|
|
31
|
+
form.defineField({ path: 'age' })
|
|
38
32
|
|
|
39
|
-
expect(
|
|
33
|
+
expect(form.isDirty.value).toBe(false)
|
|
40
34
|
|
|
41
|
-
|
|
42
|
-
expect(
|
|
35
|
+
form.data.value.name = 'Jane'
|
|
36
|
+
expect(form.isDirty.value).toBe(true)
|
|
43
37
|
|
|
44
|
-
|
|
45
|
-
expect(
|
|
38
|
+
form.data.value.name = 'John' // Back to initial
|
|
39
|
+
expect(form.isDirty.value).toBe(false)
|
|
46
40
|
})
|
|
47
41
|
|
|
48
42
|
it('should detect touched state when any field is touched', () => {
|
|
49
|
-
const
|
|
43
|
+
const initialData = {
|
|
50
44
|
name: 'John',
|
|
51
45
|
email: 'john@example.com',
|
|
52
46
|
}
|
|
53
|
-
const
|
|
47
|
+
const data = reactive(initialData)
|
|
54
48
|
const fields = useFieldRegistry({
|
|
55
|
-
|
|
56
|
-
initialData
|
|
49
|
+
data,
|
|
50
|
+
initialData,
|
|
57
51
|
})
|
|
58
52
|
|
|
59
53
|
const nameField = fields.defineField({ path: 'name' })
|
|
60
54
|
fields.defineField({ path: 'email' })
|
|
61
55
|
|
|
62
|
-
const formState = useFormState(
|
|
63
|
-
formData,
|
|
64
|
-
initialData: {
|
|
65
|
-
name: 'John',
|
|
66
|
-
email: 'john@example.com',
|
|
67
|
-
},
|
|
68
|
-
}, fields)
|
|
56
|
+
const formState = useFormState(fields)
|
|
69
57
|
|
|
70
58
|
expect(formState.isTouched.value).toBe(false)
|
|
71
59
|
|
|
@@ -74,17 +62,14 @@ describe('useFormState', () => {
|
|
|
74
62
|
})
|
|
75
63
|
|
|
76
64
|
it('should handle empty fields map', () => {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
65
|
+
const initialData = { name: 'John' }
|
|
66
|
+
const data = reactive(initialData)
|
|
79
67
|
const fields = useFieldRegistry({
|
|
80
|
-
|
|
81
|
-
initialData
|
|
68
|
+
data,
|
|
69
|
+
initialData,
|
|
82
70
|
})
|
|
83
71
|
|
|
84
|
-
const formState = useFormState(
|
|
85
|
-
formData,
|
|
86
|
-
initialData: { name: 'John' },
|
|
87
|
-
}, fields)
|
|
72
|
+
const formState = useFormState(fields)
|
|
88
73
|
|
|
89
74
|
expect(formState.isDirty.value).toBe(false)
|
|
90
75
|
expect(formState.isTouched.value).toBe(false)
|
|
@@ -100,39 +85,25 @@ describe('useFormState', () => {
|
|
|
100
85
|
},
|
|
101
86
|
},
|
|
102
87
|
}
|
|
103
|
-
const formData = reactive(JSON.parse(JSON.stringify(initialData)))
|
|
104
|
-
const fields = useFieldRegistry({
|
|
105
|
-
formData,
|
|
106
|
-
initialData,
|
|
107
|
-
})
|
|
108
88
|
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
initialData,
|
|
112
|
-
}, fields)
|
|
89
|
+
const form = useForm({ initialData: initialData })
|
|
90
|
+
form.defineField({ path: 'user' })
|
|
113
91
|
|
|
114
|
-
expect(
|
|
92
|
+
expect(form.isDirty.value).toBe(false)
|
|
115
93
|
|
|
116
|
-
|
|
117
|
-
expect(
|
|
94
|
+
form.data.value.user.address.city = 'Boston'
|
|
95
|
+
expect(form.isDirty.value).toBe(true)
|
|
118
96
|
})
|
|
119
97
|
|
|
120
98
|
it('should handle array changes', () => {
|
|
121
99
|
const initialData = { tags: ['vue', 'typescript'] }
|
|
122
|
-
const formData = reactive({ tags: [...initialData.tags] })
|
|
123
|
-
const fields = useFieldRegistry({
|
|
124
|
-
formData,
|
|
125
|
-
initialData,
|
|
126
|
-
})
|
|
127
100
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
initialData,
|
|
131
|
-
}, fields)
|
|
101
|
+
const form = useForm({ initialData: initialData })
|
|
102
|
+
form.defineField({ path: 'tags' })
|
|
132
103
|
|
|
133
|
-
expect(
|
|
104
|
+
expect(form.isDirty.value).toBe(false)
|
|
134
105
|
|
|
135
|
-
|
|
136
|
-
expect(
|
|
106
|
+
form.data.value.tags.push('forms')
|
|
107
|
+
expect(form.isDirty.value).toBe(true)
|
|
137
108
|
})
|
|
138
109
|
})
|
|
@@ -32,9 +32,9 @@ describe('Integration Tests', () => {
|
|
|
32
32
|
expect(form.isValidated.value).toBe(false)
|
|
33
33
|
|
|
34
34
|
// Fill out form
|
|
35
|
-
nameField.
|
|
36
|
-
emailField.
|
|
37
|
-
ageField.
|
|
35
|
+
nameField.setData('John')
|
|
36
|
+
emailField.setData('john@example.com')
|
|
37
|
+
ageField.setData(25)
|
|
38
38
|
|
|
39
39
|
// Form should be dirty now
|
|
40
40
|
expect(form.isDirty.value).toBe(true)
|
|
@@ -76,8 +76,8 @@ describe('Integration Tests', () => {
|
|
|
76
76
|
expect(result.errors.propertyErrors.email).toBeDefined()
|
|
77
77
|
|
|
78
78
|
// Fix the data
|
|
79
|
-
nameField.
|
|
80
|
-
emailField.
|
|
79
|
+
nameField.setData('John')
|
|
80
|
+
emailField.setData('john@example.com')
|
|
81
81
|
|
|
82
82
|
// Re-validate
|
|
83
83
|
const result2 = await form.validateForm()
|
|
@@ -93,8 +93,8 @@ describe('Integration Tests', () => {
|
|
|
93
93
|
|
|
94
94
|
const nameField = form.defineField({ path: 'name' })
|
|
95
95
|
|
|
96
|
-
expect(nameField.
|
|
97
|
-
expect(form.
|
|
96
|
+
expect(nameField.data.value).toEqual('John')
|
|
97
|
+
expect(form.data.value).toEqual({
|
|
98
98
|
name: 'John',
|
|
99
99
|
age: 30,
|
|
100
100
|
})
|