@teamnovu/kit-vue-forms 0.1.20 → 0.1.22

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.
@@ -6,6 +6,21 @@ import { ValidatorOptions } from '../composables/useValidation';
6
6
  import { EntityPaths, Paths, PickEntity, PickProps } from './util';
7
7
  import { ErrorBag, ValidationErrorMessage, ValidationErrors, ValidationResult, Validator } from './validation';
8
8
  export type FormDataDefault = object;
9
+ export type HashFn<H, I> = (item: I) => H;
10
+ export interface FieldArrayOptions<Item> {
11
+ hashFn?: HashFn<unknown, Item>;
12
+ }
13
+ export interface FieldArray<Item> {
14
+ fields: Ref<{
15
+ id: string;
16
+ item: Item;
17
+ }[]>;
18
+ push: (item: Item) => void;
19
+ remove: (item: Item) => void;
20
+ removeByIndex: (index: number) => void;
21
+ errors: Ref<ValidationErrors>;
22
+ dirty: Ref<boolean>;
23
+ }
9
24
  export interface FormField<T, P extends string> {
10
25
  data: Ref<T>;
11
26
  path: Ref<P>;
@@ -45,4 +60,5 @@ export interface Form<T extends FormDataDefault> {
45
60
  validateForm: () => Promise<ValidationResult>;
46
61
  submitHandler: (onSubmit: (data: T) => Awaitable<void>) => (event: SubmitEvent) => Promise<void>;
47
62
  getSubForm: <P extends EntityPaths<T>>(path: P, options?: SubformOptions<PickEntity<T, P>>) => Form<PickEntity<T, P>>;
63
+ useFieldArray: <K extends Paths<T>>(path: PickProps<T, K> extends unknown[] ? K : never, options?: FieldArrayOptions<PickProps<T, K> extends (infer U)[] ? U : never>) => FieldArray<PickProps<T, K> extends (infer U)[] ? U : never>;
48
64
  }
@@ -4,5 +4,5 @@ import { ValidationStrategy } from '../types/validation';
4
4
  interface SubmitHandlerOptions {
5
5
  validationStrategy?: MaybeRef<ValidationStrategy>;
6
6
  }
7
- export declare function useSubmitHandler<T extends FormDataDefault>(form: Omit<Form<T>, 'submitHandler'>, options: SubmitHandlerOptions): (onSubmit: (data: T) => Awaitable<void>) => (event: SubmitEvent) => Promise<void>;
7
+ export declare function makeSubmitHandler<T extends FormDataDefault>(form: Form<T>, options: SubmitHandlerOptions): (onSubmit: (data: T) => Awaitable<void>) => (event: SubmitEvent) => Promise<void>;
8
8
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamnovu/kit-vue-forms",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -37,7 +37,9 @@ export function useField<T, K extends string>(fieldOptions: UseFieldOptions<T, K
37
37
  shallowRef(options.initialValue),
38
38
  () => {
39
39
  initialValue.value = Object.freeze(cloneRefValue(options.initialValue))
40
- state.value = cloneRefValue(options.initialValue)
40
+ if (state.value !== unref(options.initialValue)) {
41
+ state.value = cloneRefValue(options.initialValue)
42
+ }
41
43
  },
42
44
  { flush: 'sync' },
43
45
  )
@@ -0,0 +1,144 @@
1
+ import { shallowRef, unref, watch } from 'vue'
2
+ import type {
3
+ FieldArray,
4
+ FieldArrayOptions,
5
+ Form,
6
+ FormDataDefault,
7
+ HashFn,
8
+ } from '../types/form'
9
+ import type { Paths, PickProps } from '../types/util'
10
+
11
+ export class HashStore<T, Item = unknown> {
12
+ private weakMap = new WeakMap<WeakKey, T>()
13
+ private map = new Map<unknown, T>()
14
+ private hashFn: HashFn<unknown, Item>
15
+
16
+ constructor(hashFn: HashFn<unknown, Item> = item => item) {
17
+ this.hashFn = hashFn
18
+ }
19
+
20
+ private isReferenceType(value: unknown): value is WeakKey {
21
+ return typeof value === 'object' && value !== null
22
+ }
23
+
24
+ has(item: Item) {
25
+ const hash = this.hashFn(item)
26
+ if (this.isReferenceType(hash)) {
27
+ return this.weakMap.has(hash)
28
+ } else {
29
+ return this.map.has(hash)
30
+ }
31
+ }
32
+
33
+ get(item: Item) {
34
+ const hash = this.hashFn(item)
35
+ if (this.isReferenceType(hash)) {
36
+ return this.weakMap.get(hash)
37
+ } else {
38
+ return this.map.get(hash)
39
+ }
40
+ }
41
+
42
+ set(item: Item, value: T) {
43
+ const hash = this.hashFn(item)
44
+ if (this.isReferenceType(hash)) {
45
+ this.weakMap.set(hash, value)
46
+ } else {
47
+ this.map.set(hash, value)
48
+ }
49
+ }
50
+ }
51
+
52
+ function mapIds<Item>(
53
+ hashStore: HashStore<string[], Item>,
54
+ items: Item[],
55
+ ) {
56
+ const mappedIds = new Set<string>()
57
+
58
+ return items.map((item) => {
59
+ const storeIds = [...(hashStore.get(item) ?? [])]
60
+
61
+ // Remove all used ids
62
+ const firstNotUsedId = storeIds.findIndex(id => !mappedIds.has(id))
63
+ const ids = firstNotUsedId === -1 ? [] : storeIds.slice(firstNotUsedId)
64
+
65
+ const matchingId = ids[0]
66
+
67
+ // If we have an id that is not used yet, use it
68
+ if (matchingId) {
69
+ mappedIds.add(matchingId)
70
+ return {
71
+ id: matchingId,
72
+ item,
73
+ }
74
+ }
75
+
76
+ // Otherwise create a new id
77
+ const newId = crypto.randomUUID()
78
+ hashStore.set(item, storeIds.concat([newId]))
79
+
80
+ mappedIds.add(newId)
81
+
82
+ return {
83
+ id: newId,
84
+ item,
85
+ }
86
+ })
87
+ }
88
+
89
+ export function useFieldArray<T extends FormDataDefault, K extends Paths<T>>(
90
+ form: Form<T>,
91
+ path: PickProps<T, K> extends unknown[] ? K : never,
92
+ options?: FieldArrayOptions<PickProps<T, K> extends (infer U)[] ? U : never>,
93
+ ): FieldArray<PickProps<T, K> extends (infer U)[] ? U : never> {
94
+ type Items = PickProps<T, K>
95
+ type Item = Items extends (infer U)[] ? U : never
96
+ type Id = string
97
+ type Field = {
98
+ id: Id
99
+ item: Item
100
+ }
101
+
102
+ const hashStore = new HashStore<string[], Item>(options?.hashFn)
103
+
104
+ const arrayField = form.getField(path)
105
+
106
+ const fields = shallowRef<Field[]>([])
107
+
108
+ watch(
109
+ arrayField.data,
110
+ (newItems) => {
111
+ fields.value = mapIds(hashStore, newItems) as Field[]
112
+ },
113
+ {
114
+ immediate: true,
115
+ flush: 'sync',
116
+ },
117
+ )
118
+
119
+ const push = (item: Item) => {
120
+ const current = (arrayField.data.value ?? []) as Item[]
121
+ arrayField.setData([...current, item] as Items)
122
+ }
123
+
124
+ const remove = (value: Item) => {
125
+ const current = (arrayField.data.value ?? []) as Item[]
126
+ arrayField.setData(
127
+ (current?.filter(item => item !== unref(value)) ?? []) as Items,
128
+ )
129
+ }
130
+
131
+ const removeByIndex = (index: number) => {
132
+ const current = (arrayField.data.value ?? []) as Item[]
133
+ arrayField.setData((current?.filter((_, i) => i !== index) ?? []) as Items)
134
+ }
135
+
136
+ return {
137
+ fields,
138
+ push,
139
+ remove,
140
+ removeByIndex,
141
+ errors: arrayField.errors,
142
+ dirty: arrayField.dirty,
143
+ }
144
+ }
@@ -1,4 +1,4 @@
1
- import type { Awaitable } from "@vueuse/core";
1
+ import type { Awaitable } from '@vueuse/core'
2
2
  import {
3
3
  computed,
4
4
  onScopeDispose,
@@ -9,47 +9,47 @@ import {
9
9
  unref,
10
10
  watch,
11
11
  type MaybeRef,
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";
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
- "path"
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)
@@ -57,19 +57,19 @@ function initialDataSync<T extends FormDataDefault>(
57
57
  formState: FormState<T>,
58
58
  path: Paths<T>,
59
59
  ) {
60
- const getNewValue = () => getNestedValue(formState.initialData, path);
61
- const initialValueRef = shallowRef(getNewValue());
60
+ const getNewValue = () => getNestedValue(formState.initialData, path)
61
+ const initialValueRef = shallowRef(getNewValue())
62
62
 
63
63
  watch(
64
64
  () => formState.initialData,
65
65
  () => {
66
- initialValueRef.value = getNewValue();
67
- triggerRef(initialValueRef);
66
+ initialValueRef.value = getNewValue()
67
+ triggerRef(initialValueRef)
68
68
  },
69
- { flush: "sync" },
70
- );
69
+ { flush: 'sync' },
70
+ )
71
71
 
72
- return initialValueRef;
72
+ return initialValueRef
73
73
  }
74
74
 
75
75
  export function useFieldRegistry<T extends FormDataDefault>(
@@ -77,99 +77,99 @@ export function useFieldRegistry<T extends FormDataDefault>(
77
77
  validationState: ValidationState<T>,
78
78
  fieldRegistryOptions?: FieldRegistryOptions,
79
79
  ) {
80
- const fieldReferenceCounter = new Map<Paths<T>, Rc>();
81
- 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>
82
82
  const registryOptions = {
83
83
  ...optionDefaults,
84
84
  ...fieldRegistryOptions,
85
- };
85
+ }
86
86
 
87
87
  const registerField = <K extends Paths<T>>(
88
88
  field: ResolvedFormField<T, K>,
89
89
  ) => {
90
- const path = unref(field.path) as Paths<T>;
91
- fields.set(path, field);
92
- };
90
+ const path = unref(field.path) as Paths<T>
91
+ fields.set(path, field)
92
+ }
93
93
 
94
94
  const deregisterField = (path: Paths<T>) => {
95
95
  if (!registryOptions?.keepValuesOnUnmount) {
96
- fields.get(path)?.reset();
96
+ fields.get(path)?.reset()
97
97
  }
98
- fields.delete(path);
99
- };
98
+ fields.delete(path)
99
+ }
100
100
 
101
101
  const track = (path: Paths<T>) => {
102
102
  if (!fieldReferenceCounter.has(path)) {
103
- fieldReferenceCounter.set(path, new Rc(() => deregisterField(path)));
103
+ fieldReferenceCounter.set(path, new Rc(() => deregisterField(path)))
104
104
  } else {
105
- fieldReferenceCounter.get(path)?.inc();
105
+ fieldReferenceCounter.get(path)?.inc()
106
106
  }
107
- };
107
+ }
108
108
 
109
109
  const untrack = (path: Paths<T>) => {
110
110
  if (fieldReferenceCounter.has(path)) {
111
- fieldReferenceCounter.get(path)?.dec();
111
+ fieldReferenceCounter.get(path)?.dec()
112
112
  }
113
- };
113
+ }
114
114
 
115
115
  const getField = <K extends Paths<T>>(
116
116
  options: DefineFieldOptions<PickProps<T, K>, K>,
117
117
  ): ResolvedFormField<T, K> => {
118
- const { path } = options;
118
+ const { path } = options
119
119
 
120
120
  if (!fields.has(path)) {
121
121
  const field = useField({
122
122
  path,
123
- value: getLens(toRef(formState, "data"), path),
123
+ value: getLens(toRef(formState, 'data'), path),
124
124
  initialValue: initialDataSync(formState, path),
125
125
  existsInForm: computed(() => existsPath(formState.data, unref(path))),
126
126
  errors: computed({
127
127
  get() {
128
- return validationState.errors.value.propertyErrors[path] || [];
128
+ return validationState.errors.value.propertyErrors[path] || []
129
129
  },
130
130
  set(newErrors) {
131
- validationState.errors.value.propertyErrors[path] = newErrors;
131
+ validationState.errors.value.propertyErrors[path] = newErrors
132
132
  },
133
133
  }),
134
134
  onBlur: async () => {
135
135
  await Promise.all([
136
136
  registryOptions?.onBlur?.(unref(path)),
137
137
  options.onBlur?.(),
138
- ]);
138
+ ])
139
139
  },
140
140
  onFocus: async () => {
141
141
  await Promise.all([
142
142
  registryOptions?.onFocus?.(unref(path)),
143
143
  options.onFocus?.(),
144
- ]);
144
+ ])
145
145
  },
146
- });
146
+ })
147
147
 
148
- registerField(field);
148
+ registerField(field)
149
149
  }
150
150
 
151
- const field = fields.get(path) as ResolvedFormField<T, K>;
151
+ const field = fields.get(path) as ResolvedFormField<T, K>
152
152
 
153
- track(path);
153
+ track(path)
154
154
 
155
155
  // Clean up field on unmount
156
156
  onScopeDispose(() => {
157
- untrack(path);
158
- });
157
+ untrack(path)
158
+ })
159
159
 
160
- return field;
161
- };
160
+ return field
161
+ }
162
162
 
163
163
  const defineField = <K extends Paths<T>>(
164
164
  options: DefineFieldOptions<PickProps<T, K>, K>,
165
165
  ): ResolvedFormField<T, K> => {
166
- const field = getField(options);
166
+ const field = getField(options)
167
167
 
168
168
  // TODO: If more options are ever needed than only the path we have to update the field
169
169
  // here with the new options
170
170
 
171
- return field;
172
- };
171
+ return field
172
+ }
173
173
 
174
174
  return {
175
175
  fields: computed(() => [...fields.values()] as FieldsTuple<T>),
@@ -177,9 +177,9 @@ export function useFieldRegistry<T extends FormDataDefault>(
177
177
  registerField,
178
178
  deregisterField,
179
179
  defineField,
180
- };
180
+ }
181
181
  }
182
182
 
183
183
  export type FieldRegistry<T extends FormDataDefault> = ReturnType<
184
184
  typeof useFieldRegistry<T>
185
- >;
185
+ >
@@ -8,88 +8,82 @@ import {
8
8
  type MaybeRef,
9
9
  type MaybeRefOrGetter,
10
10
  type Ref,
11
- } from "vue";
12
- import type { Form, FormDataDefault } from "../types/form";
13
- import type { EntityPaths, PickEntity } from "../types/util";
14
- import type { ValidationStrategy } from "../types/validation";
15
- import { cloneRefValue } from "../utils/general";
16
- import { useFieldRegistry } from "./useFieldRegistry";
17
- import { useFormState } from "./useFormState";
18
- import { createSubformInterface, type SubformOptions } from "./useSubform";
19
- import { useSubmitHandler } from "./useSubmitHandler";
20
- import { useValidation, type ValidationOptions } from "./useValidation";
11
+ } from 'vue'
12
+ import type { Form, FormDataDefault } from '../types/form'
13
+ import type { ValidationStrategy } from '../types/validation'
14
+ import { cloneRefValue } from '../utils/general'
15
+ import { makeSubmitHandler } from '../utils/submitHandler'
16
+ import { useFieldArray } from './useFieldArray'
17
+ import { useFieldRegistry } from './useFieldRegistry'
18
+ import { useFormState } from './useFormState'
19
+ import { createSubformInterface } from './useSubform'
20
+ import { useValidation, type ValidationOptions } from './useValidation'
21
21
 
22
22
  export interface UseFormOptions<T extends FormDataDefault>
23
23
  extends ValidationOptions<T> {
24
- initialData: MaybeRefOrGetter<T>;
25
- validationStrategy?: MaybeRef<ValidationStrategy>;
26
- keepValuesOnUnmount?: MaybeRef<boolean>;
24
+ initialData: MaybeRefOrGetter<T>
25
+ validationStrategy?: MaybeRef<ValidationStrategy>
26
+ keepValuesOnUnmount?: MaybeRef<boolean>
27
27
  }
28
28
 
29
29
  export function useForm<T extends FormDataDefault>(
30
30
  options: UseFormOptions<T>,
31
31
  ): Form<T> {
32
- const initialData = computed(() => cloneRefValue(options.initialData));
32
+ const initialData = computed(() => cloneRefValue(options.initialData))
33
33
 
34
- const data = ref<T>(cloneRefValue(initialData)) as Ref<T>;
34
+ const data = ref<T>(cloneRefValue(initialData)) as Ref<T>
35
35
 
36
36
  const state = reactive({
37
37
  initialData,
38
38
  data,
39
- });
39
+ })
40
40
 
41
41
  watch(
42
42
  initialData,
43
43
  (newValue) => {
44
- state.data = cloneRefValue(newValue);
44
+ state.data = cloneRefValue(newValue)
45
45
  },
46
- { flush: "sync" },
47
- );
46
+ { flush: 'sync' },
47
+ )
48
48
 
49
- const validationState = useValidation(state, options);
49
+ const validationState = useValidation(state, options)
50
50
  const fieldRegistry = useFieldRegistry(state, validationState, {
51
51
  keepValuesOnUnmount: options.keepValuesOnUnmount,
52
52
  onBlur: async (path: string) => {
53
- if (unref(options.validationStrategy) === "onTouch") {
54
- validationState.validateField(path);
53
+ if (unref(options.validationStrategy) === 'onTouch') {
54
+ validationState.validateField(path)
55
55
  }
56
56
  },
57
- });
58
- const formState = useFormState(fieldRegistry);
57
+ })
58
+ const formState = useFormState(fieldRegistry)
59
59
 
60
60
  const reset = () => {
61
- data.value = cloneRefValue(initialData);
62
- validationState.reset();
61
+ data.value = cloneRefValue(initialData)
62
+ validationState.reset()
63
63
  for (const field of fieldRegistry.fields.value) {
64
- field.reset();
64
+ field.reset()
65
65
  }
66
- };
66
+ }
67
67
 
68
- function getSubForm<K extends EntityPaths<T>>(
69
- path: K,
70
- subformOptions?: SubformOptions<PickEntity<T, K>>,
71
- ): Form<PickEntity<T, K>> {
72
- return createSubformInterface(formInterface, path, options, subformOptions);
68
+ if (unref(options.validationStrategy) === 'onFormOpen') {
69
+ validationState.validateForm()
73
70
  }
74
71
 
75
- const formInterface: Omit<Form<T>, "submitHandler"> = {
72
+ const form: Form<T> = {
76
73
  ...fieldRegistry,
77
74
  ...validationState,
78
75
  ...formState,
79
76
  reset,
80
- getSubForm,
81
- initialData: toRef(state, "initialData") as Form<T>["initialData"],
82
- data: toRef(state, "data") as Form<T>["data"],
83
- };
84
-
85
- const submitHandler = useSubmitHandler(formInterface, options);
86
-
87
- if (unref(options.validationStrategy) === "onFormOpen") {
88
- validationState.validateForm();
77
+ initialData: toRef(state, 'initialData') as Form<T>['initialData'],
78
+ data: toRef(state, 'data') as Form<T>['data'],
79
+ submitHandler: onSubmit => makeSubmitHandler(form, options)(onSubmit),
80
+ getSubForm: (path, subformOptions) => {
81
+ return createSubformInterface(form, path, options, subformOptions)
82
+ },
83
+ useFieldArray: (path, fieldArrayOptions) => {
84
+ return useFieldArray(form, path, fieldArrayOptions)
85
+ },
89
86
  }
90
87
 
91
- return {
92
- ...formInterface,
93
- submitHandler,
94
- };
88
+ return form
95
89
  }