Skip to content
59 changes: 52 additions & 7 deletions packages/varden/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ComputedRef,
type Ref,
type DeepReadonly,
toRaw,
} from 'vue';

import { getIssuePath, type StandardSchemaV1 } from './standard-schema';
Expand Down Expand Up @@ -35,7 +36,7 @@ export interface FormContext<T> {
resetField<Path extends Paths<T>>(path: Path): void;
setValue<Path extends Paths<T>, Value extends Get<T, Path>>(
path: Path | CompiledPath,
value: Value,
value: Value | undefined,
): void;
getValue<Path extends Paths<T>, Value extends Get<T, Path>>(path: Path | CompiledPath): Value;
setTouched<Path extends Paths<T>>(path: Path, flag?: boolean): void;
Expand All @@ -50,8 +51,13 @@ export interface _FormContext<T> extends FormContext<T> {
__meta: Map<Paths<T>, 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<unknown>['equalsFn'] & {} = (a, b) => a === b;
let _equalsFn: FormProps<unknown>['equalsFn'] & {} = strictEqualsFn;
// eslint-disable-next-line no-underscore-dangle
let _cloneFn: FormProps<unknown>['cloneFn'] & {} = structuredClone;

Expand All @@ -61,7 +67,7 @@ export function defineVardenConfig(config: { equalsFn?: FormProps<unknown>['equa
}

export function resetVardenConfig() {
_equalsFn = (a, b) => a === b;
_equalsFn = strictEqualsFn;
_cloneFn = structuredClone;
}

Expand Down Expand Up @@ -185,24 +191,43 @@ export function useForm<T = object>(props: FormProps<T>): FormContext<T> {
resetField,
setValue<Path extends Paths<T>, Value extends Get<T, Path>>(
path: Path | CompiledPath,
value: Value,
value: Value | undefined,
) {
const compiledPath = Array.isArray(path) ? path : toCompiledPath(path);
const stringPath = typeof path === 'string' ? path : compiledPath.join('.');

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<Path extends Paths<T>, Value extends Get<T, Path>>(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 extends Paths<T>>(path: Path, flag = true) {
fields.get(path)!.touched = flag;
Expand All @@ -220,7 +245,27 @@ export function useForm<T = object>(props: FormProps<T>): FormContext<T> {
onSubmit(cloneFn(currentValues.value));
},
isDirty<Path extends Paths<T>>(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 extends Paths<T>>(path: Path): boolean {
return fields.get(path)?.touched ?? false;
Expand Down
12 changes: 11 additions & 1 deletion packages/varden/src/path.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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);
});
});
7 changes: 5 additions & 2 deletions packages/varden/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
176 changes: 176 additions & 0 deletions packages/varden/tests/field-value.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading