Skip to content

Commit

Permalink
feat: submissions check schema first before defaulting to native
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 3, 2024
1 parent ce7116f commit c4f7f72
Show file tree
Hide file tree
Showing 6 changed files with 127 additions and 15 deletions.
27 changes: 23 additions & 4 deletions packages/core/src/form/formContext.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { Ref } from 'vue';
import { Arrayable, DisabledSchema, FormObject, Path, PathValue, TouchedSchema, ValiditySchema } from '../types';
import {
Arrayable,
DisabledSchema,
FormObject,
Path,
PathValue,
TouchedSchema,
TypedSchema,
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 type FormValidationMode = 'native' | 'schema';

export interface FormContext<TForm extends FormObject = FormObject> {
id: string;
getFieldValue<TPath extends Path<TForm>>(path: TPath): PathValue<TForm, TPath>;
Expand All @@ -21,6 +32,7 @@ export interface FormContext<TForm extends FormObject = FormObject> {
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;
getValidationMode(): FormValidationMode;
clearErrors: () => void;
hasErrors: () => boolean;
getValues: () => TForm;
Expand All @@ -33,26 +45,28 @@ export interface SetValueOptions {
mode: 'merge' | 'replace';
}

export interface FormContextCreateOptions<TForm extends FormObject = FormObject> {
export interface FormContextCreateOptions<TForm extends FormObject = FormObject, TOutput extends FormObject = TForm> {
id: string;
values: TForm;
touched: TouchedSchema<TForm>;
disabled: DisabledSchema<TForm>;
errors: Ref<ValiditySchema<TForm>>;
schema: TypedSchema<TForm, TOutput> | undefined;
snapshots: {
values: FormSnapshot<TForm>;
touched: FormSnapshot<TouchedSchema<TForm>>;
};
}

export function createFormContext<TForm extends FormObject = FormObject>({
export function createFormContext<TForm extends FormObject = FormObject, TOutput extends FormObject = TForm>({
id,
values,
disabled,
errors,
schema,
touched,
snapshots,
}: FormContextCreateOptions<TForm>): FormContext<TForm> {
}: FormContextCreateOptions<TForm, TOutput>): FormContext<TForm> {
function setFieldValue<TPath extends Path<TForm>>(path: TPath, value: PathValue<TForm, TPath> | undefined) {
setInPath(values, path, cloneDeep(value));
}
Expand Down Expand Up @@ -188,6 +202,10 @@ export function createFormContext<TForm extends FormObject = FormObject>({
setTouched(cloneDeep(snapshots.touched.originals.value), { mode: 'replace' });
}

function getValidationMode(): FormValidationMode {
return schema ? 'schema' : 'native';
}

return {
id,
getValues: () => cloneDeep(values),
Expand All @@ -211,5 +229,6 @@ export function createFormContext<TForm extends FormObject = FormObject>({
getFieldErrors,
hasErrors,
clearErrors,
getValidationMode,
};
}
98 changes: 94 additions & 4 deletions packages/core/src/form/useForm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ describe('form dirty state', () => {
});

describe('validation', () => {
function createInputComponent(inputRef: Ref<HTMLInputElement | undefined>, native = true) {
function createInputComponent(inputRef: Ref<HTMLInputElement | undefined>) {
return {
setup: () => {
const field = useFormField({ path: 'test' });
Expand All @@ -323,7 +323,7 @@ describe('validation', () => {
return { input: inputRef, errorMessage: field.errorMessage };
},
template: `
<input ref="input" data-testid="input" ${native ? 'required' : ''} />
<input ref="input" data-testid="input" required />
<span data-testid="err">{{ errorMessage }}</span>
`,
};
Expand Down Expand Up @@ -439,16 +439,17 @@ describe('validation', () => {
test('typed schema sets field errors', async () => {
const handler = vi.fn();
const input = ref<HTMLInputElement>();
let shouldError = true;
const schema: TypedSchema<object, object> = {
async parse() {
return {
errors: [{ path: 'test', errors: ['error'] }],
errors: shouldError ? [{ path: 'test', errors: ['error'] }] : [],
};
},
};

await render({
components: { Child: createInputComponent(input, false) },
components: { Child: createInputComponent(input) },
setup() {
const { handleSubmit, getError } = useForm({
schema,
Expand All @@ -470,5 +471,94 @@ describe('validation', () => {
await nextTick();
expect(screen.getByTestId('err').textContent).toBe('error');
expect(screen.getByTestId('form-err').textContent).toBe('error');
expect(handler).not.toHaveBeenCalled();
shouldError = false;
await fireEvent.click(screen.getByText('Submit'));
expect(handler).toHaveBeenCalledOnce();
});

test('type schema clears errors on successful submission', async () => {
const handler = vi.fn();
const input = ref<HTMLInputElement>();
const schema: TypedSchema<object, object> = {
async parse() {
return {
errors: [],
};
},
};

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

// @ts-expect-error - We don't care about our fake form here
setFieldErrors('test', 'error');

return { getError, onSubmit: handleSubmit(handler) };
},
template: `
<form @submit="onSubmit" novalidate>
<Child />
<span data-testid="form-err">{{ getError('test') }}</span>
<button type="submit">Submit</button>
</form>
`,
});

expect(screen.getByTestId('err').textContent).toBe('error');
expect(screen.getByTestId('form-err').textContent).toBe('error');
await fireEvent.click(screen.getByText('Submit'));
await nextTick();
expect(handler).toHaveBeenCalledOnce();
expect(screen.getByTestId('err').textContent).toBe('');
expect(screen.getByTestId('form-err').textContent).toBe('');
});

test('type schema parses values which is used on submission', async () => {
const handler = vi.fn();
const input = ref<HTMLInputElement>();
const schema: TypedSchema<object, { test: true; foo: string }> = {
async parse() {
return {
errors: [],
output: {
test: true,
foo: 'bar',
},
};
},
};

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

// @ts-expect-error - We don't care about our fake form here
setFieldErrors('test', 'error');

return { getError, onSubmit: handleSubmit(handler) };
},
template: `
<form @submit="onSubmit" novalidate>
<Child />
<span data-testid="form-err">{{ getError('test') }}</span>
<button type="submit">Submit</button>
</form>
`,
});

await fireEvent.click(screen.getByText('Submit'));
await nextTick();
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenLastCalledWith({ test: true, foo: 'bar' });
});
});
1 change: 1 addition & 0 deletions packages/core/src/form/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function useForm<TForm extends FormObject = FormObject, TOutput extends F
values,
touched,
disabled,
schema: opts?.schema,
errors,
snapshots: {
values: valuesSnapshot,
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/form/useFormActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
isSubmitting.value = true;
await dispatchSubmit();

// Prevent submission if the form has errors
if (form.hasErrors()) {
const validationMode = form.getValidationMode();
// Prevent submission if the form has errors and is using native validation
if (validationMode === 'native' && form.hasErrors()) {
isSubmitting.value = false;

return;
Expand All @@ -46,7 +47,7 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
unsetPath(values, path, true);
}

if (!schema) {
if (!schema || validationMode === 'native') {
const result = await cb(values as unknown as TOutput);
isSubmitting.value = false;

Expand All @@ -55,7 +56,6 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex

const { output, errors } = await schema.parse(values);
form.clearErrors();

if (errors.length) {
for (const entry of errors) {
form.setFieldErrors(entry.path as Path<TForm>, entry.errors);
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/types/typedSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export interface TypedSchemaContext {
}

export interface TypedSchema<TInput = any, TOutput = TInput> {
__type: 'VVTypedSchema';
parse(values: TInput, context?: TypedSchemaContext): Promise<{ output?: TOutput; errors: TypedSchemaError[] }>;
cast?(values: Partial<TInput>): TInput;
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/validation/useInputValidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ export function useInputValidity(opts: InputValidityOptions) {
const { setErrors, errorMessage } = opts.field;
const validityDetails = shallowRef<ValidityState>();
const form = inject(FormKey, null);
const validationMode = form?.getValidationMode() ?? 'native';

function updateValiditySync() {
validityDetails.value = opts.inputRef?.value?.validity;
// TODO: Only do that if native field/validation is enabled
setErrors(opts.inputRef?.value?.validationMessage || []);

if (validationMode === 'native') {
setErrors(opts.inputRef?.value?.validationMessage || []);
}
}

async function updateValidity() {
Expand Down

0 comments on commit c4f7f72

Please sign in to comment.