A radically different, radically powerful library for representing input form state.
Working with forms can be painful because there's soooooo much going on. You're taking completely loosey goosey schema-less data, and then filtering it, massaging it, and in an overall asynchronous fashion cajoling it into a strongly typed object that is suitable for persistence to your server. In other words, garbage in, gold out.
At any given point, there are all kinds of complex questions you want to ask your form so that you can report what's going on to your users. Here are some common ones that you may have encountered:
- Are there any outstanding edits?
- Can it be submitted? If not, why not?
- Is this form currently submitting?
- Are all of the fields valid?
- If not, which validations are failing?
And that's just the bar for entry. I got tired of answering the exact same set of questions over and over and over again inside each of my applications, and so I set out to build a library that would answer all of these questions holistically.
Form X models your form as a state machine that represents your entire form state at every moment with a single object. Because you have every minute piece of form state available at all times, you can begin to answer even more advanced questions such as:
- which rules comprise a single validation?
- which rules are dependent on other rules?
- which validations are currently pending?
- which rules within a pending validation are currently running?
And that's just the beginning. Beyond that, there are all kinds of edge-cases that just aren't solved by cobbling together a validation library with a bunch of glue code.
Form X is implemented using simple JavaScript pojos, and has absolutely no dependency on any framework.
All it does is track your state based on what you tell it, so for example, it will tell you which rules need to be run, but it will not actually do the work of running them for you.
This can be be counterintuitive at first, because it's up to you to map all of the events that actually transition the form from one state to the next. The form's sole job is to provide you with the implications of the events you've told it about. This allows you complete latitude in how you'll represent the resulting state to the user.
FormX doesn't even dictate which validation library you use since it's only responsible for tracking the result of those validations.
FormX is built using Form
, Field
, Validation
, and Rule
POJOS. Most of the time you'll be working with only the Form
constructor itself, but we'll introduce them in reverse order because
each object is built upon the next.
Rule is the most basic unit of validation.
import { Rule } from 'form-x';
let rule = Rule.create({
code(value) { return value > 5 }
description: "Greater than 5"
});
// a rule starts out in an idle state.
rule.isIdle //=> true
rule.isTriggered //=> false
rule.isRunning //=> false
rule.isFulfilled //=> false
rule.isRejected //=> false
Note: The
code
anddescription
attributes that we associated with this rule are abritrary. They are an example of how the caller can "hang" whatever data it needs to actually run and execute the rule on the rule, but theRule
object itself only tracks the state of the rule.
An idle rule is not currently considering any input, but we can "trigger" it by providing input.
let triggered = rule.setInput(6);
triggered.input //=> 6
triggered.isIdle //=> false
triggered.isTriggered //=> true
triggered.isRunning //=> true
triggered.isFulfilled //=> false
triggered.isRejected //=> false
This rule is triggered is considered triggered, but as mentioned it is
up to the implementer to actually run the rule. The Rule
object is
not responsible for performing the rule, only for containing
information about the current state of its execution.
Why have a separate state for triggered and running? Because again, it is not actually running the rule. There may be some delay between when it is decided that a rule needs to be run, and when it is actually scheduled to be run. That is up to the discretion of the user of the code. A naive implementation might run it immediately.
Calling the run
method will indicate that we are actually running
the rule, which we can then either resolve or reject.
// indicate that this rule is now running.
let running = triggred.run();
running.isIdle //=> false
running.isTriggered //=> false
running.isRunning //=> true
running.isFulfilled //=> false
running.isRejected //=> false
let settled;
if (rule.code(rule.input)) {
settled = rule.resove();
} else {
settled = rule.reject(`#{rule.input} was not greater than 5`);
}
settled.isIdle //=> false
settled.isTriggered //=> true
settled.isRunning //=> false
settled.isFulfilled //=> true
settled.isRejected //=> false
In this way, we have an object that knows at every point what state the rule is in. Here is the full state diagram for a rule.
Validations build on the concept of rules. They act as an aggregator for a set of rules and derive which rules are eligible for being run at any given time. In order for a validation to pass, all of its rules must be satisfied. Also, it also allows you to specifiy dependencies between rules so that a rule will not even be eligible for triggering until all of its antecedent rules are satisfied. Let's say we had a rule for validating passwords. We want to check that:
- it is long enough
- it contains enough special characters
- it is not within the last 3 passwords
Of course to check if the password was used as the last three passwords we're going to have to consult the backend, which is going to be asynchronous and consume more resources on both client and sever. Because of this, we don't want to run this rule unless the first two have already been satisfied. In ther words, rule three is dependent on both rules one and two.
Let's model this validation using FormX.
let validation = Validation.create({
rules: {
longEnough: {
description: "Must be at least 6 characters"
},
containsSpecialCharacters: {
description: "Must contain at least one special character ($%&*!)"
},
notWithinLastThree: {
description: ""
}
},
dependencies: {
notWithinLastThree: ['longEnough', 'containsSpecialCharacters']
}
});
validation.isIdle //=> true
validation.isPending //=> false
validaiton.isFulfilled //=> false
validation.isRejected //=> false
Like rules, validations also begin in the in the idle
state. Also
like rules, they only consider a single value at a time. To begin
validation, we set the input like we do for a rule. This will let us
know which rules have been triggered.
let pending = validation.setInput('123password!!');
validation.isIdle //=> false
validation.isPending //=> true
validaiton.isFulfilled //=> false
validation.isRejected //=> false
pending.rules.longEnough.isTriggred //=> true
pending.rules.containsSpecialCharacters.isTriggered //=> true
pending.rules.notWithinLastThree.isTriggered //> false
//rules are also partitioned by their state
pending.triggered //=> [Rule(longEnough), Rules(containsSpecialCharacters)]
pending.idle //=> [Rule(notWithinLastThree)]
As you can see, the rule that needs to talk to the server is still idle at this point. To indicate that a rule is running, use the run method:
pending = pending.run(pending.rules.longEnough);
pending.isPending //=> true
pending.rules.longEnough.isRunning //=> true
pending.rules.containsSpecialCharacters.isTriggered //=> true
pending.rules.notWithinLastThree.isTriggered //> false
Only when both antecedent rules are satisfied will our server rule be triggered:
pending = pending.resolve(pending.rules.longEnough);
pending = pending.run(pending.rules.containsSpecialCharacters);
pending.rules.longEnough.isFulfilled //=> true
pending.rules.containsSpecialCharacters.isFulfilled //=> true
pending.rules.notWithinLastThree.isTriggered //> true
Finally, assuming that our last rule is satisfied, the validation itself will be satisfied.
pending = pending.run(pending.rules.notWithinLastThree);
$.get(`/password-check/${pending.input}`)
.then(() => pending.resolve(pending.rules.notWithinLastThree))
.catch((reason) => pending.reject(pending.rules.notWithinLastThree, reason))
.then(result => {
result.isFulfilled //=> true
})
For reference, Here is the full state diagram for Validation
Field builds on Validation by acting as a bridge between it and the form submission process. It indicates whether a field is "blocking" the form from being submitted.
Some fields are optional. Some aren't. For example, when filling out the signup form for an account, most sites will require you to enter in your email address. Sometimes they'll ask you for your phone number too, but it's no big deal if you don't enter it.
We say then that the email address is required and that the phone number is optional. In other words, if the email address field is empty, then the form cannot be submitted, but if the phone number field is empty, then it can.
But.... and here's the wrinkle. If you do decide to provide your
phone number, then it needs to be validated according to the normal
rules for phone numbers. "Hullo World"
and Math.PI
simply won't
do.
Therefore if a field is not required, it is OK if its validation is idle.
FormX does not treat value presence as just another validation rule,
but instead as a first class citizen, and it is something that can be
queried directly with the field.isRequired
attribute.
Another property of fields is that they model when validation occurs. This is helpful because sometimes you want a field to be accepting input, but you don't want to validate it just yet. If you're implementing an HTML form for example, the convention is to not start validating a field unitl after it first loses focus.
Before you transition a field to a validating state, it will simply capture the input over and over in a noop. It's only once you begin validating that rules will become triggered.
import { Field } from 'form-x';
let email = Field.create({
required: true
rules: {
validEmail: {
description: "Must be a valid email address"
}
}
});
email.isBlocking //=> true
email.validation.isIdle //=> true
email = email.setInput('hello');
email.validation.isIdle //=> true
email = email.validate();
email.validation.isIdle //=> false
email.rules.validEmail.isTriggered //=> true
email.isValid //=> false
The field wraps the transitions around rules running and resolving.
email = email.run(email.rules.validEmail)
email = email.resolve(email.rules.validEmail)
email.isValid //=> true
Here's the full state machine for Field.
The Form
object is what ties it all together. It answers the
questions like
- What is the base state of the object being editted.
- Are there any edits to the base state? If so, what are they?
- If there are edits to the base object, what is the set of valid changes?
- Is the form submittable in its present form?
- Is it currently submitting at this very moment?
- If it cannot be submitted, why?
As a developer using FormX, you shouldn't need to actually create any other object except for the form because it will create all the other requisite objects for you.
To work with a form, you pass it information about the data you'll be
editting via the data
attribute.
In the case where data
is null or undefined, the form is considered
to be a "new" form instead of an "edit" and which means that it can be
submitteded immediately assuming requisite validations pass:
import { Form } from 'form-x';
let newForm = Form.create({data: null});
newForm.isNewEntity //=> true
newForm.isSubmittable //=> true
newForm.isChanged //=> true
It may seem odd at first that a newly created form is considered to be "changed", but if you think on it, the proposition that a new object should exist where no object existed before is itself a change.
More often though, we'll be creating a form that represents an edit.
Let's suppose We have a form to update a user's account information with an email address, a new password and a password confirmation.
let form = Form.create({
// values of the object you're editting.
data: { email: "alice@example.com" },
fields: {
// These are passed to the `Field.create()` method, so the
// structure will look familiar.
email: {
required: true,
rules: {
validEmailAddress: {},
emailAvailable: {}
},
dependencies: {
emailAvailable: ['validEmailAddress']
}
},
password: {
required: false
rules: {
longEnough: {
description: "Must be at least 6 characters"
},
containsSpecialCharacters: {
description: "Must contain at least one special character ($%&*!)"
},
notWithinLastThree: {
description: ""
}
},
dependencies: {
notWithinLastThree: ['longEnough', 'containsSpecialCharacters']
}
},
confirmPassword: {}
},
// these rules are associated with the form as a whole and are only
// triggered after all field validations have passed
rules: {
passwordsMatch: {}
}
});
// query the form state.
form.isChanged //=> false
form.isValidating //=> false
form.isSubmittable //=> false
form.isSubmitting //=> false
form.isAccepted //=> false
form.isRejected //=> false
The form has three values that represent the state of the edits. The
first is the data
attribute. It represents the initial object being
editted. The buffer, which is the current, as-is values of all the
edits whether they are validated or not, and finally, the changes
field, which contains all of the changed field values that have passed
all validations.
form.data //=> { email: "alice@example.com" }
form.buffer //= { email: "alice@example.com" }
form.buffer === form.data //=> true
form.changes //=> {}
To begin interacting with the form, you set the value of one of its fields. It will transition to the validating state and indicate which rules have been triggered and need to be run.
form.set('email', 'bob@example.com');
form.data //=> { email: "alice@example.com" }
form.buffer //= { email: "bob@example.com" }
form.changes //=> {}
form.isChanged //=> true
form.isValidating //=> true
form.triggered //=> [Rule(validEmailAddress)]
form.triggered.forEach(rule => form = form.run(rule));
Let's say we performed the valid email address check. We resolve rules similar to the way we did for fields and validations. Let's pretend that we ran the email validation. This will trigger further rules that had been idle before.
form = form.fulfill(form.fields.email.rules.validEmailAddress);
form.triggered //=> [Rule(emailAvailable)]
Assuming that all of the email validations now run to to completion,
we will have the change in the changes
field and the form will be
submittable.
form.changes //=> { email: "bob@example.com" }
form.isSubmittable //=> true
form = form.submit();
form.isSubmitting //=> true
form.isAccepted //=> false
form.isRejected //=> true
Not always, but most of the time, submitting the form involves a trip
to the backend, so calling submit()
just transitions it into the
submitting state and then at some later point the submission will be
either accepted or rejected.
Once the form is accepted, it's buffer and changes will be reset:
form = form.accept();
form.isAccepted //=> true
form.isChanged //=> false
form.data //=> { "alice@example.com" }
form.buffer //=> { "bob@example.com" }
form.changes //=> {}
A full diagram of the Form state machine:
$ npm install
$ npm test