@teamnovu/kit-vue-forms 0.1.13 → 0.1.15

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.
@@ -1,9 +1,10 @@
1
+ import { Awaitable } from '@vueuse/core';
1
2
  import { Ref } from 'vue';
2
3
  import { DefineFieldOptions } from '../composables/useFieldRegistry';
3
4
  import { SubformOptions } from '../composables/useSubform';
5
+ import { ValidatorOptions } from '../composables/useValidation';
4
6
  import { EntityPaths, Paths, PickEntity, PickProps } from './util';
5
7
  import { ErrorBag, ValidationErrorMessage, ValidationErrors, ValidationResult, Validator } from './validation';
6
- import { ValidatorOptions } from '../composables/useValidation';
7
8
  export type FormDataDefault = object;
8
9
  export interface FormField<T, P extends string> {
9
10
  data: Ref<T>;
@@ -42,5 +43,6 @@ export interface Form<T extends FormDataDefault> {
42
43
  defineValidator: <TData extends T>(options: ValidatorOptions<TData> | Ref<Validator<TData>>) => Ref<Validator<TData> | undefined>;
43
44
  reset: () => void;
44
45
  validateForm: () => Promise<ValidationResult>;
46
+ submitHandler: (onSubmit: (data: T) => Awaitable<void>) => (event: SubmitEvent) => Promise<void>;
45
47
  getSubForm: <P extends EntityPaths<T>>(path: P, options?: SubformOptions<PickEntity<T, P>>) => Form<PickEntity<T, P>>;
46
48
  }
package/docs/reference.md CHANGED
@@ -17,10 +17,8 @@ function useForm<T extends object>(options: {
17
17
  // this is additional to the schema for custom validations
18
18
  validateFn?: MaybeRef<ValidationFunction<T>>
19
19
  // if the form data of a property should be reset or kept if all fields corresponding to this property are unmounted
20
- // !! currently not implemented !!
21
20
  keepValuesOnUnmount?: MaybeRef<boolean>
22
21
  // when validation should be done (on touch, pre submit, etc.)
23
- // !! currently not implemented !!
24
22
  validationStrategy?: MaybeRef<ValidationStrategy>
25
23
  }): Form<T>
26
24
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamnovu/kit-vue-forms",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -13,6 +13,14 @@
13
13
  @update:model-value="setData"
14
14
  >
15
15
  <slot />
16
+
17
+ <!-- https://vue-land.github.io/faq/forwarding-slots#passing-all-slots -->
18
+ <template
19
+ v-for="(_, slotName) in $slots"
20
+ #[slotName]="slotProps"
21
+ >
22
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
23
+ </template>
16
24
  </component>
17
25
  </Field>
18
26
  </template>
@@ -1,13 +1,16 @@
1
- import { computed, reactive, shallowRef, toRefs, watch, type MaybeRef, type MaybeRefOrGetter, type Ref, type WritableComputedRef } from 'vue'
1
+ import { computed, reactive, shallowRef, toRefs, watch, type MaybeRef, type MaybeRefOrGetter, type Ref } from 'vue'
2
2
  import type { FormField } from '../types/form'
3
3
  import type { ValidationErrorMessage, ValidationErrors } from '../types/validation'
4
4
  import { cloneRefValue } from '../utils/general'
5
+ import type { Awaitable } from '@vueuse/core'
5
6
 
6
7
  export interface UseFieldOptions<T, K extends string> {
7
8
  value?: MaybeRef<T>
8
9
  initialValue?: MaybeRefOrGetter<Readonly<T>>
9
10
  path: K
10
- errors?: WritableComputedRef<ValidationErrors>
11
+ errors?: Ref<ValidationErrors>
12
+ onBlur?: () => Awaitable<void>
13
+ onFocus?: () => Awaitable<void>
11
14
  }
12
15
 
13
16
  export function useField<T, K extends string>(options: UseFieldOptions<T, K>): FormField<T, K> {
@@ -40,10 +43,13 @@ export function useField<T, K extends string>(options: UseFieldOptions<T, K>): F
40
43
 
41
44
  const onBlur = (): void => {
42
45
  state.touched = true
46
+ state.errors = []
47
+
48
+ options.onBlur?.()
43
49
  }
44
50
 
45
51
  const onFocus = (): void => {
46
- // TODO: Implement focus logic if needed
52
+ options.onFocus?.()
47
53
  }
48
54
 
49
55
  const reset = (): void => {
@@ -1,23 +1,56 @@
1
- import { computed, onScopeDispose, shallowReactive, shallowRef, toRef, triggerRef, unref, watch, watchEffect, type MaybeRef, type WatchSource } from 'vue'
1
+ import {
2
+ computed,
3
+ onScopeDispose,
4
+ shallowReactive,
5
+ shallowRef,
6
+ toRef,
7
+ triggerRef,
8
+ unref,
9
+ watchEffect,
10
+ type MaybeRef,
11
+ } from 'vue'
2
12
  import type { FieldsTuple, FormDataDefault, FormField } from '../types/form'
3
13
  import type { Paths, PickProps } from '../types/util'
4
14
  import { getLens, getNestedValue } from '../utils/path'
5
15
  import { Rc } from '../utils/rc'
6
16
  import { useField, type UseFieldOptions } from './useField'
7
17
  import type { ValidationState } from './useValidation'
18
+ import type { Awaitable } from '@vueuse/core'
8
19
 
9
20
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
21
  type FieldRegistryCache<T> = Map<Paths<T>, FormField<any, string>>
11
22
 
12
- export type ResolvedFormField<T, K extends Paths<T>> = FormField<PickProps<T, K>, K>
13
-
14
- export type DefineFieldOptions<F, K extends string> = Pick<UseFieldOptions<F, K>, 'path'>
23
+ export type ResolvedFormField<T, K extends Paths<T>> = FormField<
24
+ PickProps<T, K>,
25
+ K
26
+ >
27
+
28
+ export type DefineFieldOptions<F, K extends string> = Pick<
29
+ UseFieldOptions<F, K>,
30
+ 'path'
31
+ > & {
32
+ onBlur?: () => void
33
+ onFocus?: () => void
34
+ }
15
35
 
16
- interface FormState<T extends FormDataDefault, TIn extends FormDataDefault = T> {
36
+ interface FormState<
37
+ T extends FormDataDefault,
38
+ TIn extends FormDataDefault = T,
39
+ > {
17
40
  data: T
18
41
  initialData: TIn
19
42
  }
20
43
 
44
+ interface FieldRegistryOptions {
45
+ keepValuesOnUnmount?: MaybeRef<boolean>
46
+ onBlur?: (path: string) => Awaitable<void>
47
+ onFocus?: (path: string) => Awaitable<void>
48
+ }
49
+
50
+ const optionDefaults = {
51
+ keepValuesOnUnmount: true,
52
+ }
53
+
21
54
  // A computed that always reflects the latest value from the getter
22
55
  // This computed forces updates even if the value is the same (to trigger watchers)
23
56
  function alwaysComputed<T>(getter: () => T) {
@@ -37,16 +70,26 @@ function alwaysComputed<T>(getter: () => T) {
37
70
  export function useFieldRegistry<T extends FormDataDefault>(
38
71
  formState: FormState<T>,
39
72
  validationState: ValidationState<T>,
73
+ fieldRegistryOptions?: FieldRegistryOptions,
40
74
  ) {
41
75
  const fieldReferenceCounter = new Map<Paths<T>, Rc>()
42
76
  const fields = shallowReactive(new Map()) as FieldRegistryCache<T>
77
+ const registryOptions = {
78
+ ...optionDefaults,
79
+ ...fieldRegistryOptions,
80
+ }
43
81
 
44
- const registerField = <K extends Paths<T>>(field: ResolvedFormField<T, K>) => {
82
+ const registerField = <K extends Paths<T>>(
83
+ field: ResolvedFormField<T, K>,
84
+ ) => {
45
85
  const path = unref(field.path) as Paths<T>
46
86
  fields.set(path, field)
47
87
  }
48
88
 
49
89
  const deregisterField = (path: Paths<T>) => {
90
+ if (!registryOptions?.keepValuesOnUnmount) {
91
+ fields.get(path)?.reset()
92
+ }
50
93
  fields.delete(path)
51
94
  }
52
95
 
@@ -64,14 +107,17 @@ export function useFieldRegistry<T extends FormDataDefault>(
64
107
  }
65
108
  }
66
109
 
67
- const getField = <K extends Paths<T>>(path: K): ResolvedFormField<T, K> => {
110
+ const getField = <K extends Paths<T>>(
111
+ options: DefineFieldOptions<PickProps<T, K>, K>,
112
+ ): ResolvedFormField<T, K> => {
113
+ const { path } = options
114
+
68
115
  if (!fields.has(path)) {
69
116
  const field = useField({
70
117
  path,
71
118
  value: getLens(toRef(formState, 'data'), path),
72
- initialValue: alwaysComputed(
73
- () => getNestedValue(formState.initialData, path),
74
- ),
119
+ initialValue: alwaysComputed(() =>
120
+ getNestedValue(formState.initialData, path)),
75
121
  errors: computed({
76
122
  get() {
77
123
  return validationState.errors.value.propertyErrors[path] || []
@@ -80,6 +126,22 @@ export function useFieldRegistry<T extends FormDataDefault>(
80
126
  validationState.errors.value.propertyErrors[path] = newErrors
81
127
  },
82
128
  }),
129
+ onBlur: async () => {
130
+ await Promise.all(
131
+ [
132
+ registryOptions?.onBlur?.(unref(path)),
133
+ options.onBlur?.(),
134
+ ],
135
+ )
136
+ },
137
+ onFocus: async () => {
138
+ await Promise.all(
139
+ [
140
+ registryOptions?.onFocus?.(unref(path)),
141
+ options.onFocus?.(),
142
+ ],
143
+ )
144
+ },
83
145
  })
84
146
 
85
147
  registerField(field)
@@ -97,8 +159,10 @@ export function useFieldRegistry<T extends FormDataDefault>(
97
159
  return field
98
160
  }
99
161
 
100
- const defineField = <K extends Paths<T>>(options: DefineFieldOptions<PickProps<T, K>, K>): ResolvedFormField<T, K> => {
101
- const field = getField(options.path)
162
+ const defineField = <K extends Paths<T>>(
163
+ options: DefineFieldOptions<PickProps<T, K>, K>,
164
+ ): ResolvedFormField<T, K> => {
165
+ const field = getField(options)
102
166
 
103
167
  // TODO: If more options are ever needed than only the path we have to update the field
104
168
  // here with the new options
@@ -108,11 +172,13 @@ export function useFieldRegistry<T extends FormDataDefault>(
108
172
 
109
173
  return {
110
174
  fields: computed(() => [...fields.values()] as FieldsTuple<T>),
111
- getField,
175
+ getField: <P extends Paths<T>>(path: P) => getField({ path }),
112
176
  registerField,
113
177
  deregisterField,
114
178
  defineField,
115
179
  }
116
180
  }
117
181
 
118
- export type FieldRegistry<T extends FormDataDefault> = ReturnType<typeof useFieldRegistry<T>>
182
+ export type FieldRegistry<T extends FormDataDefault> = ReturnType<
183
+ typeof useFieldRegistry<T>
184
+ >
@@ -1,4 +1,15 @@
1
- import { computed, reactive, ref, toRef, watch, type MaybeRef, type MaybeRefOrGetter, type Ref } from 'vue'
1
+ import type { Awaitable } from '@vueuse/core'
2
+ import {
3
+ computed,
4
+ reactive,
5
+ ref,
6
+ toRef,
7
+ unref,
8
+ watch,
9
+ type MaybeRef,
10
+ type MaybeRefOrGetter,
11
+ type Ref,
12
+ } from 'vue'
2
13
  import type { AnyField, Form, FormDataDefault } from '../types/form'
3
14
  import type { EntityPaths, PickEntity } from '../types/util'
4
15
  import type { ValidationStrategy } from '../types/validation'
@@ -10,14 +21,16 @@ import { useValidation, type ValidationOptions } from './useValidation'
10
21
 
11
22
  // TODO @Elias implement validation strategy handling
12
23
 
13
- export interface UseFormOptions<T extends FormDataDefault> extends ValidationOptions<T> {
24
+ export interface UseFormOptions<T extends FormDataDefault>
25
+ extends ValidationOptions<T> {
14
26
  initialData: MaybeRefOrGetter<T>
15
27
  validationStrategy?: MaybeRef<ValidationStrategy>
16
28
  keepValuesOnUnmount?: MaybeRef<boolean>
17
29
  }
18
30
 
19
31
  export function useForm<T extends FormDataDefault>(options: UseFormOptions<T>) {
20
- const initialData = computed(() => Object.freeze(cloneRefValue(options.initialData)))
32
+ const initialData = computed(() =>
33
+ Object.freeze(cloneRefValue(options.initialData)))
21
34
 
22
35
  const data = ref<T>(cloneRefValue(initialData)) as Ref<T>
23
36
 
@@ -26,20 +39,45 @@ export function useForm<T extends FormDataDefault>(options: UseFormOptions<T>) {
26
39
  data,
27
40
  })
28
41
 
29
- watch(initialData, (newValue) => {
30
- state.data = cloneRefValue(newValue)
31
- }, { flush: 'sync' })
42
+ watch(
43
+ initialData,
44
+ (newValue) => {
45
+ state.data = cloneRefValue(newValue)
46
+ },
47
+ { flush: 'sync' },
48
+ )
32
49
 
33
50
  const validationState = useValidation(state, options)
34
- const fieldRegistry = useFieldRegistry(state, validationState)
51
+ const fieldRegistry = useFieldRegistry(state, validationState, {
52
+ keepValuesOnUnmount: options.keepValuesOnUnmount,
53
+ onBlur: async () => {
54
+ if (unref(options.validationStrategy) === 'onTouch') {
55
+ validationState.validateForm()
56
+ }
57
+ },
58
+ })
35
59
  const formState = useFormState(fieldRegistry)
36
60
 
61
+ const submitHandler = (onSubmit: (data: T) => Awaitable<void>) => {
62
+ return async (event: SubmitEvent) => {
63
+ event.preventDefault()
64
+
65
+ if (unref(options.validationStrategy) !== 'none') {
66
+ await validationState.validateForm()
67
+ }
68
+
69
+ if (!validationState.isValid.value) {
70
+ return
71
+ }
72
+
73
+ await onSubmit(state.data)
74
+ }
75
+ }
76
+
37
77
  const reset = () => {
38
78
  data.value = cloneRefValue(initialData)
39
79
  validationState.reset()
40
- fieldRegistry.fields.value.forEach(
41
- (field: AnyField<T>) => field.reset(),
42
- )
80
+ fieldRegistry.fields.value.forEach((field: AnyField<T>) => field.reset())
43
81
  }
44
82
 
45
83
  function getSubForm<K extends EntityPaths<T>>(
@@ -55,9 +93,14 @@ export function useForm<T extends FormDataDefault>(options: UseFormOptions<T>) {
55
93
  ...formState,
56
94
  reset,
57
95
  getSubForm,
96
+ submitHandler,
58
97
  initialData: toRef(state, 'initialData') as Form<T>['initialData'],
59
98
  data: toRef(state, 'data') as Form<T>['data'],
60
99
  }
61
100
 
101
+ if (unref(options.validationStrategy) === 'onFormOpen') {
102
+ validationState.validateForm()
103
+ }
104
+
62
105
  return formInterface
63
106
  }
@@ -4,7 +4,7 @@ import type { FormDataDefault } from '../types/form'
4
4
  export function useFormData<T extends FormDataDefault>(
5
5
  initialData: Ref<T>,
6
6
  ) {
7
- const data = ref(unref(initialData))
7
+ const data = ref(unref(initialData)) as Ref<T>
8
8
 
9
9
  watch(initialData, (newData) => {
10
10
  if (newData !== data.value) {
@@ -157,7 +157,7 @@ export function useValidation<T extends FormDataDefault>(
157
157
  )
158
158
 
159
159
  // Watch for changes in form data to trigger validation
160
- watch(() => formState.data, () => {
160
+ watch([() => formState.data, () => unref(options.schema)], () => {
161
161
  if (validationState.isValidated) {
162
162
  validateForm()
163
163
  }
package/src/types/form.ts CHANGED
@@ -1,9 +1,16 @@
1
+ import type { Awaitable } from '@vueuse/core'
1
2
  import type { Ref } from 'vue'
2
3
  import type { DefineFieldOptions } from '../composables/useFieldRegistry'
3
4
  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
5
  import type { ValidatorOptions } from '../composables/useValidation'
6
+ import type { EntityPaths, Paths, PickEntity, PickProps } from './util'
7
+ import type {
8
+ ErrorBag,
9
+ ValidationErrorMessage,
10
+ ValidationErrors,
11
+ ValidationResult,
12
+ Validator,
13
+ } from './validation'
7
14
 
8
15
  export type FormDataDefault = object
9
16
 
@@ -27,13 +34,13 @@ export interface FormField<T, P extends string> {
27
34
  clearErrors: () => void
28
35
  }
29
36
 
30
- export type FieldsTuple<T, TPaths = Paths<T>> = [...(
31
- TPaths extends infer P
37
+ export type FieldsTuple<T, TPaths = Paths<T>> = [
38
+ ...(TPaths extends infer P
32
39
  ? P extends string
33
40
  ? FormField<PickProps<T, P>, P>
34
41
  : never
35
- : never
36
- )[]]
42
+ : never)[],
43
+ ]
37
44
 
38
45
  export type AnyField<T> = FormField<PickProps<T, Paths<T>>, Paths<T>>
39
46
 
@@ -45,7 +52,9 @@ export interface Form<T extends FormDataDefault> {
45
52
  fields: Ref<FieldsTuple<T>>
46
53
 
47
54
  // Field operations
48
- defineField: <P extends Paths<T>>(options: DefineFieldOptions<PickProps<T, P>, P>) => FormField<PickProps<T, P>, P>
55
+ defineField: <P extends Paths<T>>(
56
+ options: DefineFieldOptions<PickProps<T, P>, P>,
57
+ ) => FormField<PickProps<T, P>, P>
49
58
  getField: <P extends Paths<T>>(path: P) => FormField<PickProps<T, P>, P>
50
59
 
51
60
  // State properties
@@ -55,12 +64,18 @@ export interface Form<T extends FormDataDefault> {
55
64
  isValidated: Ref<boolean>
56
65
  errors: Ref<ErrorBag>
57
66
 
58
- defineValidator: <TData extends T>(options: ValidatorOptions<TData> | Ref<Validator<TData>>) => Ref<Validator<TData> | undefined>
67
+ defineValidator: <TData extends T>(
68
+ options: ValidatorOptions<TData> | Ref<Validator<TData>>,
69
+ ) => Ref<Validator<TData> | undefined>
59
70
 
60
71
  // Operations
61
72
  reset: () => void
62
73
  validateForm: () => Promise<ValidationResult>
63
74
 
75
+ submitHandler: (
76
+ onSubmit: (data: T) => Awaitable<void>,
77
+ ) => (event: SubmitEvent) => Promise<void>
78
+
64
79
  // Nested subforms
65
80
  getSubForm: <P extends EntityPaths<T>>(
66
81
  path: P,
package/src/utils/path.ts CHANGED
@@ -33,7 +33,13 @@ export function setNestedValue<T, K extends Paths<T>>(obj: MaybeRef<T>, path: K
33
33
  const target = keys
34
34
  .slice(0, -1)
35
35
  .reduce(
36
- (current, key) => current?.[key],
36
+ (current, key) => {
37
+ if (current?.[key] === undefined) {
38
+ // Create the nested object if it doesn't exist
39
+ current[key] = {}
40
+ }
41
+ return current?.[key]
42
+ },
37
43
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
44
  unref(obj) as Record<string, any>,
39
45
  )
@@ -1031,6 +1031,23 @@ describe('Subform Implementation', () => {
1031
1031
  expect(result.isValid).toBe(false)
1032
1032
  expect(result.errors.general).toContain('Data is undefined')
1033
1033
  })
1034
+
1035
+ it('can create subforms on non-existent paths', async () => {
1036
+ const form = useForm<{
1037
+ test: string[]
1038
+ nonExistentPath?: { name: string }
1039
+ }>({
1040
+ initialData: { test: ['item1', 'item2'] },
1041
+ })
1042
+
1043
+ const subform = form.getSubForm('nonExistentPath')
1044
+
1045
+ const nameField = subform.defineField({ path: 'name' })
1046
+
1047
+ nameField.setData('Test Name')
1048
+
1049
+ expect(nameField.data.value).toEqual('Test Name')
1050
+ })
1034
1051
  })
1035
1052
 
1036
1053
  describe('Path Edge Cases', () => {
@@ -207,4 +207,21 @@ describe('useField', () => {
207
207
  expect(field.initialValue.value).toBe('bar')
208
208
  expect(field.data.value).toBe('modified')
209
209
  })
210
+
211
+ it('it should reset errors on a field on blur', () => {
212
+ const errors = ref(['Initial error'])
213
+
214
+ const field = useField({
215
+ initialValue: 'foo',
216
+ value: 'foo',
217
+ path: 'name',
218
+ errors,
219
+ })
220
+
221
+ expect(field.errors.value).toEqual(['Initial error'])
222
+
223
+ field.onBlur()
224
+
225
+ expect(field.errors.value).toEqual([])
226
+ })
210
227
  })