@teamnovu/kit-vue-forms 0.1.15 → 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,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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamnovu/kit-vue-forms",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -1,19 +1,28 @@
1
- import { computed, reactive, shallowRef, toRefs, watch, type MaybeRef, type MaybeRefOrGetter, type Ref } 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'
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>(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
+ }
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
- 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
+ }
57
69
  state.touched = false
58
70
  state.errors = []
59
71
  }
@@ -11,7 +11,7 @@ import {
11
11
  } from 'vue'
12
12
  import type { FieldsTuple, FormDataDefault, FormField } from '../types/form'
13
13
  import type { Paths, PickProps } from '../types/util'
14
- import { getLens, getNestedValue } from '../utils/path'
14
+ import { existsPath, getLens, getNestedValue } from '../utils/path'
15
15
  import { Rc } from '../utils/rc'
16
16
  import { useField, type UseFieldOptions } from './useField'
17
17
  import type { ValidationState } from './useValidation'
@@ -118,6 +118,7 @@ export function useFieldRegistry<T extends FormDataDefault>(
118
118
  value: getLens(toRef(formState, 'data'), path),
119
119
  initialValue: alwaysComputed(() =>
120
120
  getNestedValue(formState.initialData, path)),
121
+ existsInForm: computed(() => existsPath(formState.data, unref(path))),
121
122
  errors: computed({
122
123
  get() {
123
124
  return validationState.errors.value.propertyErrors[path] || []
@@ -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
  reactive,
@@ -9,82 +9,86 @@ import {
9
9
  type MaybeRef,
10
10
  type MaybeRefOrGetter,
11
11
  type Ref,
12
- } from 'vue'
13
- import type { AnyField, 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'
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";
21
21
 
22
22
  // TODO @Elias implement validation strategy handling
23
23
 
24
24
  export interface UseFormOptions<T extends FormDataDefault>
25
25
  extends ValidationOptions<T> {
26
- initialData: MaybeRefOrGetter<T>
27
- validationStrategy?: MaybeRef<ValidationStrategy>
28
- keepValuesOnUnmount?: MaybeRef<boolean>
26
+ initialData: MaybeRefOrGetter<T>;
27
+ validationStrategy?: MaybeRef<ValidationStrategy>;
28
+ keepValuesOnUnmount?: MaybeRef<boolean>;
29
29
  }
30
30
 
31
31
  export function useForm<T extends FormDataDefault>(options: UseFormOptions<T>) {
32
32
  const initialData = computed(() =>
33
- Object.freeze(cloneRefValue(options.initialData)))
33
+ Object.freeze(cloneRefValue(options.initialData)),
34
+ );
34
35
 
35
- const data = ref<T>(cloneRefValue(initialData)) as Ref<T>
36
+ const data = ref<T>(cloneRefValue(initialData)) as Ref<T>;
36
37
 
37
38
  const state = reactive({
38
39
  initialData,
39
40
  data,
40
- })
41
+ });
41
42
 
42
43
  watch(
43
44
  initialData,
44
45
  (newValue) => {
45
- state.data = cloneRefValue(newValue)
46
+ state.data = cloneRefValue(newValue);
46
47
  },
47
- { flush: 'sync' },
48
- )
48
+ { flush: "sync" },
49
+ );
49
50
 
50
- const validationState = useValidation(state, options)
51
+ const validationState = useValidation(state, options);
51
52
  const fieldRegistry = useFieldRegistry(state, validationState, {
52
53
  keepValuesOnUnmount: options.keepValuesOnUnmount,
53
- onBlur: async () => {
54
- if (unref(options.validationStrategy) === 'onTouch') {
55
- validationState.validateForm()
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);
56
58
  }
57
59
  },
58
- })
59
- const formState = useFormState(fieldRegistry)
60
+ });
61
+ const formState = useFormState(fieldRegistry);
60
62
 
61
63
  const submitHandler = (onSubmit: (data: T) => Awaitable<void>) => {
62
64
  return async (event: SubmitEvent) => {
63
- event.preventDefault()
65
+ event.preventDefault();
64
66
 
65
- if (unref(options.validationStrategy) !== 'none') {
66
- await validationState.validateForm()
67
+ if (unref(options.validationStrategy) !== "none") {
68
+ await validationState.validateForm();
67
69
  }
68
70
 
69
71
  if (!validationState.isValid.value) {
70
- return
72
+ return;
71
73
  }
72
74
 
73
- await onSubmit(state.data)
74
- }
75
- }
75
+ await onSubmit(state.data);
76
+ };
77
+ };
76
78
 
77
79
  const reset = () => {
78
- data.value = cloneRefValue(initialData)
79
- validationState.reset()
80
- fieldRegistry.fields.value.forEach((field: AnyField<T>) => field.reset())
81
- }
80
+ data.value = cloneRefValue(initialData);
81
+ validationState.reset();
82
+ for (const field of fieldRegistry.fields.value) {
83
+ field.reset();
84
+ }
85
+ };
82
86
 
83
87
  function getSubForm<K extends EntityPaths<T>>(
84
88
  path: K,
85
89
  options?: SubformOptions<PickEntity<T, K>>,
86
90
  ): Form<PickEntity<T, K>> {
87
- return createSubformInterface(formInterface, path, options)
91
+ return createSubformInterface(formInterface, path, options);
88
92
  }
89
93
 
90
94
  const formInterface: Form<T> = {
@@ -94,13 +98,13 @@ export function useForm<T extends FormDataDefault>(options: UseFormOptions<T>) {
94
98
  reset,
95
99
  getSubForm,
96
100
  submitHandler,
97
- initialData: toRef(state, 'initialData') as Form<T>['initialData'],
98
- data: toRef(state, 'data') as Form<T>['data'],
99
- }
101
+ initialData: toRef(state, "initialData") as Form<T>["initialData"],
102
+ data: toRef(state, "data") as Form<T>["data"],
103
+ };
100
104
 
101
- if (unref(options.validationStrategy) === 'onFormOpen') {
102
- validationState.validateForm()
105
+ if (unref(options.validationStrategy) === "onFormOpen") {
106
+ validationState.validateForm();
103
107
  }
104
108
 
105
- return formInterface
109
+ return formInterface;
106
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/utils/path.ts CHANGED
@@ -1,100 +1,129 @@
1
- import { computed, isRef, unref, type MaybeRef } from 'vue'
2
- import type { Paths, PickProps, SplitPath } from '../types/util'
3
- import type { ErrorBag, ValidationErrors } from '../types/validation'
1
+ import { computed, isRef, unref, type MaybeRef } from "vue";
2
+ import type { Paths, PickProps, SplitPath } from "../types/util";
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
- if (path === '') {
7
- return []
7
+ if (path === "") {
8
+ return [];
8
9
  }
9
- return path.split(/\s*\.\s*/).filter(Boolean)
10
+ return path.split(/\s*\.\s*/).filter(Boolean);
10
11
  }
11
12
 
12
- export function getNestedValue<T, K extends Paths<T>>(obj: T, path: K | SplitPath<K>) {
13
- const splittedPath = Array.isArray(path) ? path : splitPath(path)
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
+ ) {
29
+ const splittedPath = Array.isArray(path) ? path : splitPath(path);
14
30
  return splittedPath.reduce(
15
31
  (current, key) => current?.[key],
16
32
  obj as Record<string, never>,
17
- ) as PickProps<T, K>
33
+ ) as PickProps<T, K>;
18
34
  }
19
35
 
20
- export function setNestedValue<T, K extends Paths<T>>(obj: MaybeRef<T>, path: K | SplitPath<K>, value: PickProps<T, K>): void {
21
- const keys = Array.isArray(path) ? path : splitPath(path)
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 {
41
+ const keys = Array.isArray(path) ? path : splitPath(path);
22
42
 
23
- const lastKey = keys.at(-1)!
43
+ const lastKey = keys.at(-1)!;
24
44
 
25
45
  if (!lastKey) {
26
46
  if (!isRef(obj)) {
27
47
  // We cannot do anything here as we have nothing we can assign to
28
- return
48
+ return;
29
49
  }
30
50
 
31
- obj.value = value
51
+ obj.value = value;
32
52
  } else {
33
- const target = keys
34
- .slice(0, -1)
35
- .reduce(
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
- },
43
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
- unref(obj) as Record<string, any>,
45
- )
46
-
47
- target[lastKey] = value
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
+ );
64
+
65
+ target[lastKey] = value;
48
66
  }
49
67
  }
50
68
 
51
- export const getLens = <T, K extends Paths<T>>(data: MaybeRef<T>, key: MaybeRef<K | SplitPath<K>>) => {
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
- return getNestedValue(unref(data), unref(key))
75
+ return getNestedValue(unref(data), unref(key));
55
76
  },
56
77
  set(value: PickProps<T, K>) {
57
- setNestedValue(data, unref(key), value)
78
+ setNestedValue(data, unref(key), value);
58
79
  },
59
- })
60
- }
80
+ });
81
+ };
61
82
 
62
- type JoinPath<Base extends string, Sub extends string> = `${Base}${Base extends '' ? '' : Sub extends '' ? '' : '.'}${Sub}`
63
- export function joinPath<Base extends string, Sub extends string>(basePath: Base, subPath: Sub): JoinPath<Base, Sub> {
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
- return '' as JoinPath<Base, Sub>
92
+ return "" as JoinPath<Base, Sub>;
66
93
  }
67
94
 
68
95
  if (!basePath && subPath) {
69
- return subPath as JoinPath<Base, Sub>
96
+ return subPath as JoinPath<Base, Sub>;
70
97
  }
71
98
 
72
99
  if (!subPath && basePath) {
73
- return basePath as JoinPath<Base, Sub>
100
+ return basePath as JoinPath<Base, Sub>;
74
101
  }
75
102
 
76
- return `${basePath}.${subPath}` as JoinPath<Base, Sub>
103
+ return `${basePath}.${subPath}` as JoinPath<Base, Sub>;
77
104
  }
78
105
 
79
106
  export function filterErrorsForPath(errors: ErrorBag, path: string): ErrorBag {
80
107
  // Handle empty path - return all errors
81
108
  if (!path) {
82
- return errors
109
+ return errors;
83
110
  }
84
111
 
85
- const pathPrefix = `${path}.`
86
- const filteredPropertyErrors: Record<string, ValidationErrors> = Object.fromEntries(
87
- Object.entries(errors.propertyErrors)
88
- .filter(([errorPath]) => {
89
- return errorPath.startsWith(pathPrefix)
90
- })
91
- .map(
92
- ([errorPath, errorMessages]) => [errorPath.slice(pathPrefix.length), errorMessages],
93
- ),
94
- )
112
+ const pathPrefix = `${path}.`;
113
+ const filteredPropertyErrors: Record<string, ValidationErrors> =
114
+ Object.fromEntries(
115
+ Object.entries(errors.propertyErrors)
116
+ .filter(([errorPath]) => {
117
+ return errorPath.startsWith(pathPrefix);
118
+ })
119
+ .map(([errorPath, errorMessages]) => [
120
+ errorPath.slice(pathPrefix.length),
121
+ errorMessages,
122
+ ]),
123
+ );
95
124
 
96
125
  return {
97
126
  general: errors.general, // Keep general errors
98
127
  propertyErrors: filteredPropertyErrors,
99
- }
128
+ };
100
129
  }
@@ -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
- expect(form.isValidated.value).toBe(false)
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 () => {