Skip to content

Commit

Permalink
feat(form): Native validation tracking and syncing (#41)
Browse files Browse the repository at this point in the history
* feat: basic validity state tracking by form

* fix: return empty array if no errors and clone retrieved errors

* fix: touched state pathless form

* test: add form validation tests

* fix: always show errors on mounted with native errors

* feat: added displayError utility

* feat: allow display error to recieve custom message externally

* feat: sync errors to native input validity
  • Loading branch information
logaretm authored Aug 3, 2024
1 parent fe9710f commit b062ee5
Show file tree
Hide file tree
Showing 22 changed files with 466 additions and 81 deletions.
28 changes: 25 additions & 3 deletions packages/core/src/form/formContext.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DisabledSchema, FormObject, Path, PathValue, TouchedSchema } from '../types';
import { cloneDeep, merge } from '../utils/common';
import { escapePath, getFromPath, isPathSet, setInPath, unsetPath as unsetInObject } from '../utils/path';
import { Arrayable, DisabledSchema, FormObject, Path, PathValue, TouchedSchema, ValiditySchema } from '../types';
import { cloneDeep, merge, normalizeArrayable } from '../utils/common';
import { escapePath, findLeaf, getFromPath, isPathSet, setInPath, unsetPath as unsetInObject } from '../utils/path';
import { FormSnapshot } from './formSnapshot';

export interface FormContext<TForm extends FormObject = FormObject> {
Expand All @@ -18,6 +18,9 @@ export interface FormContext<TForm extends FormObject = FormObject> {
setInitialValues: (newValues: Partial<TForm>, opts?: SetValueOptions) => void;
setInitialTouched: (newTouched: Partial<TouchedSchema<TForm>>, opts?: SetValueOptions) => void;
setFieldDisabled<TPath extends Path<TForm>>(path: TPath, value: boolean): void;
getFieldErrors<TPath extends Path<TForm>>(path: TPath): string[];
setFieldErrors<TPath extends Path<TForm>>(path: TPath, message: Arrayable<string>): void;
hasErrors: () => boolean;
getValues: () => TForm;
setValues: (newValues: Partial<TForm>, opts?: SetValueOptions) => void;
revertValues: () => void;
Expand All @@ -33,6 +36,7 @@ export interface FormContextCreateOptions<TForm extends FormObject = FormObject>
values: TForm;
touched: TouchedSchema<TForm>;
disabled: DisabledSchema<TForm>;
errors: ValiditySchema<TForm>;
snapshots: {
values: FormSnapshot<TForm>;
touched: FormSnapshot<TouchedSchema<TForm>>;
Expand All @@ -43,6 +47,7 @@ export function createFormContext<TForm extends FormObject = FormObject>({
id,
values,
disabled,
errors,
touched,
snapshots,
}: FormContextCreateOptions<TForm>): FormContext<TForm> {
Expand Down Expand Up @@ -70,12 +75,14 @@ export function createFormContext<TForm extends FormObject = FormObject>({
unsetInObject(values, path, true);
unsetInObject(touched, path, true);
unsetInObject(disabled, escapePath(path), true);
unsetInObject(errors, escapePath(path), true);
}

function unsetPath<TPath extends Path<TForm>>(path: TPath) {
unsetInObject(values, path, false);
unsetInObject(touched, path, false);
unsetInObject(disabled, escapePath(path), false);
unsetInObject(errors, escapePath(path), false);
}

function getFieldInitialValue<TPath extends Path<TForm>>(path: TPath) {
Expand All @@ -94,6 +101,10 @@ export function createFormContext<TForm extends FormObject = FormObject>({
setInPath(disabled, escapePath(path), value);
}

function hasErrors() {
return !!findLeaf(errors, l => Array.isArray(l) && l.length > 0);
}

function setInitialValues(newValues: Partial<TForm>, opts?: SetValueOptions) {
if (opts?.mode === 'merge') {
snapshots.values.initials.value = merge(cloneDeep(snapshots.values.initials.value), cloneDeep(newValues));
Expand Down Expand Up @@ -140,6 +151,14 @@ export function createFormContext<TForm extends FormObject = FormObject>({
});
}

function getFieldErrors<TPath extends Path<TForm>>(path: TPath) {
return [...(getFromPath<string[]>(errors, escapePath(path), []) || [])];
}

function setFieldErrors<TPath extends Path<TForm>>(path: TPath, message: Arrayable<string>) {
setInPath(errors, escapePath(path), message ? normalizeArrayable(message) : []);
}

function setTouched(newTouched: Partial<TouchedSchema<TForm>>, opts?: SetValueOptions) {
if (opts?.mode === 'merge') {
merge(touched, newTouched);
Expand Down Expand Up @@ -182,5 +201,8 @@ export function createFormContext<TForm extends FormObject = FormObject>({
setInitialTouched,
getFieldOriginalValue,
setFieldDisabled,
setFieldErrors,
getFieldErrors,
hasErrors,
};
}
39 changes: 39 additions & 0 deletions packages/core/src/form/useErrorDisplay.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { renderSetup } from '@test-utils/index';
import { useErrorDisplay } from './useErrorDisplay';
import { useFormField } from './useFormField';

test('displays field errors only if they are touched', async () => {
const { setErrors, isValid, errorMessage, displayError, setTouched } = await renderSetup(() => {
const field = useFormField({ initialValue: 'bar' });
const { displayError } = useErrorDisplay(field);

return { ...field, displayError };
});

expect(isValid.value).toBe(true);
expect(errorMessage.value).toBe('');
expect(displayError()).toBe('');

setErrors('error');
expect(errorMessage.value).toBe('error');
expect(displayError()).toBe('');
expect(isValid.value).toBe(false);

setTouched(true);
expect(displayError()).toBe('error');
});

test('controls display of custom messages as well', async () => {
const { isValid, displayError, setTouched } = await renderSetup(() => {
const field = useFormField({ initialValue: 'bar' });
const { displayError } = useErrorDisplay(field);

return { ...field, displayError };
});

expect(displayError('custom error')).toBe('');
expect(isValid.value).toBe(true);

setTouched(true);
expect(displayError('custom error')).toBe('custom error');
});
11 changes: 11 additions & 0 deletions packages/core/src/form/useErrorDisplay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { FormField } from './useFormField';

export function useErrorDisplay(field: FormField<any>) {

Check warning on line 3 in packages/core/src/form/useErrorDisplay.ts

View workflow job for this annotation

GitHub Actions / ts-lint-test

Unexpected any. Specify a different type
function displayError(msg?: string) {
const error = msg || field.errorMessage.value;

return field.isTouched.value ? error : '';
}

return { displayError };
}
98 changes: 97 additions & 1 deletion packages/core/src/form/useForm.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { renderSetup } from '@test-utils/index';
import { useForm } from './useForm';
import { useFormField } from './useFormField';
import { nextTick, ref } from 'vue';
import { nextTick, Ref, ref } from 'vue';
import { useInputValidity } from '../validation/useInputValidity';
import { fireEvent, render, screen } from '@testing-library/vue';

describe('form values', () => {
test('it initializes form values', async () => {
Expand Down Expand Up @@ -309,3 +311,97 @@ describe('form dirty state', () => {
expect(field.isDirty.value).toBe(false);
});
});

describe('validation', () => {
function createInputComponent(inputRef: Ref<HTMLInputElement | undefined>) {
return {
setup: () => {
const field = useFormField({ path: 'test' });
useInputValidity({ inputRef, field });

return { input: inputRef, errorMessage: field.errorMessage };
},
template: `
<input ref="input" data-testid="input" required />
<span data-testid="err">{{ errorMessage }}</span>
`,
};
}

test('updates the form errors', async () => {
const input = ref<HTMLInputElement>();

await render({
components: { Child: createInputComponent(input) },
setup() {
const { getError } = useForm();

return { getError };
},
template: `
<form>
<Child />
<span data-testid="form-err">{{ getError('test') }}</span>
</form>
`,
});

await fireEvent.blur(screen.getByTestId('input'));
expect(screen.getByTestId('err').textContent).toBe('Constraints not satisfied');
expect(screen.getByTestId('form-err').textContent).toBe('Constraints not satisfied');
});

test('updates the form isValid', async () => {
const input = ref<HTMLInputElement>();

await render({
components: { Child: createInputComponent(input) },
setup() {
const { isValid } = useForm();

return { isValid };
},
template: `
<form>
<Child />
<span v-if="isValid">Form is valid</span>
<span v-else>Form is invalid</span>
</form>
`,
});

expect(screen.getByText('Form is valid')).toBeDefined();
await fireEvent.blur(screen.getByTestId('input'));
expect(screen.getByText('Form is invalid')).toBeDefined();
});

test('prevents submission if the form is not valid', async () => {
const input = ref<HTMLInputElement>();
const handler = vi.fn();

await render({
components: { Child: createInputComponent(input) },
setup() {
const { handleSubmit } = useForm();

return { onSubmit: handleSubmit(handler) };
},
template: `
<form @submit="onSubmit" novalidate>
<Child />
<button type="submit">Submit</button>
</form>
`,
});

await nextTick();
await fireEvent.click(screen.getByText('Submit'));
expect(handler).not.toHaveBeenCalled();
await fireEvent.change(screen.getByTestId('input'), { target: { value: 'test' } });
await fireEvent.click(screen.getByText('Submit'));
expect(handler).toHaveBeenCalledOnce();
});
});
33 changes: 23 additions & 10 deletions packages/core/src/form/useForm.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { computed, InjectionKey, provide, reactive, readonly } from 'vue';
import { cloneDeep, isEqual, useUniqId } from '../utils/common';
import { FormObject, MaybeAsync, MaybeGetter, TouchedSchema, Path } from '../types';
import { FormObject, MaybeAsync, MaybeGetter, TouchedSchema, DisabledSchema, ValiditySchema, Path } from '../types';
import { createFormContext, FormContext } from './formContext';
import { FormTransactionManager, useFormTransactions } from './useFormTransactions';
import { useFormActions } from './useFormActions';
Expand Down Expand Up @@ -29,34 +29,44 @@ export function useForm<TForm extends FormObject = FormObject>(opts?: Partial<Fo

const values = reactive(cloneDeep(valuesSnapshot.originals.value)) as TForm;
const touched = reactive(cloneDeep(touchedSnapshot.originals.value)) as TouchedSchema<TForm>;
const disabled = {} as Partial<Record<Path<TForm>, boolean>>;

const isTouched = computed(() => {
return !!findLeaf(touched, l => l === true);
});

const isDirty = computed(() => {
return !isEqual(values, valuesSnapshot.originals.value);
});
const disabled = {} as DisabledSchema<TForm>;
const errors = reactive({}) as ValiditySchema<TForm>;

const ctx = createFormContext({
id: opts?.id || useUniqId('form'),
values,
touched,
disabled,
errors,
snapshots: {
values: valuesSnapshot,
touched: touchedSnapshot,
},
});

const isTouched = computed(() => {
return !!findLeaf(touched, l => l === true);
});

const isDirty = computed(() => {
return !isEqual(values, valuesSnapshot.originals.value);
});

const isValid = computed(() => {
return !ctx.hasErrors();
});

function onAsyncInit(v: TForm) {
ctx.setValues(v, { mode: 'merge' });
}

const transactionsManager = useFormTransactions(ctx);
const { actions, onSubmitted, isSubmitting } = useFormActions(ctx, disabled);

function getError<TPath extends Path<TForm>>(path: TPath): string | undefined {
return ctx.getFieldErrors(path)[0];
}

provide(FormKey, {
...ctx,
...transactionsManager,
Expand All @@ -69,11 +79,14 @@ export function useForm<TForm extends FormObject = FormObject>(opts?: Partial<Fo
isSubmitting,
isTouched,
isDirty,
isValid,
setFieldValue: ctx.setFieldValue,
getFieldValue: ctx.getFieldValue,
isFieldTouched: ctx.isFieldTouched,
setFieldTouched: ctx.setFieldTouched,
setFieldErrors: ctx.setFieldErrors,
setValues: ctx.setValues,
getError,
...actions,
};
}
8 changes: 8 additions & 0 deletions packages/core/src/form/useFormActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export function useFormActions<TForm extends FormObject = FormObject>(
e.preventDefault();
isSubmitting.value = true;
await dispatchSubmit();

// Prevent submission if the form has errors
if (form.hasErrors()) {
isSubmitting.value = false;

return;
}

// Clone the values to prevent mutation or reactive leaks
const values = cloneDeep(form.getValues());
const disabledPaths = Object.entries(disabled)
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/form/useFormField.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,18 @@ test('formless fields maintain their own dirty state', async () => {
setValue('bar');
expect(isDirty.value).toBe(false);
});

test('formless fields maintain their own error state', async () => {
const { setErrors, isValid, errorMessage, errors } = await renderSetup(() => {
return useFormField({ initialValue: 'bar' });
});

expect(isValid.value).toBe(true);
expect(errorMessage.value).toBe('');
expect(errors.value).toEqual([]);
setErrors('error');

expect(isValid.value).toBe(false);
expect(errorMessage.value).toBe('error');
expect(errors.value).toEqual(['error']);
});
Loading

0 comments on commit b062ee5

Please sign in to comment.