目次

Version 2, last updated by mads379 at Nov 27 19:17 UTC

This article has been copied from the Lift Blog: http://lift.la/lifts-screen

Background

Much of the web is creating input forms for users to submit, validating those input forms and if the forms pass validation, an action is performed. If the forms don’t pass validation, the user is told which fields caused the validation problems and is given an opportunity to fix the problems. Lift provides a single-screen input/validation mechanism called LiftScreen and a multi-page input/validation mechanism (with stateful next/previous buttons) called Wizard. This post will discuss LiftScreen and the next post will discuss Wizard.

Both Wizard and Screen share the following attributes:

  • All logic can be tested without involving HTTP
  • All logic is declarative
  • All state is managed by Lift
  • The back-button works as the user would expect it to work
  • The form elements are strongly typed
  • The rendering logic and templates is divorced from the form logic

Basics

First, let’s declare a very simple input screen that asks your favorite ice cream flavor:

object AskAboutIceCream1 extends LiftScreen {
  val flavor = field(S ? "What's your favorite Ice cream flavor", "")
  def finish() {
    S.notice("I like "+flavor.is+" too!")
  }
}

We create an object, a Scala singleton, called AskAboutIceCream1 which extends LiftScreen. We declare a single field called flavor. In our view, we refer to the LiftScreen with the following code:

<lift:AskAboutIceCream1/>

And we get a display:

Image 1

When we submit the form, a notice is displayed agreeing with our ice cream choice. But, we can enter a blank ice cream name and it will still be accepted. That’s not optimal. We need to add some validation:

object AskAboutIceCream2 extends LiftScreen {
  val flavor = field(S ? "What's your favorite Ice cream flavor", "",
                     trim, 
                     valMinLen(2, "Name too short"),
                     valMaxLen(40, "That's a long name"))

  def finish() {
    S.notice("I like "+flavor.is+" too!")
  }
}

This code trims the incoming string (removes any leading and trailing spaces) and then makes sure the length is reasonable. So, if we enter a blank value, we get:

image 2

We can add another field, this time a Boolean which turns into a checkbox:

object AskAboutIceCream3 extends LiftScreen {
  val flavor = field(S ? "What's your favorite Ice cream flavor", "",
                     trim, valMinLen(2,S ? "Name too short"),
                     valMaxLen(40,S ? "That's a long name"))

  val sauce = field(S ? "Like chocalate sauce?", false)

  def finish() {
    if (sauce) {
      S.notice(flavor.is+" tastes especially good with chocolate sauce!")
    }
    else S.notice("I like "+flavor.is+" too!")
  }
}

And our display looks like:

image 3

The Boolean sauce field defaults to creating a checkbox rather than an text field.

We can also do cross-field validation:

object AskAboutIceCream4 extends LiftScreen {
  val flavor = field(S ? "What's your favorite Ice cream flavor", "",
                     trim, valMinLen(2,S ? "Name too short"),
                     valMaxLen(40,S ? "That's a long name"))

  val sauce = field(S ? "Like chocalate sauce?", false)

  override def validations = notTooMuchChocolate _ :: super.validations

  def notTooMuchChocolate(): Errors = {
    if (sauce && flavor.toLowerCase.contains("chocolate")) "That's a lot of chocolate"
    else Nil
  }

  def finish() {
    if (sauce) {
      S.notice(flavor.is+" tastes especially good with chocolate sauce!")
    }
    else S.notice("I like "+flavor.is+" too!")
  }
}

So, you you change the chocolate box and enter a flavor that contains chocolate, you get an error indicating that there’s just too much chocolate.

Working with Mapper and Record instances

Turns out that LiftScreen works just ducky with Mapper and Record:

object PersonScreen extends LiftScreen {
	
  object person extends ScreenVar(Person.create)

  override def screenTop =
  <b>A single screen with some input validation</b>

  _register(() => person.is)

  val shouldSave = field("Save ?", false)

  val likeCats = builder("Do you like cats?", "") ^/
  (s => if (Helpers.toBoolean(s)) Nil else "You have to like cats") make

  def finish() {
    S.notice("Thank you for adding "+person.is)
    if (shouldSave.is) {
      person.is.save
      S.notice(person.is.toString+" Saved in the database")
    }
  }
}

Note the _register(() => person.is) line. It registers all for fields in the instance of Person that’s created in the ScreenVar. A ScreenVar is a screen-local variable.

But, what about testing?

Before we get to the nuts and bolts, let’s look at a Wizard test (we’re jumping ahead a bit, but one of the goals of LiftScreen and Wizard is to allow definition and use outside the scope of an HTTP request). Here’s our Wizard:

val MyWizard = new Wizard {
object completeInfo extends WizardVar(false)

def finish() {
  S.notice("Thank you for registering your pet")
  completeInfo.set(true)
}

val nameAndAge = new Screen {
  val name = field(S ? "First Name", "",
                   valMinLen(2, S ?? "Name Too Short"))
  val age = field(S ? "Age", 0,
                  minVal(5, S ?? "Too young"),
    maxVal(120, S ?? "You should be dead"))
  override def nextScreen = if (age.is < 18) parentName else favoritePet
}

val parentName = new Screen {
  val parentName = field(S ? "Mom or Dad's name", "",
                         valMinLen(2, S ?? "Name Too Short"),
                         valMaxLen(40, S ?? "Name Too Long"))
}

val favoritePet = new Screen {
  val petName = field(S ? "Pet's name", "",
                      valMinLen(2, S ?? "Name Too Short"),
                      valMaxLen(40, S ?? "Name Too Long"))
}

And here’s the test:

MyWizard.currentScreen.open_! must_== MyWizard.nameAndAge
// validate that we don't go forward unless we've got a name and age
MyWizard.nextScreen

MyWizard.currentScreen.open_! must_== MyWizard.nameAndAge

MyWizard.nameAndAge.name.set("David")

MyWizard.nameAndAge.age.set(14)

MyWizard.nextScreen

// we get to the parentName field because the age is < 18
MyWizard.currentScreen.open_! must_== MyWizard.parentName

// go back and change age
MyWizard.prevScreen

MyWizard.currentScreen.open_! must_== MyWizard.nameAndAge

MyWizard.nameAndAge.age.set(45)

MyWizard.nextScreen

// 45 year olds get right to the favorite pet page
MyWizard.currentScreen.open_! must_== MyWizard.favoritePet

S.clearCurrentNotices

MyWizard.favoritePet.petName.set("Elwood")

MyWizard.nextScreen

MyWizard.currentScreen must_== Empty

MyWizard.completeInfo.is must_== true

So, we’re able to walk the wizard back and forth simulating what the user enters and insuring that we’re getting to the expected states.

The nuts & bolts

Let’s walk through how all of this works.

First, a LiftScreen is a Lift Snippet. This means that it you can refer to the LiftScreen just by its name, e.g. <lift:AskAboutIceCream4/>

Each of the fields in the Screen are statically typed variables. We can define them with the field[T](name: => String, default: T, stuff: FilterOrValidate[T]*). Lift determines the type of the field based on the default value for the field. The FilterOrValidate varg allows you to specify the filter (transformations of input, e.g., toLowerCase) and validation functions.

LiftScreen, by default, uses to type of the field to vend an HTML form that corresponds to the type (e.g., an <input type="text"/> for a String and a checkbox for a Boolean). You can set up global Type → Form vendors in LiftRules.vendForm for application-scope form vending.

You can also manually create a form field by creating an instance of Field:

trait Field extends BaseField {
  def default: ValueType

  def is: ValueType

  /**
  * Set to true if this field is part of a multi-part mime upload
  */
  override def uploadField_? = false

  def set(v: ValueType): Unit

  implicit def manifest: Manifest[ValueType]

  override def helpAsHtml: Box[NodeSeq] = Empty

  /**
  *  Is the field editable
  */
  def editable_? = true

  def toForm: Box[NodeSeq]

  def validate: List[FieldError]

  def validations: List[ValueType => List[FieldError]] = Nil

  def setFilter: List[ValueType => ValueType] = Nil
}

Creating the HTML

So, you’ve gotten to the point where you get how to define screens. But how does the screen get turned into HTML?

By default, the /templates-hidden/wizard-all.html template is chosen. You can override the template in ScreenRules (global) or by defining the template path in your LiftScreen. You just supply the bind points (the <wizard:xxx/> tags) and LiftScreen (and Wizard) will bind correctly to the form.

<div>
  <wizard:screen_info><div>Page <wizard:screen_number/> of <wizard:total_screens/></div></wizard:screen_info>
  <wizard:wizard_top> <div> <wizard:bind/> </div> </wizard:wizard_top>
  <wizard:screen_top> <div> <wizard:bind/> </div> </wizard:screen_top>
  <wizard:errors> <div> <ul> <wizard:item> <li> <wizard:bind/> </li> </wizard:item> </ul> </div> </wizard:errors>
  <div> <wizard:fields>
      <table>
        <wizard:line>
          <tr>
            <td>
              <wizard:label><label wizard:for=""><wizard:bind/></label></wizard:label>
     <wizard:help><span><wizard:bind/></span></wizard:help>

     <wizard:field_errors> <ul> <wizard:error> <li> <wizard:bind/> </li> </wizard:error> </ul> </wizard:field_errors>

          </td>
          <td> <wizard:form/> </td>
          </tr>
        </wizard:line>
      </table>
    </wizard:fields> </div>
  <div> <table> <tr> <td> <wizard:prev/> </td> <td> <wizard:cancel/> </td> <td> <wizard:next/> </td> </tr> </table> </div>
  <wizard:screen_bottom> <div> <wizard:bind/> </div> </wizard:screen_bottom>
  <wizard:wizard_bottom> <div> <wizard:bind/> </div> </wizard:wizard_bottom>
</div>

But wait…

So, we’ve covered the basics of LiftScreen. All the LiftScreen concepts carry over to Wizard. In a few days, I’ll be walking you through creating multi-page input Wizards.