@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.
- package/CHANGELOG.md +16 -0
- package/dist/components/Field.vue.d.ts +4 -1
- package/dist/composables/useFieldRegistry.d.ts +2 -1
- package/dist/composables/useInitialDataOverride.d.ts +13 -0
- package/dist/index.js +489 -439
- package/dist/types/form.d.ts +15 -1
- package/dist/utils/path.d.ts +1 -0
- package/docs/reference.md +65 -0
- package/package.json +1 -1
- package/src/composables/useField.ts +10 -19
- package/src/composables/useFieldArray.ts +16 -0
- package/src/composables/useFieldRegistry.ts +33 -10
- package/src/composables/useForm.ts +4 -2
- package/src/composables/useInitialDataOverride.ts +130 -0
- package/src/types/form.ts +18 -1
- package/src/utils/path.ts +11 -0
- package/tests/formState.test.ts +6 -3
- package/tests/initialDataOverride.test.ts +479 -0
- package/tests/useFieldArray.test.ts +112 -0
package/dist/types/form.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/utils/path.d.ts
CHANGED
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
import type { Awaitable } from '@vueuse/core'
|
|
2
|
-
import { computed, reactive,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
61
|
-
path:
|
|
64
|
+
function initialDataSync<T extends FormDataDefault, K extends Paths<T>>(
|
|
65
|
+
initialDataOverride: InitialDataOverride<T>,
|
|
66
|
+
path: K,
|
|
62
67
|
) {
|
|
63
|
-
|
|
64
|
-
const
|
|
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
|
-
() =>
|
|
68
|
-
() => {
|
|
69
|
-
initialValueRef.value =
|
|
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(
|
|
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(
|
|
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: (
|
|
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) {
|
package/tests/formState.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
79
|
+
const initialDataOverride = useInitialDataOverride(toRef(state, 'initialData'))
|
|
80
|
+
const fields = useFieldRegistry(state, validationState, initialDataOverride)
|
|
78
81
|
|
|
79
82
|
const formState = useFormState(fields)
|
|
80
83
|
|