@teamnovu/kit-vue-forms 0.1.14 → 0.1.16

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
  }
@@ -1,11 +1,14 @@
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>>;
8
- type JoinPath<Base extends string, Sub extends string> = `${Base}${Base extends '' ? '' : Sub extends '' ? '' : '.'}${Sub}`;
11
+ type JoinPath<Base extends string, Sub extends string> = `${Base}${Base extends "" ? "" : Sub extends "" ? "" : "."}${Sub}`;
9
12
  export declare function joinPath<Base extends string, Sub extends string>(basePath: Base, subPath: Sub): JoinPath<Base, Sub>;
10
13
  export declare function filterErrorsForPath(errors: ErrorBag, path: string): ErrorBag;
11
14
  export {};
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.14",
3
+ "version": "0.1.16",
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,4 +1,5 @@
1
- import { computed, reactive, shallowRef, toRefs, watch, type MaybeRef, type MaybeRefOrGetter, type Ref, type WritableComputedRef } from 'vue'
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'
@@ -7,10 +8,21 @@ 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
+ existsInForm?: MaybeRef<boolean>
13
+ onBlur?: () => Awaitable<void>
14
+ onFocus?: () => Awaitable<void>
11
15
  }
12
16
 
13
- export function useField<T, K extends string>(options: UseFieldOptions<T, K>): FormField<T, K> {
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
+ }
14
26
  const initialValue = shallowRef(Object.freeze(cloneRefValue(options.initialValue))) as Ref<Readonly<T | undefined>>
15
27
 
16
28
  const state = reactive({
@@ -40,14 +52,20 @@ export function useField<T, K extends string>(options: UseFieldOptions<T, K>): F
40
52
 
41
53
  const onBlur = (): void => {
42
54
  state.touched = true
55
+ state.errors = []
56
+
57
+ options.onBlur?.()
43
58
  }
44
59
 
45
60
  const onFocus = (): void => {
46
- // TODO: Implement focus logic if needed
61
+ options.onFocus?.()
47
62
  }
48
63
 
49
64
  const reset = (): void => {
50
- state.value = cloneRefValue(state.initialValue)
65
+ const lastPathPart = state.path.split('.').at(-1) || ''
66
+ if (unref(options.existsInForm) && !/^\d+$/.test(lastPathPart)) {
67
+ state.value = cloneRefValue(state.initialValue)
68
+ }
51
69
  state.touched = false
52
70
  state.errors = []
53
71
  }
@@ -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
- import { getLens, getNestedValue } from '../utils/path'
14
+ import { existsPath, 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,18 @@ 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)),
121
+ existsInForm: computed(() => existsPath(formState.data, unref(path))),
75
122
  errors: computed({
76
123
  get() {
77
124
  return validationState.errors.value.propertyErrors[path] || []
@@ -80,6 +127,22 @@ export function useFieldRegistry<T extends FormDataDefault>(
80
127
  validationState.errors.value.propertyErrors[path] = newErrors
81
128
  },
82
129
  }),
130
+ onBlur: async () => {
131
+ await Promise.all(
132
+ [
133
+ registryOptions?.onBlur?.(unref(path)),
134
+ options.onBlur?.(),
135
+ ],
136
+ )
137
+ },
138
+ onFocus: async () => {
139
+ await Promise.all(
140
+ [
141
+ registryOptions?.onFocus?.(unref(path)),
142
+ options.onFocus?.(),
143
+ ],
144
+ )
145
+ },
83
146
  })
84
147
 
85
148
  registerField(field)
@@ -97,8 +160,10 @@ export function useFieldRegistry<T extends FormDataDefault>(
97
160
  return field
98
161
  }
99
162
 
100
- const defineField = <K extends Paths<T>>(options: DefineFieldOptions<PickProps<T, K>, K>): ResolvedFormField<T, K> => {
101
- const field = getField(options.path)
163
+ const defineField = <K extends Paths<T>>(
164
+ options: DefineFieldOptions<PickProps<T, K>, K>,
165
+ ): ResolvedFormField<T, K> => {
166
+ const field = getField(options)
102
167
 
103
168
  // TODO: If more options are ever needed than only the path we have to update the field
104
169
  // here with the new options
@@ -108,11 +173,13 @@ export function useFieldRegistry<T extends FormDataDefault>(
108
173
 
109
174
  return {
110
175
  fields: computed(() => [...fields.values()] as FieldsTuple<T>),
111
- getField,
176
+ getField: <P extends Paths<T>>(path: P) => getField({ path }),
112
177
  registerField,
113
178
  deregisterField,
114
179
  defineField,
115
180
  }
116
181
  }
117
182
 
118
- export type FieldRegistry<T extends FormDataDefault> = ReturnType<typeof useFieldRegistry<T>>
183
+ export type FieldRegistry<T extends FormDataDefault> = ReturnType<
184
+ typeof useFieldRegistry<T>
185
+ >
@@ -1,52 +1,94 @@
1
- import { computed, reactive, ref, toRef, watch, type MaybeRef, type MaybeRefOrGetter, type Ref } from 'vue'
2
- import type { AnyField, 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'
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";
13
+ import type { Form, FormDataDefault } from "../types/form";
14
+ import type { EntityPaths, PickEntity } from "../types/util";
15
+ import type { ValidationStrategy } from "../types/validation";
16
+ import { cloneRefValue } from "../utils/general";
17
+ import { useFieldRegistry } from "./useFieldRegistry";
18
+ import { useFormState } from "./useFormState";
19
+ import { createSubformInterface, type SubformOptions } from "./useSubform";
20
+ 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> {
14
- initialData: MaybeRefOrGetter<T>
15
- validationStrategy?: MaybeRef<ValidationStrategy>
16
- keepValuesOnUnmount?: MaybeRef<boolean>
24
+ export interface UseFormOptions<T extends FormDataDefault>
25
+ extends ValidationOptions<T> {
26
+ initialData: MaybeRefOrGetter<T>;
27
+ validationStrategy?: MaybeRef<ValidationStrategy>;
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)),
34
+ );
21
35
 
22
- const data = ref<T>(cloneRefValue(initialData)) as Ref<T>
36
+ const data = ref<T>(cloneRefValue(initialData)) as Ref<T>;
23
37
 
24
38
  const state = reactive({
25
39
  initialData,
26
40
  data,
27
- })
41
+ });
28
42
 
29
- watch(initialData, (newValue) => {
30
- state.data = cloneRefValue(newValue)
31
- }, { flush: 'sync' })
43
+ watch(
44
+ initialData,
45
+ (newValue) => {
46
+ state.data = cloneRefValue(newValue);
47
+ },
48
+ { flush: "sync" },
49
+ );
50
+
51
+ const validationState = useValidation(state, options);
52
+ const fieldRegistry = useFieldRegistry(state, validationState, {
53
+ keepValuesOnUnmount: options.keepValuesOnUnmount,
54
+ onBlur: async (path: string) => {
55
+ if (unref(options.validationStrategy) === "onTouch") {
56
+ // TODO: Only validate the specific field that was touched
57
+ validationState.validateField(path);
58
+ }
59
+ },
60
+ });
61
+ const formState = useFormState(fieldRegistry);
62
+
63
+ const submitHandler = (onSubmit: (data: T) => Awaitable<void>) => {
64
+ return async (event: SubmitEvent) => {
65
+ event.preventDefault();
66
+
67
+ if (unref(options.validationStrategy) !== "none") {
68
+ await validationState.validateForm();
69
+ }
32
70
 
33
- const validationState = useValidation(state, options)
34
- const fieldRegistry = useFieldRegistry(state, validationState)
35
- const formState = useFormState(fieldRegistry)
71
+ if (!validationState.isValid.value) {
72
+ return;
73
+ }
74
+
75
+ await onSubmit(state.data);
76
+ };
77
+ };
36
78
 
37
79
  const reset = () => {
38
- data.value = cloneRefValue(initialData)
39
- validationState.reset()
40
- fieldRegistry.fields.value.forEach(
41
- (field: AnyField<T>) => field.reset(),
42
- )
43
- }
80
+ data.value = cloneRefValue(initialData);
81
+ validationState.reset();
82
+ for (const field of fieldRegistry.fields.value) {
83
+ field.reset();
84
+ }
85
+ };
44
86
 
45
87
  function getSubForm<K extends EntityPaths<T>>(
46
88
  path: K,
47
89
  options?: SubformOptions<PickEntity<T, K>>,
48
90
  ): Form<PickEntity<T, K>> {
49
- return createSubformInterface(formInterface, path, options)
91
+ return createSubformInterface(formInterface, path, options);
50
92
  }
51
93
 
52
94
  const formInterface: Form<T> = {
@@ -55,9 +97,14 @@ export function useForm<T extends FormDataDefault>(options: UseFormOptions<T>) {
55
97
  ...formState,
56
98
  reset,
57
99
  getSubForm,
58
- initialData: toRef(state, 'initialData') as Form<T>['initialData'],
59
- data: toRef(state, 'data') as Form<T>['data'],
100
+ submitHandler,
101
+ initialData: toRef(state, "initialData") as Form<T>["initialData"],
102
+ data: toRef(state, "data") as Form<T>["data"],
103
+ };
104
+
105
+ if (unref(options.validationStrategy) === "onFormOpen") {
106
+ validationState.validateForm();
60
107
  }
61
108
 
62
- return formInterface
109
+ return formInterface;
63
110
  }
@@ -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
@@ -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,