@teamnovu/kit-vue-forms 0.1.21 → 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.21",
3
+ "version": "0.1.22",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -1,15 +1,144 @@
1
- import type { Form, FormDataDefault, FormField } from "../types/form";
2
- import type { Paths, PickProps } from "../types/util";
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
+ }
3
88
 
4
89
  export function useFieldArray<T extends FormDataDefault, K extends Paths<T>>(
5
90
  form: Form<T>,
6
91
  path: PickProps<T, K> extends unknown[] ? K : never,
7
- ) {
8
- type Items = PickProps<T, K> & unknown[];
9
- type Item = Items[number];
10
- const arrayField = form.getField(path) as FormField<Items, K>;
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
+ )
11
118
 
12
119
  const push = (item: Item) => {
13
- arrayField.setData([...arrayField.data.value, item] as Items);
14
- };
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
+ }
15
144
  }
@@ -10,13 +10,13 @@ import {
10
10
  type Ref,
11
11
  } from 'vue'
12
12
  import type { Form, FormDataDefault } from '../types/form'
13
- import type { EntityPaths, PickEntity } from '../types/util'
14
13
  import type { ValidationStrategy } from '../types/validation'
15
14
  import { cloneRefValue } from '../utils/general'
15
+ import { makeSubmitHandler } from '../utils/submitHandler'
16
+ import { useFieldArray } from './useFieldArray'
16
17
  import { useFieldRegistry } from './useFieldRegistry'
17
18
  import { useFormState } from './useFormState'
18
- import { createSubformInterface, type SubformOptions } from './useSubform'
19
- import { useSubmitHandler } from './useSubmitHandler'
19
+ import { createSubformInterface } from './useSubform'
20
20
  import { useValidation, type ValidationOptions } from './useValidation'
21
21
 
22
22
  export interface UseFormOptions<T extends FormDataDefault>
@@ -65,31 +65,25 @@ export function useForm<T extends FormDataDefault>(
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
77
  initialData: toRef(state, 'initialData') as Form<T>['initialData'],
82
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
+ },
83
86
  }
84
87
 
85
- const submitHandler = useSubmitHandler(formInterface, options)
86
-
87
- if (unref(options.validationStrategy) === 'onFormOpen') {
88
- validationState.validateForm()
89
- }
90
-
91
- return {
92
- ...formInterface,
93
- submitHandler,
94
- }
88
+ return form
95
89
  }
@@ -1,47 +1,47 @@
1
- import { computed, isRef, unref, type Ref } from "vue";
1
+ import { computed, isRef, unref, type Ref } from 'vue'
2
2
  import type {
3
3
  FieldsTuple,
4
4
  Form,
5
5
  FormDataDefault,
6
6
  FormField,
7
- } from "../types/form";
8
- import type { EntityPaths, Paths, PickEntity, PickProps } from "../types/util";
9
- import type { ValidationResult, Validator } from "../types/validation";
7
+ } from '../types/form'
8
+ import type { EntityPaths, Paths, PickEntity, PickProps } from '../types/util'
9
+ import type { ValidationResult, Validator } from '../types/validation'
10
10
  import {
11
11
  filterErrorsForPath,
12
12
  getLens,
13
13
  getNestedValue,
14
14
  joinPath,
15
- } from "../utils/path";
16
- import type { DefineFieldOptions } from "./useFieldRegistry";
17
- import type { UseFormOptions } from "./useForm";
18
- import { useSubmitHandler } from "./useSubmitHandler";
15
+ } from '../utils/path'
16
+ import { makeSubmitHandler } from '../utils/submitHandler'
17
+ import { useFieldArray } from './useFieldArray'
18
+ import type { DefineFieldOptions } from './useFieldRegistry'
19
+ import type { UseFormOptions } from './useForm'
19
20
  import {
20
21
  createValidator,
21
22
  SuccessValidationResult,
22
23
  type ValidatorOptions,
23
- } from "./useValidation";
24
+ } from './useValidation'
24
25
 
25
26
  export interface SubformOptions<_T extends FormDataDefault> {
26
27
  // Additional subform-specific options can be added here
27
28
  }
28
29
 
29
30
  class NestedValidator<T extends FormDataDefault, P extends Paths<T>>
30
- implements Validator<T>
31
- {
31
+ implements Validator<T> {
32
32
  constructor(
33
33
  private path: P,
34
34
  private validator: Validator<PickEntity<T, P>> | undefined,
35
35
  ) {}
36
36
 
37
37
  async validate(data: T): Promise<ValidationResult> {
38
- const subFormData = getNestedValue(data, this.path) as PickEntity<T, P>;
38
+ const subFormData = getNestedValue(data, this.path) as PickEntity<T, P>
39
39
 
40
40
  if (!this.validator) {
41
- return SuccessValidationResult;
41
+ return SuccessValidationResult
42
42
  }
43
43
 
44
- const validationResult = await this.validator.validate(subFormData);
44
+ const validationResult = await this.validator.validate(subFormData)
45
45
 
46
46
  return {
47
47
  isValid: validationResult.isValid,
@@ -55,7 +55,7 @@ class NestedValidator<T extends FormDataDefault, P extends Paths<T>>
55
55
  )
56
56
  : {},
57
57
  },
58
- };
58
+ }
59
59
  }
60
60
  }
61
61
 
@@ -63,22 +63,22 @@ export function createSubformInterface<
63
63
  T extends FormDataDefault,
64
64
  K extends EntityPaths<T>,
65
65
  >(
66
- mainForm: Omit<Form<T>, "submitHandler">,
66
+ mainForm: Form<T>,
67
67
  path: K,
68
68
  formOptions?: UseFormOptions<T>,
69
69
  _options?: SubformOptions<PickEntity<T, K>>,
70
70
  ): Form<PickEntity<T, K>> {
71
- type ST = PickEntity<T, K>;
72
- type SP = Paths<ST>;
73
- type MP<P extends SP> = `${K}.${P}`;
74
- type ScopedMainPaths = Paths<T> & MP<SP>;
71
+ type ST = PickEntity<T, K>
72
+ type SP = Paths<ST>
73
+ type MP<P extends SP> = `${K}.${P}`
74
+ type ScopedMainPaths = Paths<T> & MP<SP>
75
75
 
76
76
  // Create reactive data scoped to subform path
77
- const data = getLens(mainForm.data, path) as Ref<ST>;
77
+ const data = getLens(mainForm.data, path) as Ref<ST>
78
78
 
79
79
  const initialData = computed(() => {
80
- return getNestedValue(mainForm.initialData.value, path) as ST;
81
- });
80
+ return getNestedValue(mainForm.initialData.value, path) as ST
81
+ })
82
82
 
83
83
  const adaptMainFormField = <S extends SP>(
84
84
  field: FormField<PickProps<T, ScopedMainPaths>, ScopedMainPaths>,
@@ -86,37 +86,37 @@ export function createSubformInterface<
86
86
  // Where P ist the full path in the main form, we need to adapt it to the subform's path
87
87
  return {
88
88
  ...field,
89
- path: computed(() => unref(field.path).replace(path + ".", "")),
89
+ path: computed(() => unref(field.path).replace(path + '.', '')),
90
90
  setData: (newData: PickProps<ST, S>) => {
91
- field.setData(newData as PickProps<T, ScopedMainPaths>);
91
+ field.setData(newData as PickProps<T, ScopedMainPaths>)
92
92
  },
93
- } as unknown as FormField<PickProps<ST, S>, S>;
94
- };
93
+ } as unknown as FormField<PickProps<ST, S>, S>
94
+ }
95
95
 
96
96
  const getField = <P extends SP>(fieldPath: P) => {
97
- const fullPath = joinPath(path, fieldPath);
98
- const mainFormField = mainForm.getField(fullPath as ScopedMainPaths);
97
+ const fullPath = joinPath(path, fieldPath)
98
+ const mainFormField = mainForm.getField(fullPath as ScopedMainPaths)
99
99
 
100
100
  if (!mainFormField) {
101
- return {} as FormField<PickProps<ST, P>, P>;
101
+ return {} as FormField<PickProps<ST, P>, P>
102
102
  }
103
103
 
104
- return adaptMainFormField<P>(mainFormField);
105
- };
104
+ return adaptMainFormField<P>(mainFormField)
105
+ }
106
106
 
107
107
  // Field operations with path transformation
108
108
  const defineField = <P extends SP>(
109
109
  fieldOptions: DefineFieldOptions<ST, P>,
110
110
  ) => {
111
- const fullPath = joinPath(path, fieldOptions.path);
111
+ const fullPath = joinPath(path, fieldOptions.path)
112
112
 
113
113
  const mainField = mainForm.defineField({
114
114
  ...fieldOptions,
115
115
  path: fullPath as ScopedMainPaths,
116
- });
116
+ })
117
117
 
118
- return adaptMainFormField<P>(mainField);
119
- };
118
+ return adaptMainFormField<P>(mainField)
119
+ }
120
120
 
121
121
  const fields = computed(<P extends SP>() => {
122
122
  return (
@@ -126,11 +126,11 @@ export function createSubformInterface<
126
126
  >[]
127
127
  )
128
128
  .filter((field) => {
129
- const fieldPath = field.path.value;
130
- return fieldPath.startsWith(path + ".") || fieldPath === path;
129
+ const fieldPath = field.path.value
130
+ return fieldPath.startsWith(path + '.') || fieldPath === path
131
131
  })
132
- .map((field) => adaptMainFormField(field)) as FieldsTuple<ST, P>;
133
- });
132
+ .map(field => adaptMainFormField(field)) as FieldsTuple<ST, P>
133
+ })
134
134
 
135
135
  // Helper function to get all fields without type parameter
136
136
  const getAllSubformFields = () => {
@@ -140,57 +140,54 @@ export function createSubformInterface<
140
140
  ScopedMainPaths
141
141
  >[]
142
142
  ).filter((field) => {
143
- const fieldPath = field.path.value;
144
- return fieldPath.startsWith(path + ".") || fieldPath === path;
145
- });
146
- };
143
+ const fieldPath = field.path.value
144
+ return fieldPath.startsWith(path + '.') || fieldPath === path
145
+ })
146
+ }
147
147
 
148
148
  // State computed from main form with path filtering
149
149
  const isDirty = computed(() =>
150
- getAllSubformFields().some((field) => field.dirty.value),
151
- );
150
+ getAllSubformFields().some(field => field.dirty.value))
152
151
  const isTouched = computed(() =>
153
- getAllSubformFields().some((field) => field.touched.value),
154
- );
152
+ getAllSubformFields().some(field => field.touched.value))
155
153
 
156
154
  // Validation delegates to main form
157
- const isValid = computed(() => mainForm.isValid.value);
158
- const isValidated = computed(() => mainForm.isValidated.value);
155
+ const isValid = computed(() => mainForm.isValid.value)
156
+ const isValidated = computed(() => mainForm.isValidated.value)
159
157
  const errors = computed(() =>
160
- filterErrorsForPath(unref(mainForm.errors), path),
161
- );
158
+ filterErrorsForPath(unref(mainForm.errors), path))
162
159
 
163
- const validateForm = () => mainForm.validateForm();
160
+ const validateForm = () => mainForm.validateForm()
164
161
 
165
162
  // Nested subforms
166
163
  const getSubForm = <P extends EntityPaths<ST>>(
167
164
  subPath: P,
168
165
  subOptions?: SubformOptions<PickEntity<ST, P>>,
169
166
  ) => {
170
- const fullPath = joinPath(path, subPath) as EntityPaths<T>;
167
+ const fullPath = joinPath(path, subPath) as EntityPaths<T>
171
168
  return mainForm.getSubForm(
172
169
  fullPath,
173
170
  subOptions as SubformOptions<PickEntity<T, typeof fullPath>>,
174
- ) as Form<PickEntity<ST, P>>;
175
- };
171
+ ) as Form<PickEntity<ST, P>>
172
+ }
176
173
 
177
174
  // Reset scoped to this subform
178
- const reset = () => getAllSubformFields().forEach((field) => field.reset());
175
+ const reset = () => getAllSubformFields().forEach(field => field.reset())
179
176
 
180
177
  const defineValidator = (
181
178
  options: ValidatorOptions<ST> | Ref<Validator<ST>>,
182
179
  ) => {
183
- const subValidator = isRef(options) ? options : createValidator(options);
180
+ const subValidator = isRef(options) ? options : createValidator(options)
184
181
  const validator = computed(
185
182
  () => new NestedValidator<T, K>(path, unref(subValidator)),
186
- );
183
+ )
187
184
 
188
- mainForm.defineValidator(validator);
185
+ mainForm.defineValidator(validator)
189
186
 
190
- return subValidator;
191
- };
187
+ return subValidator
188
+ }
192
189
 
193
- const subFormInterface: Omit<Form<ST>, "submitHandler"> = {
190
+ const subForm: Form<ST> = {
194
191
  data: data,
195
192
  fields,
196
193
  initialData,
@@ -205,12 +202,11 @@ export function createSubformInterface<
205
202
  reset,
206
203
  validateForm,
207
204
  getSubForm,
208
- };
209
-
210
- const submitHandler = useSubmitHandler(subFormInterface, formOptions ?? {});
205
+ submitHandler: onSubmit => makeSubmitHandler(subForm, formOptions ?? {})(onSubmit),
206
+ useFieldArray: (fieldArrayPath, fieldArrayOptions) => {
207
+ return useFieldArray(subForm, fieldArrayPath, fieldArrayOptions)
208
+ },
209
+ }
211
210
 
212
- return {
213
- ...subFormInterface,
214
- submitHandler,
215
- };
211
+ return subForm
216
212
  }
package/src/types/form.ts CHANGED
@@ -14,6 +14,24 @@ import type {
14
14
 
15
15
  export type FormDataDefault = object
16
16
 
17
+ export type HashFn<H, I> = (item: I) => H
18
+
19
+ export interface FieldArrayOptions<Item> {
20
+ hashFn?: HashFn<unknown, Item>
21
+ }
22
+
23
+ export interface FieldArray<Item> {
24
+ fields: Ref<{
25
+ id: string
26
+ item: Item
27
+ }[]>
28
+ push: (item: Item) => void
29
+ remove: (item: Item) => void
30
+ removeByIndex: (index: number) => void
31
+ errors: Ref<ValidationErrors>
32
+ dirty: Ref<boolean>
33
+ }
34
+
17
35
  export interface FormField<T, P extends string> {
18
36
  data: Ref<T>
19
37
  path: Ref<P>
@@ -81,4 +99,12 @@ export interface Form<T extends FormDataDefault> {
81
99
  path: P,
82
100
  options?: SubformOptions<PickEntity<T, P>>,
83
101
  ) => Form<PickEntity<T, P>>
102
+
103
+ // Field arrays
104
+ useFieldArray: <K extends Paths<T>>(
105
+ path: PickProps<T, K> extends unknown[] ? K : never,
106
+ options?: FieldArrayOptions<
107
+ PickProps<T, K> extends (infer U)[] ? U : never
108
+ >,
109
+ ) => FieldArray<PickProps<T, K> extends (infer U)[] ? U : never>
84
110
  }
@@ -7,8 +7,8 @@ interface SubmitHandlerOptions {
7
7
  validationStrategy?: MaybeRef<ValidationStrategy>
8
8
  }
9
9
 
10
- export function useSubmitHandler<T extends FormDataDefault>(
11
- form: Omit<Form<T>, 'submitHandler'>,
10
+ export function makeSubmitHandler<T extends FormDataDefault>(
11
+ form: Form<T>,
12
12
  options: SubmitHandlerOptions,
13
13
  ) {
14
14
  return (onSubmit: (data: T) => Awaitable<void>) => {