diff --git a/packages/varden/src/lib.ts b/packages/varden/src/lib.ts index a70bd3e..85efe34 100644 --- a/packages/varden/src/lib.ts +++ b/packages/varden/src/lib.ts @@ -6,6 +6,7 @@ import { type ComputedRef, type Ref, type DeepReadonly, + toRaw, } from 'vue'; import { getIssuePath, type StandardSchemaV1 } from './standard-schema'; @@ -35,7 +36,7 @@ export interface FormContext { resetField>(path: Path): void; setValue, Value extends Get>( path: Path | CompiledPath, - value: Value, + value: Value | undefined, ): void; getValue, Value extends Get>(path: Path | CompiledPath): Value; setTouched>(path: Path, flag?: boolean): void; @@ -50,8 +51,13 @@ export interface _FormContext extends FormContext { __meta: Map, FieldMeta>; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function strictEqualsFn(a: any, b: any): boolean { + return a === b; +} + // eslint-disable-next-line no-underscore-dangle -let _equalsFn: FormProps['equalsFn'] & {} = (a, b) => a === b; +let _equalsFn: FormProps['equalsFn'] & {} = strictEqualsFn; // eslint-disable-next-line no-underscore-dangle let _cloneFn: FormProps['cloneFn'] & {} = structuredClone; @@ -61,7 +67,7 @@ export function defineVardenConfig(config: { equalsFn?: FormProps['equa } export function resetVardenConfig() { - _equalsFn = (a, b) => a === b; + _equalsFn = strictEqualsFn; _cloneFn = structuredClone; } @@ -185,7 +191,7 @@ export function useForm(props: FormProps): FormContext { resetField, setValue, Value extends Get>( path: Path | CompiledPath, - value: Value, + value: Value | undefined, ) { const compiledPath = Array.isArray(path) ? path : toCompiledPath(path); const stringPath = typeof path === 'string' ? path : compiledPath.join('.'); @@ -193,16 +199,35 @@ export function useForm(props: FormProps): FormContext { set(currentValues.value, compiledPath, cloneFn(value)); const meta = fields.get(stringPath); - const isDirty = !equalsFn(get(initialValues, compiledPath), value); + const isDirty = !equalsFn(get(initialValues, compiledPath, Empty), value); if (meta) { meta.dirty = isDirty; } else { fields.set(stringPath, createFieldMeta(false, isDirty, '', 0)); } + + // cleanup child fields + for (const [nestedPath, nestedMeta] of fields) { + if (nestedPath.startsWith(`${stringPath}.`)) { + if (nestedMeta !== undefined) { + if (nestedMeta.refCount === 0) { + fields.delete(nestedPath); + } else { + const compiledNestedPath = toCompiledPath(nestedPath); + nestedMeta.dirty = !equalsFn( + get(initialValues, compiledNestedPath), + get(currentValues.value, compiledNestedPath), + ); + } + } + } + } + applyValidation(); }, getValue, Value extends Get>(path: Path | CompiledPath): Value { - return get(currentValues.value, Array.isArray(path) ? path : toCompiledPath(path)); + const a = get(currentValues.value, Array.isArray(path) ? path : toCompiledPath(path)); + return cloneFn(toRaw(a)); }, setTouched>(path: Path, flag = true) { fields.get(path)!.touched = flag; @@ -220,7 +245,27 @@ export function useForm(props: FormProps): FormContext { onSubmit(cloneFn(currentValues.value)); }, isDirty>(path: Path): boolean { - return fields.get(path)?.dirty ?? false; + // TODO: consider shipping dequal for deep equality + // this case is only possible if equalsFn is not strict equality + if (equalsFn !== strictEqualsFn) { + const compiledPath = toCompiledPath(path); + return !equalsFn( + get(currentValues.value, compiledPath, Empty), + get(initialValues, compiledPath, Empty), + ); + } + + for (const [field, meta] of fields) { + if (field === path) return meta.dirty; + if (field.startsWith(`${path}.`) && meta.dirty === true) return true; + } + + // TODO: should be tracked after first check? + const compiledPath = toCompiledPath(path); + return !equalsFn( + get(currentValues.value, compiledPath, Empty), + get(initialValues, compiledPath, Empty), + ); }, isTouched>(path: Path): boolean { return fields.get(path)?.touched ?? false; diff --git a/packages/varden/src/path.spec.ts b/packages/varden/src/path.spec.ts index 9b92fb1..44663f1 100644 --- a/packages/varden/src/path.spec.ts +++ b/packages/varden/src/path.spec.ts @@ -1,7 +1,7 @@ import { expect, it, describe } from 'vitest'; import { - del, get, isArrayIndex, set, toCompiledPath, + del, Empty, get, isArrayIndex, set, toCompiledPath, } from './path'; describe('path utilities', () => { @@ -34,6 +34,11 @@ describe('path utilities', () => { expect(get({ foo: { bar: '' } }, toCompiledPath('foo.bar'), 'TEST')).toBe(''); }); + it('path is longer than object', () => { + expect(get({ foo: undefined }, toCompiledPath('foo.bar.baz'), 'TEST')).toBe('TEST'); + expect(get({ foo: undefined }, toCompiledPath('foo.bar'))).toBe(undefined); + }); + it('should delete value by path', () => { const value = { foo: { bar: 2, baz: 3 } }; del(value, toCompiledPath('foo.bar')); @@ -69,4 +74,9 @@ describe('path utilities', () => { // @ts-expect-error test case expect(test.foo.bar[0].baz).toBe(4); }); + + it('should return Empty for nested path under undefined field', () => { + const test = { user: undefined }; + expect(get(test, toCompiledPath('user.name'), Empty)).toBe(Empty); + }); }); diff --git a/packages/varden/src/path.ts b/packages/varden/src/path.ts index 7737498..49f6396 100644 --- a/packages/varden/src/path.ts +++ b/packages/varden/src/path.ts @@ -45,11 +45,14 @@ export function get( if (typeof object === 'object' && object !== null && path[i]! in object) { object = object[path[i]!]; } else { - return object[path[i]!] ?? defaultValue; + // target path is longer than the object, return defaultValue + return object?.[path[i]!] ?? defaultValue; } } - return object[path[limit]!]; + // early return for null/undefined objects + if (!object) return defaultValue; + return path[limit]! in object ? object[path[limit]!] : defaultValue; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/varden/tests/field-value.spec.ts b/packages/varden/tests/field-value.spec.ts new file mode 100644 index 0000000..e4f48da --- /dev/null +++ b/packages/varden/tests/field-value.spec.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import * as v from 'valibot'; +import { dequal } from 'dequal'; + +import { useForm } from '../src/lib'; +import { useFieldValue } from '../src/composables'; + +const onSubmit = () => { }; + +describe('form.setValue plain', () => { + it('should set the value of a field', () => { + const form = useForm({ + onSubmit: () => { }, + schema: v.object({ name: v.string() }), + }); + + form.setValue('name', 'foo'); + expect(form.values.value.name).toBe('foo'); + expect(form.isDirty('name')).toBe(true); + }); + + it('should treat undefined and missing field as dirty (shallow)', () => { + const form = useForm({ + onSubmit, + schema: v.object({ foo: v.string() }), + }); + + form.setValue('foo', undefined); + expect(form.values.value?.foo).toBe(undefined); + expect(form.isDirty('foo')).toBe(true); + }); + + it('should treat undefined and missing field as dirty (deep)', () => { + const form = useForm({ + onSubmit, + schema: v.object({ foo: v.object({ bar: v.string() }) }), + }); + + form.setValue('foo.bar', undefined); + expect(form.values.value?.foo?.bar).toBe(undefined); + expect(form.isDirty('foo.bar')).toBe(true); + }); +}); + +describe('form.setValue object', () => { + it('should set the value of an object field', () => { + const form = useForm({ + onSubmit: () => { }, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + form.setValue('user', { name: 'foo' }); + expect(form.values.value?.user).toEqual({ name: 'foo' }); + expect(form.isDirty('user')).toBe(true); + }); + + it('should set copied value of an object field', () => { + const form = useForm({ + onSubmit: () => { }, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + const value = { name: 'foo' }; + form.setValue('user', value); + value.name = 'bar'; + + expect(form.values.value?.user).toEqual({ name: 'foo' }); + expect(form.isDirty('user')).toBe(true); + }); + + it('should reset child metadata', () => { + const form = useForm({ + onSubmit: () => { }, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + form.setValue('user.name', 'foo'); + form.setValue('user', undefined); + + expect(form.values.value?.user?.name).toBeUndefined(); + expect(form.isDirty('user.name')).toBe(false); + }); + + it('should keep child metadata for registered fields', () => { + const form = useForm({ + onSubmit: () => { }, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + const name = useFieldValue(form, 'user.name'); + name.value = 'foo'; + form.setTouched('user.name'); + form.setValue('user', undefined); + + expect(form.values.value?.user?.name).toBeUndefined(); + expect(form.isDirty('user.name')).toBe(false); + expect(form.isTouched('user.name')).toBe(true); + }); + + it('should mark child as not dirty when parent is set to initial values', () => { + const form = useForm({ + onSubmit: () => { }, + initial: { user: { name: 'initial' } }, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + const name = useFieldValue(form, 'user.name'); + name.value = 'changed'; + expect(form.isDirty('user.name')).toBe(true); + + form.setValue('user', { name: 'initial' }); + + expect(form.values.value?.user?.name).toBe('initial'); + expect(form.isDirty('user.name')).toBe(false); + }); + + it('should mark child as dirty when parent is set to different values', () => { + const form = useForm({ + onSubmit: () => { }, + initial: { user: { name: 'initial' } }, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + useFieldValue(form, 'user.name'); + expect(form.isDirty('user.name')).toBe(false); + + form.setValue('user', { name: 'changed' }); + + expect(form.values.value?.user?.name).toBe('changed'); + expect(form.isDirty('user.name')).toBe(true); + }); + + it('should treat untracked parent as dirty if child is dirty', () => { + const form = useForm({ + onSubmit, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + form.setValue('user.name', 'test'); + expect(form.isDirty('user')).toBe(true); + }); + + it('should treat child as dirty when parent affects child field state', () => { + const form = useForm({ + onSubmit, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + form.setValue('user', { name: 'Jack' }); + expect(form.isDirty('user.name')).toBe(true); + }); + + it('should mark parent dirty if child is changed back to original value', () => { + const form = useForm({ + onSubmit, + initial: { user: { name: 'initial' } }, + equalsFn: dequal, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + form.setValue('user', { name: 'changed' }); + form.setValue('user.name', 'initial'); + expect(form.isDirty('user')).toBe(false); + }); + + it('should return a safe copy of the value', () => { + const form = useForm({ + onSubmit, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + form.setValue('user', { name: 'initial' }); + const a = form.getValue('user'); + a.name = 'changed'; + expect(form.values.value.user?.name).toBe('initial'); + }); +});