Skip to content

Commit

Permalink
fix: manually track the dirty state (#128)
Browse files Browse the repository at this point in the history
* fix: manually track the dirty state

* fix: properly track dirty around field mutations

* chore: add changeset
  • Loading branch information
logaretm authored Feb 7, 2025
1 parent e873ea0 commit 3c955de
Show file tree
Hide file tree
Showing 10 changed files with 131 additions and 33 deletions.
5 changes: 5 additions & 0 deletions .changeset/giant-cooks-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@formwerk/core': patch
---

fix: track dirty state manually
2 changes: 2 additions & 0 deletions packages/core/src/types/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export type FormSchema<TInput extends FormObject = FormObject, TOutput = TInput>

export type TouchedSchema<TForm extends FormObject> = Simplify<Schema<TForm, boolean>>;

export type DirtySchema<TForm extends FormObject> = Simplify<Schema<TForm, boolean>>;

export type DisabledSchema<TForm extends FormObject> = Partial<Record<Path<TForm>, boolean>>;

export type ErrorsSchema<TForm extends FormObject> = Partial<Record<Path<TForm>, string[]>>;
Expand Down
53 changes: 51 additions & 2 deletions packages/core/src/useForm/formContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
StandardSchema,
ErrorsSchema,
IssueCollection,
DirtySchema,
} from '../types';
import { cloneDeep, isEqual, normalizeArrayable } from '../utils/common';
import {
Expand All @@ -35,6 +36,8 @@ export interface BaseFormContext<TForm extends FormObject = FormObject> {
setTouched<TPath extends Path<TForm>>(path: TPath, value: boolean): void;
isTouched<TPath extends Path<TForm>>(path?: TPath): boolean;
isDirty<TPath extends Path<TForm>>(path?: TPath): boolean;
setDirty(value: boolean): void;
setDirty<TPath extends Path<TForm>>(path: TPath, value: boolean): void;
isFieldSet<TPath extends Path<TForm>>(path: TPath): boolean;
getFieldInitialValue<TPath extends Path<TForm>>(path: TPath): PathValue<TForm, TPath>;
getFieldOriginalValue<TPath extends Path<TForm>>(path: TPath): PathValue<TForm, TPath>;
Expand All @@ -54,6 +57,7 @@ export interface BaseFormContext<TForm extends FormObject = FormObject> {
setValues: (newValues: Partial<TForm>, opts?: SetValueOptions) => void;
revertValues: () => void;
revertTouched: () => void;
revertDirty: () => void;
isPathDisabled: (path: Path<TForm>) => boolean;
}

Expand All @@ -65,13 +69,15 @@ export interface FormContextCreateOptions<TForm extends FormObject = FormObject,
id: string;
values: TForm;
touched: TouchedSchema<TForm>;
dirty: DirtySchema<TForm>;
disabled: DisabledSchema<TForm>;
errors: Ref<ErrorsSchema<TForm>>;
submitErrors: Ref<ErrorsSchema<TForm>>;
schema: StandardSchema<TForm, TOutput> | undefined;
snapshots: {
values: FormSnapshot<TForm>;
touched: FormSnapshot<TouchedSchema<TForm>>;
dirty: FormSnapshot<DirtySchema<TForm>>;
};
}

Expand All @@ -80,13 +86,16 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
values,
disabled,
errors,
dirty,
submitErrors,
schema,
touched,
snapshots,
}: FormContextCreateOptions<TForm, TOutput>): BaseFormContext<TForm> {
function setValue<TPath extends Path<TForm>>(path: TPath, value: PathValue<TForm, TPath> | undefined) {
setInPath(values, path, cloneDeep(value));
const oldValue = getFieldOriginalValue(path);
setDirty(path, !isEqual(oldValue, value));
}

function setTouched(value: boolean): void;
Expand Down Expand Up @@ -123,10 +132,29 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput

function isDirty<TPath extends Path<TForm>>(path?: TPath) {
if (!path) {
return !isEqual(values, snapshots.values.originals.value);
return !!findLeaf(dirty, l => l === true);
}

return !isEqual(getValue(path), getFieldOriginalValue(path));
const value = getFromPath(dirty, path);
if (isObject(value)) {
return !!findLeaf(value, v => !!v);
}

return !!value;
}

function setDirty(value: boolean): void;
function setDirty<TPath extends Path<TForm>>(path: TPath, value: boolean): void;
function setDirty<TPath extends Path<TForm>>(pathOrValue: TPath | boolean, valueOrUndefined?: boolean) {
if (typeof pathOrValue === 'boolean') {
for (const key in dirty) {
setInPath(dirty, key, pathOrValue, true);
}

return;
}

setInPath(dirty, pathOrValue, valueOrUndefined, true);
}

function isFieldSet<TPath extends Path<TForm>>(path: TPath) {
Expand Down Expand Up @@ -267,6 +295,21 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
merge(touched, newTouched);
}

function updateDirty(newDirty: Partial<DirtySchema<TForm>>, opts?: SetValueOptions) {
if (opts?.behavior === 'merge') {
merge(dirty, newDirty);

return;
}

// Delete all keys, then set new values
Object.keys(dirty).forEach(key => {
delete dirty[key as keyof typeof dirty];
});

merge(dirty, newDirty);
}

function clearErrors(path?: string) {
if (!path) {
errors.value = {} as ErrorsSchema<TForm>;
Expand Down Expand Up @@ -301,6 +344,10 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
updateTouched(cloneDeep(snapshots.touched.originals.value), { behavior: 'replace' });
}

function revertDirty() {
updateDirty(cloneDeep(snapshots.dirty.originals.value), { behavior: 'replace' });
}

function getValidationMode(): FormValidationMode {
return schema ? 'schema' : 'aggregate';
}
Expand All @@ -314,6 +361,7 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
getValue,
isTouched,
isDirty,
setDirty,
isFieldSet,
destroyPath,
unsetPath,
Expand All @@ -322,6 +370,7 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
revertValues,
revertTouched,
setInitialValues,
revertDirty,
setInitialTouched,
getFieldOriginalValue,
setFieldDisabled,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/useForm/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
GroupValidationResult,
GenericFormSchema,
StandardSchema,
DirtySchema,
} from '../types';
import { createFormContext, BaseFormContext } from './formContext';
import { FormTransactionManager, useFormTransactions } from './useFormTransactions';
Expand Down Expand Up @@ -44,6 +45,11 @@ export interface FormProps<
*/
initialTouched?: TouchedSchema<TInput>;

/**
* The initial dirty state for form fields.
*/
initialDirty?: DirtySchema<TInput>;

/**
* The validation schema for the form.
*/
Expand Down Expand Up @@ -90,6 +96,7 @@ export function useForm<
TOutput extends FormObject = StandardSchemaV1.InferOutput<TSchema>,
>(props?: Partial<FormProps<TSchema, TInput>>) {
const touchedSnapshot = useFormSnapshots(props?.initialTouched);
const dirtySnapshot = useFormSnapshots(props?.initialDirty);
const valuesSnapshot = useFormSnapshots<TInput, TOutput>(props?.initialValues as TInput, {
onAsyncInit,
schema: props?.schema as StandardSchema<TInput, TOutput>,
Expand All @@ -100,6 +107,7 @@ export function useForm<
const isHtmlValidationDisabled = () => props?.disableHtmlValidation ?? getConfig().disableHtmlValidation;
const values = reactive(cloneDeep(valuesSnapshot.originals.value)) as PartialDeep<TInput>;
const touched = reactive(cloneDeep(touchedSnapshot.originals.value)) as TouchedSchema<TInput>;
const dirty = reactive(cloneDeep(dirtySnapshot.originals.value)) as DirtySchema<TInput>;
const disabled = reactive({}) as DisabledSchema<TInput>;
const errors = ref({}) as Ref<ErrorsSchema<TInput>>;
const submitErrors = ref({}) as Ref<ErrorsSchema<TInput>>;
Expand All @@ -109,12 +117,14 @@ export function useForm<
values: values as TInput,
touched,
disabled,
dirty,
schema: props?.schema as StandardSchema<TInput, TOutput>,
errors,
submitErrors,
snapshots: {
values: valuesSnapshot,
touched: touchedSnapshot,
dirty: dirtySnapshot,
},
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/useForm/useFormActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex

form.revertValues();
form.revertTouched();
form.revertDirty();
submitAttemptsCount.value = 0;
isSubmitAttempted.value = false;

Expand Down
22 changes: 13 additions & 9 deletions packages/core/src/useForm/useFormTransactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@ import { nextTick } from 'vue';
import { FormObject, Path, PathValue } from '../types';
import { BaseFormContext } from './formContext';

interface SetPathStateTransaction<TForm extends FormObject> {
kind: 2;
interface BaseStateTransaction<TForm extends FormObject> {
path: Path<TForm>;
value: PathValue<TForm, Path<TForm>>;
touched: boolean;
dirty: boolean;
disabled: boolean;
errors: string[];
}

interface SetPathStateTransaction<TForm extends FormObject> extends BaseStateTransaction<TForm> {
kind: 2;
}

interface UnsetPathStateTransaction<TForm extends FormObject> {
kind: 1;
path: Path<TForm>;
Expand All @@ -21,13 +25,8 @@ interface DestroyPathStateTransaction<TForm extends FormObject> {
path: Path<TForm>;
}

interface InitializeFieldTransaction<TForm extends FormObject> {
interface InitializeFieldTransaction<TForm extends FormObject> extends BaseStateTransaction<TForm> {
kind: 3;
path: Path<TForm>;
value: PathValue<TForm, Path<TForm>>;
touched: boolean;
disabled: boolean;
errors: string[];
}

export type FormTransaction<TForm extends FormObject> =
Expand All @@ -49,7 +48,10 @@ const TransactionKind = {
export interface FormTransactionManager<TForm extends FormObject> {
transaction(
tr: (
formCtx: Pick<BaseFormContext<TForm>, 'getValues' | 'getValue' | 'isFieldSet' | 'isTouched' | 'getErrors'>,
formCtx: Pick<
BaseFormContext<TForm>,
'getValues' | 'getValue' | 'isFieldSet' | 'isTouched' | 'getErrors' | 'isDirty'
>,
codes: typeof TransactionKind,
) => FormTransaction<TForm> | null,
): void;
Expand Down Expand Up @@ -89,6 +91,7 @@ export function useFormTransactions<TForm extends FormObject>(form: BaseFormCont
if (tr.kind === TransactionKind.SET_PATH) {
form.setValue(tr.path, tr.value);
form.setTouched(tr.path, tr.touched);
form.setDirty(tr.path, tr.dirty);
form.setFieldDisabled(tr.path, tr.disabled);
form.setErrors(tr.path, tr.errors);
continue;
Expand All @@ -109,6 +112,7 @@ export function useFormTransactions<TForm extends FormObject>(form: BaseFormCont
form.setValue(tr.path, tr.value ?? formInit);
form.setFieldDisabled(tr.path, tr.disabled);
form.setTouched(tr.path, tr.touched);
form.setDirty(tr.path, tr.dirty);
form.unsetInitialValue(tr.path);
form.setErrors(tr.path, tr.errors);
continue;
Expand Down
36 changes: 28 additions & 8 deletions packages/core/src/useFormField/useFormField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface FormFieldOptions<TValue = unknown> {
path: MaybeRefOrGetter<string | undefined> | undefined;
initialValue: TValue;
initialTouched: boolean;
initialDirty: boolean;
syncModel: boolean;
modelName: string;
disabled: MaybeRefOrGetter<boolean | undefined>;
Expand Down Expand Up @@ -138,7 +139,14 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
return field;
}

initFormPathIfNecessary(form, getPath, initialValue, opts?.initialTouched ?? false, isDisabled);
initFormPathIfNecessary({
form,
getPath,
initialValue,
initialTouched: opts?.initialTouched ?? false,
initialDirty: opts?.initialDirty ?? false,
isDisabled,
});

form.onSubmitAttempt(() => {
setTouched(true);
Expand Down Expand Up @@ -175,6 +183,7 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
path: newPath,
value: cloneDeep(oldPath ? tf.getValue(oldPath) : pathlessValue.value),
touched: oldPath ? tf.isTouched(oldPath) : pathlessTouched.value,
dirty: oldPath ? tf.isDirty(oldPath) : isDirty.value,
disabled: isDisabled.value,
errors: [...(oldPath ? tf.getErrors(oldPath) : pathlessValidity.errors.value)],
};
Expand Down Expand Up @@ -298,16 +307,26 @@ function createLocalValueRef<TValue = unknown>(initialValue?: TValue) {
};
}

interface FormPathInitOptions {
form: FormContext;
getPath: Getter<string | undefined>;
initialValue: unknown;
initialTouched: boolean;
initialDirty: boolean;
isDisabled: MaybeRefOrGetter<boolean>;
}

/**
* Sets the initial value of the form if not already set and if an initial value is provided.
*/
function initFormPathIfNecessary(
form: FormContext,
getPath: Getter<string | undefined>,
initialValue: unknown,
initialTouched: boolean,
isDisabled: MaybeRefOrGetter<boolean>,
) {
function initFormPathIfNecessary({
form,
getPath,
initialValue,
initialTouched,
initialDirty,
isDisabled,
}: FormPathInitOptions) {
const path = getPath();
if (!path) {
return;
Expand All @@ -320,6 +339,7 @@ function initFormPathIfNecessary(
path,
value: initialValue ?? form.getFieldInitialValue(path),
touched: initialTouched,
dirty: initialDirty,
disabled: toValue(isDisabled),
errors: [...tf.getErrors(path)],
}));
Expand Down
19 changes: 12 additions & 7 deletions packages/core/src/useFormGroup/useFormGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
StandardSchema,
ValidationResult,
} from '../types';
import { isEqual, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common';
import { normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common';
import { FormKey } from '../useForm';
import { useValidationProvider } from '../validation/useValidationProvider';
import { FormValidationMode } from '../useForm/formContext';
Expand Down Expand Up @@ -137,16 +137,16 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
}

function getValue(path?: string) {
return form?.getValue(prefixPath(path) ?? '');
if (!path) {
return form?.getValue(getPath()) ?? {};
}

return form?.getValue(prefixPath(path) || '');
}

const isValid = computed(() => getErrors().length === 0);
const isTouched = computed(() => form?.isTouched(getPath()) ?? false);
const isDirty = computed(() => {
const path = getPath();

return !isEqual(getValue(), form?.getFieldOriginalValue(path) ?? {});
});
const isDirty = computed(() => form?.isDirty(getPath()) ?? false);

function getError(path: string) {
return form?.getErrors(prefixPath(path) ?? '')?.[0];
Expand Down Expand Up @@ -236,6 +236,11 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
* Gets the group's value, passing in a path will return the value of that field.
*/
getValue,
/**
* Gets the values for the form group.
* @deprecated Use `getValue` without arguments instead.
*/
getValues: () => getValue(),
/**
* Gets the error for a given field.
*/
Expand Down
Loading

0 comments on commit 3c955de

Please sign in to comment.