@teamnovu/kit-vue-forms 0.2.18 → 0.3.0

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.
@@ -18,6 +18,13 @@ export interface FieldItem<Item, Path extends string> {
18
18
  export interface FieldArray<Item, Path extends string> {
19
19
  items: ShallowRef<FieldItem<Item, Path>[]>;
20
20
  push: (item: Item) => FieldItem<Item, Path>;
21
+ /**
22
+ * Pushes a new item and anchors its subtree as the baseline for that index
23
+ * (subtree-scoped `setInitialData`). The new item's subfields are clean from
24
+ * the moment they're registered, while the array field itself stays dirty
25
+ * because its baseline still reflects the external `initialData`.
26
+ */
27
+ pushPristine: (item: Item) => FieldItem<Item, Path>;
21
28
  remove: (id: string) => void;
22
29
  insert: (item: Item, index: number) => FieldItem<Item, Path>;
23
30
  field: FormField<Item[], Path>;
@@ -33,8 +40,15 @@ export interface FormField<T, P extends string> {
33
40
  /**
34
41
  * Sets the initial data for the field. If the field is not dirty, it also updates the current data.
35
42
  * @param newData - The new initial data to set.
43
+ * @param options - Optional. Pass `{ replace: true }` to replace the subtree entirely instead of deep-merging.
44
+ * Pass `{ scope: 'subtree' }` to anchor the baseline only for this field and its descendants — ancestors
45
+ * continue to read from the external initialData and stay dirty if the override changed the tree shape.
46
+ * Defaults to `{ scope: 'tree' }`, which makes the override visible to ancestors as well.
36
47
  */
37
- setInitialData: (newData: T) => void;
48
+ setInitialData: (newData: T, options?: {
49
+ replace?: boolean;
50
+ scope?: 'tree' | 'subtree';
51
+ }) => void;
38
52
  onBlur: () => void;
39
53
  onFocus: () => void;
40
54
  reset: () => void;
@@ -10,5 +10,6 @@ export declare function setNestedValue<T, K extends Paths<T>>(obj: MaybeRef<T>,
10
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>>;
11
11
  type JoinPath<Base extends string, Sub extends string> = `${Base}${Base extends '' ? '' : Sub extends '' ? '' : '.'}${Sub}`;
12
12
  export declare function joinPath<Base extends string, Sub extends string>(basePath: Base, subPath: Sub): JoinPath<Base, Sub>;
13
+ export declare function dropOverridesAtAndBelow(overrides: Map<string, unknown>, path: string): void;
13
14
  export declare function filterErrorsForPath(errors: ErrorBag, path: string): ErrorBag;
14
15
  export {};
package/docs/reference.md CHANGED
@@ -130,6 +130,56 @@ setInitialData(fetchedData.firstName)
130
130
  // If the field wasn't dirty, the current data is also updated
131
131
  ```
132
132
 
133
+ ### Calling `setInitialData` on a parent path
134
+ Overrides set on a parent path propagate down to its subfields. The default behavior is a **deep merge** with the underlying `initialData`, so subfields that are not mentioned in the override fall through to the external value:
135
+
136
+ ```typescript
137
+ const form = useForm({
138
+ initialData: { user: { name: 'A', email: 'x@x' } },
139
+ })
140
+
141
+ form.getField('user').setInitialData({ name: 'B' })
142
+
143
+ form.getField('user.name').initialValue.value // 'B' (from override)
144
+ form.getField('user.email').initialValue.value // 'x@x' (from external)
145
+ form.getField('user.name').dirty.value // false
146
+ ```
147
+
148
+ Pass `{ replace: true }` as the second argument to replace the subtree wholesale instead of merging — keys missing from the override resolve to `undefined`:
149
+
150
+ ```typescript
151
+ form.getField('user').setInitialData({ name: 'B' }, { replace: true })
152
+
153
+ form.getField('user.email').initialValue.value // undefined
154
+ ```
155
+
156
+ ### Scoping the override: `tree` (default) vs `subtree`
157
+ `setInitialData` accepts a `scope` option that controls who sees the new baseline:
158
+
159
+ - **`scope: 'tree'` (default)** — the override becomes part of the global baseline. Both the targeted path *and its ancestors* read it, so the form goes back to non-dirty if `data` matches. Use this when an async load gives you a value that should be treated as if it had been the original initial all along.
160
+ - **`scope: 'subtree'`** — the override anchors the baseline only for the targeted path and its descendants. Strict ancestors keep reading the external `initialData`, so they stay dirty when the change altered the tree shape (e.g. a new array item). Use this when something was added that the user should still be warned about losing on navigation, but whose internal fields shouldn't be marked dirty from the get-go.
161
+
162
+ ```typescript
163
+ const form = useForm({
164
+ initialData: { rows: [{ name: 'A' }] as { name: string }[] },
165
+ })
166
+
167
+ // Push a new row (array structure changes → form is dirty)
168
+ form.data.value.rows.push({ name: 'new' })
169
+
170
+ // Anchor the new row's subtree as its own baseline.
171
+ form.getField('rows.1').setInitialData({ name: 'new' }, { scope: 'subtree' })
172
+
173
+ form.getField('rows.1.name').dirty.value // false (reads the anchor)
174
+ form.getField('rows').dirty.value // true (array baseline unchanged)
175
+ form.isDirty.value // true
176
+ ```
177
+
178
+ ### Interaction with external `initialData` and other overrides
179
+ - **External reassignment wipes overrides.** When the `initialData` ref passed to `useForm` is reassigned, all overrides applied via `setInitialData` are dropped — the form's baseline goes back to the external source.
180
+ - **Cascade on ancestor write.** Calling `setInitialData` on a path drops any existing override on that path *or below* before applying the new one (e.g. setting an override on `user` clears a prior override on `user.name`).
181
+ - **`form.reset()` keeps overrides.** Reset rebuilds `data` from the merged tree (external `initialData` + active overrides), so a `setInitialData` call is treated as the new programmatic baseline.
182
+
133
183
  ## Method `defineValidator`
134
184
  The `defineValidator` method allows subcomponents to add validation rules to a form without needing access to the initial `useForm` call. The validator is automatically removed when the component unmounts.
135
185
 
@@ -197,6 +247,21 @@ hobbies.remove(hobbies.items.value[0].id)
197
247
  </div>
198
248
  ```
199
249
 
250
+ ### `pushPristine` — add a new item without dirtying its subfields
251
+ `pushPristine(item)` works like `push`, but additionally anchors the new index's subtree as its own baseline (a `setInitialData(item, { scope: 'subtree' })` on the new index path). The new item's subfields start non-dirty, while the array field itself stays dirty — its baseline still reflects the external `initialData`, so navigation guards / dirty checks still warn that something was added.
252
+
253
+ ```typescript
254
+ const rows = form.getFieldArray('rows')
255
+
256
+ rows.pushPristine({ name: '', email: '' })
257
+
258
+ form.getField('rows.1.name').dirty.value // false (reads the subtree anchor)
259
+ rows.field.dirty.value // true (a new row exists)
260
+ form.isDirty.value // true
261
+ ```
262
+
263
+ Use `push` when the new item is a user edit you want flagged everywhere; use `pushPristine` when it's a blank/placeholder row whose internal fields shouldn't light up dirty/validation noise until the user actually touches them.
264
+
200
265
  For objects where identity should be based on a property (e.g. `id`) rather than reference equality, provide a `hashFn`:
201
266
  ```typescript
202
267
  const products = form.getFieldArray('products', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamnovu/kit-vue-forms",
3
- "version": "0.2.18",
3
+ "version": "0.3.0",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
1
  import type { Awaitable } from '@vueuse/core'
2
- import { computed, reactive, shallowRef, toRefs, unref, watch, type MaybeRef, type MaybeRefOrGetter, type Ref } from 'vue'
2
+ import { computed, reactive, toRefs, unref, watch, type MaybeRef, type MaybeRefOrGetter, type Ref } from 'vue'
3
3
  import type { FormField } from '../types/form'
4
4
  import type { ValidationErrorMessage, ValidationErrors } from '../types/validation'
5
5
  import { cloneRefValue } from '../utils/general'
@@ -24,33 +24,20 @@ export function useField<T, K extends string>(fieldOptions: UseFieldOptions<T, K
24
24
  ...defaultOptions,
25
25
  ...fieldOptions,
26
26
  }
27
- const initialValue = shallowRef(Object.freeze(cloneRefValue(options.initialValue))) as Ref<Readonly<T | undefined>>
28
-
29
27
  const state = reactive({
30
28
  value: options.value,
31
29
  path: options.path,
32
- initialValue,
30
+ initialValue: options.initialValue,
33
31
  errors: options.errors,
34
32
  touched: false,
35
33
  })
36
34
 
37
- watch(
38
- shallowRef(options.initialValue),
39
- () => {
40
- initialValue.value = Object.freeze(cloneRefValue(options.initialValue))
41
- if (state.value !== unref(options.initialValue)) {
42
- state.value = cloneRefValue(options.initialValue)
43
- }
44
- },
45
- { flush: 'sync' },
46
- )
47
-
48
35
  watch(() => state.value, (newData) => {
49
36
  fieldOptions.onChange?.(newData as T)
50
37
  }, { deep: true })
51
38
 
52
39
  const dirty = computed(() => {
53
- return JSON.stringify(state.value) !== JSON.stringify(state.initialValue)
40
+ return JSON.stringify(state.value) !== JSON.stringify(cloneRefValue(state.initialValue as T))
54
41
  })
55
42
 
56
43
  const setData = (newData: T): void => {
@@ -71,17 +58,21 @@ export function useField<T, K extends string>(fieldOptions: UseFieldOptions<T, K
71
58
  const reset = (): void => {
72
59
  const lastPathPart = state.path.split('.').at(-1) || ''
73
60
  if (unref(options.existsInForm) && !/^\d+$/.test(lastPathPart)) {
74
- state.value = cloneRefValue(state.initialValue)
61
+ state.value = cloneRefValue(state.initialValue as T)
75
62
  }
76
63
  state.touched = false
77
64
  state.errors = []
78
65
  }
79
66
 
80
- const setInitialData = (newData: T): void => {
67
+ // This method is replaced by the registry and should never be called directly.
68
+ // This only acts as a stub.
69
+ const setInitialData = (newData: T, _options?: { replace?: boolean }): void => {
81
70
  if (!dirty.value) {
82
71
  setData(cloneRefValue(newData))
83
72
  }
84
- state.initialValue = newData
73
+ // Standalone-mode fallback: when no registry has replaced this method, keep
74
+ // the previous behavior of updating the local baseline directly.
75
+ state.initialValue = newData as typeof state.initialValue
85
76
  }
86
77
 
87
78
  const setErrors = (newErrors: ValidationErrorMessage[]): void => {
@@ -132,6 +132,21 @@ export function useFieldArray<T extends FormDataDefault, K extends Paths<T>, TOu
132
132
  return items.value.at(-1)!
133
133
  }
134
134
 
135
+ const pushPristine = (item: Item) => {
136
+ const current = (arrayField.data.value ?? []) as Item[]
137
+ const newIndex = current.length
138
+ arrayField.setData([...current, item] as Items)
139
+
140
+ // Anchor the new item's subtree as its own baseline. Subtree scope keeps
141
+ // the array field (and any further ancestors) dirty against the external
142
+ // initialData, while the new index and its descendants read the anchor.
143
+ const newItemPath = `${path}.${newIndex}` as Paths<T>
144
+ const newItemField = form.getField(newItemPath) as unknown as FormField<Item, string>
145
+ newItemField.setInitialData(item, { scope: 'subtree' })
146
+
147
+ return items.value.at(-1)!
148
+ }
149
+
135
150
  const remove = (id: Id) => {
136
151
  const currentData = (arrayField.data.value ?? []) as Item[]
137
152
  const currentItem = items.value.findIndex(
@@ -165,6 +180,7 @@ export function useFieldArray<T extends FormDataDefault, K extends Paths<T>, TOu
165
180
  return {
166
181
  items,
167
182
  push,
183
+ pushPristine,
168
184
  remove,
169
185
  insert,
170
186
  field: arrayField,
@@ -12,9 +12,14 @@ import {
12
12
  } from 'vue'
13
13
  import type { FieldsTuple, FormDataDefault, FormField } from '../types/form'
14
14
  import type { Paths, PickProps } from '../types/util'
15
- import { existsPath, getLens, getNestedValue } from '../utils/path'
15
+ import { cloneRefValue } from '../utils/general'
16
+ import { existsPath, getLens } from '../utils/path'
16
17
  import { Rc } from '../utils/rc'
17
18
  import { useField, type UseFieldOptions } from './useField'
19
+ import type {
20
+ InitialDataOverride,
21
+ InitialDataOverrideSetOptions,
22
+ } from './useInitialDataOverride'
18
23
  import type { ValidationState } from './useValidation'
19
24
 
20
25
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -56,17 +61,18 @@ const optionDefaults = {
56
61
 
57
62
  // A computed that always reflects the latest value from the getter
58
63
  // This computed forces updates even if the value is the same (to trigger watchers)
59
- function initialDataSync<T extends FormDataDefault>(
60
- formState: FormState<T>,
61
- path: Paths<T>,
64
+ function initialDataSync<T extends FormDataDefault, K extends Paths<T>>(
65
+ initialDataOverride: InitialDataOverride<T>,
66
+ path: K,
62
67
  ) {
63
- const getNewValue = () => getNestedValue(formState.initialData, path)
64
- const initialValueRef = shallowRef(getNewValue())
68
+ type FieldValue = PickProps<T, K>
69
+ const getNewValue = () => initialDataOverride.resolveAt(path) as FieldValue
70
+ const initialValueRef = shallowRef<FieldValue>(getNewValue())
65
71
 
66
72
  watch(
67
- () => formState.initialData,
68
- () => {
69
- initialValueRef.value = getNewValue()
73
+ () => initialDataOverride.resolveAt(path),
74
+ (newValue) => {
75
+ initialValueRef.value = newValue as FieldValue
70
76
  triggerRef(initialValueRef)
71
77
  },
72
78
  { flush: 'sync' },
@@ -78,6 +84,7 @@ function initialDataSync<T extends FormDataDefault>(
78
84
  export function useFieldRegistry<T extends FormDataDefault, TOut = T>(
79
85
  formState: FormState<T>,
80
86
  validationState: ValidationState<T, TOut>,
87
+ initialDataOverride: InitialDataOverride<T>,
81
88
  fieldRegistryOptions?: FieldRegistryOptions,
82
89
  ) {
83
90
  const fieldReferenceCounter = new Map<Paths<T>, Rc>()
@@ -124,7 +131,7 @@ export function useFieldRegistry<T extends FormDataDefault, TOut = T>(
124
131
  const field = useField({
125
132
  path,
126
133
  value: getLens(toRef(formState, 'data'), path),
127
- initialValue: initialDataSync(formState, path),
134
+ initialValue: initialDataSync(initialDataOverride, path),
128
135
  existsInForm: computed(() => existsPath(formState.data, unref(path))),
129
136
  errors: computed({
130
137
  get() {
@@ -154,6 +161,22 @@ export function useFieldRegistry<T extends FormDataDefault, TOut = T>(
154
161
  },
155
162
  })
156
163
 
164
+ // Replace setInitialData with a registry-provided version that writes to
165
+ // the override layer so subfields resolve their baseline from the merged
166
+ // tree.
167
+ field.setInitialData = (
168
+ newData: PickProps<T, K>,
169
+ setOptions?: InitialDataOverrideSetOptions,
170
+ ) => {
171
+ // Capture dirty state before the override write, since writing to the
172
+ // override layer synchronously updates this field's initialValue.
173
+ const wasDirty = field.dirty.value
174
+ initialDataOverride.set(path, newData, setOptions)
175
+ if (!wasDirty) {
176
+ field.setData(cloneRefValue(newData))
177
+ }
178
+ }
179
+
157
180
  registerField(field)
158
181
  }
159
182
 
@@ -15,6 +15,7 @@ import { makeSubmitHandler } from '../utils/submitHandler'
15
15
  import { useFieldArray } from './useFieldArray'
16
16
  import { useFieldRegistry } from './useFieldRegistry'
17
17
  import { useFormState } from './useFormState'
18
+ import { useInitialDataOverride } from './useInitialDataOverride'
18
19
  import { createSubformInterface } from './useSubform'
19
20
  import {
20
21
  useValidation,
@@ -60,6 +61,7 @@ export function useForm<T extends FormDataDefault, TOut = T>(
60
61
 
61
62
  /* eslint-enable no-redeclare */
62
63
  const initialData = computed(() => cloneRefValue(options.initialData))
64
+ const initialDataOverride = useInitialDataOverride(initialData)
63
65
 
64
66
  const data = ref<T>(cloneRefValue(initialData)) as Ref<T>
65
67
 
@@ -77,7 +79,7 @@ export function useForm<T extends FormDataDefault, TOut = T>(
77
79
  )
78
80
 
79
81
  const validationState = useValidation(state, options)
80
- const fieldRegistry = useFieldRegistry(state, validationState, {
82
+ const fieldRegistry = useFieldRegistry(state, validationState, initialDataOverride, {
81
83
  keepValuesOnUnmount: options.keepValuesOnUnmount,
82
84
  onBlur: async (path: string) => {
83
85
  validationState.validateStrategy('validateOnBlur', path)
@@ -92,7 +94,7 @@ export function useForm<T extends FormDataDefault, TOut = T>(
92
94
  const formState = useFormState(fieldRegistry)
93
95
 
94
96
  const reset = () => {
95
- data.value = cloneRefValue(initialData)
97
+ data.value = cloneRefValue(initialDataOverride.effectiveInitialData)
96
98
  validationState.reset()
97
99
  for (const field of fieldRegistry.fields.value) {
98
100
  field.reset()
@@ -0,0 +1,130 @@
1
+ import { merge } from 'lodash-es'
2
+ import {
3
+ computed,
4
+ shallowReactive,
5
+ watch,
6
+ type ComputedRef,
7
+ type Ref,
8
+ } from 'vue'
9
+ import type { FormDataDefault } from '../types/form'
10
+ import type { Paths, PickProps } from '../types/util'
11
+ import { cloneRefValue } from '../utils/general'
12
+ import {
13
+ dropOverridesAtAndBelow,
14
+ getNestedValue,
15
+ setNestedValue,
16
+ } from '../utils/path'
17
+
18
+ export type InitialDataOverrideScope = 'tree' | 'subtree'
19
+
20
+ export interface InitialDataOverrideSetOptions {
21
+ replace?: boolean
22
+ scope?: InitialDataOverrideScope
23
+ }
24
+
25
+ export interface InitialDataOverride<T extends FormDataDefault> {
26
+ // Full merge of external initialData with all overrides regardless of scope.
27
+ // Used by form.reset() to rebuild the data tree.
28
+ effectiveInitialData: ComputedRef<T>
29
+ // Per-field baseline resolution. Honors scope: subtree-scoped overrides at
30
+ // paths strictly below `path` are invisible to that read, so ancestors keep
31
+ // their external baseline and stay dirty when descendants extend the tree.
32
+ resolveAt: (path: string) => unknown
33
+ set: (
34
+ path: string,
35
+ value: unknown,
36
+ options?: InitialDataOverrideSetOptions,
37
+ ) => void
38
+ }
39
+
40
+ interface OverrideEntry {
41
+ value: unknown
42
+ replace: boolean
43
+ scope: InitialDataOverrideScope
44
+ }
45
+
46
+ const isPlainObject = (v: unknown): v is Record<string, unknown> => {
47
+ if (v === null || typeof v !== 'object') return false
48
+ const proto = Object.getPrototypeOf(v)
49
+ // Only treat literal `{}` / `Object.create(null)` as mergeable. Class
50
+ // instances, Date, Map, Set, RegExp, etc. have no enumerable own props for
51
+ // lodash.merge to copy and would silently collapse to `{}`.
52
+ return proto === Object.prototype || proto === null
53
+ }
54
+
55
+ const isStrictDescendant = (candidate: string, ancestor: string): boolean => {
56
+ if (ancestor === '') return candidate !== ''
57
+ return candidate.startsWith(ancestor + '.')
58
+ }
59
+
60
+ export function useInitialDataOverride<T extends FormDataDefault>(
61
+ initialData: Ref<T> | ComputedRef<T>,
62
+ ): InitialDataOverride<T> {
63
+ const overrides = shallowReactive(new Map<string, OverrideEntry>())
64
+
65
+ const buildTree = (readingPath?: string): T => {
66
+ const result = cloneRefValue(initialData)
67
+ const sortedEntries = [...overrides.entries()].sort(
68
+ ([a], [b]) => a.split('.').length - b.split('.').length,
69
+ )
70
+
71
+ for (const [path, entry] of sortedEntries) {
72
+ // For a per-field read, skip subtree-scoped overrides that live strictly
73
+ // below the reading path — those anchors must remain invisible to
74
+ // ancestors of their own subtree.
75
+ if (
76
+ readingPath !== undefined
77
+ && entry.scope === 'subtree'
78
+ && isStrictDescendant(path, readingPath)
79
+ ) {
80
+ continue
81
+ }
82
+
83
+ const typedPath = path as Paths<T>
84
+ const existing = getNestedValue<T, Paths<T>>(result, typedPath)
85
+
86
+ const nextValue = entry.replace || !isPlainObject(entry.value) || !isPlainObject(existing)
87
+ ? entry.value
88
+ : merge({}, existing, entry.value)
89
+
90
+ setNestedValue<T, Paths<T>>(
91
+ result,
92
+ typedPath,
93
+ nextValue as PickProps<T, Paths<T>>,
94
+ )
95
+ }
96
+
97
+ return result
98
+ }
99
+
100
+ const effectiveInitialData = computed<T>(() => buildTree())
101
+
102
+ const resolveAt = (path: string): unknown => {
103
+ const tree = buildTree(path)
104
+ if (path === '') return tree
105
+ return getNestedValue<T, Paths<T>>(tree, path as Paths<T>)
106
+ }
107
+
108
+ // External initialData reassignment wipes all overrides. Programmatic
109
+ // overrides via setInitialData stay scoped to their own subtrees.
110
+ watch(
111
+ initialData,
112
+ () => {
113
+ overrides.clear()
114
+ },
115
+ { flush: 'sync' },
116
+ )
117
+
118
+ return {
119
+ effectiveInitialData,
120
+ resolveAt,
121
+ set(path, value, options) {
122
+ dropOverridesAtAndBelow(overrides, path)
123
+ overrides.set(path, {
124
+ value,
125
+ replace: options?.replace ?? false,
126
+ scope: options?.scope ?? 'tree',
127
+ })
128
+ },
129
+ }
130
+ }
package/src/types/form.ts CHANGED
@@ -29,6 +29,13 @@ export interface FieldItem<Item, Path extends string> {
29
29
  export interface FieldArray<Item, Path extends string> {
30
30
  items: ShallowRef<FieldItem<Item, Path>[]>
31
31
  push: (item: Item) => FieldItem<Item, Path>
32
+ /**
33
+ * Pushes a new item and anchors its subtree as the baseline for that index
34
+ * (subtree-scoped `setInitialData`). The new item's subfields are clean from
35
+ * the moment they're registered, while the array field itself stays dirty
36
+ * because its baseline still reflects the external `initialData`.
37
+ */
38
+ pushPristine: (item: Item) => FieldItem<Item, Path>
32
39
  remove: (id: string) => void
33
40
  insert: (item: Item, index: number) => FieldItem<Item, Path>
34
41
  field: FormField<Item[], Path>
@@ -45,8 +52,18 @@ export interface FormField<T, P extends string> {
45
52
  /**
46
53
  * Sets the initial data for the field. If the field is not dirty, it also updates the current data.
47
54
  * @param newData - The new initial data to set.
55
+ * @param options - Optional. Pass `{ replace: true }` to replace the subtree entirely instead of deep-merging.
56
+ * Pass `{ scope: 'subtree' }` to anchor the baseline only for this field and its descendants — ancestors
57
+ * continue to read from the external initialData and stay dirty if the override changed the tree shape.
58
+ * Defaults to `{ scope: 'tree' }`, which makes the override visible to ancestors as well.
48
59
  */
49
- setInitialData: (newData: T) => void
60
+ setInitialData: (
61
+ newData: T,
62
+ options?: {
63
+ replace?: boolean
64
+ scope?: 'tree' | 'subtree'
65
+ },
66
+ ) => void
50
67
  onBlur: () => void
51
68
  onFocus: () => void
52
69
  reset: () => void
package/src/utils/path.ts CHANGED
@@ -103,6 +103,17 @@ export function joinPath<Base extends string, Sub extends string>(
103
103
  return `${basePath}.${subPath}` as JoinPath<Base, Sub>
104
104
  }
105
105
 
106
+ export function dropOverridesAtAndBelow(
107
+ overrides: Map<string, unknown>,
108
+ path: string,
109
+ ): void {
110
+ for (const key of [...overrides.keys()]) {
111
+ if (key === path || path === '' || key.startsWith(path + '.')) {
112
+ overrides.delete(key)
113
+ }
114
+ }
115
+ }
116
+
106
117
  export function filterErrorsForPath(errors: ErrorBag, path: string): ErrorBag {
107
118
  // Handle empty path - return all errors
108
119
  if (!path) {
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { reactive } from 'vue'
2
+ import { reactive, toRef } from 'vue'
3
3
  import { useFieldRegistry } from '../src/composables/useFieldRegistry'
4
4
  import { useFormState } from '../src/composables/useFormState'
5
5
  import { useForm } from '../src'
6
+ import { useInitialDataOverride } from '../src/composables/useInitialDataOverride'
6
7
  import { useValidation } from '../src/composables/useValidation'
7
8
 
8
9
  describe('useFormState', () => {
@@ -51,7 +52,8 @@ describe('useFormState', () => {
51
52
  initialData,
52
53
  })
53
54
  const validationState = useValidation(state, {})
54
- const fields = useFieldRegistry(state, validationState)
55
+ const initialDataOverride = useInitialDataOverride(toRef(state, 'initialData'))
56
+ const fields = useFieldRegistry(state, validationState, initialDataOverride)
55
57
 
56
58
  const nameField = fields.defineField({ path: 'name' })
57
59
  fields.defineField({ path: 'email' })
@@ -74,7 +76,8 @@ describe('useFormState', () => {
74
76
  initialData,
75
77
  })
76
78
  const validationState = useValidation(state, {})
77
- const fields = useFieldRegistry(state, validationState)
79
+ const initialDataOverride = useInitialDataOverride(toRef(state, 'initialData'))
80
+ const fields = useFieldRegistry(state, validationState, initialDataOverride)
78
81
 
79
82
  const formState = useFormState(fields)
80
83