diff --git a/packages/shared/utils.ts b/packages/shared/utils.ts index 191060e6b..14b36b2d8 100644 --- a/packages/shared/utils.ts +++ b/packages/shared/utils.ts @@ -69,8 +69,8 @@ export function merge(target: any, source: any) { /** * Constructs a path with dot paths for arrays to use brackets to be compatible with vee-validate path syntax */ -export function normalizeFormPath(path: string): string { - const pathArr = path.split('.'); +export function normalizeFormPath(path: Array | string): string { + const pathArr = Array.isArray(path) ? path : path.split('.'); if (!pathArr.length) { return ''; } diff --git a/packages/valibot/src/index.ts b/packages/valibot/src/index.ts index 83df11243..8e2dadef2 100644 --- a/packages/valibot/src/index.ts +++ b/packages/valibot/src/index.ts @@ -1,5 +1,11 @@ import { PartialDeep } from 'type-fest'; -import { cleanupNonNestedPath, isNotNestedPath, type TypedSchema, type TypedSchemaError } from 'vee-validate'; +import { + cleanupNonNestedPath, + isNotNestedPath, + getPathSegments, + type TypedSchema, + type TypedSchemaError, +} from 'vee-validate'; import { InferOutput, InferInput, @@ -155,7 +161,7 @@ function getSchemaForPath( return schema.entries[cleanupNonNestedPath(path)]; } - const paths = (path || '').split(/\.|\[(\d+)\]/).filter(Boolean); + const paths = getPathSegments(path); let currentSchema: BaseSchema> = schema; for (let i = 0; i <= paths.length; i++) { diff --git a/packages/vee-validate/src/index.ts b/packages/vee-validate/src/index.ts index 9f5a9b3a2..68f472112 100644 --- a/packages/vee-validate/src/index.ts +++ b/packages/vee-validate/src/index.ts @@ -1,7 +1,7 @@ export { validate, validateObjectSchema as validateObject } from './validate'; export { defineRule } from './defineRule'; export { configure } from './config'; -export { normalizeRules, isNotNestedPath, cleanupNonNestedPath } from './utils'; +export { normalizeRules, isNotNestedPath, cleanupNonNestedPath, getPathSegments } from './utils'; export { Field, FieldBindingObject, ComponentFieldBindingObject, FieldSlotProps } from './Field'; export { Form, FormSlotProps } from './Form'; export { FieldArray } from './FieldArray'; diff --git a/packages/vee-validate/src/types/forms.ts b/packages/vee-validate/src/types/forms.ts index 64bdc21e6..ac9b7c8f2 100644 --- a/packages/vee-validate/src/types/forms.ts +++ b/packages/vee-validate/src/types/forms.ts @@ -346,9 +346,9 @@ export interface PrivateFormContext< stageInitialValue(path: string, value: unknown, updateOriginal?: boolean): void; unsetInitialValue(path: string): void; handleSubmit: HandleSubmitFactory & { withControlled: HandleSubmitFactory }; - setFieldInitialValue(path: string, value: unknown, updateOriginal?: boolean): void; + setFieldInitialValue(path: Array | string, value: unknown, updateOriginal?: boolean): void; createPathState>( - path: MaybeRef, + path: MaybeRefOrGetter | TPath>, config?: Partial>, ): PathState>; getPathState>(path: TPath): PathState> | undefined; diff --git a/packages/vee-validate/src/useField.ts b/packages/vee-validate/src/useField.ts index 7cf185833..2bb34e7a5 100644 --- a/packages/vee-validate/src/useField.ts +++ b/packages/vee-validate/src/useField.ts @@ -99,7 +99,7 @@ export function useField( } function _useField( - path: MaybeRefOrGetter, + path: MaybeRefOrGetter | string>, rules?: MaybeRef>, opts?: Partial>, ): FieldContext { @@ -143,7 +143,7 @@ function _useField( }); const isTyped = !isCallable(validator.value) && isTypedSchema(toValue(rules)); - const { id, value, initialValue, meta, setState, errors, flags } = useFieldState(name, { + const { id, value, initialValue, meta, setState, errors, flags } = useFieldState(path, { modelValue, form, bails, @@ -325,7 +325,11 @@ function _useField( return; } - meta.validated ? validateWithStateMutation() : validateValidStateOnly(); + if (meta.validated) { + validateWithStateMutation(); + } else { + validateValidStateOnly(); + } }, { deep: true, @@ -399,7 +403,11 @@ function _useField( const shouldValidate = !isEqual(deps, oldDeps); if (shouldValidate) { - meta.validated ? validateWithStateMutation() : validateValidStateOnly(); + if (meta.validated) { + validateWithStateMutation(); + } else { + validateValidStateOnly(); + } } }); diff --git a/packages/vee-validate/src/useFieldState.ts b/packages/vee-validate/src/useFieldState.ts index 50ce1b2fa..50f693b89 100644 --- a/packages/vee-validate/src/useFieldState.ts +++ b/packages/vee-validate/src/useFieldState.ts @@ -1,6 +1,6 @@ import { computed, isRef, reactive, ref, Ref, unref, watch, MaybeRef, MaybeRefOrGetter, toValue } from 'vue'; import { FieldMeta, FieldState, FieldValidator, InputType, PrivateFormContext, PathState, TypedSchema } from './types'; -import { getFromPath, isEqual, normalizeErrorItem } from './utils'; +import { getFromPath, isEqual, normalizeErrorItem, serializePath } from './utils'; export interface StateSetterInit extends FieldState { initialValue: TValue; @@ -8,7 +8,7 @@ export interface StateSetterInit extends FieldState { export interface FieldStateComposable { id: number; - path: MaybeRef; + path: MaybeRefOrGetter | string>; meta: FieldMeta; value: Ref; flags: PathState['__flags']; @@ -30,7 +30,7 @@ export interface StateInit { let ID_COUNTER = 0; export function useFieldState( - path: MaybeRef, + path: MaybeRefOrGetter | string>, init: Partial>, ): FieldStateComposable { const { value, initialValue, setInitialValue } = _useFieldValue(path, init.modelValue, init.form); @@ -86,11 +86,11 @@ export function useFieldState( } if ('errors' in state) { - init.form?.setFieldError(unref(path), state.errors); + init.form?.setFieldError(serializePath(toValue(path)), state.errors); } if ('touched' in state) { - init.form?.setFieldTouched(unref(path), state.touched ?? false); + init.form?.setFieldTouched(serializePath(toValue(path)), state.touched ?? false); } if ('initialValue' in state) { @@ -120,7 +120,7 @@ interface FieldValueComposable { * Creates the field value and resolves the initial value */ export function _useFieldValue( - path: MaybeRef, + path: MaybeRefOrGetter | string>, modelValue?: MaybeRef, form?: PrivateFormContext, ): FieldValueComposable { @@ -131,7 +131,7 @@ export function _useFieldValue( return unref(modelRef) as TValue; } - return getFromPath(form.initialValues.value, unref(path), unref(modelRef)) as TValue; + return getFromPath(form.initialValues.value, toValue(path), unref(modelRef)) as TValue; } function setInitialValue(value: TValue) { @@ -140,7 +140,7 @@ export function _useFieldValue( return; } - form.setFieldInitialValue(unref(path), value, true); + form.setFieldInitialValue(toValue(path), value, true); } const initialValue = computed(resolveInitialValue); @@ -161,14 +161,14 @@ export function _useFieldValue( // prioritize model value over form values // #3429 const currentValue = resolveModelValue(modelValue, form, initialValue, path); - form.stageInitialValue(unref(path), currentValue, true); + form.stageInitialValue(serializePath(toValue(path)), currentValue, true); // otherwise use a computed setter that triggers the `setFieldValue` const value = computed({ get() { - return getFromPath(form.values, unref(path)) as TValue; + return getFromPath(form.values, toValue(path)) as TValue; }, set(newVal) { - form.setFieldValue(unref(path), newVal, false); + form.setFieldValue(serializePath(toValue(path)), newVal, false); }, }) as Ref; @@ -189,7 +189,7 @@ function resolveModelValue( modelValue: MaybeRef | undefined, form: PrivateFormContext, initialValue: MaybeRef | undefined, - path: MaybeRef, + path: MaybeRefOrGetter | string>, ): TValue { if (isRef(modelValue)) { return unref(modelValue); @@ -199,7 +199,7 @@ function resolveModelValue( return modelValue; } - return getFromPath(form.values, unref(path), unref(initialValue)) as TValue; + return getFromPath(form.values, toValue(path), unref(initialValue)) as TValue; } /** diff --git a/packages/vee-validate/src/useForm.ts b/packages/vee-validate/src/useForm.ts index c133b1166..01a334e38 100644 --- a/packages/vee-validate/src/useForm.ts +++ b/packages/vee-validate/src/useForm.ts @@ -68,6 +68,7 @@ import { omit, debounceNextTick, normalizeEventValue, + serializePath, } from './utils'; import { FormContextKey, PublicFormContextKey } from './symbols'; import { validateTypedSchema, validateObjectSchema } from './validate'; @@ -271,11 +272,11 @@ export function useForm< const schema = opts?.validationSchema; function createPathState>( - path: MaybeRefOrGetter, + path: MaybeRefOrGetter | TPath>, config?: Partial>, ): PathState { const initialValue = computed(() => getFromPath(initialValues.value, toValue(path))); - const pathStateExists = pathStateLookup.value[toValue(path)]; + const pathStateExists = pathStateLookup.value[serializePath(toValue(path))]; const isCheckboxOrRadio = config?.type === 'checkbox' || config?.type === 'radio'; if (pathStateExists && isCheckboxOrRadio) { pathStateExists.multiple = true; @@ -343,12 +344,13 @@ export function useForm< }) as PathState; pathStates.value.push(state); - pathStateLookup.value[pathValue] = state; + const pathString = serializePath(pathValue); + pathStateLookup.value[pathString] = state; rebuildPathLookup(); - if (errors.value[pathValue] && !initialErrors[pathValue]) { + if (errors.value[pathString] && !initialErrors[pathString]) { nextTick(() => { - validateField(pathValue, { mode: 'silent' }); + validateField(pathString, { mode: 'silent' }); }); } @@ -357,7 +359,8 @@ export function useForm< watch(path, newPath => { rebuildPathLookup(); const nextValue = deepCopy(currentValue.value); - pathStateLookup.value[newPath] = state; + const pathString = serializePath(newPath); + pathStateLookup.value[pathString] = state; nextTick(() => { setInPath(formValues, newPath, nextValue); @@ -842,7 +845,12 @@ export function useForm< setFieldError(toValue(state.path) as Path, undefined); }); - opts?.force ? forceSetValues(newValues, false) : setValues(newValues, false); + if (opts?.force) { + forceSetValues(newValues, false); + } else { + setValues(newValues, false); + } + setErrors(resetState?.errors || {}); submitCount.value = resetState?.submitCount || 0; nextTick(() => { @@ -963,7 +971,7 @@ export function useForm< } } - function setFieldInitialValue(path: string, value: unknown, updateOriginal = false) { + function setFieldInitialValue(path: Array | string, value: unknown, updateOriginal = false) { setInPath(initialValues.value, path, deepCopy(value)); if (updateOriginal) { setInPath(originalInitialValues.value, path, deepCopy(value)); diff --git a/packages/vee-validate/src/utils/assertions.ts b/packages/vee-validate/src/utils/assertions.ts index 658ad9ba1..34bd2ba82 100644 --- a/packages/vee-validate/src/utils/assertions.ts +++ b/packages/vee-validate/src/utils/assertions.ts @@ -38,7 +38,9 @@ export function isEmptyContainer(value: unknown): boolean { /** * Checks if the path opted out of nested fields using `[fieldName]` syntax */ -export function isNotNestedPath(path: string) { +export function isNotNestedPath(path: Array | string): path is string { + if (Array.isArray(path)) return false; + return /^\[.+\]$/i.test(path); } diff --git a/packages/vee-validate/src/utils/common.ts b/packages/vee-validate/src/utils/common.ts index 33a977422..57242ea9c 100644 --- a/packages/vee-validate/src/utils/common.ts +++ b/packages/vee-validate/src/utils/common.ts @@ -30,15 +30,18 @@ type NestedRecord = Record | { [k: string]: NestedRecord }; /** * Gets a nested property value from an object */ -export function getFromPath(object: NestedRecord | undefined, path: string): TValue | undefined; +export function getFromPath( + object: NestedRecord | undefined, + path: Array | string, +): TValue | undefined; export function getFromPath( object: NestedRecord | undefined, - path: string, + path: Array | string, fallback?: TFallback, ): TValue | TFallback; export function getFromPath( object: NestedRecord | undefined, - path: string, + path: Array | string, fallback?: TFallback, ): TValue | TFallback | undefined { if (!object) { @@ -49,30 +52,90 @@ export function getFromPath( return object[cleanupNonNestedPath(path)] as TValue | undefined; } - const resolvedValue = (path || '') - .split(/\.|\[(\d+)\]/) - .filter(Boolean) - .reduce((acc, propKey) => { - if (isContainerValue(acc) && propKey in acc) { - return acc[propKey]; - } + const resolvedValue = getPathSegments(path).reduce((acc, propKey) => { + if (isContainerValue(acc) && propKey in acc) { + return acc[propKey]; + } - return fallback; - }, object as unknown); + return fallback; + }, object as unknown); return resolvedValue as TValue | undefined; } +export function getPathSegments( + path: (() => string) | Array | string | undefined | null, +): Array { + const segments: string[] = []; + + if (!path) return segments; + + if (Array.isArray(path)) return path; + + let isInsideBrackets = false; + + let segment = ''; + const pathString = typeof path === 'function' ? path() : path; + + for (const char of pathString) { + if (char === '[') { + segments.push(segment); + segment = ''; + isInsideBrackets = true; + } else if (char === ']' && isInsideBrackets) { + segments.push(segment); + segment = ''; + isInsideBrackets = false; + } else if (char === '.' && !isInsideBrackets) { + segments.push(segment); + segment = ''; + } else { + segment += char; + } + } + + segments.push(segment); + + if (isInsideBrackets) { + throw new Error('Invalid path syntax: ' + path); + } + + return segments.filter(Boolean); +} + +export function serializePath(path: Array | TPath): TPath { + if (!Array.isArray(path)) return path; + + const segments = path.map(segment => (typeof segment === 'number' || segment.includes('.') ? [segment] : segment)); + + let pathString = ''; + + segments.forEach(segment => { + const isArray = Array.isArray(segment); + const segmentString = isArray ? `[${segment[0]}]` : segment; + + if (pathString === '') { + pathString = segmentString; + } else if (isArray) { + pathString += segmentString; + } else { + pathString += `.${segmentString}`; + } + }); + + return pathString as TPath; +} + /** * Sets a nested property value in a path, creates the path properties if it doesn't exist */ -export function setInPath(object: NestedRecord, path: string, value: unknown): void { +export function setInPath(object: NestedRecord, path: Array | string, value: unknown): void { if (isNotNestedPath(path)) { object[cleanupNonNestedPath(path)] = value; return; } - const keys = path.split(/\.|\[(\d+)\]/).filter(Boolean); + const keys = getPathSegments(path); let acc: Record = object; for (let i = 0; i < keys.length; i++) { // Last key, set it @@ -111,7 +174,7 @@ export function unsetPath(object: NestedRecord, path: string): void { return; } - const keys = path.split(/\.|\[(\d+)\]/).filter(Boolean); + const keys = getPathSegments(path); let acc: Record = object; for (let i = 0; i < keys.length; i++) { // Last key, unset it @@ -172,7 +235,11 @@ export function resolveNextCheckboxValue(currentValue: T | T[], checkedValue: const newVal = [...currentValue]; // Use isEqual since checked object values can possibly fail the equality check #3883 const idx = newVal.findIndex(v => isEqual(v, checkedValue)); - idx >= 0 ? newVal.splice(idx, 1) : newVal.push(checkedValue); + if (idx >= 0) { + newVal.splice(idx, 1); + } else { + newVal.push(checkedValue); + } return newVal; } diff --git a/packages/vee-validate/tests/useField.spec.ts b/packages/vee-validate/tests/useField.spec.ts index e3156cad0..be4b34671 100644 --- a/packages/vee-validate/tests/useField.spec.ts +++ b/packages/vee-validate/tests/useField.spec.ts @@ -1060,4 +1060,142 @@ describe('useField()', () => { expect(form.values.field).toEqual('test'); }); + + test('it should handle brackets path syntax in path', async () => { + let form!: FormContext; + + mountWithHoc({ + setup() { + form = useForm({ + initialValues: { + user: { + 'full.name': 'user.full.name', + addresses: ['user.address[0]'], + 'first.name': 'user[first.name]', + 'deep.nested': { + 'field.0': 'user[deep.nested][field.0]', + 'field.1': ['user[deep.nested][field.1][0]'], + 'field.2': [ + { + key1: 'user[deep.nested][field.2].key1', + 'key.2': 'user[deep.nested][field.2][key.2]', + }, + ], + }, + }, + }, + }); + + return { form }; + }, + template: ` +
+ + + + + + + +
+ `, + components: { + CustomField: { + props: { + path: [Array, String], + }, + setup(props: any) { + const { value } = useField(() => props.path); + value.value = 'new value'; + + return { value }; + }, + template: '', + }, + }, + }); + + await flushPromises(); + + expect(form.values.user['full.name']).toEqual('new value'); + expect(form.values.user.addresses[0]).toEqual('new value'); + expect(form.values.user['first.name']).toEqual('new value'); + expect(form.values.user['deep.nested']['field.0']).toEqual('new value'); + expect(form.values.user['deep.nested']['field.1'][0]).toEqual('new value'); + expect(form.values.user['deep.nested']['field.2'][0].key1).toEqual('new value'); + expect(form.values.user['deep.nested']['field.2'][0]['key.2']).toEqual('new value'); + + expect(Object.keys(form.values)).toEqual(['user']); + expect(Object.keys(form.values.user)).toEqual(['full.name', 'addresses', 'first.name', 'deep.nested']); + expect(Object.keys(form.values.user['deep.nested'])).toEqual(['field.0', 'field.1', 'field.2']); + }); + + test('it should accept a path array', async () => { + let form!: FormContext; + + mountWithHoc({ + setup() { + form = useForm({ + initialValues: { + user: { + 'full.name': 'user.full.name', + addresses: ['user.address[0]'], + 'first.name': 'user[first.name]', + 'deep.nested': { + 'field.0': 'user[deep.nested][field.0]', + 'field.1': ['user[deep.nested][field.1][0]'], + 'field.2': [ + { + key1: 'user[deep.nested][field.2].key1', + 'key.2': 'user[deep.nested][field.2][key.2]', + }, + ], + }, + }, + }, + }); + + return { form }; + }, + template: ` +
+ + + + + + + +
+ `, + components: { + CustomField: { + props: { + path: [Array, String], + }, + setup(props: any) { + const { value } = useField(() => props.path); + value.value = 'new value'; + + return { value }; + }, + template: '', + }, + }, + }); + + await flushPromises(); + + expect(form.values.user['full.name']).toEqual('new value'); + expect(form.values.user.addresses[0]).toEqual('new value'); + expect(form.values.user['first.name']).toEqual('new value'); + expect(form.values.user['deep.nested']['field.0']).toEqual('new value'); + expect(form.values.user['deep.nested']['field.1'][0]).toEqual('new value'); + expect(form.values.user['deep.nested']['field.2'][0].key1).toEqual('new value'); + expect(form.values.user['deep.nested']['field.2'][0]['key.2']).toEqual('new value'); + + expect(Object.keys(form.values)).toEqual(['user']); + expect(Object.keys(form.values.user)).toEqual(['full.name', 'addresses', 'first.name', 'deep.nested']); + expect(Object.keys(form.values.user['deep.nested'])).toEqual(['field.0', 'field.1', 'field.2']); + }); }); diff --git a/packages/yup/src/index.ts b/packages/yup/src/index.ts index 6444c879f..4fbfe14f5 100644 --- a/packages/yup/src/index.ts +++ b/packages/yup/src/index.ts @@ -5,6 +5,7 @@ import { isNotNestedPath, cleanupNonNestedPath, TypedSchemaPathDescription, + getPathSegments, } from 'vee-validate'; import { PartialDeep } from 'type-fest'; import { isIndex, isObject, merge } from '../../shared'; @@ -116,7 +117,7 @@ function getSpecForPath(path: string, schema: Schema): AnyObjectSchema['spec'] | return (field as AnyObjectSchema)?.spec || null; } - const paths = (path || '').split(/\.|\[(\d+)\]/).filter(Boolean); + const paths = getPathSegments(path); let currentSchema = schema; for (let i = 0; i < paths.length; i++) { diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index 8b1a1501d..ca6e9fc2a 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -12,7 +12,13 @@ import { ZodFirstPartyTypeKind, } from 'zod'; import { PartialDeep } from 'type-fest'; -import { isNotNestedPath, type TypedSchema, type TypedSchemaError, cleanupNonNestedPath } from 'vee-validate'; +import { + isNotNestedPath, + type TypedSchema, + type TypedSchemaError, + cleanupNonNestedPath, + getPathSegments, +} from 'vee-validate'; import { isIndex, isObject, merge, normalizeFormPath } from '../../shared'; /** @@ -157,7 +163,7 @@ function getSchemaForPath(path: string, schema: ZodSchema): ZodSchema | null { return schema.shape[cleanupNonNestedPath(path)]; } - const paths = (path || '').split(/\.|\[(\d+)\]/).filter(Boolean); + const paths = getPathSegments(path); let currentSchema: ZodSchema = schema; for (let i = 0; i <= paths.length; i++) {