@teamnovu/kit-vue-forms 0.0.1

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.
Files changed (45) hide show
  1. package/PLAN.md +209 -0
  2. package/dist/composables/useField.d.ts +12 -0
  3. package/dist/composables/useFieldRegistry.d.ts +15 -0
  4. package/dist/composables/useForm.d.ts +10 -0
  5. package/dist/composables/useFormState.d.ts +7 -0
  6. package/dist/composables/useSubform.d.ts +5 -0
  7. package/dist/composables/useValidation.d.ts +30 -0
  8. package/dist/index.d.ts +6 -0
  9. package/dist/index.mjs +414 -0
  10. package/dist/types/form.d.ts +41 -0
  11. package/dist/types/util.d.ts +26 -0
  12. package/dist/types/validation.d.ts +16 -0
  13. package/dist/utils/general.d.ts +2 -0
  14. package/dist/utils/path.d.ts +11 -0
  15. package/dist/utils/type-helpers.d.ts +3 -0
  16. package/dist/utils/validation.d.ts +3 -0
  17. package/dist/utils/zod.d.ts +3 -0
  18. package/package.json +41 -0
  19. package/src/composables/useField.ts +74 -0
  20. package/src/composables/useFieldRegistry.ts +53 -0
  21. package/src/composables/useForm.ts +54 -0
  22. package/src/composables/useFormData.ts +16 -0
  23. package/src/composables/useFormState.ts +21 -0
  24. package/src/composables/useSubform.ts +173 -0
  25. package/src/composables/useValidation.ts +227 -0
  26. package/src/index.ts +11 -0
  27. package/src/types/form.ts +58 -0
  28. package/src/types/util.ts +73 -0
  29. package/src/types/validation.ts +22 -0
  30. package/src/utils/general.ts +7 -0
  31. package/src/utils/path.ts +87 -0
  32. package/src/utils/type-helpers.ts +3 -0
  33. package/src/utils/validation.ts +66 -0
  34. package/src/utils/zod.ts +24 -0
  35. package/tests/formState.test.ts +138 -0
  36. package/tests/integration.test.ts +200 -0
  37. package/tests/nestedPath.test.ts +651 -0
  38. package/tests/path-utils.test.ts +159 -0
  39. package/tests/subform.test.ts +1348 -0
  40. package/tests/useField.test.ts +147 -0
  41. package/tests/useForm.test.ts +178 -0
  42. package/tests/useValidation.test.ts +216 -0
  43. package/tsconfig.json +18 -0
  44. package/vite.config.js +39 -0
  45. package/vitest.config.ts +14 -0
@@ -0,0 +1,87 @@
1
+ import { computed, unref, type MaybeRef } from 'vue'
2
+ import type { Paths, PickProps, SplitPath } from '../types/util'
3
+ import type { ErrorBag, ValidationErrors } from '../types/validation'
4
+
5
+ export function splitPath(path: string): string[] {
6
+ if (path === '') {
7
+ return []
8
+ }
9
+ return path.split(/\s*\.\s*/).filter(Boolean)
10
+ }
11
+
12
+ export function getNestedValue<T, K extends Paths<T>>(obj: T, path: K | SplitPath<K>) {
13
+ const splittedPath = Array.isArray(path) ? path : splitPath(path)
14
+ return splittedPath.reduce(
15
+ (current, key) => current?.[key],
16
+ obj as Record<string, never>,
17
+ ) as PickProps<T, K>
18
+ }
19
+
20
+ export function setNestedValue<T, K extends Paths<T>>(obj: T, path: K | SplitPath<K>, value: PickProps<T, K>): void {
21
+ const keys = Array.isArray(path) ? path : splitPath(path)
22
+ if (keys.length === 0) {
23
+ throw new Error('Path cannot be empty')
24
+ }
25
+
26
+ const lastKey = keys.at(-1)!
27
+ const target = keys
28
+ .slice(0, -1)
29
+ .reduce(
30
+ (current, key) => current[key],
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ obj as Record<string, any>,
33
+ )
34
+
35
+ target[lastKey] = value
36
+ }
37
+
38
+ export const getLens = <T, K extends Paths<T>>(formData: MaybeRef<T>, key: MaybeRef<K | SplitPath<K>>) => {
39
+ return computed({
40
+ get() {
41
+ return getNestedValue(unref(formData), unref(key))
42
+ },
43
+ set(value: PickProps<T, K>) {
44
+ setNestedValue(unref(formData), unref(key), value)
45
+ },
46
+ })
47
+ }
48
+
49
+ type JoinPath<Base extends string, Sub extends string> = `${Base}${Base extends '' ? '' : Sub extends '' ? '' : '.'}${Sub}`
50
+ export function joinPath<Base extends string, Sub extends string>(basePath: Base, subPath: Sub): JoinPath<Base, Sub> {
51
+ if (!basePath && !subPath) {
52
+ return '' as JoinPath<Base, Sub>
53
+ }
54
+
55
+ if (!basePath && subPath) {
56
+ return subPath as JoinPath<Base, Sub>
57
+ }
58
+
59
+ if (!subPath && basePath) {
60
+ return basePath as JoinPath<Base, Sub>
61
+ }
62
+
63
+ return `${basePath}.${subPath}` as JoinPath<Base, Sub>
64
+ }
65
+
66
+ export function filterErrorsForPath(errors: ErrorBag, path: string): ErrorBag {
67
+ // Handle empty path - return all errors
68
+ if (!path) {
69
+ return errors
70
+ }
71
+
72
+ const pathPrefix = `${path}.`
73
+ const filteredPropertyErrors: Record<string, ValidationErrors> = Object.fromEntries(
74
+ Object.entries(errors.propertyErrors)
75
+ .filter(([errorPath]) => {
76
+ return errorPath.startsWith(pathPrefix)
77
+ })
78
+ .map(
79
+ ([errorPath, errorMessages]) => [errorPath.slice(pathPrefix.length), errorMessages],
80
+ ),
81
+ )
82
+
83
+ return {
84
+ general: errors.general, // Keep general errors
85
+ propertyErrors: filteredPropertyErrors,
86
+ }
87
+ }
@@ -0,0 +1,3 @@
1
+ export type DeepPartial<T> = {
2
+ [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
3
+ }
@@ -0,0 +1,66 @@
1
+ import type { ErrorBag, ValidationErrors } from '../types/validation'
2
+
3
+ function deduplicate<T extends Array<unknown>>(arr: T): T {
4
+ return arr.filter(
5
+ (value, index, self) => self.indexOf(value) === index,
6
+ ) as T
7
+ }
8
+
9
+ function mergeErrorMessages(...msgs: ValidationErrors[]) {
10
+ return msgs.slice(1).reduce((acc, msg) => {
11
+ if (!acc && !msg) {
12
+ return undefined
13
+ }
14
+ const hasMsgErrors = (msg?.length ?? 0) > 0
15
+ if (!acc && (msg?.length ?? 0) > 0) {
16
+ return msg
17
+ }
18
+ if (!hasMsgErrors) {
19
+ return acc
20
+ }
21
+ const allMessages = (acc ?? []).concat(msg!)
22
+ return deduplicate(allMessages)
23
+ }, msgs[0] as ValidationErrors)
24
+ }
25
+
26
+ function mergePropertyErrors(...propertyErrors: Record<string, ValidationErrors>[]): Record<string, ValidationErrors> {
27
+ const allKeys = propertyErrors.map(errs => Object.keys(errs)).flat()
28
+
29
+ return allKeys.reduce((acc, key) => {
30
+ const values = propertyErrors.map(errs => errs[key]).filter(Boolean) as ValidationErrors[]
31
+
32
+ return {
33
+ ...acc,
34
+ [key]: mergeErrorMessages(...values),
35
+ }
36
+ }, {} as Record<string, ValidationErrors>)
37
+ }
38
+
39
+ export function mergeErrors(...errorBags: ErrorBag[]): ErrorBag {
40
+ if (!errorBags.length) {
41
+ return {
42
+ general: [],
43
+ propertyErrors: {},
44
+ }
45
+ }
46
+
47
+ const firstBag = errorBags[0]
48
+
49
+ if (errorBags.length === 1) {
50
+ return firstBag
51
+ }
52
+
53
+ return errorBags.slice(1).reduce(
54
+ (acc, current) => ({
55
+ general: mergeErrorMessages(acc.general, current.general),
56
+ propertyErrors: mergePropertyErrors(acc.propertyErrors ?? {}, current.propertyErrors ?? {}),
57
+ }),
58
+ firstBag,
59
+ )
60
+ }
61
+
62
+ export function hasErrors(errorBag: ErrorBag): boolean {
63
+ const hasGeneralErrors = (errorBag.general?.length ?? 0) > 0
64
+ const hasPropertyErrors = Object.entries(errorBag.propertyErrors).filter(([, errors]) => errors?.length).length > 0
65
+ return hasGeneralErrors || hasPropertyErrors
66
+ }
@@ -0,0 +1,24 @@
1
+ import z from 'zod'
2
+ import type { ErrorBag } from '../types/validation'
3
+
4
+ export function flattenError(error: z.ZodError): ErrorBag {
5
+ const general = error.issues
6
+ .filter(issue => issue.path.length === 0)
7
+ .map(issue => issue.message)
8
+
9
+ const propertyErrors = error.issues
10
+ .filter(issue => issue.path.length > 0)
11
+ .reduce((acc, issue) => {
12
+ const path = issue.path.join('.')
13
+
14
+ return {
15
+ ...acc,
16
+ [path]: [...(acc[path] ?? []), issue.message],
17
+ }
18
+ }, {} as ErrorBag['propertyErrors'])
19
+
20
+ return {
21
+ general,
22
+ propertyErrors,
23
+ }
24
+ }
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { reactive } from 'vue'
3
+ import { FieldRegistry, useFieldRegistry } from '../src/composables/useFieldRegistry'
4
+ import { useFormState } from '../src/composables/useFormState'
5
+
6
+ describe('useFormState', () => {
7
+ it('should detect dirty state when form data changes', () => {
8
+ const initialData = {
9
+ name: 'John',
10
+ age: 30,
11
+ }
12
+ const formData = reactive({ ...initialData })
13
+ const fields = {} as FieldRegistry<typeof initialData>
14
+
15
+ const formState = useFormState({
16
+ formData,
17
+ initialData,
18
+ }, fields)
19
+
20
+ expect(formState.isDirty.value).toBe(false)
21
+
22
+ formData.name = 'Jane'
23
+ expect(formState.isDirty.value).toBe(true)
24
+ })
25
+
26
+ it('should not be dirty when data equals initial data', () => {
27
+ const initialData = {
28
+ name: 'John',
29
+ age: 30,
30
+ }
31
+ const formData = reactive({ ...initialData })
32
+ const fields = {} as FieldRegistry<typeof initialData>
33
+
34
+ const formState = useFormState({
35
+ formData,
36
+ initialData,
37
+ }, fields)
38
+
39
+ expect(formState.isDirty.value).toBe(false)
40
+
41
+ formData.name = 'Jane'
42
+ expect(formState.isDirty.value).toBe(true)
43
+
44
+ formData.name = 'John' // Back to initial
45
+ expect(formState.isDirty.value).toBe(false)
46
+ })
47
+
48
+ it('should detect touched state when any field is touched', () => {
49
+ const data = {
50
+ name: 'John',
51
+ email: 'john@example.com',
52
+ }
53
+ const formData = reactive(data)
54
+ const fields = useFieldRegistry({
55
+ formData,
56
+ initialData: data,
57
+ })
58
+
59
+ const nameField = fields.defineField({ path: 'name' })
60
+ fields.defineField({ path: 'email' })
61
+
62
+ const formState = useFormState({
63
+ formData,
64
+ initialData: {
65
+ name: 'John',
66
+ email: 'john@example.com',
67
+ },
68
+ }, fields)
69
+
70
+ expect(formState.isTouched.value).toBe(false)
71
+
72
+ nameField.onBlur()
73
+ expect(formState.isTouched.value).toBe(true)
74
+ })
75
+
76
+ it('should handle empty fields map', () => {
77
+ const data = { name: 'John' }
78
+ const formData = reactive(data)
79
+ const fields = useFieldRegistry({
80
+ formData,
81
+ initialData: data,
82
+ })
83
+
84
+ const formState = useFormState({
85
+ formData,
86
+ initialData: { name: 'John' },
87
+ }, fields)
88
+
89
+ expect(formState.isDirty.value).toBe(false)
90
+ expect(formState.isTouched.value).toBe(false)
91
+ })
92
+
93
+ it('should handle complex nested object changes', () => {
94
+ const initialData = {
95
+ user: {
96
+ name: 'John',
97
+ address: {
98
+ street: '123 Main St',
99
+ city: 'New York',
100
+ },
101
+ },
102
+ }
103
+ const formData = reactive(JSON.parse(JSON.stringify(initialData)))
104
+ const fields = useFieldRegistry({
105
+ formData,
106
+ initialData,
107
+ })
108
+
109
+ const formState = useFormState({
110
+ formData,
111
+ initialData,
112
+ }, fields)
113
+
114
+ expect(formState.isDirty.value).toBe(false)
115
+
116
+ formData.user.address.city = 'Boston'
117
+ expect(formState.isDirty.value).toBe(true)
118
+ })
119
+
120
+ it('should handle array changes', () => {
121
+ const initialData = { tags: ['vue', 'typescript'] }
122
+ const formData = reactive({ tags: [...initialData.tags] })
123
+ const fields = useFieldRegistry({
124
+ formData,
125
+ initialData,
126
+ })
127
+
128
+ const formState = useFormState({
129
+ formData,
130
+ initialData,
131
+ }, fields)
132
+
133
+ expect(formState.isDirty.value).toBe(false)
134
+
135
+ formData.tags.push('forms')
136
+ expect(formState.isDirty.value).toBe(true)
137
+ })
138
+ })
@@ -0,0 +1,200 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { nextTick, ref } from 'vue'
3
+ import { z } from 'zod'
4
+ import { useForm } from '../src/composables/useForm'
5
+
6
+ describe('Integration Tests', () => {
7
+ it('should handle complete form workflow', async () => {
8
+ const schema = z.object({
9
+ name: z.string().min(2, 'Name must be at least 2 characters'),
10
+ email: z.email('Invalid email'),
11
+ age: z.number().min(18, 'Must be 18 or older'),
12
+ })
13
+
14
+ const form = useForm({
15
+ initialData: {
16
+ name: '',
17
+ email: '',
18
+ age: 0,
19
+ },
20
+ schema,
21
+ })
22
+
23
+ // Create fields
24
+ const nameField = form.defineField({ path: 'name' })
25
+ const emailField = form.defineField({ path: 'email' })
26
+ const ageField = form.defineField({ path: 'age' })
27
+
28
+ // Initial state
29
+ expect(form.isDirty.value).toBe(false)
30
+ expect(form.isTouched.value).toBe(false)
31
+ expect(form.isValid.value).toBe(true) // No validation run yet
32
+ expect(form.isValidated.value).toBe(false)
33
+
34
+ // Fill out form
35
+ nameField.setValue('John')
36
+ emailField.setValue('john@example.com')
37
+ ageField.setValue(25)
38
+
39
+ // Form should be dirty now
40
+ expect(form.isDirty.value).toBe(true)
41
+
42
+ // Touch fields
43
+ nameField.onBlur()
44
+ emailField.onBlur()
45
+
46
+ // Form should be touched
47
+ expect(form.isTouched.value).toBe(true)
48
+
49
+ // Validate form
50
+ const result = await form.validateForm()
51
+ expect(result.isValid).toBe(true)
52
+ expect(form.isValidated.value).toBe(true)
53
+ })
54
+
55
+ it('should handle form with validation errors', async () => {
56
+ const schema = z.object({
57
+ name: z.string().min(2),
58
+ email: z.string().email(),
59
+ })
60
+
61
+ const form = useForm({
62
+ initialData: {
63
+ name: 'A',
64
+ email: 'invalid',
65
+ },
66
+ schema,
67
+ })
68
+
69
+ const nameField = form.defineField({ path: 'name' })
70
+ const emailField = form.defineField({ path: 'email' })
71
+
72
+ // Validate with invalid data
73
+ const result = await form.validateForm()
74
+ expect(result.isValid).toBe(false)
75
+ expect(result.errors.propertyErrors.name).toBeDefined()
76
+ expect(result.errors.propertyErrors.email).toBeDefined()
77
+
78
+ // Fix the data
79
+ nameField.setValue('John')
80
+ emailField.setValue('john@example.com')
81
+
82
+ // Re-validate
83
+ const result2 = await form.validateForm()
84
+ expect(result2.isValid).toBe(true)
85
+ })
86
+
87
+ it('should handle reactive initial data changes', async () => {
88
+ const initialData = ref({
89
+ name: 'John',
90
+ age: 30,
91
+ })
92
+ const form = useForm({ initialData })
93
+
94
+ const nameField = form.defineField({ path: 'name' })
95
+
96
+ expect(nameField.value.value).toEqual('John')
97
+ expect(form.formData.value).toEqual({
98
+ name: 'John',
99
+ age: 30,
100
+ })
101
+
102
+ // Change initial data
103
+ initialData.value = {
104
+ name: 'Jane',
105
+ age: 25,
106
+ }
107
+ await nextTick()
108
+
109
+ expect(form.initialData.value).toEqual({
110
+ name: 'Jane',
111
+ age: 25,
112
+ })
113
+ })
114
+
115
+ it('should handle nested object validation', async () => {
116
+ const schema = z.object({
117
+ user: z.object({
118
+ name: z.string().min(2),
119
+ contact: z.object({
120
+ email: z.string().email(),
121
+ }),
122
+ }),
123
+ })
124
+
125
+ const form = useForm({
126
+ initialData: {
127
+ user: {
128
+ name: 'A',
129
+ contact: {
130
+ email: 'invalid',
131
+ },
132
+ },
133
+ },
134
+ schema,
135
+ })
136
+
137
+ const result = await form.validateForm()
138
+ expect(result.isValid).toBe(false)
139
+ expect(result.errors.propertyErrors['user.name']).toBeDefined()
140
+ expect(result.errors.propertyErrors['user.contact.email']).toBeDefined()
141
+ })
142
+
143
+ it('should merge multiple validation sources', async () => {
144
+ const schema = z.object({
145
+ name: z.string().min(1),
146
+ })
147
+
148
+ const validateFn = async (data: { name: string }) => ({
149
+ isValid: data.name !== 'forbidden',
150
+ errors: {
151
+ general: data.name === 'forbidden' ? ['Forbidden name'] : [],
152
+ propertyErrors: {},
153
+ },
154
+ })
155
+
156
+ const externalErrors = ref({
157
+ general: ['External error'],
158
+ propertyErrors: { name: ['External field error'] },
159
+ })
160
+
161
+ const form = useForm({
162
+ initialData: { name: 'forbidden' },
163
+ schema,
164
+ validateFn,
165
+ errors: externalErrors,
166
+ })
167
+
168
+ const result = await form.validateForm()
169
+ expect(result.isValid).toBe(false)
170
+
171
+ // Should have errors from all sources
172
+ expect(result.errors.general).toContain('Forbidden name')
173
+ expect(result.errors.general).toContain('External error')
174
+ expect(result.errors.propertyErrors.name).toContain('External field error')
175
+ })
176
+
177
+ it('should handle field registry operations', () => {
178
+ const form = useForm({
179
+ initialData: {
180
+ name: 'John',
181
+ email: 'john@test.com',
182
+ },
183
+ })
184
+
185
+ // Define fields
186
+ const nameField = form.defineField({ path: 'name' })
187
+ form.defineField({ path: 'email' })
188
+
189
+ // Check registry
190
+ expect(form.getFields().length).toBe(2)
191
+
192
+ // Get specific field
193
+ const retrievedNameField = form.getField('name')
194
+ expect(retrievedNameField).toBe(nameField)
195
+
196
+ // Try to get non-existent field
197
+ const nonExistentField = form.getField('nonexistent')
198
+ expect(nonExistentField).toBeUndefined()
199
+ })
200
+ })