@teamnovu/kit-vue-forms 0.0.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/PLAN.md +209 -0
- package/dist/composables/useField.d.ts +12 -0
- package/dist/composables/useFieldRegistry.d.ts +15 -0
- package/dist/composables/useForm.d.ts +10 -0
- package/dist/composables/useFormState.d.ts +7 -0
- package/dist/composables/useSubform.d.ts +5 -0
- package/dist/composables/useValidation.d.ts +30 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.mjs +414 -0
- package/dist/types/form.d.ts +41 -0
- package/dist/types/util.d.ts +26 -0
- package/dist/types/validation.d.ts +16 -0
- package/dist/utils/general.d.ts +2 -0
- package/dist/utils/path.d.ts +11 -0
- package/dist/utils/type-helpers.d.ts +3 -0
- package/dist/utils/validation.d.ts +3 -0
- package/dist/utils/zod.d.ts +3 -0
- package/package.json +41 -0
- package/src/composables/useField.ts +74 -0
- package/src/composables/useFieldRegistry.ts +53 -0
- package/src/composables/useForm.ts +54 -0
- package/src/composables/useFormData.ts +16 -0
- package/src/composables/useFormState.ts +21 -0
- package/src/composables/useSubform.ts +173 -0
- package/src/composables/useValidation.ts +227 -0
- package/src/index.ts +11 -0
- package/src/types/form.ts +58 -0
- package/src/types/util.ts +73 -0
- package/src/types/validation.ts +22 -0
- package/src/utils/general.ts +7 -0
- package/src/utils/path.ts +87 -0
- package/src/utils/type-helpers.ts +3 -0
- package/src/utils/validation.ts +66 -0
- package/src/utils/zod.ts +24 -0
- package/tests/formState.test.ts +138 -0
- package/tests/integration.test.ts +200 -0
- package/tests/nestedPath.test.ts +651 -0
- package/tests/path-utils.test.ts +159 -0
- package/tests/subform.test.ts +1348 -0
- package/tests/useField.test.ts +147 -0
- package/tests/useForm.test.ts +178 -0
- package/tests/useValidation.test.ts +216 -0
- package/tsconfig.json +18 -0
- package/vite.config.js +39 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { computed, reactive, ref, toRef, type MaybeRef, type MaybeRefOrGetter, type Ref } from 'vue'
|
|
2
|
+
import type { Form, FormDataDefault } from '../types/form'
|
|
3
|
+
import type { EntityPaths, PickEntity } from '../types/util'
|
|
4
|
+
import type { ValidationStrategy } from '../types/validation'
|
|
5
|
+
import { cloneRefValue } from '../utils/general'
|
|
6
|
+
import { useFieldRegistry } from './useFieldRegistry'
|
|
7
|
+
import { useFormState } from './useFormState'
|
|
8
|
+
import { createSubformInterface, type SubformOptions } from './useSubform'
|
|
9
|
+
import { useValidation, type ValidationOptions } from './useValidation'
|
|
10
|
+
|
|
11
|
+
export interface UseFormOptions<T extends FormDataDefault> extends ValidationOptions<T> {
|
|
12
|
+
initialData: MaybeRefOrGetter<T>
|
|
13
|
+
validationStrategy?: MaybeRef<ValidationStrategy>
|
|
14
|
+
keepValuesOnUnmount?: MaybeRef<boolean>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useForm<T extends FormDataDefault>(options: UseFormOptions<T>) {
|
|
18
|
+
const initialData = computed(() => Object.freeze(cloneRefValue(options.initialData)))
|
|
19
|
+
|
|
20
|
+
const formData = ref<T>(cloneRefValue(initialData)) as Ref<T>
|
|
21
|
+
|
|
22
|
+
const state = reactive({
|
|
23
|
+
initialData: initialData,
|
|
24
|
+
formData,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const fields = useFieldRegistry(state)
|
|
28
|
+
const validationState = useValidation(state, options)
|
|
29
|
+
const formState = useFormState(state, fields)
|
|
30
|
+
|
|
31
|
+
const reset = () => {
|
|
32
|
+
formData.value = cloneRefValue(initialData)
|
|
33
|
+
fields.getFields().forEach(field => field.reset())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getSubForm<K extends EntityPaths<T>>(
|
|
37
|
+
path: K,
|
|
38
|
+
options?: SubformOptions<PickEntity<T, K>>,
|
|
39
|
+
): Form<PickEntity<T, K>> {
|
|
40
|
+
return createSubformInterface(formInterface, path, options)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const formInterface: Form<T> = {
|
|
44
|
+
...fields,
|
|
45
|
+
...validationState,
|
|
46
|
+
...formState,
|
|
47
|
+
reset,
|
|
48
|
+
getSubForm,
|
|
49
|
+
initialData: toRef(state, 'initialData') as Form<T>['initialData'],
|
|
50
|
+
formData: toRef(state, 'formData') as Form<T>['formData'],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return formInterface
|
|
54
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ref, type Ref, unref, watch } from 'vue'
|
|
2
|
+
import type { FormDataDefault } from '../types/form'
|
|
3
|
+
|
|
4
|
+
export function useFormData<T extends FormDataDefault>(
|
|
5
|
+
initialData: Ref<T>,
|
|
6
|
+
) {
|
|
7
|
+
const formData = ref(unref(initialData))
|
|
8
|
+
|
|
9
|
+
watch(initialData, (newData) => {
|
|
10
|
+
if (newData !== formData.value) {
|
|
11
|
+
formData.value = newData
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
return { formData }
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { computed, unref } from 'vue'
|
|
2
|
+
import type { FormDataDefault, FormState } from '../types/form'
|
|
3
|
+
import type { FieldRegistry } from './useFieldRegistry'
|
|
4
|
+
|
|
5
|
+
export function useFormState<T extends FormDataDefault>(
|
|
6
|
+
formState: FormState<T>,
|
|
7
|
+
formFieldRegistry: FieldRegistry<T>,
|
|
8
|
+
) {
|
|
9
|
+
const isDirty = computed(() => {
|
|
10
|
+
return JSON.stringify(formState.formData) !== JSON.stringify(formState.initialData)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const isTouched = computed(() => {
|
|
14
|
+
return formFieldRegistry.getFields().some(field => unref(field.touched))
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
isDirty,
|
|
19
|
+
isTouched,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { computed, isRef, unref, type Ref } from 'vue'
|
|
2
|
+
import type { Form, FormDataDefault, FormField } from '../types/form'
|
|
3
|
+
import type { EntityPaths, Paths, PickEntity, PickProps } from '../types/util'
|
|
4
|
+
import type { ValidationResult, Validator } from '../types/validation'
|
|
5
|
+
import { filterErrorsForPath, getLens, getNestedValue, joinPath } from '../utils/path'
|
|
6
|
+
import type { DefineFieldOptions } from './useFieldRegistry'
|
|
7
|
+
import { createValidator, SuccessValidationResult, type ValidatorOptions } from './useValidation'
|
|
8
|
+
|
|
9
|
+
export interface SubformOptions<_T extends FormDataDefault> {
|
|
10
|
+
// Additional subform-specific options can be added here
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class NestedValidator<T extends FormDataDefault, P extends Paths<T>> implements Validator<T> {
|
|
14
|
+
constructor(
|
|
15
|
+
private path: P,
|
|
16
|
+
private validator: Validator<PickEntity<T, P>> | undefined,
|
|
17
|
+
) {
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async validate(data: T): Promise<ValidationResult> {
|
|
21
|
+
const subFormData = getNestedValue(data, this.path) as PickEntity<T, P>
|
|
22
|
+
|
|
23
|
+
if (!this.validator) {
|
|
24
|
+
return SuccessValidationResult
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const validationResult = await this.validator.validate(subFormData)
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
isValid: validationResult.isValid,
|
|
31
|
+
errors: {
|
|
32
|
+
general: validationResult.errors.general || [],
|
|
33
|
+
propertyErrors: validationResult.errors.propertyErrors
|
|
34
|
+
? Object.fromEntries(
|
|
35
|
+
Object.entries(validationResult.errors.propertyErrors).map(([key, errors]) => [
|
|
36
|
+
joinPath(this.path, key),
|
|
37
|
+
errors,
|
|
38
|
+
]),
|
|
39
|
+
)
|
|
40
|
+
: {},
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createSubformInterface<
|
|
47
|
+
T extends FormDataDefault,
|
|
48
|
+
K extends EntityPaths<T>,
|
|
49
|
+
>(
|
|
50
|
+
mainForm: Form<T>,
|
|
51
|
+
path: K,
|
|
52
|
+
_options?: SubformOptions<PickEntity<T, K>>,
|
|
53
|
+
): Form<PickEntity<T, K>> {
|
|
54
|
+
type ST = PickEntity<T, K>
|
|
55
|
+
type SP = Paths<ST>
|
|
56
|
+
type MP<P extends SP> = `${K}.${P}`
|
|
57
|
+
type ScopedMainPaths = Paths<T> & MP<SP>
|
|
58
|
+
|
|
59
|
+
// Create reactive data scoped to subform path
|
|
60
|
+
const formData = getLens(mainForm.formData, path) as Ref<ST>
|
|
61
|
+
|
|
62
|
+
const initialData = computed(() => {
|
|
63
|
+
return getNestedValue(mainForm.initialData.value, path) as ST
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const adaptMainFormField = <S extends SP>(
|
|
67
|
+
field: FormField<PickProps<T, ScopedMainPaths>, ScopedMainPaths>,
|
|
68
|
+
): FormField<PickProps<ST, S>, S> => {
|
|
69
|
+
// Where P ist the full path in the main form, we need to adapt it to the subform's path
|
|
70
|
+
return {
|
|
71
|
+
...field,
|
|
72
|
+
path: computed(() => unref(field.path).replace(path + '.', '')),
|
|
73
|
+
setValue: (newValue: PickProps<ST, S>) => {
|
|
74
|
+
field.setValue(newValue as PickProps<T, ScopedMainPaths>)
|
|
75
|
+
},
|
|
76
|
+
} as unknown as FormField<PickProps<ST, S>, S>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const getField = <P extends SP>(fieldPath: P) => {
|
|
80
|
+
const fullPath = joinPath(path, fieldPath)
|
|
81
|
+
const mainFormField = mainForm.getField(fullPath as ScopedMainPaths)
|
|
82
|
+
|
|
83
|
+
if (!mainFormField) {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return adaptMainFormField<P>(mainFormField)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Field operations with path transformation
|
|
91
|
+
const defineField = <P extends SP>(fieldOptions: DefineFieldOptions<ST, P>) => {
|
|
92
|
+
const fullPath = joinPath(path, fieldOptions.path)
|
|
93
|
+
|
|
94
|
+
const mainField = mainForm.defineField({
|
|
95
|
+
...fieldOptions,
|
|
96
|
+
path: fullPath as ScopedMainPaths,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
return adaptMainFormField<P>(mainField)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const getFields = <P extends SP>(): FormField<PickProps<ST, P>, P>[] => {
|
|
103
|
+
return (mainForm.getFields() as FormField<PickProps<T, ScopedMainPaths>, ScopedMainPaths>[])
|
|
104
|
+
.filter((field) => {
|
|
105
|
+
const fieldPath = field.path.value
|
|
106
|
+
return fieldPath.startsWith(path + '.') || fieldPath === path
|
|
107
|
+
})
|
|
108
|
+
.map(field => adaptMainFormField(field))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Helper function to get all fields without type parameter
|
|
112
|
+
const getAllSubformFields = () => {
|
|
113
|
+
return (mainForm.getFields() as FormField<PickProps<T, ScopedMainPaths>, ScopedMainPaths>[])
|
|
114
|
+
.filter((field) => {
|
|
115
|
+
const fieldPath = field.path.value
|
|
116
|
+
return fieldPath.startsWith(path + '.') || fieldPath === path
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// State computed from main form with path filtering
|
|
121
|
+
const isDirty = computed(() => getAllSubformFields().some(field => field.dirty.value))
|
|
122
|
+
const isTouched = computed(() => getAllSubformFields().some(field => field.touched.value))
|
|
123
|
+
|
|
124
|
+
// Validation delegates to main form
|
|
125
|
+
const isValid = computed(() => mainForm.isValid.value)
|
|
126
|
+
const isValidated = computed(() => mainForm.isValidated.value)
|
|
127
|
+
const errors = computed(() => filterErrorsForPath(unref(mainForm.errors), path))
|
|
128
|
+
|
|
129
|
+
const validateForm = () => mainForm.validateForm()
|
|
130
|
+
|
|
131
|
+
// Nested subforms
|
|
132
|
+
const getSubForm = <P extends EntityPaths<ST>>(
|
|
133
|
+
subPath: P,
|
|
134
|
+
subOptions?: SubformOptions<PickEntity<ST, P>>,
|
|
135
|
+
) => {
|
|
136
|
+
const fullPath = joinPath(path, subPath) as EntityPaths<T>
|
|
137
|
+
return mainForm.getSubForm(
|
|
138
|
+
fullPath,
|
|
139
|
+
subOptions as SubformOptions<PickEntity<T, typeof fullPath>>,
|
|
140
|
+
) as Form<PickEntity<ST, P>>
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Reset scoped to this subform
|
|
144
|
+
const reset = () => getAllSubformFields().forEach(field => field.reset())
|
|
145
|
+
|
|
146
|
+
const defineValidator = (options: ValidatorOptions<ST> | Ref<Validator<ST>>) => {
|
|
147
|
+
const subValidator = isRef(options) ? options : createValidator(options)
|
|
148
|
+
const validator = computed(
|
|
149
|
+
() => new NestedValidator<T, K>(path, unref(subValidator)),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
mainForm.defineValidator(validator)
|
|
153
|
+
|
|
154
|
+
return subValidator
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
formData,
|
|
159
|
+
initialData,
|
|
160
|
+
defineField,
|
|
161
|
+
getField,
|
|
162
|
+
getFields,
|
|
163
|
+
isDirty,
|
|
164
|
+
isTouched,
|
|
165
|
+
isValid,
|
|
166
|
+
isValidated,
|
|
167
|
+
errors,
|
|
168
|
+
defineValidator,
|
|
169
|
+
reset,
|
|
170
|
+
validateForm,
|
|
171
|
+
getSubForm,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { computed, getCurrentScope, isRef, onBeforeUnmount, reactive, ref, toRefs, unref, watch, type MaybeRef, type Ref } from 'vue'
|
|
2
|
+
import z from 'zod'
|
|
3
|
+
import type { FormDataDefault } from '../types/form'
|
|
4
|
+
import type { ErrorBag, ValidationFunction, ValidationResult, Validator } from '../types/validation'
|
|
5
|
+
import { hasErrors, mergeErrors } from '../utils/validation'
|
|
6
|
+
import { flattenError } from '../utils/zod'
|
|
7
|
+
|
|
8
|
+
export interface ValidatorOptions<T> {
|
|
9
|
+
schema?: MaybeRef<z.ZodType>
|
|
10
|
+
validateFn?: MaybeRef<ValidationFunction<T>>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ValidationOptions<T> extends ValidatorOptions<T> {
|
|
14
|
+
errors?: MaybeRef<ErrorBag>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const SuccessValidationResult: ValidationResult = {
|
|
18
|
+
isValid: true,
|
|
19
|
+
errors: {
|
|
20
|
+
general: [],
|
|
21
|
+
propertyErrors: {},
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class ZodSchemaValidator<T extends FormDataDefault> implements Validator<T> {
|
|
26
|
+
constructor(private schema?: z.ZodType<T>) {}
|
|
27
|
+
|
|
28
|
+
async validate(data: T): Promise<ValidationResult> {
|
|
29
|
+
if (!this.schema) {
|
|
30
|
+
return SuccessValidationResult
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const result = await this.schema.safeParseAsync(data)
|
|
34
|
+
|
|
35
|
+
if (result.success) {
|
|
36
|
+
return SuccessValidationResult
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const zodErrors = flattenError(result.error)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
isValid: false,
|
|
43
|
+
errors: {
|
|
44
|
+
general: zodErrors.general ?? [],
|
|
45
|
+
propertyErrors: zodErrors.propertyErrors ?? {},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class FunctionValidator<T extends FormDataDefault> implements Validator<T> {
|
|
52
|
+
constructor(private validateFn?: ValidationFunction<T>) {}
|
|
53
|
+
|
|
54
|
+
async validate(data: T): Promise<ValidationResult> {
|
|
55
|
+
if (!this.validateFn) {
|
|
56
|
+
return SuccessValidationResult
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const result = await this.validateFn(data)
|
|
61
|
+
|
|
62
|
+
if (result.isValid) {
|
|
63
|
+
return SuccessValidationResult
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return {
|
|
69
|
+
isValid: false,
|
|
70
|
+
errors: {
|
|
71
|
+
general: [(error as Error).message || 'Validation error'],
|
|
72
|
+
propertyErrors: {},
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class CombinedValidator<T extends FormDataDefault> implements Validator<T> {
|
|
80
|
+
private schemaValidator: ZodSchemaValidator<T>
|
|
81
|
+
private functionValidator: FunctionValidator<T>
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
private schema: z.ZodType<T>,
|
|
85
|
+
private validateFn: ValidationFunction<T>,
|
|
86
|
+
) {
|
|
87
|
+
this.schemaValidator = new ZodSchemaValidator(this.schema)
|
|
88
|
+
this.functionValidator = new FunctionValidator(this.validateFn)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async validate(data: T): Promise<ValidationResult> {
|
|
92
|
+
const [schemaResult, functionResult] = await Promise.all([
|
|
93
|
+
this.schemaValidator.validate(data),
|
|
94
|
+
this.functionValidator.validate(data),
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
const isValid = schemaResult.isValid && functionResult.isValid
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
isValid,
|
|
101
|
+
errors: mergeErrors(schemaResult.errors, functionResult.errors),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createValidator<T extends FormDataDefault>(
|
|
107
|
+
options: ValidatorOptions<T>,
|
|
108
|
+
): Ref<Validator<T> | undefined> {
|
|
109
|
+
return computed(() => new CombinedValidator(
|
|
110
|
+
unref(options.schema) as z.ZodType<T>,
|
|
111
|
+
unref(options.validateFn) as ValidationFunction<T>,
|
|
112
|
+
))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function useValidation<T extends FormDataDefault>(
|
|
116
|
+
formState: { formData: T },
|
|
117
|
+
options: ValidationOptions<T>,
|
|
118
|
+
) {
|
|
119
|
+
const validationState = reactive({
|
|
120
|
+
validators: ref<Ref<Validator<T> | undefined>[]>([createValidator(options)]),
|
|
121
|
+
isValidated: false,
|
|
122
|
+
errors: unref(options.errors) ?? {
|
|
123
|
+
general: [],
|
|
124
|
+
propertyErrors: {},
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Watch for changes in the error bag and update validation state
|
|
129
|
+
watch(() => unref(options.errors), async () => {
|
|
130
|
+
const validationResults = await getValidationResults()
|
|
131
|
+
|
|
132
|
+
updateErrors(validationResults.errors)
|
|
133
|
+
}, { immediate: true })
|
|
134
|
+
|
|
135
|
+
// Watch for changes in validation function or schema
|
|
136
|
+
// to trigger validation. Only run if validation is already validated.
|
|
137
|
+
watch(
|
|
138
|
+
[() => validationState.validators],
|
|
139
|
+
async (validators) => {
|
|
140
|
+
if (!validationState.isValidated) {
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (validators) {
|
|
145
|
+
const validationResults = await getValidationResults()
|
|
146
|
+
validationState.errors = validationResults.errors
|
|
147
|
+
} else {
|
|
148
|
+
validationState.errors = SuccessValidationResult.errors
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
{ immediate: true },
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
// Watch for changes in form data to trigger validation
|
|
155
|
+
watch(() => formState.formData, () => {
|
|
156
|
+
if (validationState.isValidated) {
|
|
157
|
+
validateForm()
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const defineValidator = (options: ValidatorOptions<T> | Ref<Validator<T>>) => {
|
|
162
|
+
const validator = isRef(options) ? options : createValidator(options)
|
|
163
|
+
|
|
164
|
+
validationState.validators.push(validator)
|
|
165
|
+
|
|
166
|
+
if (getCurrentScope()) {
|
|
167
|
+
onBeforeUnmount(() => {
|
|
168
|
+
validationState.validators = validationState.validators.filter(
|
|
169
|
+
v => v !== validator,
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return validator
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function getValidationResults() {
|
|
178
|
+
const validationResults = await Promise.all(
|
|
179
|
+
validationState.validators
|
|
180
|
+
.filter(validator => unref(validator) !== undefined)
|
|
181
|
+
.map(validator => unref(validator)!.validate(formState.formData)),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
const isValid = validationResults.every(result => result.isValid)
|
|
185
|
+
|
|
186
|
+
let { errors } = SuccessValidationResult
|
|
187
|
+
|
|
188
|
+
if (!isValid) {
|
|
189
|
+
const validationErrors = validationResults.map(result => result.errors)
|
|
190
|
+
|
|
191
|
+
errors = mergeErrors(...validationErrors)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
errors,
|
|
196
|
+
isValid,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const updateErrors = (newErrors: ErrorBag) => {
|
|
201
|
+
validationState.errors = mergeErrors(unref(options.errors) ?? SuccessValidationResult.errors, newErrors)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const validateForm = async (): Promise<ValidationResult> => {
|
|
205
|
+
const validationResults = await getValidationResults()
|
|
206
|
+
|
|
207
|
+
updateErrors(validationResults.errors)
|
|
208
|
+
|
|
209
|
+
validationState.isValidated = true
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
isValid: !hasErrors(validationResults.errors),
|
|
213
|
+
errors: validationState.errors,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const isValid = computed(() => !hasErrors(validationState.errors))
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
...toRefs(validationState),
|
|
221
|
+
validateForm,
|
|
222
|
+
defineValidator,
|
|
223
|
+
isValid,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export type ValidationState<T extends FormDataDefault> = ReturnType<typeof useValidation<T>>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Main composable
|
|
2
|
+
export { useForm } from './composables/useForm'
|
|
3
|
+
export type { UseFormOptions } from './composables/useForm'
|
|
4
|
+
|
|
5
|
+
// Field composable
|
|
6
|
+
export { useField } from './composables/useField'
|
|
7
|
+
export type { UseFieldOptions } from './composables/useField'
|
|
8
|
+
|
|
9
|
+
// Types
|
|
10
|
+
export type { ValidationStrategy, ValidationErrorMessage as ErrorMessage, ValidationResult, ErrorBag } from './types/validation'
|
|
11
|
+
export type { DeepPartial, FormData } from './utils/type-helpers'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import type { DefineFieldOptions } from '../composables/useFieldRegistry'
|
|
3
|
+
import type { SubformOptions } from '../composables/useSubform'
|
|
4
|
+
import type { EntityPaths, Paths, PickEntity, PickProps } from './util'
|
|
5
|
+
import type { ErrorBag, ValidationErrorMessage, ValidationErrors, ValidationResult, Validator } from './validation'
|
|
6
|
+
import type { ValidatorOptions } from '../composables/useValidation'
|
|
7
|
+
|
|
8
|
+
export type FormDataDefault = object
|
|
9
|
+
|
|
10
|
+
export interface FormState<T extends FormDataDefault, TIn extends FormDataDefault = T> {
|
|
11
|
+
formData: T
|
|
12
|
+
initialData: TIn
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FormField<T, P extends string> {
|
|
16
|
+
value: Ref<T>
|
|
17
|
+
path: Ref<P>
|
|
18
|
+
initialValue: Readonly<Ref<T>>
|
|
19
|
+
errors: Ref<ValidationErrors>
|
|
20
|
+
touched: Ref<boolean>
|
|
21
|
+
dirty: Ref<boolean>
|
|
22
|
+
setValue: (newValue: T) => void
|
|
23
|
+
onBlur: () => void
|
|
24
|
+
onFocus: () => void
|
|
25
|
+
reset: () => void
|
|
26
|
+
setErrors: (newErrors: ValidationErrorMessage[]) => void
|
|
27
|
+
clearErrors: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Form<T extends FormDataDefault> {
|
|
31
|
+
// Data properties
|
|
32
|
+
formData: Ref<T>
|
|
33
|
+
initialData: Readonly<Ref<T>>
|
|
34
|
+
|
|
35
|
+
// Field operations
|
|
36
|
+
defineField: <P extends Paths<T>>(options: DefineFieldOptions<PickProps<T, P>, P>) => FormField<PickProps<T, P>, P>
|
|
37
|
+
getField: <P extends Paths<T>>(path: P) => FormField<PickProps<T, P>, P> | undefined
|
|
38
|
+
getFields: () => FormField<PickProps<T, Paths<T>>, Paths<T>>[]
|
|
39
|
+
|
|
40
|
+
// State properties
|
|
41
|
+
isDirty: Ref<boolean>
|
|
42
|
+
isTouched: Ref<boolean>
|
|
43
|
+
isValid: Ref<boolean>
|
|
44
|
+
isValidated: Ref<boolean>
|
|
45
|
+
errors: Ref<ErrorBag>
|
|
46
|
+
|
|
47
|
+
defineValidator: (options: ValidatorOptions<T> | Ref<Validator<T>>) => Ref<Validator<T> | undefined>
|
|
48
|
+
|
|
49
|
+
// Operations
|
|
50
|
+
reset: () => void
|
|
51
|
+
validateForm: () => Promise<ValidationResult>
|
|
52
|
+
|
|
53
|
+
// Nested subforms
|
|
54
|
+
getSubForm: <P extends EntityPaths<T>>(
|
|
55
|
+
path: P,
|
|
56
|
+
options?: SubformOptions<PickEntity<T, P>>,
|
|
57
|
+
) => Form<PickEntity<T, P>>
|
|
58
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { FormDataDefault } from './form'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Takes a dot-connected path and returns a tuple of its parts.
|
|
5
|
+
*/
|
|
6
|
+
export type SplitPath<TPath extends string> =
|
|
7
|
+
TPath extends `${infer T1}.${infer T2}`
|
|
8
|
+
? [T1, ...SplitPath<T2>]
|
|
9
|
+
: [TPath]
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Picks the exact type of the Entity at the nested PropertyKeys path.
|
|
13
|
+
*/
|
|
14
|
+
export type PickProps<Entity, PropertyKeys extends string> =
|
|
15
|
+
PropertyKeys extends `${infer TRoot}.${infer TRest}`
|
|
16
|
+
? TRoot extends keyof Entity
|
|
17
|
+
? TRest extends string
|
|
18
|
+
? Entity[TRoot] extends object
|
|
19
|
+
? PickProps<Entity[TRoot], TRest>
|
|
20
|
+
: never
|
|
21
|
+
: never
|
|
22
|
+
// We might have an array at hand but the key is a string with a number in it
|
|
23
|
+
: TRoot extends `${number}`
|
|
24
|
+
? Entity extends unknown[]
|
|
25
|
+
? TRest extends string
|
|
26
|
+
? Entity[number] extends object
|
|
27
|
+
? PickProps<Entity[number], TRest>
|
|
28
|
+
: never
|
|
29
|
+
: never
|
|
30
|
+
: never
|
|
31
|
+
: never
|
|
32
|
+
// We might have an array at hand but the key is a string with a number in it
|
|
33
|
+
: PropertyKeys extends keyof Entity
|
|
34
|
+
? Entity[PropertyKeys]
|
|
35
|
+
: PropertyKeys extends `${number}`
|
|
36
|
+
? Entity extends unknown[]
|
|
37
|
+
? Entity[number]
|
|
38
|
+
: never
|
|
39
|
+
: never
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolves to a union of dot-connected paths of all nested properties of T.
|
|
43
|
+
*/
|
|
44
|
+
export type Paths<T, Seen = never> =
|
|
45
|
+
T extends Seen ? never :
|
|
46
|
+
T extends Array<infer ArrayType> ? `${number}` | `${number}.${Paths<ArrayType, Seen | T>}` :
|
|
47
|
+
T extends object
|
|
48
|
+
? {
|
|
49
|
+
[K in keyof T]-?: `${Exclude<K, symbol>}${'' | `.${Paths<T[K], Seen | T>}`}`
|
|
50
|
+
}[keyof T]
|
|
51
|
+
: never
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Removes the last part of a dot-connected path.
|
|
55
|
+
*/
|
|
56
|
+
export type ButLast<T extends string> =
|
|
57
|
+
T extends `${infer Rest}.${infer Last}`
|
|
58
|
+
? ButLast<Last> extends ''
|
|
59
|
+
? Rest
|
|
60
|
+
: `${Rest}.${ButLast<Last>}`
|
|
61
|
+
: never
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Combines Paths<T> with ButLast<Paths<T>> to include all paths except the last part.
|
|
65
|
+
* The & Paths<T> ensures that there are no entity paths that are not also available in Paths<T>.
|
|
66
|
+
*/
|
|
67
|
+
export type EntityPaths<T> = ButLast<Paths<T>> & Paths<T>
|
|
68
|
+
|
|
69
|
+
export type PickEntity<Entity, PropertyKeys extends string> =
|
|
70
|
+
PropertyKeys extends unknown ? PickProps<Entity, EntityPaths<Entity> & PropertyKeys> & FormDataDefault : never
|
|
71
|
+
|
|
72
|
+
export type RestPath<T extends string, P extends string> =
|
|
73
|
+
P extends `${T}.${infer Rest}` ? Rest : never
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FormDataDefault } from './form'
|
|
2
|
+
|
|
3
|
+
export type ValidationStrategy = 'onTouch' | 'onFormOpen' | 'none' | 'preSubmit'
|
|
4
|
+
|
|
5
|
+
export type ValidationErrorMessage = string
|
|
6
|
+
export type ValidationErrors = ValidationErrorMessage[] | undefined
|
|
7
|
+
|
|
8
|
+
export interface ErrorBag {
|
|
9
|
+
general: ValidationErrors
|
|
10
|
+
propertyErrors: Record<string, ValidationErrors>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ValidationResult {
|
|
14
|
+
isValid: boolean
|
|
15
|
+
errors: ErrorBag
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Validator<T extends FormDataDefault = FormDataDefault> {
|
|
19
|
+
validate: (data: T) => Promise<ValidationResult>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ValidationFunction<T> = (data: T) => Promise<ValidationResult>
|