LiftScreen
This article has been copied from the Lift Blog: http://lift.la/lifts-screen
- Background
- Basics
- Working with Mapper and Record instances
- Using LiftScreen with Mapper OneToMany
- But, what about testing?
- The nuts & bolts
- Creating the HTML
- But wait…
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 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 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 chocolate 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>
addFields(() => 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 addFields(() => 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.
To register only specific fields of a MapperClass use for example:
addFields(() => person.is.email)
This automatically uses defined validations in the MapperClass to validate the fields in the screen.
To localize the displayNames of the fields one can set the default MapperRules.displayNameCalculator
:
val displayNameCalculator : Vendor[(BaseMapper, Locale, String) => String] =
{(m : BaseMapper, l : Locale, s : String) => S ? (m.getClass.getSimpleName + "."+ s)}
MapperRules.displayNameCalculator.default.set(displayNameCalculator)
Using LiftScreen with Mapper OneToMany
If your form has data that is stored on multiple related tables, you can save your data like this:
object Inventorycreate extends LiftScreen {
//One object for each database table you define
object product extends ScreenVar(Inventory.create)
object description extends ScreenVar(InventoryDescription.create)
override def screenTop =
<b>A single screen with some input validation</b>
//Here we add all the fields from the Inventory model
//but only add two of the fields from the
//InventoryDescription model
addFields(() => product.is)
addFields(() => description.is.language)
addFields(() => description.is.text)
def finish() {
//This is the key line so that the InventoryDescription row gets the ID of the parent table (Inventory in this case)
product.is.description += description
product.is.save
S.notice(product.is.toString+" Saved in the database")
S.notice(description.is.toString+" Saved in the database")
}
}
But, what about testing?
Before we get to the nuts and bolts, let’s look at a Wizard test . 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 and validation functions.
LiftScreen, by default, uses the type of the field to vend an HTML form that corresponds to the type . 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?
Finding the Template
By default, the LiftScreen class will look in the /templates-hidden folder of the root of your web application for a template called wizard-all. Normal internationalization rules apply when it comes to resolving wizard-all to an actual template resource. For example, /templates-hidden/wizard-all_en_GB.html , /templates-hidden/wizard-all.html, etc.
Setting a New Default Template
You can set a new default template for all LiftScreens by adding the following line to your Boot class .
LiftScreenRules.allTemplatePath.default.set(() => List("templates-hidden", "my-default-template"))
Setting a Template Per Screen
You can override the default template on a per-screen basis by adding the following line to the LiftScreen classes that require a different template.
override protected def allTemplatePath = List("templates-hidden", "my-screen-template")
Sample HTML
You just supply the bind points and LiftScreen 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>
However, when you are using HTML5 instead of XHTML, the above template won’t work because the HTML5 parser doesn’t allow the <wizard:line>
as first element in the table. Use this template instead:
<div>
<wizard:wizard_top> <div> <wizard:bind/> </div> </wizard:wizard_top>
<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>
<table lift:bind="wizard:fields">
<tr lift:bind="wizard:line">
<td>
<wizard:label>
<label wizard:for=""><wizard:bind></wizard:bind></label>
</wizard:label>
<wizard:help><span><wizard:bind></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></wizard:form>
</td>
</tr>
</table>
<table>
<tr>
<td><wizard:prev></wizard:prev></td>
<td><wizard:cancel></wizard:cancel></td>
<td><wizard:next></wizard:next></td>
</tr>
</table>
<wizard:screen_bottom><div><wizard:bind></wizard:bind></div></wizard:screen_bottom>
<wizard:wizard_bottom><div><wizard:bind></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.
Comments are disabled for this space. In order to enable comments, Messages tool must be added to project.
You can add Messages tool from Tools section on the Admin tab.