hacklanta / lift-formality

Formality is a new way of doing form creation in Lift—type safe, CSS selector based, with minimal repetition of fields and their names and values, it also supports event callbacks and validations.

GitHub

Formality

Formality is a library that provides better, cleaner, more palatable form handling for Lift. Form handling without the pain, if you will.

The goal of creating this library was to eliminate as much repetition as possible while wielding the full power of Lift's stateful form mechanism and its CSS selector transforms, as well as embracing Lift's own error-reporting mechanisms.

Status

Formality is still in an early state. While there are tests ensuring everything works, use in production is likely to uncover some undiscovered bugs. Those bugs will be crushed as quickly as possible once reported on Github.

Including

You can include Formality directly as a dependency in your build.sbt, suffixing the major and minor Lift version you are using (3.0 thru 3.3 are currently supported).

For example to include the build for 3.3, you would write:

libraryDependencies ++= Seq(
  "com.hacklanta" %% "lift-formality_3.3" % "1.2.0"
)

And for 3.2 you would write:

libraryDependencies ++= Seq(
  "com.hacklanta" %% "lift-formality_3.2" % "1.2.0"
)

And soforth.

Usage

Formality provides a DSL for binding forms already defined in the HTML. It leaves as much of the markup as possible to the markup itself, and layers on only the necessary functionality from Lift's side. Amongst other things, this means that the type of a field in the HTML is left to the markup.

A basic form snippet would look like this:

  import com.hacklanta.formality.Formality._

  val registrationForm =
    form withFields(
      field[String](".name"),
      field[String](".phone-number"),
      field[Int](".age"),
      checkboxField("#terms-and-conditions")
    ) onSuccess { (name, phoneNumber, age, termsAndConditions) =>
      // Assuming a case class User(name: String, age: Int, phoneNumber: String, termsAndConditions: Boolean).
      User(name, age, phoneNumber, termsAndConditions).save
    }

  "form" #> registrationForm.binder

The corresponding HTML5 markup could be:

<form data-lift="Registration.form">
  <input class="name">
  <input class="phone-number" placeholder="(XXX) XXX-XXXX">
  <input class="age" type="number">

  <label for="terms-and-conditions>
    <input type="checkbox" id="terms-and-conditions">
    I agree to the <a href="/terms">Terms and Conditions</a>
  </label>
</form>

This is a very basic registration form with no validations or event callbacks. The key here is that our success handler gets a set of properly-typed values. It is only called if all of the entries can properly be deserialized into their requested types (for example, putting in "hello" for the age would mean the success callback would not run).

Behind the scenes, Formality tries to deserialize the values. If the deserialization fails, S.error is automatically set for the appropriate field. You can then customize the behavior of S.error as you desire (see LiftRules for more, specifically the noticesToJsCmd property).

Validations

Formality can also do validation:

  import com.hacklanta.formality.Formality._
  import com.hacklanta.formality.Html5Validations._

  val nameField = field[String](".name") ? notEmpty
  val phoneNumberField = field[String](".phone-number")
  val ageField = field[Int](".age") ? inRange(15, 120)
  val termsField =
    checkboxField("#terms-and-conditions") ? { incoming: Boolean =>
      if (incoming) {
        Empty
      } else {
        Full("You must agree to the terms and conditions.")
      }
    }

  val registrationForm =
    form withFields(
      nameField,
      phoneNumberField,
      ageField,
      termsField
    ) onSuccess {
      // same as above
    }

Now we've taken it a step further. Validations can be simple functions. These functions take the deserialized value (i.e., the value after it has been typed properly) and return a Full Box with an error message when there is an error, or an Empty Box if the value is valid. In cases where a validation fails (by providing an error message), that error message is associated with the appropriate field by calling S.error.

Validations can also be more than simple functions. The built-in validations, like inRange and notEmpty, extend the Validation trait. These Validations are still functions that work as specified above (i.e., they have an apply method that behaves as we specified above). However, they also have a binder method that returns a CssSel. These can be used to provide client-side attributes to also enforce the validation client-side. Client-side validations have the advantage of being able to run faster and without taxing the server. Validations allow you to specify client-side validations and pair them with server-side validations to ensure that valid data is what makes it into your database.

The two built-ins used above are from the Html5Validations object, which does client-side validation using HTML5 attributes. In particular, the notEmpty validation applies a required attribute to the input, and the inRange validation applies min and max attributes to the input.

Also in progress is an object, ParsleyValidations, meant to support the set of validations that are supported by Parsley.js. These will have matching server-side validation implementations.

In certain very specific cases, it can be useful to run a validation even if the value in question isn't actually submitted in the form. Typically this is used for a check that a required field was submitted, particularly when it comes from a checkbox or radio button. Either way, in these cases a validation can take a Box[T] instead of T. It will receive an Empty if the field is not submitted, and a Full with the deserialized value if the field is submitted. Lift-formality's own notBlank/notEmpty validators will correctly handle unsubmitted fields.

Event handling

Formality can also add server-side event handlers to form inputs:

  import com.hacklanta.formality.Formality._
  import com.hacklanta.formality.Html5Validations._

  val nameField = field[String](".name") ? notEmpty
  val phoneNumberField = field[String](".phone-number")
  val ageField =
    field[Int](".age") ?
      inRange(15, 120) ->
      on("change", { incoming: Int =>
        if (incoming < 15) {
          Run("$('#terms-and-conditions').attr('disabled', 'disabled')")
        }
      })
  val termsField =
    checkboxField("#terms-and-conditions") ? { incoming: Boolean =>
      if (incoming) {
        Empty
      } else {
        Full("You must agree to the terms and conditions.")
      }
    }

  val registrationForm =
    form withFields(
      nameField,
      phoneNumberField,
      ageField,
      termsField
    ) onSuccess {
        // same as above
    }

-> on(<event>, <handler) is the syntax used to add an event handler to a field. The event is whatever the event name will be on the client, as a String, and the handler takes in the deserialized incoming value and does something with it. It is expected to return a JsCmd, though keep in mind that there is an implicit conversion from Unit to JsCmd when necessary. Above, on change, we disable the terms and conditions checkbox to ensure the user cannot select it if they are below 15*.

* - Obviously we could do this on the client as well, but this is merely an example :)

Operator Allergies

In case you have an aversion to using operators, the ? operator that adds a validation to a field is originally named validatingWith, so you can use that instead. Additionally, the -> operator that adds an event handler is originally named handlingEvent.

Failure Handling

Formality's default behavior is to send down deserialization and validation errors using S.error. However, you may want to take additional action when dealing with failures. Or, you may want to ignore S.error in favor of your own error handling strategy. To do this, you can add a failure handler to the form:

  val registrationForm =
    form withFields(
      nameField,
      phoneNumberField,
      ageField,
      termsField
    ) onSuccess {
        // same as above
    } onFailure { failures =>
      failures.foreach {
        case ParamFailure(message, _, _, validationErrors) =>
          logger.error("Got " + message + " with validation errors: " + validationErrors)
        case Failure(message, _, _) =>
          logger.error("Got " + message)
        case _ =>
      }
    }

Note that the basic Failure message when validations fail simply says " failed validations." However, in such cases, the failure is a ParamFailure whose parameter is a list of Strings representing the validation errors for that field.

Field Groups

Formality provides an abstraction above fields, called field groups. This lets you group fields into nested groups. By default, this looks like this:

  val registrationForm =
    form withFields(
      fieldGroup.withFields(
        nameField,
        phoneNumberField,
        ageField
      ),
      termsField
    ) onSuccess {
      case ((name :+: phoneNumber :+: age :+: HNil), terms) =>
        // Assuming a case class User(name: String, age: Int, phoneNumber: String) this time.
        if (terms) {
          User(name, phoneNumber, age).save
        }
      User(name, age, phoneNumber, termsAndConditions).save
    }

But field groups also have a very important property: they can be converted into a different type using its as function. In particular, a field group can be a placeholder for a type that involves its combined fields. Let's redo the above:

  val registrationForm =
    form withFields(
      fieldGroup.withFields(
        nameField,
        phoneNumberField,
        ageField
      ).as(User.apply _),
      termsField
    ) onSuccess { (user, terms) =>
      if (terms) {
        user.save
      }
    }

Our success handler now gets a full-on User object instead of the decomposed fields, and we could use the User constructor directly.

as takes a function that takes the parameters from the group and produces a type T. However, sometimes we want to be able to fail at the conversion step. In those cases, we can use withConverter instead, which allows us to return a Box[T], where a non-Full will cause a failed form submission:

  val registrationForm =
    form withFields(
      fieldGroup.withFields(
        nameField,
        phoneNumberField,
        ageField
      ).withConverter(User.createUnique _),
      termsField
    ) onSuccess { (user, terms) =>
      if (terms) {
        user.save
      }
    }

Above, createUnique can be considered as a version of User that creates the new User object and returns a Full unless, say, the name is not unique. In that case it returns a Failure.

Note that failures at the group conversion level don't trigger S.error, since there's no concrete field to associate the failure with. You can do your own handling of the failure in the onFailure handler.

Field groups with boxed converters

Field groups can also use a converter that takes the boxed values of the fields that it is made up of. This can be used, for example, to make choices between two fields that supply the same data. Let's take as an example an upload form that accepts a document in PDF format or a pasted text file:

  val fileNameField = field[String](".file-name")
  val pastedTextField = field[String](".pasted-text")
  val uploadedFileField = fileUploadField(".pasted-text")

  val uploadForm =
    form withFields(
      fileNameField,
      fieldGroup.withFields(
        pastedTextField,
        uploadedFileField
      ).withBoxedConverter { (text: Box[String], file: Box[FileParamHolder]) =>
        text.map(_.getBytes("UTF-8")) or file.data
      }
    ).onSuccess { (fileName: String, fileData: Array[Data]))
      DbFile.create(fileName, fileData)
    }

Above, we have a form with a field for the file's name, and then a group that encloses the pasted text and the file upload. The converter receives both as boxed values, which will be Empty if the user didn't specify anything for the fields in question. The converter then uses the pasted text if it was specified, or if not chooses the file data. The success handler then only sees two parameters: the files name, and the final data array that represents its contents, which we've resolved in the field group converter.

Scoping field groups

You can also add a scope to a field group. This is a CSS selector that ensures the fields in the field group are bound only if they're found within elements that match that selector:

  val registrationForm =
    form withFields(
      fieldGroup(".user").withFields(
        nameField,
        phoneNumberField,
        ageField
      ).withConverter(User.createUnique _),
      termsField
    ) onSuccess ...

With the above code, the markup that we introduced at the beginning would no longer successfully bind the user part of the registration form. Instead, we would need something like this:

<form data-lift="Registration.form">
  <fieldset class="user">
    <legend>User Information</legend>

    <input class="name">
    <input class="phone-number" placeholder="(XXX) XXX-XXXX">
    <input class="age" type="number">
  </fieldset>

  <label for="terms-and-conditions>
    <input type="checkbox" id="terms-and-conditions">
    I agree to the <a href="/terms">Terms and Conditions</a>
  </label>
</form>

License

lift-formality is provided under the terms of the MIT License. No warranties are made, express or implied. See the LICENSE file in this same directory.

Author/Contributors

lift-formality is copyright Hacklanta, and was originally conceived and written by Antonio Salazar Cardozo.

You can find our writings on the Hacklanta blog.