Skip to content

Commit

Permalink
feat: perform schema validation initially and on submit
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 5, 2024
1 parent b439d5e commit 6145f48
Show file tree
Hide file tree
Showing 7 changed files with 79 additions and 44 deletions.
15 changes: 12 additions & 3 deletions packages/core/src/form/formContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
PathValue,
TouchedSchema,
TypedSchema,
ValiditySchema,
ErrorsSchema,
TypedSchemaError,
} from '../types';
import { cloneDeep, merge, normalizeArrayable } from '../utils/common';
import { escapePath, findLeaf, getFromPath, isPathSet, setInPath, unsetPath as unsetInObject } from '../utils/path';
Expand All @@ -33,6 +34,7 @@ export interface FormContext<TForm extends FormObject = FormObject> {
getFieldErrors<TPath extends Path<TForm>>(path: TPath): string[];
setFieldErrors<TPath extends Path<TForm>>(path: TPath, message: Arrayable<string>): void;
getValidationMode(): FormValidationMode;
getErrors: () => TypedSchemaError[];
clearErrors: () => void;
hasErrors: () => boolean;
getValues: () => TForm;
Expand All @@ -50,7 +52,7 @@ export interface FormContextCreateOptions<TForm extends FormObject = FormObject,
values: TForm;
touched: TouchedSchema<TForm>;
disabled: DisabledSchema<TForm>;
errors: Ref<ValiditySchema<TForm>>;
errors: Ref<ErrorsSchema<TForm>>;
schema: TypedSchema<TForm, TOutput> | undefined;
snapshots: {
values: FormSnapshot<TForm>;
Expand Down Expand Up @@ -121,6 +123,12 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
return !!findLeaf(errors.value, l => Array.isArray(l) && l.length > 0);
}

function getErrors(): TypedSchemaError[] {
return Object.entries(errors.value)
.map<TypedSchemaError>(([key, value]) => ({ path: key, errors: value as string[] }))
.filter(e => e.errors.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 @@ -191,7 +199,7 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
}

function clearErrors() {
errors.value = {} as ValiditySchema<TForm>;
errors.value = {} as ErrorsSchema<TForm>;
}

function revertValues() {
Expand Down Expand Up @@ -228,6 +236,7 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
setFieldErrors,
getFieldErrors,
hasErrors,
getErrors,
clearErrors,
getValidationMode,
};
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/form/useForm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,13 +245,12 @@ describe('form submit', () => {
const cb = vi.fn();
const onSubmit = handleSubmit(cb);
expect(values).toEqual(defaults());
onSubmit(new Event('submit'));
await nextTick();
await onSubmit(new Event('submit'));
expect(cb).toHaveBeenLastCalledWith(defaults());

disabled.value = true;
onSubmit(new Event('submit'));
await nextTick();
await onSubmit(new Event('submit'));
expect(cb).toHaveBeenCalledTimes(2);
expect(cb).toHaveBeenLastCalledWith({ multiple: ['field 1', 'field 3', 'field 4'] });
});
});
Expand Down Expand Up @@ -414,6 +413,7 @@ describe('validation', () => {
expect(handler).not.toHaveBeenCalled();
await fireEvent.change(screen.getByTestId('input'), { target: { value: 'test' } });
await fireEvent.click(screen.getByText('Submit'));
await nextTick();
expect(handler).toHaveBeenCalledOnce();
});

Expand Down
20 changes: 14 additions & 6 deletions packages/core/src/form/useForm.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { computed, InjectionKey, provide, reactive, readonly, Ref, ref } from 'vue';
import { computed, InjectionKey, onMounted, provide, reactive, readonly, Ref, ref } from 'vue';
import { cloneDeep, isEqual, useUniqId } from '../utils/common';
import {
FormObject,
MaybeAsync,
MaybeGetter,
TouchedSchema,
DisabledSchema,
ValiditySchema,
ErrorsSchema,
Path,
TypedSchema,
} from '../types';
Expand All @@ -26,7 +26,8 @@ export interface FormOptions<TForm extends FormObject = FormObject, TOutput exte
export interface FormContextWithTransactions<TForm extends FormObject = FormObject>
extends FormContext<TForm>,
FormTransactionManager<TForm> {
onSubmitted(cb: () => void): void;
onSubmitAttempt(cb: () => void): void;
onValidateTriggered(cb: () => void): void;
}

export const FormKey: InjectionKey<FormContextWithTransactions<any>> = Symbol('Formwerk FormKey');

Check warning on line 33 in packages/core/src/form/useForm.ts

View workflow job for this annotation

GitHub Actions / ts-lint-test

Unexpected any. Specify a different type
Expand All @@ -42,7 +43,7 @@ export function useForm<TForm extends FormObject = FormObject, TOutput extends F
const values = reactive(cloneDeep(valuesSnapshot.originals.value)) as TForm;
const touched = reactive(cloneDeep(touchedSnapshot.originals.value)) as TouchedSchema<TForm>;
const disabled = {} as DisabledSchema<TForm>;
const errors = ref({}) as Ref<ValiditySchema<TForm>>;
const errors = ref({}) as Ref<ErrorsSchema<TForm>>;

const ctx = createFormContext({
id: opts?.id || useUniqId('form'),
Expand Down Expand Up @@ -74,7 +75,7 @@ export function useForm<TForm extends FormObject = FormObject, TOutput extends F
}

const transactionsManager = useFormTransactions(ctx);
const { actions, onSubmitted, isSubmitting } = useFormActions<TForm, TOutput>(ctx, {
const { actions, onSubmitAttempt, onValidateTriggered, isSubmitting } = useFormActions<TForm, TOutput>(ctx, {
disabled,
schema: opts?.schema,
});
Expand All @@ -90,9 +91,16 @@ export function useForm<TForm extends FormObject = FormObject, TOutput extends F
provide(FormKey, {
...ctx,
...transactionsManager,
onSubmitted,
onSubmitAttempt,
onValidateTriggered,
} as FormContextWithTransactions<TForm>);

if (ctx.getValidationMode() === 'schema') {
onMounted(() => {
actions.validate();
});
}

return {
values: readonly(values),
context: ctx,
Expand Down
74 changes: 46 additions & 28 deletions packages/core/src/form/useFormActions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { shallowRef } from 'vue';
import { DisabledSchema, FormObject, MaybeAsync, Path, TouchedSchema, TypedSchema } from '../types';
import { DisabledSchema, FormObject, MaybeAsync, Path, TouchedSchema, TypedSchema, TypedSchemaError } from '../types';
import { cloneDeep } from '../utils/common';
import { createEventDispatcher } from '../utils/events';
import { FormContext, SetValueOptions } from './formContext';
Expand All @@ -15,64 +15,79 @@ export interface FormActionsOptions<TForm extends FormObject = FormObject, TOutp
disabled: DisabledSchema<TForm>;
}

export interface FormValidationResult<TOutput extends FormObject = FormObject> {
isValid: boolean;
errors: TypedSchemaError[];
output: TOutput;
}

export function useFormActions<TForm extends FormObject = FormObject, TOutput extends FormObject = TForm>(
form: FormContext<TForm>,
{ disabled, schema }: FormActionsOptions<TForm, TOutput>,
) {
const isSubmitting = shallowRef(false);
const [dispatchSubmit, onSubmitted] = createEventDispatcher<void>('submit');
const [dispatchSubmit, onSubmitAttempt] = createEventDispatcher<void>('submit');
const [dispatchValidate, onValidateTriggered] = createEventDispatcher<void>('validate');

function handleSubmit<TReturns>(cb: (values: TOutput) => MaybeAsync<TReturns>) {
function handleSubmit<TReturns>(onSuccess: (values: TOutput) => MaybeAsync<TReturns>) {
return async function onSubmit(e: Event) {
e.preventDefault();
isSubmitting.value = true;
await dispatchSubmit();

const validationMode = form.getValidationMode();
// Prevent submission if the form has errors and is using native validation
if (validationMode === 'native' && form.hasErrors()) {
dispatchSubmit();
const { isValid, output } = await validate();
// Prevent submission if the form has errors
if (!isValid) {
isSubmitting.value = false;

return;
}

// Clone the values to prevent mutation or reactive leaks
const values = cloneDeep(form.getValues());
const disabledPaths = Object.entries(disabled)
.filter(([, v]) => !!v)
.map(([k]) => k)
.sort((a, b) => b.localeCompare(a)) as (keyof DisabledSchema<TForm>)[];

for (const path of disabledPaths) {
unsetPath(values, path, true);
unsetPath(output, path, true);
}

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

return result;
}
return result;
};
}

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);
}
async function validate(): Promise<FormValidationResult<TOutput>> {
if (form.getValidationMode() === 'native' || !schema) {
await dispatchValidate();

isSubmitting.value = false;
return {
isValid: !form.hasErrors(),
errors: form.getErrors(),
output: cloneDeep(form.getValues() as unknown as TOutput),
};
}

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

const result = await cb(output ?? (values as unknown as TOutput));
isSubmitting.value = false;
applyErrors(errors);

return result;
return {
isValid: !errors.length,
errors,
output: cloneDeep(output ?? (form.getValues() as unknown as TOutput)),
};
}

function applyErrors(errors: TypedSchemaError[]) {
for (const entry of errors) {
form.setFieldErrors(entry.path as Path<TForm>, entry.errors);
}
}

function reset(state?: Partial<ResetState<TForm>>, opts?: SetValueOptions) {
if (state?.values) {
form.setInitialValues(state.values, opts);
Expand All @@ -84,14 +99,17 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex

form.revertValues();
form.revertTouched();
validate();
}

return {
actions: {
handleSubmit,
reset,
validate,
},
onSubmitted,
onSubmitAttempt,
onValidateTriggered,
isSubmitting,
};
}
2 changes: 1 addition & 1 deletion packages/core/src/form/useFormField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
toValue(opts?.disabled) ?? false,
);

form.onSubmitted(() => {
form.onSubmitAttempt(() => {
setTouched(true);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/types/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ export type TouchedSchema<TForm extends FormObject> = Simplify<Schema<TForm, boo

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

export type ValiditySchema<TForm extends FormObject> = Partial<Record<Path<TForm>, string[]>>;
export type ErrorsSchema<TForm extends FormObject> = Partial<Record<Path<TForm>, string[]>>;
2 changes: 1 addition & 1 deletion packages/core/src/validation/useInputValidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function useInputValidity(opts: InputValidityOptions) {

useEventListener(opts.inputRef, opts?.events || ['invalid', 'change', 'blur'], updateValidity);

form?.onSubmitted(updateValiditySync);
form?.onValidateTriggered(updateValiditySync);

if (opts.inputRef) {
watch(errorMessage, msg => {
Expand Down

0 comments on commit 6145f48

Please sign in to comment.