@teamnovu/kit-vue-forms 0.1.15 → 0.1.17
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/dist/components/FormPart.vue.d.ts +1 -1
- package/dist/composables/useField.d.ts +3 -2
- package/dist/composables/useFieldRegistry.d.ts +2 -2
- package/dist/composables/useSubform.d.ts +1 -1
- package/dist/composables/useValidation.d.ts +1 -0
- package/dist/index.js +353 -329
- package/dist/types/form.d.ts +1 -1
- package/dist/utils/path.d.ts +3 -0
- package/package.json +2 -2
- package/src/composables/useField.ts +16 -4
- package/src/composables/useFieldRegistry.ts +75 -74
- package/src/composables/useForm.ts +8 -9
- package/src/composables/useSubform.ts +1 -1
- package/src/composables/useValidation.ts +17 -0
- package/src/types/form.ts +1 -1
- package/src/utils/path.ts +49 -21
- package/tests/useForm.test.ts +40 -0
- package/tests/useValidation.test.ts +26 -2
package/dist/types/form.d.ts
CHANGED
|
@@ -44,5 +44,5 @@ export interface Form<T extends FormDataDefault> {
|
|
|
44
44
|
reset: () => void;
|
|
45
45
|
validateForm: () => Promise<ValidationResult>;
|
|
46
46
|
submitHandler: (onSubmit: (data: T) => Awaitable<void>) => (event: SubmitEvent) => Promise<void>;
|
|
47
|
-
getSubForm: <P extends EntityPaths<T>>(path: P, options?: SubformOptions<PickEntity<T, P>>) => Form<PickEntity<T, P
|
|
47
|
+
getSubForm: <P extends EntityPaths<T>>(path: P, options?: SubformOptions<PickEntity<T, P>>) => Omit<Form<PickEntity<T, P>>, 'submitHandler'>;
|
|
48
48
|
}
|
package/dist/utils/path.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { MaybeRef, WritableComputedRef } from 'vue';
|
|
2
2
|
import { Paths, PickProps, SplitPath } from '../types/util';
|
|
3
3
|
import { ErrorBag } from '../types/validation';
|
|
4
|
+
import { FormField } from '../types/form';
|
|
4
5
|
export declare function splitPath(path: string): string[];
|
|
6
|
+
export declare function existsPath<T, K extends Paths<T>>(obj: T, path: K | SplitPath<K>): boolean;
|
|
7
|
+
export declare function existsFieldPath<T, K extends Paths<T>>(field: FormField<T, K>): boolean;
|
|
5
8
|
export declare function getNestedValue<T, K extends Paths<T>>(obj: T, path: K | SplitPath<K>): PickProps<T, K>;
|
|
6
9
|
export declare function setNestedValue<T, K extends Paths<T>>(obj: MaybeRef<T>, path: K | SplitPath<K>, value: PickProps<T, K>): void;
|
|
7
10
|
export declare const getLens: <T, K extends Paths<T>>(data: MaybeRef<T>, key: MaybeRef<K | SplitPath<K>>) => WritableComputedRef<PickProps<T, K>, PickProps<T, K>>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamnovu/kit-vue-forms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@vitest/ui": "^2.0.0",
|
|
27
27
|
"happy-dom": "^12.0.0",
|
|
28
28
|
"vitest": "^2.0.0",
|
|
29
|
-
"vue": "^3.5.
|
|
29
|
+
"vue": "^3.5.25"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@vueuse/core": "^13.5.0",
|
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { Awaitable } from '@vueuse/core'
|
|
2
|
+
import { computed, reactive, shallowRef, toRefs, unref, watch, type MaybeRef, type MaybeRefOrGetter, type Ref } from 'vue'
|
|
2
3
|
import type { FormField } from '../types/form'
|
|
3
4
|
import type { ValidationErrorMessage, ValidationErrors } from '../types/validation'
|
|
4
5
|
import { cloneRefValue } from '../utils/general'
|
|
5
|
-
import type { Awaitable } from '@vueuse/core'
|
|
6
6
|
|
|
7
7
|
export interface UseFieldOptions<T, K extends string> {
|
|
8
8
|
value?: MaybeRef<T>
|
|
9
9
|
initialValue?: MaybeRefOrGetter<Readonly<T>>
|
|
10
10
|
path: K
|
|
11
11
|
errors?: Ref<ValidationErrors>
|
|
12
|
+
existsInForm?: MaybeRef<boolean>
|
|
12
13
|
onBlur?: () => Awaitable<void>
|
|
13
14
|
onFocus?: () => Awaitable<void>
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function useField<T, K extends string>(
|
|
17
|
+
export function useField<T, K extends string>(fieldOptions: UseFieldOptions<T, K>): FormField<T, K> {
|
|
18
|
+
const defaultOptions = {
|
|
19
|
+
existsInForm: true,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const options = {
|
|
23
|
+
...defaultOptions,
|
|
24
|
+
...fieldOptions,
|
|
25
|
+
}
|
|
17
26
|
const initialValue = shallowRef(Object.freeze(cloneRefValue(options.initialValue))) as Ref<Readonly<T | undefined>>
|
|
18
27
|
|
|
19
28
|
const state = reactive({
|
|
@@ -53,7 +62,10 @@ export function useField<T, K extends string>(options: UseFieldOptions<T, K>): F
|
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
const reset = (): void => {
|
|
56
|
-
|
|
65
|
+
const lastPathPart = state.path.split('.').at(-1) || ''
|
|
66
|
+
if (unref(options.existsInForm) && !/^\d+$/.test(lastPathPart)) {
|
|
67
|
+
state.value = cloneRefValue(state.initialValue)
|
|
68
|
+
}
|
|
57
69
|
state.touched = false
|
|
58
70
|
state.errors = []
|
|
59
71
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Awaitable } from "@vueuse/core";
|
|
1
2
|
import {
|
|
2
3
|
computed,
|
|
3
4
|
onScopeDispose,
|
|
@@ -6,65 +7,69 @@ import {
|
|
|
6
7
|
toRef,
|
|
7
8
|
triggerRef,
|
|
8
9
|
unref,
|
|
9
|
-
|
|
10
|
+
watch,
|
|
10
11
|
type MaybeRef,
|
|
11
|
-
} from
|
|
12
|
-
import type { FieldsTuple, FormDataDefault, FormField } from
|
|
13
|
-
import type { Paths, PickProps } from
|
|
14
|
-
import { getLens, getNestedValue } from
|
|
15
|
-
import { Rc } from
|
|
16
|
-
import { useField, type UseFieldOptions } from
|
|
17
|
-
import type { ValidationState } from
|
|
18
|
-
import type { Awaitable } from '@vueuse/core'
|
|
12
|
+
} from "vue";
|
|
13
|
+
import type { FieldsTuple, FormDataDefault, FormField } from "../types/form";
|
|
14
|
+
import type { Paths, PickProps } from "../types/util";
|
|
15
|
+
import { existsPath, getLens, getNestedValue } from "../utils/path";
|
|
16
|
+
import { Rc } from "../utils/rc";
|
|
17
|
+
import { useField, type UseFieldOptions } from "./useField";
|
|
18
|
+
import type { ValidationState } from "./useValidation";
|
|
19
19
|
|
|
20
20
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
-
type FieldRegistryCache<T> = Map<Paths<T>, FormField<any, string
|
|
21
|
+
type FieldRegistryCache<T> = Map<Paths<T>, FormField<any, string>>;
|
|
22
22
|
|
|
23
23
|
export type ResolvedFormField<T, K extends Paths<T>> = FormField<
|
|
24
24
|
PickProps<T, K>,
|
|
25
25
|
K
|
|
26
|
-
|
|
26
|
+
>;
|
|
27
27
|
|
|
28
28
|
export type DefineFieldOptions<F, K extends string> = Pick<
|
|
29
29
|
UseFieldOptions<F, K>,
|
|
30
|
-
|
|
30
|
+
"path"
|
|
31
31
|
> & {
|
|
32
|
-
onBlur?: () => void
|
|
33
|
-
onFocus?: () => void
|
|
34
|
-
}
|
|
32
|
+
onBlur?: () => void;
|
|
33
|
+
onFocus?: () => void;
|
|
34
|
+
};
|
|
35
35
|
|
|
36
36
|
interface FormState<
|
|
37
37
|
T extends FormDataDefault,
|
|
38
38
|
TIn extends FormDataDefault = T,
|
|
39
39
|
> {
|
|
40
|
-
data: T
|
|
41
|
-
initialData: TIn
|
|
40
|
+
data: T;
|
|
41
|
+
initialData: TIn;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
interface FieldRegistryOptions {
|
|
45
|
-
keepValuesOnUnmount?: MaybeRef<boolean
|
|
46
|
-
onBlur?: (path: string) => Awaitable<void
|
|
47
|
-
onFocus?: (path: string) => Awaitable<void
|
|
45
|
+
keepValuesOnUnmount?: MaybeRef<boolean>;
|
|
46
|
+
onBlur?: (path: string) => Awaitable<void>;
|
|
47
|
+
onFocus?: (path: string) => Awaitable<void>;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const optionDefaults = {
|
|
51
51
|
keepValuesOnUnmount: true,
|
|
52
|
-
}
|
|
52
|
+
};
|
|
53
53
|
|
|
54
54
|
// A computed that always reflects the latest value from the getter
|
|
55
55
|
// This computed forces updates even if the value is the same (to trigger watchers)
|
|
56
|
-
function
|
|
57
|
-
|
|
56
|
+
function initialDataSync<T extends FormDataDefault>(
|
|
57
|
+
formState: FormState<T>,
|
|
58
|
+
path: Paths<T>,
|
|
59
|
+
) {
|
|
60
|
+
const getNewValue = () => getNestedValue(formState.initialData, path);
|
|
61
|
+
const initialValueRef = shallowRef(getNewValue());
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
watch(
|
|
64
|
+
() => formState.initialData,
|
|
60
65
|
() => {
|
|
61
|
-
initialValueRef.value =
|
|
62
|
-
triggerRef(initialValueRef)
|
|
66
|
+
initialValueRef.value = getNewValue();
|
|
67
|
+
triggerRef(initialValueRef);
|
|
63
68
|
},
|
|
64
|
-
{ flush:
|
|
65
|
-
)
|
|
69
|
+
{ flush: "sync" },
|
|
70
|
+
);
|
|
66
71
|
|
|
67
|
-
return initialValueRef
|
|
72
|
+
return initialValueRef;
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
export function useFieldRegistry<T extends FormDataDefault>(
|
|
@@ -72,103 +77,99 @@ export function useFieldRegistry<T extends FormDataDefault>(
|
|
|
72
77
|
validationState: ValidationState<T>,
|
|
73
78
|
fieldRegistryOptions?: FieldRegistryOptions,
|
|
74
79
|
) {
|
|
75
|
-
const fieldReferenceCounter = new Map<Paths<T>, Rc>()
|
|
76
|
-
const fields = shallowReactive(new Map()) as FieldRegistryCache<T
|
|
80
|
+
const fieldReferenceCounter = new Map<Paths<T>, Rc>();
|
|
81
|
+
const fields = shallowReactive(new Map()) as FieldRegistryCache<T>;
|
|
77
82
|
const registryOptions = {
|
|
78
83
|
...optionDefaults,
|
|
79
84
|
...fieldRegistryOptions,
|
|
80
|
-
}
|
|
85
|
+
};
|
|
81
86
|
|
|
82
87
|
const registerField = <K extends Paths<T>>(
|
|
83
88
|
field: ResolvedFormField<T, K>,
|
|
84
89
|
) => {
|
|
85
|
-
const path = unref(field.path) as Paths<T
|
|
86
|
-
fields.set(path, field)
|
|
87
|
-
}
|
|
90
|
+
const path = unref(field.path) as Paths<T>;
|
|
91
|
+
fields.set(path, field);
|
|
92
|
+
};
|
|
88
93
|
|
|
89
94
|
const deregisterField = (path: Paths<T>) => {
|
|
90
95
|
if (!registryOptions?.keepValuesOnUnmount) {
|
|
91
|
-
fields.get(path)?.reset()
|
|
96
|
+
fields.get(path)?.reset();
|
|
92
97
|
}
|
|
93
|
-
fields.delete(path)
|
|
94
|
-
}
|
|
98
|
+
fields.delete(path);
|
|
99
|
+
};
|
|
95
100
|
|
|
96
101
|
const track = (path: Paths<T>) => {
|
|
97
102
|
if (!fieldReferenceCounter.has(path)) {
|
|
98
|
-
fieldReferenceCounter.set(path, new Rc(() => deregisterField(path)))
|
|
103
|
+
fieldReferenceCounter.set(path, new Rc(() => deregisterField(path)));
|
|
99
104
|
} else {
|
|
100
|
-
fieldReferenceCounter.get(path)?.inc()
|
|
105
|
+
fieldReferenceCounter.get(path)?.inc();
|
|
101
106
|
}
|
|
102
|
-
}
|
|
107
|
+
};
|
|
103
108
|
|
|
104
109
|
const untrack = (path: Paths<T>) => {
|
|
105
110
|
if (fieldReferenceCounter.has(path)) {
|
|
106
|
-
fieldReferenceCounter.get(path)?.dec()
|
|
111
|
+
fieldReferenceCounter.get(path)?.dec();
|
|
107
112
|
}
|
|
108
|
-
}
|
|
113
|
+
};
|
|
109
114
|
|
|
110
115
|
const getField = <K extends Paths<T>>(
|
|
111
116
|
options: DefineFieldOptions<PickProps<T, K>, K>,
|
|
112
117
|
): ResolvedFormField<T, K> => {
|
|
113
|
-
const { path } = options
|
|
118
|
+
const { path } = options;
|
|
114
119
|
|
|
115
120
|
if (!fields.has(path)) {
|
|
116
121
|
const field = useField({
|
|
117
122
|
path,
|
|
118
|
-
value: getLens(toRef(formState,
|
|
119
|
-
initialValue:
|
|
120
|
-
|
|
123
|
+
value: getLens(toRef(formState, "data"), path),
|
|
124
|
+
initialValue: initialDataSync(formState, path),
|
|
125
|
+
existsInForm: computed(() => existsPath(formState.data, unref(path))),
|
|
121
126
|
errors: computed({
|
|
122
127
|
get() {
|
|
123
|
-
return validationState.errors.value.propertyErrors[path] || []
|
|
128
|
+
return validationState.errors.value.propertyErrors[path] || [];
|
|
124
129
|
},
|
|
125
130
|
set(newErrors) {
|
|
126
|
-
validationState.errors.value.propertyErrors[path] = newErrors
|
|
131
|
+
validationState.errors.value.propertyErrors[path] = newErrors;
|
|
127
132
|
},
|
|
128
133
|
}),
|
|
129
134
|
onBlur: async () => {
|
|
130
|
-
await Promise.all(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
],
|
|
135
|
-
)
|
|
135
|
+
await Promise.all([
|
|
136
|
+
registryOptions?.onBlur?.(unref(path)),
|
|
137
|
+
options.onBlur?.(),
|
|
138
|
+
]);
|
|
136
139
|
},
|
|
137
140
|
onFocus: async () => {
|
|
138
|
-
await Promise.all(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
],
|
|
143
|
-
)
|
|
141
|
+
await Promise.all([
|
|
142
|
+
registryOptions?.onFocus?.(unref(path)),
|
|
143
|
+
options.onFocus?.(),
|
|
144
|
+
]);
|
|
144
145
|
},
|
|
145
|
-
})
|
|
146
|
+
});
|
|
146
147
|
|
|
147
|
-
registerField(field)
|
|
148
|
+
registerField(field);
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
const field = fields.get(path) as ResolvedFormField<T, K
|
|
151
|
+
const field = fields.get(path) as ResolvedFormField<T, K>;
|
|
151
152
|
|
|
152
|
-
track(path)
|
|
153
|
+
track(path);
|
|
153
154
|
|
|
154
155
|
// Clean up field on unmount
|
|
155
156
|
onScopeDispose(() => {
|
|
156
|
-
untrack(path)
|
|
157
|
-
})
|
|
157
|
+
untrack(path);
|
|
158
|
+
});
|
|
158
159
|
|
|
159
|
-
return field
|
|
160
|
-
}
|
|
160
|
+
return field;
|
|
161
|
+
};
|
|
161
162
|
|
|
162
163
|
const defineField = <K extends Paths<T>>(
|
|
163
164
|
options: DefineFieldOptions<PickProps<T, K>, K>,
|
|
164
165
|
): ResolvedFormField<T, K> => {
|
|
165
|
-
const field = getField(options)
|
|
166
|
+
const field = getField(options);
|
|
166
167
|
|
|
167
168
|
// TODO: If more options are ever needed than only the path we have to update the field
|
|
168
169
|
// here with the new options
|
|
169
170
|
|
|
170
|
-
return field
|
|
171
|
-
}
|
|
171
|
+
return field;
|
|
172
|
+
};
|
|
172
173
|
|
|
173
174
|
return {
|
|
174
175
|
fields: computed(() => [...fields.values()] as FieldsTuple<T>),
|
|
@@ -176,9 +177,9 @@ export function useFieldRegistry<T extends FormDataDefault>(
|
|
|
176
177
|
registerField,
|
|
177
178
|
deregisterField,
|
|
178
179
|
defineField,
|
|
179
|
-
}
|
|
180
|
+
};
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
export type FieldRegistry<T extends FormDataDefault> = ReturnType<
|
|
183
184
|
typeof useFieldRegistry<T>
|
|
184
|
-
|
|
185
|
+
>;
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type MaybeRefOrGetter,
|
|
11
11
|
type Ref,
|
|
12
12
|
} from 'vue'
|
|
13
|
-
import type {
|
|
13
|
+
import type { Form, FormDataDefault } from '../types/form'
|
|
14
14
|
import type { EntityPaths, PickEntity } from '../types/util'
|
|
15
15
|
import type { ValidationStrategy } from '../types/validation'
|
|
16
16
|
import { cloneRefValue } from '../utils/general'
|
|
@@ -19,8 +19,6 @@ import { useFormState } from './useFormState'
|
|
|
19
19
|
import { createSubformInterface, type SubformOptions } from './useSubform'
|
|
20
20
|
import { useValidation, type ValidationOptions } from './useValidation'
|
|
21
21
|
|
|
22
|
-
// TODO @Elias implement validation strategy handling
|
|
23
|
-
|
|
24
22
|
export interface UseFormOptions<T extends FormDataDefault>
|
|
25
23
|
extends ValidationOptions<T> {
|
|
26
24
|
initialData: MaybeRefOrGetter<T>
|
|
@@ -29,8 +27,7 @@ export interface UseFormOptions<T extends FormDataDefault>
|
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
export function useForm<T extends FormDataDefault>(options: UseFormOptions<T>) {
|
|
32
|
-
const initialData = computed(() =>
|
|
33
|
-
Object.freeze(cloneRefValue(options.initialData)))
|
|
30
|
+
const initialData = computed(() => cloneRefValue(options.initialData))
|
|
34
31
|
|
|
35
32
|
const data = ref<T>(cloneRefValue(initialData)) as Ref<T>
|
|
36
33
|
|
|
@@ -50,9 +47,9 @@ export function useForm<T extends FormDataDefault>(options: UseFormOptions<T>) {
|
|
|
50
47
|
const validationState = useValidation(state, options)
|
|
51
48
|
const fieldRegistry = useFieldRegistry(state, validationState, {
|
|
52
49
|
keepValuesOnUnmount: options.keepValuesOnUnmount,
|
|
53
|
-
onBlur: async () => {
|
|
50
|
+
onBlur: async (path: string) => {
|
|
54
51
|
if (unref(options.validationStrategy) === 'onTouch') {
|
|
55
|
-
validationState.
|
|
52
|
+
validationState.validateField(path)
|
|
56
53
|
}
|
|
57
54
|
},
|
|
58
55
|
})
|
|
@@ -77,13 +74,15 @@ export function useForm<T extends FormDataDefault>(options: UseFormOptions<T>) {
|
|
|
77
74
|
const reset = () => {
|
|
78
75
|
data.value = cloneRefValue(initialData)
|
|
79
76
|
validationState.reset()
|
|
80
|
-
fieldRegistry.fields.value
|
|
77
|
+
for (const field of fieldRegistry.fields.value) {
|
|
78
|
+
field.reset()
|
|
79
|
+
}
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
function getSubForm<K extends EntityPaths<T>>(
|
|
84
83
|
path: K,
|
|
85
84
|
options?: SubformOptions<PickEntity<T, K>>,
|
|
86
|
-
): Form<PickEntity<T, K
|
|
85
|
+
): Omit<Form<PickEntity<T, K>>, 'submitHandler'> {
|
|
87
86
|
return createSubformInterface(formInterface, path, options)
|
|
88
87
|
}
|
|
89
88
|
|
|
@@ -50,7 +50,7 @@ export function createSubformInterface<
|
|
|
50
50
|
mainForm: Form<T>,
|
|
51
51
|
path: K,
|
|
52
52
|
_options?: SubformOptions<PickEntity<T, K>>,
|
|
53
|
-
): Form<PickEntity<T, K
|
|
53
|
+
): Omit<Form<PickEntity<T, K>>, 'submitHandler'> {
|
|
54
54
|
type ST = PickEntity<T, K>
|
|
55
55
|
type SP = Paths<ST>
|
|
56
56
|
type MP<P extends SP> = `${K}.${P}`
|
|
@@ -215,6 +215,22 @@ export function useValidation<T extends FormDataDefault>(
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
const validateField = async (path: string): Promise<ValidationResult> => {
|
|
219
|
+
const validationResults = await getValidationResults()
|
|
220
|
+
|
|
221
|
+
updateErrors({
|
|
222
|
+
general: validationResults.errors.general,
|
|
223
|
+
propertyErrors: {
|
|
224
|
+
[path]: validationResults.errors.propertyErrors[path],
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
isValid: !hasErrors(validationResults.errors),
|
|
230
|
+
errors: validationState.errors,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
218
234
|
const isValid = computed(() => !hasErrors(validationState.errors))
|
|
219
235
|
|
|
220
236
|
const reset = () => {
|
|
@@ -225,6 +241,7 @@ export function useValidation<T extends FormDataDefault>(
|
|
|
225
241
|
return {
|
|
226
242
|
...toRefs(validationState),
|
|
227
243
|
validateForm,
|
|
244
|
+
validateField,
|
|
228
245
|
defineValidator,
|
|
229
246
|
isValid,
|
|
230
247
|
reset,
|
package/src/types/form.ts
CHANGED
package/src/utils/path.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed, isRef, unref, type MaybeRef } from 'vue'
|
|
2
2
|
import type { Paths, PickProps, SplitPath } from '../types/util'
|
|
3
3
|
import type { ErrorBag, ValidationErrors } from '../types/validation'
|
|
4
|
+
import type { FormField } from '../types/form'
|
|
4
5
|
|
|
5
6
|
export function splitPath(path: string): string[] {
|
|
6
7
|
if (path === '') {
|
|
@@ -9,7 +10,22 @@ export function splitPath(path: string): string[] {
|
|
|
9
10
|
return path.split(/\s*\.\s*/).filter(Boolean)
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
export function
|
|
13
|
+
export function existsPath<T, K extends Paths<T>>(
|
|
14
|
+
obj: T,
|
|
15
|
+
path: K | SplitPath<K>,
|
|
16
|
+
): boolean {
|
|
17
|
+
const splittedPath = Array.isArray(path) ? path : splitPath(path) as SplitPath<K>
|
|
18
|
+
return !!getNestedValue(obj, splittedPath.slice(0, -1) as SplitPath<K>)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function existsFieldPath<T, K extends Paths<T>>(field: FormField<T, K>) {
|
|
22
|
+
return existsPath(field.data.value, field.path.value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getNestedValue<T, K extends Paths<T>>(
|
|
26
|
+
obj: T,
|
|
27
|
+
path: K | SplitPath<K>,
|
|
28
|
+
) {
|
|
13
29
|
const splittedPath = Array.isArray(path) ? path : splitPath(path)
|
|
14
30
|
return splittedPath.reduce(
|
|
15
31
|
(current, key) => current?.[key],
|
|
@@ -17,7 +33,11 @@ export function getNestedValue<T, K extends Paths<T>>(obj: T, path: K | SplitPat
|
|
|
17
33
|
) as PickProps<T, K>
|
|
18
34
|
}
|
|
19
35
|
|
|
20
|
-
export function setNestedValue<T, K extends Paths<T>>(
|
|
36
|
+
export function setNestedValue<T, K extends Paths<T>>(
|
|
37
|
+
obj: MaybeRef<T>,
|
|
38
|
+
path: K | SplitPath<K>,
|
|
39
|
+
value: PickProps<T, K>,
|
|
40
|
+
): void {
|
|
21
41
|
const keys = Array.isArray(path) ? path : splitPath(path)
|
|
22
42
|
|
|
23
43
|
const lastKey = keys.at(-1)!
|
|
@@ -30,25 +50,26 @@ export function setNestedValue<T, K extends Paths<T>>(obj: MaybeRef<T>, path: K
|
|
|
30
50
|
|
|
31
51
|
obj.value = value
|
|
32
52
|
} else {
|
|
33
|
-
const target = keys
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
unref(obj) as Record<string, any>,
|
|
45
|
-
)
|
|
53
|
+
const target = keys.slice(0, -1).reduce(
|
|
54
|
+
(current, key) => {
|
|
55
|
+
if (current?.[key] === undefined) {
|
|
56
|
+
// Create the nested object if it doesn't exist
|
|
57
|
+
current[key] = {}
|
|
58
|
+
}
|
|
59
|
+
return current?.[key]
|
|
60
|
+
},
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
|
+
unref(obj) as Record<string, any>,
|
|
63
|
+
)
|
|
46
64
|
|
|
47
65
|
target[lastKey] = value
|
|
48
66
|
}
|
|
49
67
|
}
|
|
50
68
|
|
|
51
|
-
export const getLens = <T, K extends Paths<T>>(
|
|
69
|
+
export const getLens = <T, K extends Paths<T>>(
|
|
70
|
+
data: MaybeRef<T>,
|
|
71
|
+
key: MaybeRef<K | SplitPath<K>>,
|
|
72
|
+
) => {
|
|
52
73
|
return computed({
|
|
53
74
|
get() {
|
|
54
75
|
return getNestedValue(unref(data), unref(key))
|
|
@@ -59,8 +80,14 @@ export const getLens = <T, K extends Paths<T>>(data: MaybeRef<T>, key: MaybeRef<
|
|
|
59
80
|
})
|
|
60
81
|
}
|
|
61
82
|
|
|
62
|
-
type JoinPath<
|
|
63
|
-
|
|
83
|
+
type JoinPath<
|
|
84
|
+
Base extends string,
|
|
85
|
+
Sub extends string,
|
|
86
|
+
> = `${Base}${Base extends '' ? '' : Sub extends '' ? '' : '.'}${Sub}`
|
|
87
|
+
export function joinPath<Base extends string, Sub extends string>(
|
|
88
|
+
basePath: Base,
|
|
89
|
+
subPath: Sub,
|
|
90
|
+
): JoinPath<Base, Sub> {
|
|
64
91
|
if (!basePath && !subPath) {
|
|
65
92
|
return '' as JoinPath<Base, Sub>
|
|
66
93
|
}
|
|
@@ -88,9 +115,10 @@ export function filterErrorsForPath(errors: ErrorBag, path: string): ErrorBag {
|
|
|
88
115
|
.filter(([errorPath]) => {
|
|
89
116
|
return errorPath.startsWith(pathPrefix)
|
|
90
117
|
})
|
|
91
|
-
.map(
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
.map(([errorPath, errorMessages]) => [
|
|
119
|
+
errorPath.slice(pathPrefix.length),
|
|
120
|
+
errorMessages,
|
|
121
|
+
]),
|
|
94
122
|
)
|
|
95
123
|
|
|
96
124
|
return {
|
package/tests/useForm.test.ts
CHANGED
|
@@ -405,6 +405,46 @@ describe("useForm", () => {
|
|
|
405
405
|
expect(form.data.value.name).toBe("A");
|
|
406
406
|
});
|
|
407
407
|
|
|
408
|
+
it("it not create empty objects if the field is going to be destroyed", async () => {
|
|
409
|
+
const form = useForm({
|
|
410
|
+
initialData: {
|
|
411
|
+
data: { names: [] as string[] },
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const scope = effectScope();
|
|
416
|
+
|
|
417
|
+
scope.run(() => {
|
|
418
|
+
const nameField = form.defineField({ path: "data.names.0" });
|
|
419
|
+
nameField.setData("Modified");
|
|
420
|
+
form.reset();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
scope.stop();
|
|
424
|
+
|
|
425
|
+
expect(form.data.value.data.names).toHaveLength(0);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("it not create empty objects if the nested array field is going to be destroyed", async () => {
|
|
429
|
+
const form = useForm({
|
|
430
|
+
initialData: {
|
|
431
|
+
data: [] as Array<{ name: string }>,
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const scope = effectScope();
|
|
436
|
+
|
|
437
|
+
scope.run(() => {
|
|
438
|
+
const nameField = form.defineField({ path: "data.0.name" });
|
|
439
|
+
nameField.setData("Modified");
|
|
440
|
+
form.reset();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
scope.stop();
|
|
444
|
+
|
|
445
|
+
expect(form.data.value.data).toHaveLength(0);
|
|
446
|
+
});
|
|
447
|
+
|
|
408
448
|
describe("useForm - submit handler", () => {
|
|
409
449
|
it(
|
|
410
450
|
"it should not call the handler when validation errors exist",
|
|
@@ -294,7 +294,31 @@ describe('useValidation', () => {
|
|
|
294
294
|
|
|
295
295
|
const nameField = form.getField('name')
|
|
296
296
|
|
|
297
|
-
|
|
297
|
+
// Simulate blur event
|
|
298
|
+
nameField.onBlur()
|
|
299
|
+
|
|
300
|
+
// onBlur is not async but the validation runs async
|
|
301
|
+
await delay()
|
|
302
|
+
|
|
303
|
+
expect(form.isValid.value).toBe(false)
|
|
304
|
+
expect(form.errors.value.propertyErrors.name).toHaveLength(1)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('should not validate other fields than the blurred one', async () => {
|
|
308
|
+
const schema = z.object({
|
|
309
|
+
name: z.string().min(2),
|
|
310
|
+
email: z.string().email(),
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const initialData = { name: 'A', email: 'invalid-email' }
|
|
314
|
+
const form = useForm({
|
|
315
|
+
initialData,
|
|
316
|
+
schema,
|
|
317
|
+
validationStrategy: 'onTouch',
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
const nameField = form.getField('name')
|
|
321
|
+
form.getField('email')
|
|
298
322
|
|
|
299
323
|
// Simulate blur event
|
|
300
324
|
nameField.onBlur()
|
|
@@ -302,9 +326,9 @@ describe('useValidation', () => {
|
|
|
302
326
|
// onBlur is not async but the validation runs async
|
|
303
327
|
await delay()
|
|
304
328
|
|
|
305
|
-
expect(form.isValidated.value).toBe(true)
|
|
306
329
|
expect(form.isValid.value).toBe(false)
|
|
307
330
|
expect(form.errors.value.propertyErrors.name).toHaveLength(1)
|
|
331
|
+
expect(form.errors.value.propertyErrors.email ?? []).toHaveLength(0)
|
|
308
332
|
})
|
|
309
333
|
|
|
310
334
|
it('should validate the form on form open if configured', async () => {
|