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

Add Input.error prop, useValidateOnSubmit hook #1534

Merged
merged 2 commits into from
Apr 29, 2024

Conversation

robertknight
Copy link
Member

This PR adds a couple of APIs designed to make handling custom validation errors and custom presentation of validation errors easier:

  • The new Input.error prop specifies an error message that is synced to the underlying <input> DOM element using HTMLInputElement.setCustomValidity. This in turn will prevent submission of any containing form which has validation checks enabled (the default).
  • For forms which want to control presentation of validation error messages, the new useValidateOnSubmit hook can be used together with a form's noValidate property. This hook implements the parts of native validation that are still useful when validation errors are presented in a custom way: checking the validation status using HTMLFormElement.checkValidity and focusing the first control with an error

See the tests for useValidateOnSubmit for a worked example of a simple form using both of these.

I plan to use these new changes downstream to resolve hypothesis/product-backlog#1535. This PR only applies the change for Input components, but the same change should be applied to Select and Textarea in future.

@robertknight robertknight force-pushed the custom-form-validation branch 2 times, most recently from ee9c7df to 2e9049f Compare April 26, 2024 12:28
Copy link

codecov bot commented Apr 26, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 100.00%. Comparing base (1a337f4) to head (9c4d65e).
Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main     #1534   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           61        63    +2     
  Lines          972      1001   +29     
  Branches       373       382    +9     
=========================================
+ Hits           972      1001   +29     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@robertknight robertknight force-pushed the custom-form-validation branch from 2e9049f to 141b196 Compare April 26, 2024 12:36
@robertknight robertknight marked this pull request as ready for review April 26, 2024 13:59
@robertknight robertknight requested a review from acelaya April 29, 2024 08:41
Copy link
Contributor

@acelaya acelaya left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good in general.

Comment on lines 23 to 25
if (ref.current) {
ref.current.setCustomValidity(error ?? '');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (ref.current) {
ref.current.setCustomValidity(error ?? '');
}
ref.current?.setCustomValidity(error ?? '');

Comment on lines +62 to +64
if (error) {
feedback = 'error';
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will overwrite an explicitly set feedback when an error is set, which might not be super intuitive:

// This will render as `feedback="error"`.
<Input error="Review this and that" feedback="warning" />

That said, considering we intend to get rid of the feedback prop, maybe we can just accept this trade-off in exchange for simplicity.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, considering we intend to get rid of the feedback prop, maybe we can just accept this trade-off in exchange for simplicity.

That's my plan.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works for me then

Comment on lines 28 to 33
const onSubmit = (event: Event) => {
if (event.type !== 'submit') {
throw new Error('Event type is not "submit"');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would typing event as SubmitEvent help avoiding this check? If I'm not wrong, with that we would ensure the returned event handler is not allowed anywhere other than onSubmit, as TypeScript will complain.

This is safer nonetheless.

Copy link
Member Author

@robertknight robertknight Apr 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idea. I will do. I might keep the check in case there are any JS consumers in tests that misuse this somehow.

Comment on lines 49 to 67
if ('validity' in el && !el.validity.valid) {
if (!foundFirst) {
el.focus();
foundFirst = true;
}

// If the user has focused an empty, required input field and
// triggered a submission by pressing Enter, that will not trigger
// a "change" event. Trigger this event to allow the input's "change"
// handler to update its custom error.
if (el.validity.valueMissing) {
el.dispatchEvent(new Event('change'));
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm getting this right, this if statement does two things:

  1. Dispatch change event for every invalid element.
  2. Focus the first invalid element.

I think all nested ifs make it a bit harder to follow due to all the conditions you have to mentally drag.

Perhaps a simple solution would be to invert the validity check condition and discard that early:

Suggested change
if ('validity' in el && !el.validity.valid) {
if (!foundFirst) {
el.focus();
foundFirst = true;
}
// If the user has focused an empty, required input field and
// triggered a submission by pressing Enter, that will not trigger
// a "change" event. Trigger this event to allow the input's "change"
// handler to update its custom error.
if (el.validity.valueMissing) {
el.dispatchEvent(new Event('change'));
}
}
}
if (!('validity' in el) || el.validity.valid) {
continue;
}
if (!foundFirst) {
el.focus();
foundFirst = true;
}
// If the user has focused an empty, required input field and
// triggered a submission by pressing Enter, that will not trigger
// a "change" event. Trigger this event to allow the input's "change"
// handler to update its custom error.
if (el.validity.valueMissing) {
el.dispatchEvent(new Event('change'));
}
}

Then this translates into 3 individual conditions which are easier to mentally parse.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, this is better. Will do.

Comment on lines +87 to +95
const onSelectURL = sinon.stub();
const form = mount(<CustomForm onSelectURL={onSelectURL} />);
changeURL(form, 'not valid');

const input = form.find('input').getDOMNode();
const focusStub = sinon.stub(input, 'focus');
submitForm(form);

assert.calledOnce(focusStub);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps you could attach the component to the DOM when mounting it (mount(..., { attachTo: container })), and then check if document.activeElement is equal to form.find('input').getDOMNode() instead of stubbing the focus method. But I have to admit I have faced flaky tests when trying to do that, so I'm fine with this if the other approach is problematic.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I have to admit I have faced flaky tests when trying to do that, so I'm fine with this if the other approach is problematic.

Indeed that's the rationale here - a call to HTMLElement.focus is much easier to check for as there is no asynchrony involved.

This sets a custom validation error for the input component which is
communicated to the browser via `HTMLInputElement.setCustomValidity`.

This, along with any native validation constraints such as `required`, will
prevent form submission until they are resolved.
By default forms handle validation automatically when submitted, displaying an
error message and focusing the first control with an error. This hook is useful
if the caller wants to customize how validation error messages are presented on
submission.
@robertknight robertknight force-pushed the custom-form-validation branch from 141b196 to 9c4d65e Compare April 29, 2024 10:22
@robertknight robertknight merged commit 8888841 into main Apr 29, 2024
4 checks passed
@robertknight robertknight deleted the custom-form-validation branch April 29, 2024 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Handling form validation errors in a more accessible way
2 participants