Skip to content

Commit

Permalink
feat: Support Default Error Messages for Observed Forms
Browse files Browse the repository at this point in the history
This enables developer to remove redundant/repetitive
code from their application. It also empower developers
to have a central, more ergonomic way to use validation
tools like Zod.
  • Loading branch information
ITenthusiasm committed Feb 11, 2024
1 parent 6999255 commit 48d22db
Show file tree
Hide file tree
Showing 7 changed files with 297 additions and 27 deletions.
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Documentation

- [ ] Add more detailed examples of how to use `Zod` with the `defaultErrors.validate` option.
- [ ] Figure out a Logo for the `enthusiastic-js` Organization and maybe the Form Observer package?
- [ ] In the interest of time, we're probably going to have to do the bare minimum when it comes to the documentation. Make the API clear, give some helpful examples, etc. After we've release the first draft of the project, we can start thinking about how to "perfect" the docs. But for now, don't get too paranoid about the wording.
- [ ] Adding demos somewhere in this repo (or in something like a CodeSandbox) would likely be helpful for developers. **Edit**: We now have examples for the `FormValidityObserver`. Would examples for the `FormObserver` or the `FormStorageObserver` also be helpful?
Expand Down
19 changes: 15 additions & 4 deletions docs/form-validity-observer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ The `FormValidityObserver()` constructor creates a new observer and configures i
The <code>renderer</code> defaults to a function that accepts error messages of type <code>string</code> and renders them to the DOM as raw HTML.
</p>
</dd>
<dt id="form-validity-observer-options-default-errors"><code>defaultErrors: ValidationErrors&lt;M, E&gt;</code></dt>
<dd>
<p>
Configures the default error messages to display for the validation constraints. (See the <a href="#method-formvalidityobserverconfigureename-string-errormessages-validationerrorsm-e-void"><code>configure</code></a> method for more details about error message configuration, and refer to the <a href="./types.md#validationerrorsm-e"><code>ValidationErrors</code></a> type for more details about validation constraints.)
</p>
<p>
<blockquote>
<strong>Note: The <code>defaultErrors.validate</code> option will provide a default custom validation function for <em>all</em> fields in your form.</strong> This is primarily useful if you have a reusable validation function that you want to apply to all of your form's fields (for example, if you are using <a href="https://zod.dev">Zod</a>). See <a href="./guides.md#getting-the-most-out-of-the-defaulterrors-option"><i>Getting the Most out of the <code>defaultErrors</code></i> Option</a> for examples on how to use this option effectively.
</blockquote>
</p>
</dd>
</dl>
</dd>
</dl>
Expand Down Expand Up @@ -204,11 +215,11 @@ form1.elements[0].dispatchEvent(new FocusEvent("focusout")); // Does nothing

### Method: `FormValidityObserver.configure<E>(name: string, errorMessages: `[`ValidationErrors<M, E>`](./types.md#validationerrorsm-e)`): void`

Configures the error messages that will be displayed for a form field's validation constraints. If an error message is not configured for a validation constraint, then the field's [`validationMessage`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/validationMessage) will be used instead. For [native form fields](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements), the browser automatically supplies a default `validationMessage` depending on the broken constraint.
Configures the error messages that will be displayed for a form field's validation constraints. If an error message is not configured for a validation constraint and there is no corresponding [default configuration](#form-validity-observer-options-default-errors), then the field's [`validationMessage`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/validationMessage) will be used instead. For [native form fields](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements), the browser automatically supplies a default `validationMessage` depending on the broken constraint.

> Note: If the field is _only_ using the browser's default error messages, it does _not_ need to be `configure`d.
> Note: If the field is _only_ using the configured [`defaultErrors`](#form-validity-observer-options-default-errors) and/or the browser's default error messages, it _does not_ need to be `configure`d.
The Form Element Type, `E`, represents the form field being configured. This type is inferred from the `errorMessages` configuration and defaults to a general [`ValidatableField`](./types.md#validatablefield).
The Field Element Type, `E`, represents the form field being configured. This type is inferred from the `errorMessages` configuration and defaults to a general [`ValidatableField`](./types.md#validatablefield).

#### Parameters

Expand Down Expand Up @@ -297,7 +308,7 @@ When the `focus` option is `false`, you can consider `validateField()` to be an

Marks the form field having the specified `name` as invalid (via the [`[aria-invalid="true"]`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-invalid) attribute) and applies the provided error `message` to it. Typically, you shouldn't need to call this method manually; but in rare situations it might be helpful.

The Form Element Type, `E`, represents the invalid form field. This type is inferred from the error `message` if it is a function. Otherwise, `E` defaults to a general [`ValidatableField`](./types.md#validatablefield).
The Field Element Type, `E`, represents the invalid form field. This type is inferred from the error `message` if it is a function. Otherwise, `E` defaults to a general [`ValidatableField`](./types.md#validatablefield).

#### Parameters

Expand Down
145 changes: 145 additions & 0 deletions docs/form-validity-observer/guides.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Here you'll find helpful tips on how to use the `FormValidityObserver` effective

- [Enabling/Disabling Accessible Error Messages](#enabling-accessible-error-messages-during-form-submissions)
- [Keeping Track of Visited/Dirty Fields](#keeping-track-of-visiteddirty-fields)
- [Getting the Most out of the `defaultErrors` option](#getting-the-most-out-of-the-defaulterrors-option)
- [Keeping Track of Form Data](#keeping-track-of-form-data)
- [Recommendations for Conditionally Rendered Fields](#recommendations-for-conditionally-rendered-fields)
- [Recommendations for Styling Form Fields and Their Error Messages](#recommendations-for-styling-form-fields-and-their-error-messages)
Expand Down Expand Up @@ -129,6 +130,150 @@ To get an idea of how these event listeners would function, you can play around

You can learn more about what can be done with forms using pure JS on our [Philosophy](../extras/philosophy.md#avoid-unnecessary-overhead-and-reinventing-the-wheel) page.

## Getting the Most out of the `defaultErrors` Option

Typically, we want the error messages in our application to be consistent. Unfortunately, this can sometimes cause us to write the same error messages over and over again. For example, consider a message that might be displayed for the `required` constraint:

```html
<form>
<label for="first-name">First Name</label>
<input id="first-name" type="text" required aria-describedby="first-name-error" />
<div id="first-name-error" role="alert"></div>

<label for="last-name">Last Name</label>
<input id="last-name" type="text" required aria-describedby="last-name-error" />
<div id="last-name-error" role="alert"></div>

<label for="email">Email</label>
<input id="email" type="email" required aria-describedby="email-error" />
<div id="email-error" role="alert"></div>

<!-- Other Fields ... -->
</div>
```

We might configure our error messages like so

```js
const observer = new FormValidityObserver("focusout");
observer.configure("first-name", { required: "First Name is required." });
observer.configure("last-name", { required: "Last Name is required." });
observer.configure("email", { required: "Email is required." });
// Configurations for other fields ...
```

But this is redundant (and consequently, error-prone). Since all of our error messages for the `required` constraint follow the same format (`"<FIELD_NAME> is required"`), it would be better for us to use the [`defaultErrors`](./README.md#form-validity-observer-options-default-errors) configuration option instead.

```js
const observer = new FormValidityObserver("focusout", {
defaultErrors: {
required: (field) => `${field.labels?.[0].textContent ?? "This field"} is required.`;
},
});
```
This gives us one consistent way to define the `required` error message for _all_ of our fields. Of course, it's possible that not all of your form controls will be labeled by a `<label>` element. For instance, a `radiogroup` is typically labeled by a `<legend>` instead. In this case, you may choose to make the error message more generic
```js
const observer = new FormValidityObserver("focusout", {
defaultErrors: { required: "This field is required" },
});
```
Or you may choose to make the error message more flexible
```js
const observer = new FormValidityObserver("focusout", {
defaultErrors: {
required(field) {
if (field instanceof HTMLInputElement && field.type === "radio") {
const radiogroup = input.closest("fieldset[role='radiogroup']");
const legend = radiogroup.firstElementChild.matches("legend") ? radiogroup.firstElementChild : null;
return `${legend?.textContent ?? "This field"} is required.`;
}
return `${field.labels?.[0].textContent ?? "This field"} is required.`;
},
},
});
```
And if you ever need a _unique_ error message for a specific field, you can still configure it explicitly.
```js
const observer = new FormValidityObserver("focusout", {
defaultErrors: { required: "This field is required" },
});
observer.configure("my-unique-field", { required: "This field has a unique `required` error!" });
```
### Default Validation Functions
The `validate` option in the `defaultErrors` object provides a default custom validation function for _all_ of the fields in your form. This can be helpful if you have a reusable validation function that you want to apply to all of your form's fields. For example, if you're using [`Zod`](https://zod.dev) to validate your form data, you could do something like this:
```js
const schema = z.object({
"first-name": z.string(),
"last-name": z.string(),
email: z.string().email(),
});
const observer = new FormValidityObserver("focusout", {
defaultErrors: {
validate(field) {
const result = schema.shape[field.name].safeParse(field.value);
// Extract field's error message from `result`
return errorMessage;
},
},
});
```
By leveraging `defaultErrors.validate`, you can easily use Zod (or any other validation tool) on your frontend. If you're using an SSR framework, you can use the exact same tool on your backend. It's the best of both worlds!
### Zod Validation with Nested Fields
For more complex form structures (e.g., "Nested Fields" as objects or arrays), you will need to write some advanced logic to make sure that you access the correct `safeParse` function. For example, if you have a field named `address.name.first`, then you'll need to recursively follow the path from `address` to `first` to access the correct `safeParse` function. The [`shape`](https://zod.dev/?id=shape) property (for objects) and the [`element`](https://zod.dev/?id=element) property (for arrays) in Zod will help you accomplish this. Alternatively, you can flatten your object structure entirely:
```js
const schema = z.object({
"address.name.first": z.string(),
"address.name.last": z.string(),
"address.city": z.string(),
// Other fields...
});
```
This enables you to use the approach that we showed above without having to write any recursive logic. It's arguably more performant than defining and walking through nested objects, but it requires you to be doubly sure that you're spelling all of your fields' names correctly. Also note that the logic for handling arrays in this example would still take a little effort and may require some recursion. However, this logic shouldn't be too difficult to write.
If there's sufficient interest from the community, then we may add some Zod helper functions to our packages to take this burden off of developers.
### Zod Validation Using Existing Libraries
Another option is to use an existing library that validates forms with Zod (e.g., `@conform-to/zod`) and to extract the error messages from that tool. For example, you might do something like the following:
```js
import { FormValidityObserver } from "@form-observer";
import { parseWithZod } from "@conform-to/zod";
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
password: z.string(),
});
const observer = new FormValidityObserver("focusout", {
defaultErrors: {
validate(field) {
const results = parseWithZod(new FormData(field.form), schema);
// Grab the correct error message from `results` object by using `field.name`.
return errorMessage;
},
},
});
```
## Keeping Track of Form Data
Many form libraries offer stateful solutions for managing the data in your forms as JSON. But there are a few disadvantages to this approach:
Expand Down
1 change: 1 addition & 0 deletions docs/form-validity-observer/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ Remember that each instance of the `FormValidityObserver` determines its `M` typ
### Primary Uses

- [`FormValidityObserver.configure`](./README.md#method-formvalidityobserverconfigureename-string-errormessages-validationerrorsm-e-void)
- [`FormValidityObserverOptions.defaultErrors`](./README.md#form-validity-observer-options-default-errors)

## `ErrorDetails<M, E>`

Expand Down
20 changes: 14 additions & 6 deletions packages/core/FormValidityObserver.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export interface ValidationErrors<M, E extends ValidatableField = ValidatableFie
validate?(field: E): void | ErrorDetails<M, E> | Promise<void | ErrorDetails<M, E>>;
}

export interface FormValidityObserverOptions<M> {
export interface FormValidityObserverOptions<M, E extends ValidatableField = ValidatableField> {
/**
* Indicates that the observer's event listener should be called during the event capturing phase instead of
* the event bubbling phase. Defaults to `false`.
Expand All @@ -57,6 +57,12 @@ export interface FormValidityObserverOptions<M> {
* (e.g., DOM Nodes, React Elements, etc.) to the DOM instead.
*/
renderer?(errorContainer: HTMLElement, errorMessage: M | null): void;

/**
* The default errors to display for the field constraints. (The `validate` option configures the default
* _custom validation function_ used for all form fields.)
*/
defaultErrors?: ValidationErrors<M, E>;
}

export interface ValidateFieldOptions {
Expand All @@ -76,9 +82,9 @@ interface FormValidityObserverConstructor {
*
* @param types The type(s) of event(s) that trigger(s) form field validation.
*/
new <T extends OneOrMany<EventType>, M = string>(
new <T extends OneOrMany<EventType>, M = string, E extends ValidatableField = ValidatableField>(
types: T,
options?: FormValidityObserverOptions<M>,
options?: FormValidityObserverOptions<M, E>,
): FormValidityObserver<M>;
}

Expand Down Expand Up @@ -153,10 +159,12 @@ interface FormValidityObserver<M = string> {

/**
* Configures the error messages that will be displayed for a form field's validation constraints.
* If an error message is not configured for a validation constraint, then the browser's default error message
* for that constraint will be used instead.
* If an error message is not configured for a validation constraint and there is no corresponding
* {@link FormValidityObserverOptions.defaultErrors default configuration}, then the browser's
* default error message for that constraint will be used instead.
*
* Note: If the field is _only_ using the browser's default error messages, it does _not_ need to be `configure`d.
* Note: If the field is _only_ using the configured {@link FormValidityObserverOptions.defaultErrors `defaultErrors`}
* and/or the browser's default error messages, it _does not_ need to be `configure`d.
*
* @param name The `name` of the form field
* @param errorMessages A `key`-`value` pair of validation constraints (key)
Expand Down
Loading

0 comments on commit 48d22db

Please sign in to comment.