Skip to content

Instantly share code, notes, and snippets.

@EndangeredMassa
Last active January 21, 2020 22:48
Show Gist options
  • Save EndangeredMassa/b7bd5edc10751909ebc75dd498cccfad to your computer and use it in GitHub Desktop.
Save EndangeredMassa/b7bd5edc10751909ebc75dd498cccfad to your computer and use it in GitHub Desktop.

Turns Out: Forms are Hard

The Ember community is tackling web forms in an effort to make them easier to work with. This article attempts to summarize and expand on the conversation so far.

Form Use Cases

I think it's useful to start with use cases that can guide us on the journey.

The book "Form Design Patterns" by Adam Silver covers 10 examples of forms. Those cover a lot of ground and therefore may serve a set of use cases against which the form solution can be checked. https://www.smashingmagazine.com/2018/10/form-design-patterns-release/

  • registration form
  • checkout form
  • flight booking form
  • login form
  • inbox
  • search form
  • filter form
  • upload form
  • expense form
  • long and complicated form

API Requirements

There appears to be a set of guidelines and requirements that we're looking for. These are based on a lot of experience (across many people) writing forms, getting them wrong, and fixing them over time.

These requirements describe things that the form solution must support. That doesn't mean the form solution must implement the functionality directly--just that a developer can build a form with the form solution that has that functionality. For example, the form solution doesn't need built-in validations, but it must allow a developer to build a form that has validations.

Accessibility: The solution MUST support accessible form building as much as possible.

Abstraction: The solution MUST provide multiple layers of abstraction that are built on top of each other. This will allow a developer to drop down to a lower level when the higher level is too specific in its solution.

Fields: The solution MUST support all field types. It MUST also support custom field types by exposing the primitives required to integrate that with the solution.

Data: The form data MUST support scalar and vector values being bound to form controls. The interface must accept an Ember Data model, POJO, or ChangeSet.

Validations: The solution must support executing any validation library (although it's OK to force a common interface) as well as rendering validation errors.

Errors: The solution MUST support rendering of field-level and form-level errors.

Form Submission State: The solution MAY support the rendering of elements based on the current submission state of the form. This may use ember-concurrency.

Form API

The form solution itself will likely include a form component.

<Form @onSubmit={{this.mySubmit}} @data={{this.someChangeset}} @validator={{this.someValidator}} as |form|>
  <!-- ... -->
</Form>

@onSubmit: the function to call when the submit action is sent to the form. The arguments passed to it would include the modified @data property.

@data: the form data to be operated on. This will be mutated.

@validator: a function that accepts the current @data and returns a validation error object.

|form|: yielding out some helpers, component modifiers, and/or other components.

Beyond that, there's a lot to discuss.

What follows is a series of increasing levels of abstraction over forms and example APIs at each level. The intent here is to understand the potential levels of abstraction. In a solution to this problem, not all levels of abstraction would be supported. Part of this effort would be to decide which levels are worth shipping.

Layer 0: Ember Core [no components]

This is an example of what is often done with current Ember.

<form onsubmit={{this.handleSubmit}}>
  <label for="firstName">First name</label>
  <input id="firstName" type="text" onblur={{ action (mut this.firstName) value="target.value" }}>

  <label for="lastName">Last name</label>
  <input id="lastName" type="text" onblur={{ action (mut this.lastName) value="target.value" }}>

  <label for="email">Email</label>
  {{#if this.emailError}}
    <div class="error">
      {{this.emailError}}
    </div>
  {{/if}}
  <input id="email" type="email" onblur={{ this.validateEmail }}>

  <button>Submit</button>
</form>

Layer 1: Global Wiring [no components]

This is an example of what could be done with general component modifiers and helpers provided by an addon.

We could use a component modifier like register that will two-way databind a form field to a property in context so that we can retrieve the value on submit. It can also smooth over the target properties of various form elements.

The validate and get-errors modifiers could help us wire up validations.

<form onsubmit={{this.handleSubmit}}>
  <label>
    First name
    <input type="text" {{register this.firstName}}>
  </label>

  <label>
    Last name
    <input type="text" {{register this.lastName}}>
  </label>

  <label for="email">Email</label>
  {{#if (get-errors 'email')}}
    <div class="error">
      {{(get-errors 'email')}}
    </div>
  {{/if}}
  <input id="email" type="email" {{validate this.Email type='email'}}>

  <button>Submit</button>
</form>

Layer 2: Field Wiring [form component]

This solution provides a Form component that manages state and responds to submission and validation events.

<Form @onSubmit={{this.saveRecord}} @data={{this.record}} @validator={{this.someValidator}} as |form|>
  {{#form.helpersFor 'firstName' as |fieldHelpers|}}
    <label for={{fieldHelpers.fieldId}}>First name</label>
    <input
      id={{fieldHelpers.fieldId}}
      value={{fieldHelpers.value}}
      oninput={{fieldHelpers.onChange}}
    >
  {{/form.helpersFor}}
  
  {{#form.helpersFor 'lastName' as |fieldHelpers|}}
    <label for={{fieldHelpers.fieldId}}>Last name</label>
    <input
      id={{fieldHelpers.fieldId}}
      value={{fieldHelpers.value}}
      oninput={{fieldHelpers.onChange}}
    >
  {{/form.helpersFor}}

  {{#form.helpersFor 'email' as |fieldHelpers|}}
    {{#if fieldHelpers.hasError}}
      {{fieldHelpers.error}}
    {{/if}}

    <label for={{fieldHelpers.fieldId}}>Email</label>
    <input
      id={{fieldHelpers.fieldId}}
      value={{fieldHelpers.value}}
      oninput={{fieldHelpers.onChange}}
    >
  {{/form.helpersFor}}
  
  <button>Submit</button>
</Form>

Layer 3: Form Wiring [form component]

This solution focuses on a single form component that does enough of the work for you without getting in the way of actual form (technical and aesthetic) design.

<Form @onSubmit={{this.mySubmit}} @data={{this.someChangeset}} @validator={{this.someValidator}} as |form|>
  <label>
    First name
    <input type="text" {{form.register 'firstName'}}>
  </label>
  <label>
    Last name
    <input type="text" {{form.register 'firstName'}}>
  </label>
  <label>
    Email
    <input type="email" {{form.register 'email'}}>
  </label>
  
  {{#if form.isInvalid}}
    {{#each form.errors as |error|}}
      <div class="error">
        {{error}}
      </div>
    {{/each}}
  {{/if}}
  
  <button>Submit</button>
</Form>

The register component modifier knows how to handle values for various form fields, how to populate a data structure with scalar and vector values, and already has access to the @data. It must be called on all form fields. It assumes you want to validate and change data on blur.

Layer 4: Field Building [form + contextual components]

Rather than Compound Components, this version only provides a 1:1 mapping of original HTML form fields to a custom component pre-bound to form data and with events to validate and update data.

<Form @onSubmit={{this.mySubmit}} @data={{this.someChangeset}} @validator={{this.someValidator}} as |form|>
  <label>
    First name
    <form.errorFor @value="firstName" />
    <form.Input @value="firstName" />
  </label>
  
  <form.labelFor @value="lastName">
    Last name
  </label>
  <form.errorFor @value="lastName" />
  <form.Input @value="lastName" />
  
  <button>Submit</button>
</Form>

The labels can use the value as a way to generate the proper id to point to the form element. This does mean that no two form elements can be bound to the same value, which seems like a reasonable limitation of this approach.

Layer 5: Form Building [form + contextual components]

This solution focuses on doing as much for the dev as is reasonable.

<Form @onSubmit={{this.mySubmit}} @data={{this.someChangeset}} @validator={{this.someValidator}} as |form|>
  <form.Input @label="first name" @value="firstName">
  <form.Input @label="last name" @value="lastName">
  
  <button>Submit</button>
</Form>

How it Works:

  • fields are included with generated IDs for generated labels to point to when the @label attribute is provided
  • values are one-way bound to field values
  • the validations for a given field happen on blur of that field
  • the validations for the whole form are run pre-submit, preventing @onSubmit from being called if invalid
  • the changed values are submitted to the @onSubmit action

This is a high level of abstraction over html forms. If the dev needs to do something that this interface does not support, there needs to be an escape hatch down to a lower level. This could be accomplished by having the Compound Components yield out their children components so that the dev can arrange and modify them individually. This is useful for changing the relative position of elements in the DOM and styling individual elements.

<Form @onSubmit={{this.mySubmit}} @data={{this.someChangeset}} @validator={{this.someValidator}} as |form|>
  <form.Input @value="firstName" as |i|>
    <i.Label class="mb-5">
      First name
    </i.Label>
    <i.Input class="border-red" />
  </form.Input>
  
  <form.Input @value="lastName" as |i|>
    <i.Label class="mb-5">
      Last name
    </i.Label>
    <i.Input class="border-red" />
  </form.Input>
  
  <button>Submit</button>
</Form>

Shipping Layers of Abstraction

You'll notice the 6 layers of abstraction have some obvious grouping, especially after the two basic cases of layers 0 and 1.

Layers 2 and 4 are field focused whereas layers 3 and 5 are form focused. Layer 5 has an escape hatch at layer 4 and layer 3 has an escape hatch at layer 2.

If a dev needed to drop down a layer of abstraction, the could easily do so from 5 to 4 and 3 to 2 without rewriting their forms. If they need to drop further, however, they'd have to rewrite all form fields to use the lower level.

I think this means we have 3 real options for shipping a form solution as an addon. It's one of:

  • Shipping layers 2 and 3
  • Shipping layers 4 and 5
  • Shipping layers 2, 3, 4 and 5 where they all work together

The final option is more complicated, but it would give a lot of flexibility to devs.

Field States

Fields can be in various states. A form solution should support rendering decisions based on all of them. They can be:

  • cleanliness: pristine vs. dirty
  • validity: validating vs. valid vs. invalid (with errors)

Note that validation can by asynchronous over an indeterminate period of time. Being able to render an indication of that state is important for the form.

Form States

Forms can be in various states. A form solution should support rendering decisions based on all of them. They can be:

  • cleanliness: pristine vs. dirty
  • validity: validating vs. valid vs. invalid (with errors)
  • submission: unsubmitted vs. submitting vs. succeeded vs. failed (with errors)

Note that validation and form submission can by asynchronous over an indeterminate period of time. Being able to render an indication of those states is important for the form.

Validation

There are 4 types of validations. Each one can happen synchronously or asynchronously, against known data or retrieved data from memory, from local storage, a server, a browser permission request, or wherever. They can also happen at different times, such as field blur, field change, form submit, or other events.

Partial Type Validation: Validates a field value's type without checking for completness. Example: an email field with current value "sean@abc" could be marked valid because it is valid so far.

Complete Type Validation: Validates a field value's type completely. Example: an email field with value "sean@example.com" could be marked valid.

Field Data Validation: Validates a field value after it is complete and the type has been validated within the context of the form.

Form Data Validation: Validates an entire form's submitted data on submission of the form. Example: two fields have mutually exclusive values and the form is therefore marked invalid.

How exactly that validator should work as it integrates with the form is still worth discussing.

Normalization

Sometimes an dev wants to normalize data after it has been entered. The common case is to standardize the format for a phone number. Normalization is really just a validation that, if valid, may also want to modify the value. Given this, the form solution should allow validations to mutate the values they are validating. This leaves the actual details of normalization up to the validation library that is plugged in.

Submission

Form submission should always go through the submit event, often triggered by a button, an input of type "submit", or hitting Enter while inside a form field. The changed values from the form fields should be provided to the @onSubmit handler for processing.

The submission itself is quite often asynchronous. Forms should be able to make rendering decisions based on submission states.

Unsubmitted: This is the initial state of a form. The submit event has never been fired.

Submitting: The submission is asynchronous and currently in progress.

Succeeded: The submission succeeded without error.

Failed: The submission failed with errors.

<Form @onSubmit={{this.saveRecord}} @data={{this.record}} as |form|>
  <label>
    First name
    <input type="text" {{form.register 'firstName' validateOn='blur'}} >
  </label>
  <label>
    Last name
    <input type="text">
  </label>
  
  {{#if form.isSubmitting}}
    <Loading />
  {{/if}}
  
  <submit>Submit</submit>
</Form>

Linting Enforcement

We may be able to provide linting rules along with an addon that provides the Form component and related API surface (helpers, modifiers, and other components). These linting rules could make it easier to enforce consistent API usage where run-time detection would be a lot harder and less convenient.

We could then enforce the form.register requirement with a linting rule.

<Form @onSubmit={{this.saveRecord}} @data={{this.record}} as |form|>
  <label>
    First name
    <input type="text" {{form.register 'firstName' validateOn='blur'}} >
  </label>
  <label>
    Last name
    <input type="text">
  </label>
</Form>

The linter would fail on the input for last name saying that register must be called there.

Open Questions

Obviously, everything above is still being discussed. Below are specific questions that definitely need more discussion.

  • Can you yield helpers or modifiers?
  • For the layers of abstraction that manage it, should onSubmit be called only after validations have shown the form to be valid?
  • Can we reasonably ship a linting rule and automatically modify the template linting config to include the new rule?

Special Thanks

As I said at the top, this is a summarization and expansion on an ongoing conversation in the Ember community. These ideas should not be wholly attributed to me. You'll find many of them came from (in no particular order) Preston Sego, Trek Glowacki, Garrick, Chris Krycho, Frédéric Soumaré, Ben Demboski, Ralph Mack, Thomas Gossmann, Howie Bollinger, Chris Thoburn, and I'm sure many others.

Keep up the strong conversation and we'll arrive at strong solutions!

@gossi
Copy link

gossi commented Sep 13, 2019

This is an amazing write-up of what happened in #topic-forms. My suggestions/additions:

The validation part talkes about the what and the when, it should be either/or = do one thing, not two.
Instead I like what @lupestro was saying as: "lex-validate after keystroke, parse-validate and self-validate on blur, cross-validate" combined with a strategy (think strategy design pattern) that contains the when to do.

The validity state is missing the validated state. I even think these are two. validating, validated, unvalidated (the initial one). And only for validated there is a second one that is valid or invalid.

@dbollinger
Copy link

This is great, much appreciated!

My biggest question is: what's our first step towards realizing all of this?

@EndangeredMassa
Copy link
Author

The validation part talkes about the what and the when, it should be either/or = do one thing, not two.
Instead I like what @lupestro was saying as: "lex-validate after keystroke, parse-validate and self-validate on blur, cross-validate" combined with a strategy (think strategy design pattern) that contains the when to do.

I think that's a helpful distinction. I started from @lupestro's great breakdown and arrived at these. I agree that separating types of validation from when they can occur will help make this clearer.

The validity state is missing the validated state. I even think these are two. validating, validated, unvalidated (the initial one). And only for validated there is a second one that is valid or invalid.

I don't think I understand the suggestion. Can you list out the states you think should exist with brief descriptions?

My biggest question is: what's our first step towards realizing all of this?

I think the next step is to choose a layer of abstraction and build an experimental addon. Example: ember-spicy-forms.

@MelSumner
Copy link

  • Form validation could be split up into client-side validation and server-side validation. I think there is a benefit to using them both- client-side validation will be faster, is supported in native HTML (via pattern attribute, etc), and doesn't require calls to the server. Server-side validation can validate (and provide for normalization of) the actual data being sent to the server, and is better for security.
  • I wonder how to best talk about accessibility in forms- context changing, label elements for inputs, and auto-submitting forms are usually the places where I see the most errors.

@lupestro
Copy link

I think Chris Krycho's musings on the separation of layers is an important concept in here. Elaborating on it from my own perspective, forms are a presentation/UI construct with an API for:

  1. collecting a partial or complete set of values from the user to be negotiated for use in an application (commonly called a changeset)
  2. presenting to the user the terms of negotiation that the user needs to fulfill (labels and instructions, often embedded in the static markup)
  3. presenting to the user the terms of negotiation that the user has failed to fulfill (customarily in the form of error messages)

In short, they are a mechanism to communicate negotiation, not to perform negotiation. The business layer conducts negotiation with the user, communicating with the user through the form.

The information a form exchanges with the business logic through its API should be as close as possible to the actual presented form of the data. In particular, text should be exchanged unparsed - both parsing and normalization should be considered business logic. No data becomes "real" to the rest of the program until the negotiation, possibly involving both client and server, has completed, the user's submission is accepted, and the data appears in the UI in what the business logic deems to be normalized form.

Popular libraries conflate separate concerns when they embed validation in the form or use model data from the data layer verbatim in changesets. Both appear as a great convenience when you are prototyping because they are mostly the same but can become a hopeless tangle when real-world business logic starts getting involved.

Once the data becomes "real" to the application, when data is submitted to the server for storage, there may be another level of validation, but that isn't validation of the user input but of the parameters of the storage request. It is prompted by the contract between server and client, not the contract between the application and the user. If something fails here, it isn't about what the user submitted in good faith, it's about how to honor or default on "the contract already signed".

There are a number of interesting error cases here, and it may be that server-side validation needs to reserve resources that the negotiation is contingent upon, like naming, so that the contract can be honored when the storage actually occurs, releasing those resources if the negotiation breaks down. This, in turn, means the server needs to know when the negotiation has been abandoned. Nobody said application design wouldn't be messy, 😦 but at least, with a clean separation of concerns and responsibilities, any needed mess can be dealt with in an orderly way.

@gossi
Copy link

gossi commented Sep 25, 2019

@EndangeredMassa I see now, why it is unclear. Let me try to clarify:

First of all, we are using the term validity/validation for a couple of things. I want to split this apart into validationStage, validationResult, validationStrategy and validationOccasion (those names are up for discussion, I'm not convinced by them either).

  1. validationStage - means the stage in which the validation is in, which can be unvalidated (initial), validating and validated (that is important as most of the time, you want to show error messages, when the stage is advanced to validated).

  2. validationResult - on a high level it's binary, success or fail but can be more fine-grained going down about what went wrong (e.g. too less characters)

  3. validationStrategy - what is validated: from correct formats, e.g. emails to semantics of business logic. Whatever it is is encapsulated in the strategy.

  4. validationOccasion - then when "lex-validate after keystroke, parse-validate and self-validate on blur, cross-validate" (by @lupestro). You can use an occasion and combine it with a strategy.

I see these four parts for now as covered by the term validation and the validity "state". This validity state appears on field and form.

Given the validationStrategy this can be used to run a server-side validation or even using the DOM API and e.g. the pattern attribute to check for a validation result. Basically, you are free to write them the way you want and even to compose them together the way you want. So, works nicely with what all of you mentioned :)

Disclaimer: I've not taken more than 5 minutes to come up with some terms here. I think they are not even the best ones (partly far away from there) but I would be happy to discuss with you and if the result is we have a solid terminology, I would be more than happy 😊

@dbollinger
Copy link

I created a gist showing a form component using yup's validation schemas. Next step would probably be to apply these different validation requirements that have been discussed --

https://gist.github.com/dbollinger/1fa62ee975ba852633326355d409f7ca

@conradlz
Copy link

conradlz commented Oct 6, 2019

I think we might be ready to start some development on this. I would like to propose we make an addon composed of smaller addons containing some of the aspects like changesets, validation, input mapping, and form field components. So we could allow access to levels 0-3 without the neccesity to include later if they are undesired.

Could this be a situation where we could use a monorepo to host the work?

@runspired
Copy link

I still feel what we did in ember-stickler was a good POC for this :) separation of validation from masking, buffering, etc. and allowing for multiple sources and levels of errors is very powerful.

@sandstrom
Copy link

Good summary!

Some thoughts:

  1. Regarding normalization, sometimes you'd want to present some value to the user and use something else "internally" (i.e. for validation and data persistence). For example a number could be presented as 1 000,50 or 1,000.50. I wouldn't couple normalization with validation, rather I'd see normalization as a step that occurs before the validation.

Sometimes an dev wants to normalize data after it has been entered. The common case is to standardize the format for a phone number. Normalization is really just a validation that, if valid, may also want to modify the value. Given this, the form solution should allow validations to mutate the values they are validating. This leaves the actual details of normalization up to the validation library that is plugged in.

  1. This can easily become too big of a beast. I'd think hard about what parts could reasonably be left out, or separated by clean interfaces so they're easy to switch out. For example, I like your thinking on validation and how one should only think about the interface and errors, not the actual validation implementation (within this scope).

  2. I agree with Accessibility: The solution MUST support accessible form building as much as possible.. There are tons of applications where there is zero need for accessibility, for example a bunch of internal tools and business tools, among other things. Or maybe you're writing two separate applications, one for accessibility/screen-readers and one for regular browsers. I'd leave it out totally, but otherwise this is more of a nice-to-have. Again, this can quickly turn into an utopia that will never materialize, and that would be sad.

@egaba
Copy link

egaba commented Oct 16, 2019

As @dbollinger has already surfaced, Yup is an excellent client-side validation library that does not conflate the data layer with UI components. Instead, you define schemas, which given a set of input data, efficiently validates the data. See this example below of how we could use it in a controller.

// controller.js
import Controller from '@ember/controller';
import * as yup from 'yup';

export default Controller.extend({
  schema: Ember.computed(function() {
    return yup.object().shape({
      username: yup.string().required(),
      age: yup.number().min(18, 'you must be at least ${min} years of age in order to join this app').required(),
      email: yup.string().email().required(),
      countryCode: yup.string().required(),
      zipCode: yup.string().required().matches(/\d{5}(-?\d{4})?|\s*/, 'must be a 5 or 9 digit zip code'),
    });
  }),
  formData: {
    username: '',
    age: '',
    email: '',
    countryCode: '',
    zipCode: '',
    gender: '',
  },
  errors: Ember.computed(function() {
    return Ember.A();
  }),
  actions: {
    validate() {
      const errors = this.get('errors');
      errors.clear();
      this.get('schema').validate(this.get('formData'), { abortEarly: false }).then((data) => {
        this.set('isValid', true);
      }).catch((err) => {
        this.set('isValid', false);
        errors.addObjects(err.errors)
      }).finally(() => {
        this.set('didAttemptValidate', true);
      });
    },
  }
});
<-- template.hbs -->
<form {{action "validate" on="submit"}}>
  {{#if isValid}}
    <p>valid</p>
  {{else if didAttemptValidate}}
    <ul>
      {{#each errors as |msg|}}
        <li>{{msg}}</li>
      {{/each}}
    </ul>
  {{/if}}

  {{#each-in formData as |name value|}}
    <div>
      <label for="{{name}}-input">{{name}}</label>
      <div>
        <input
          id="{{name}}-input"
          oninput={{action (mut (get formData name)) value="target.value"}}
          value={{get formData name}}
        >
      </div>
    </div>
  {{/each-in}}

  <footer>
    <button type="submit">validate</button>
  </footer>
</form>

Here's a live demo of this concept in action: https://egaba.github.io/ember-yup/#/getting-started/using-schemas

As @lupestro aforementioned, the separation of layers is very important. I am the author of ember-yup and have (unintentionally) crossed the boundary a few times with UI components and ultimately ended up removing them from being imported.

What I ended up with was another concept, which was to extend Models with a Schema mixin that allows Models to validate their data. It began to strike a nice balance of validation and usage.

import DS from 'ember-data';
const { Model } = DS;
import Validate from 'ember-yup/mixins/validate-model';

export default Model.extend(Validate, {
  username: DS.attr({
    validate: {
      required: true,
    },
  }),
  age: DS.attr('number', {
    validate: {
      required: true,
      min: 18,
    },
  }),
  email: DS.attr('string', {
    validate: {
      type: 'email',
      required: true,
    },
  }),
  countryCode: DS.attr({
    validate: {
      required: true,
      oneOf: ['US', 'ES', 'JP', 'SK']
    },
  }),
  zipCode: DS.attr('string', {
    validate: {
      required: true,
      when: {
        countryCode: {
          is: 'US',
          then: {
            matches: /\d{5}(-?\d{4})?/,
          }
        }
      }
    },
  }),
  gender: DS.attr(),
});

By extending Models with this Schema mixin, it gives Models the ability to record.validate().then(...).catch(...) and record.save({ validate: true }), in which both add messages to the record's errors object similar in how a server would populate the object (and does NOT create a network request if isInvalid).

// controller.js
actions: {
  validate() {
    this.get('model').validate().then(function(data) {
      console.log('validate success', data);
    }).catch((errors) => {
      console.log('validate errors', errors.get('messages'));
    });
  },
  save() {
    this.get('model').save({ validate: true }).then(function(data) {
      console.log('save success', data);
    }).catch(function(errors) {
      console.log('save errors', errors.get('messages'));
    });
  }
}

A live demo of this concept can be seen here: https://egaba.github.io/ember-yup/#/getting-started/ember-data

Anyways, let me know what you guys think. We could potentially use this repo for what we want to build. This addon needs some heavy cleanup, but I'd be willing to drive if I could get help.

@sandstrom
Copy link

I agree, Yup is an interesting library for schema validation.

A similar library that I also find useful is https://github.com/cross-check/cross-check/tree/master/packages/schema. It has some nifty features around drafts and schema validation in different states (you may validate a draft blog post differently from a final blog post).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment