Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support submit errors and error message upon submitting a form #89

Merged
merged 6 commits into from
Dec 29, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/modern-brooms-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@formwerk/core': minor
---

feat: adding `submitErrors` and `submitErrorMessage` in `useFormField`. `getSubmitError` and `getSubmitErrors' in 'useForm'.
37 changes: 37 additions & 0 deletions packages/core/src/useForm/formContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,14 @@ export interface BaseFormContext<TForm extends FormObject = FormObject> {
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[];
getFieldSubmitErrors<TPath extends Path<TForm>>(path: TPath): string[];
setFieldErrors<TPath extends Path<TForm>>(path: TPath, message: Arrayable<string>): void;
setFieldSubmitErrors<TPath extends Path<TForm>>(path: TPath, message: Arrayable<string>): void;
getValidationMode(): FormValidationMode;
getErrors: () => IssueCollection[];
getSubmitErrors: () => IssueCollection[];
clearErrors: (path?: string) => void;
clearSubmitErrors: (path?: string) => void;
hasErrors: () => boolean;
getValues: () => TForm;
setValues: (newValues: Partial<TForm>, opts?: SetValueOptions) => void;
Expand All @@ -64,6 +68,7 @@ export interface FormContextCreateOptions<TForm extends FormObject = FormObject,
touched: TouchedSchema<TForm>;
disabled: DisabledSchema<TForm>;
errors: Ref<ErrorsSchema<TForm>>;
submitErrors: Ref<ErrorsSchema<TForm>>;
schema: StandardSchema<TForm, TOutput> | undefined;
snapshots: {
values: FormSnapshot<TForm>;
Expand All @@ -76,6 +81,7 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
values,
disabled,
errors,
submitErrors,
schema,
touched,
snapshots,
Expand Down Expand Up @@ -158,6 +164,12 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
.filter(e => e.messages.length > 0);
}

function getSubmitErrors(): IssueCollection[] {
return Object.entries(submitErrors.value)
.map<IssueCollection>(([key, value]) => ({ path: key, messages: value as string[] }))
.filter(e => e.messages.length > 0);
}

function setInitialValues(newValues: Partial<TForm>, opts?: SetValueOptions) {
if (opts?.behavior === 'merge') {
snapshots.values.initials.value = merge(cloneDeep(snapshots.values.initials.value), cloneDeep(newValues));
Expand Down Expand Up @@ -208,10 +220,18 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
return [...(getFromPath<string[]>(errors.value, escapePath(path), []) || [])];
}

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

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

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

function setTouched(newTouched: Partial<TouchedSchema<TForm>>, opts?: SetValueOptions) {
if (opts?.behavior === 'merge') {
merge(touched, newTouched);
Expand Down Expand Up @@ -240,6 +260,19 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
});
}

function clearSubmitErrors(path?: string) {
if (!path) {
submitErrors.value = {} as ErrorsSchema<TForm>;
return;
}

Object.keys(submitErrors.value).forEach(key => {
if (key === path || key.startsWith(path)) {
delete submitErrors.value[key as Path<TForm>];
}
});
}

function revertValues() {
setValues(cloneDeep(snapshots.values.originals.value), { behavior: 'replace' });
}
Expand Down Expand Up @@ -273,10 +306,14 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
getFieldOriginalValue,
setFieldDisabled,
setFieldErrors,
setFieldSubmitErrors,
getFieldErrors,
getFieldSubmitErrors,
hasErrors,
getErrors,
getSubmitErrors,
clearErrors,
clearSubmitErrors,
getValidationMode,
isPathDisabled,
};
Expand Down
52 changes: 52 additions & 0 deletions packages/core/src/useForm/useForm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,58 @@ describe('form validation', () => {
await fireEvent.blur(screen.getByTestId('input'));
expect(screen.getByText('Form is invalid')).toBeDefined();
});

test('update submit errors when submitting a form', async () => {
const input = ref<HTMLInputElement>();

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

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

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

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

<button type="submit">Submit</button>
</form>
`,
});

expect(screen.getByTestId('submit-err').textContent).toBe('');
await fireEvent.click(screen.getByText('Submit'));
await flush();
expect(screen.getByTestId('submit-err').textContent).toBe('Constraints not satisfied');
// enter a value to make the form valid and submit again
await fireEvent.update(screen.getByTestId('input'), 'test');
// update validity
await fireEvent.blur(screen.getByTestId('input'));
await flush();
expect(screen.getByTestId('submit-err').textContent).toBe('Constraints not satisfied');
expect(screen.getByTestId('err').textContent).toBe('');
// when submitting clearing the submit errors
await fireEvent.click(screen.getByText('Submit'));
await flush();
expect(screen.getByTestId('submit-err').textContent).toBe('');
});
});

describe('Standard Schema', () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/useForm/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export function useForm<
const touched = reactive(cloneDeep(touchedSnapshot.originals.value)) as TouchedSchema<TInput>;
const disabled = reactive({}) as DisabledSchema<TInput>;
const errors = ref({}) as Ref<ErrorsSchema<TInput>>;
const submitErrors = ref({}) as Ref<ErrorsSchema<TInput>>;

const ctx = createFormContext<TInput, TOutput>({
id,
Expand All @@ -111,6 +112,7 @@ export function useForm<
disabled,
schema: props?.schema as StandardSchema<TInput, TOutput>,
errors,
submitErrors,
snapshots: {
values: valuesSnapshot,
touched: touchedSnapshot,
Expand Down Expand Up @@ -149,6 +151,14 @@ export function useForm<
return ctx.isPathDisabled(path) ? undefined : ctx.getFieldErrors(path)[0];
}

function getSubmitErrors() {
return ctx.getSubmitErrors();
}

function getSubmitError<TPath extends Path<TInput>>(path: TPath): string | undefined {
return ctx.isPathDisabled(path) ? undefined : ctx.getFieldSubmitErrors(path)[0];
}

function displayError(path: Path<TInput>) {
return ctx.isFieldTouched(path) && !ctx.isPathDisabled(path) ? getError(path) : undefined;
}
Expand Down Expand Up @@ -266,6 +276,14 @@ export function useForm<
* Gets all the errors for the form.
*/
getErrors,
/**
* Gets the submit errors for a field.
*/
getSubmitError,
/**
* Gets all the submit errors for the form.
*/
getSubmitErrors,
/**
* Props for the form element.
*/
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/useForm/useFormActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,14 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex

// No need to wait for this event to propagate, it is used for non-validation stuff like setting touched state.
dispatchSubmit();
const { isValid, output } = await validate();
const { isValid, output, errors } = await validate();

updateSubmitValidationStateFromResult(errors);

// Prevent submission if the form has errors
if (!isValid) {
isSubmitting.value = false;
scrollToFirstInvalidField(form.id, scrollToInvalidFieldOnSubmit);

return;
}

Expand All @@ -118,6 +120,11 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
};
}

function updateSubmitValidationStateFromResult(errors: IssueCollection[]) {
form.clearSubmitErrors();
applySubmitErrors(errors);
}

function updateValidationStateFromResult(result: FormValidationResult<TOutput>) {
form.clearErrors();
applyErrors(result.errors);
Expand All @@ -139,6 +146,12 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
}
}

function applySubmitErrors(errors: IssueCollection[]) {
for (const entry of errors) {
form.setFieldSubmitErrors(entry.path as Path<TForm>, entry.messages);
}
}

async function reset(state?: Partial<ResetState<TForm>>, opts?: SetValueOptions) {
if (state?.values) {
form.setInitialValues(state.values, opts);
Expand All @@ -161,6 +174,7 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
}

form.clearErrors();
form.clearSubmitErrors();

return Promise.resolve();
}
Expand Down
30 changes: 28 additions & 2 deletions packages/core/src/useFormField/useFormField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export type FormField<TValue> = {
isDisabled: Ref<boolean>;
errors: Ref<string[]>;
errorMessage: Ref<string>;
submitErrors: Ref<string[]>;
submitErrorMessage: Ref<string | undefined>;
schema: StandardSchema<TValue> | undefined;
validate(mutate?: boolean): Promise<ValidationResult>;
getPath: Getter<string | undefined>;
Expand All @@ -49,7 +51,9 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
const initialValue = opts?.initialValue;
const { fieldValue, pathlessValue, setValue } = useFieldValue(getPath, form, initialValue);
const { isTouched, pathlessTouched, setTouched } = useFieldTouched(getPath, form);
const { errors, setErrors, isValid, errorMessage, pathlessValidity } = useFieldValidity(getPath, isDisabled, form);
const { errors, setErrors, isValid, errorMessage, pathlessValidity, submitErrors, submitErrorMessage } =
useFieldValidity(getPath, isDisabled, form);

const { displayError } = useErrorDisplay(errorMessage, isTouched);

const isDirty = computed(() => {
Expand Down Expand Up @@ -126,6 +130,8 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
setTouched,
setErrors,
displayError,
submitErrors,
submitErrorMessage,
};

if (!form) {
Expand Down Expand Up @@ -191,13 +197,15 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
function useFieldValidity(getPath: Getter<string | undefined>, isDisabled: Ref<boolean>, form?: FormContext | null) {
const validity = form ? createFormValidityRef(getPath, form) : createLocalValidity();
const errorMessage = computed(() => (isDisabled.value ? '' : (validity.errors.value[0] ?? '')));
const submitErrorMessage = computed(() => (isDisabled.value ? '' : (validity.submitErrors.value[0] ?? '')));
const isValid = computed(() => (isDisabled.value ? true : validity.errors.value.length === 0));

return {
...validity,
errors: computed(() => (isDisabled.value ? [] : validity.errors.value)),
isValid,
errorMessage,
submitErrorMessage,
};
}

Expand Down Expand Up @@ -326,6 +334,12 @@ function createFormValidityRef(getPath: Getter<string | undefined>, form: FormCo
return path ? form.getFieldErrors(path) : pathlessValidity.errors.value;
}) as Ref<string[]>;

const submitErrors = computed(() => {
const path = getPath();

return path ? form.getFieldSubmitErrors(path) : [];
});

function setErrors(messages: Arrayable<string>) {
pathlessValidity.setErrors(messages);
const path = getPath();
Expand All @@ -338,14 +352,17 @@ function createFormValidityRef(getPath: Getter<string | undefined>, form: FormCo
pathlessValidity,
errors,
setErrors,
submitErrors,
};
}

function createLocalValidity() {
const errors = shallowRef<string[]>([]);
const submitErrors = shallowRef<string[]>([]);

const api = {
errors,
submitErrors,
setErrors(messages: Arrayable<string>) {
errors.value = messages ? normalizeArrayable(messages) : [];
},
Expand All @@ -372,7 +389,14 @@ export type ExposedField<TValue> = {
* The errors for the field.
*/
errors: Ref<string[]>;

/**
* The errors for the field when submitting.
logaretm marked this conversation as resolved.
Show resolved Hide resolved
*/
submitErrors: Ref<string[]>;
/**
* The error message for the field when submitting.
logaretm marked this conversation as resolved.
Show resolved Hide resolved
*/
submitErrorMessage: Ref<string | undefined>;
/**
* The value of the field.
*/
Expand Down Expand Up @@ -422,6 +446,8 @@ export function exposeField<TReturns extends object, TValue>(
displayError: field.displayError,
errorMessage: field.errorMessage,
errors: field.errors,
submitErrors: field.submitErrors,
submitErrorMessage: field.submitErrorMessage,
fieldValue: field.fieldValue as Ref<TValue>,
isDirty: field.isDirty,
isTouched: field.isTouched,
Expand Down
Loading