I've blogged before about formlets, a nice abstraction of HTML forms. I started with a basic implementation, then showed validation. Now I'll present a non-toy implementation of formlets I called FsFormlets (really original name, I know). By "non-toy" I mean this implementation is not a proof of concept or just for didactic purposes, but is meant to be eventually production-quality.
I don't like to explain things in a vacuum, so I'll use a typical registration form to show how it works:
Even though FsFormlets can be used to generate HTML on its own, there's a much better tool for this in F#: Wing Beats. Wing Beats is an EDSL in F# to generate HTML, much like SharpDOM in C# or Blaze in Haskell, or Eliom's XHTML.M in OCaml (except that XHTML.M actually validates HTML statically). So I've added a module to integrate FsFormlets to Wing Beats. This integration is a separate assembly; FsFormlets is stand-alone (it only requires .NET 3.5 SP1 and the F# runtime). We'll use FsFormlets to express forms, and Wing Beats to express the rest of HTML. Also, we'll handle web requests with Figment, with the help of some more glue code to integrate it with FsFormlets and Wing Beats.
Layout
Let's start by defining a layout in Wing Beats:
let e = XhtmlElement() let layout title body = [ e.DocTypeHTML5 e.Html [ e.Head [ e.Title [ &title ] e.Style [ &".error {color:red;}" &"body {font-family:Verdana,Geneva,sans-serif; line-height: 160%;}" ] ] e.Body [ yield e.H1 [ &title ] yield! body ] ] ]
No formlets so far, this is all pure HTML, expressed as a regular function. Now we'll build the form bottom-up.
ReCaptcha
FsFormlets already includes a reCaptcha formlet (I'll show its innards in a future post). We just have to configure it with a pair of public and private key (get it here) before using it:
let reCaptcha = reCaptcha {PublicKey = "your_public_key"; PrivateKey = "your_private_key"; MockedResult = None}
MockedResult lets you skip the actual validation web call and force a result, for testing purposes.
Date input
Now the date formlet, which is built from three inputs, plus labels and validation:
let f = e.Formlets let dateFormlet : DateTime Formlet = let baseFormlet = yields t3 <*> (f.Text(maxlength = 2, attributes = ["type","number"; "min","1"; "max","12"; "required","required"; "size","3"]) |> f.WithLabel "Month: ") <*> (f.Text(maxlength = 2, attributes = ["type","number"; "min","1"; "max","31"; "required","required"; "size","3"]) |> f.WithLabel "Day: ") <*> (f.Text(maxlength = 4, attributes = ["type","number"; "min","1900"; "required","required"; "size","5"]) |> f.WithLabel "Year: ") let isDate (month,day,year) = let pad n (v: string) = v.PadLeft(n,'0') let ymd = sprintf "%s%s%s" (pad 4 year) (pad 2 month) (pad 2 day) DateTime.TryParseExact(ymd, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None) |> fst let dateValidator = err isDate (fun _ -> "Invalid date") baseFormlet |> satisfies dateValidator |> map (fun (month,day,year) -> DateTime(int year,int month,int day))
Here, baseFormlet
is of type (string * string * string) Formlet
, that is, it collects values in their raw form. This baseFormlet
is then validated to make sure it's a date, and finally mapped to a DateTime. Note the use of the 'number' input type (a HTML5 input) and also HTML5 validation attributes (required, min, max)
Password input
The password formlet is next:
let doublePassword = let compressedLength (s: string) = use buffer = new MemoryStream() use comp = new DeflateStream(buffer, CompressionMode.Compress) use w = new StreamWriter(comp) w.Write(s) w.Flush() buffer.Length let isStrong s = compressedLength s >= 106L let f = yields t2 <*> (f.Password(required = true) |> f.WithLabel "Password: ") <+ e.Br() <*> (f.Password(required = true) |> f.WithLabel "Repeat password: ") let areEqual (a,b) = a = b f |> satisfies (err areEqual (fun _ -> "Passwords don't match")) |> map fst |> satisfies (err isStrong (fun _ -> "Password too weak"))
There are two things we validate here for passwords: first, the two entered passwords must match; and second, it must be strong enough. To measure password strength I use the compression technique I described in my last post.
The <+
operator is used to add pure markup to the formlet, using Wing Beats.
Also note the 'required = true
' parameter. In the date formlet, we just used required=required
as an HTML attribute. Here we used the optional argument required=true, which validates the formlet both at the client (with a required=required
HTML attribute) and the server (it has a satisfies
clause). In the date formlet we don't really care to validate server-side if the user filled out each input, we just want to know if it's a valid date or not.
The optional parameter 'maxlength' does a similar thing: it outputs a maxlength attribute, and also checks the maximum length of he POSTed value. Sure all browsers implement maxlength properly, but anyone can easily craft a request (e.g. with cUrl, no need to program at all) to circumvent it. I see malicious bots doing things like this every day. This makes sure the data is really valid before you start processing it.
Putting it all together
Moving on, let's write the final formlet and store the posted information in a record:
type PersonalInfo = { Name: string Email: string Password: string DateOfBirth: DateTime } let registrationFormlet ip = yields (fun n e p d -> { Name = n; Email = e; Password = p; DateOfBirth = d }) <*> (f.Text(required = true) |> f.WithLabel "Name: ") <+ e.Br() <*> (f.Email(required = true) |> f.WithLabel "Email: ") <+ e.Br() <*> doublePassword <+ e.Br() <+ &"Date of birth: " <*> dateFormlet <+ e.Br() <+ &"Please read very carefully these terms and conditions before registering for this online program, blah blah blah" <+ e.Br() <* (f.Checkbox(false) |> satisfies (err id (fun _ -> "Please accept the terms and conditions")) |> f.WithLabel "I agree to the terms and conditions above") <* reCaptcha ip
Some notes about this last snippet:
f.Email(required = true)
generates an<input type="email" required="required"/>
(again, HTML5 !), all server-validated.- Unlike previous formlets, this one is a function, because reCaptcha needs the client's IP address to validate.
If you've read my previous posts about formlets, you may be wondering why I'm using things like f.Text()
and f.Checkbox()
(which are members of an object) instead of the regular functions input
and checkbox
. Those functions are also present in FsFormlets and you may use them interchangeably with the object-style formlets, e.g. instead of f.CheckBox(false)
you can write checkbox false []
. The object-style formlets build on top of functional formlets, adding optional parameters for validation. They also integrate more seamlessly with Wing Beats.
And we're done with the form! Now let's build the actual page that contains it. Using the layout we wrote earlier:
let s = e.Shortcut let registrationPage form = layout "Registration" [ s.FormPost "" [ e.Fieldset [ yield e.Legend [ &"Please fill the fields below" ] yield!!+form yield e.Br() yield s.Submit "Register!" ] ] ]
This is also pure Wing Beats markup, I think it doesn't need much explanation except for the "yield!!+form" which indicates where to render the formlet (yes, the operator doesn't look very friendly, I'll probably change it). Note how easy it is to compose pieces of potentially reusable HTML with Wing Beats as they're just functions.
Handling requests with Figment
Now all we need to do is bind the formlet to a URL to render the page:
get "register" (fun _ -> registrationFormlet "" |> renderToXml |> registrationPage |> Result.wbview)
and handle the form POST:
post "register" (fun ctx -> let env = EnvDict.fromFormAndFiles ctx.Request match run (registrationFormlet ctx.IP) env with | Success v -> Result.redirectf "thankyou?n=%s" v.Name | Failure(errorForm,_) -> errorForm |> registrationPage |> Result.wbview)
Oh, I almost forgot the little "thank you" after-registration page:
get "thankyou" (fun ctx -> Result.contentf "Thank you for registering, %s" ctx.QueryString.["n"])
Now, this workflow we've just modeled is pretty common:
- Show form.
- User submits form.
- Validate form. If errors, show form again to user.
- Process form data.
- Redirect.
So it's worthy of abstraction. The only moving parts are the formlet, the page and what to do on successful validation, so instead of mapping get and post individually we can say:
formAction "register" { Formlet = fun ctx -> registrationFormlet ctx.IP Page = fun _ -> registrationPage Success = fun _ v -> Result.redirectf "thankyou?n=%s" v.Name }
HTML5 ready
Implementation of HTML5 in browsers has exploded in 2010 (see the beautiful html5readiness.com for reference). In particular, HTML5 forms are already implemented in Chrome 10, Opera and Firefox 4 (with caveats). Safari and IE haven't implemented it yet (at least not in versions 5.0.4 and 9 respectively), but WebKit already supports it so I guess Safari and other WebKit-based browsers will implement this in the short-term. So there's little reason not to use the new elements and attributes right now, as long as you also have server-side validation.
For example, here's how Chrome validates required fields when trying to submit:
Whereas in Safari 5.0.4/Windows the server-side validation kicks in:
If you want browser-side validation in Safari, IE, etc, you can easily use an unobtrusive polyfill. A thorough list of cross-browser HTML5 polyfills is available here.
For example, applying jQuery.tools is as easy as:
let jsValidation = e.Div [ s.JavascriptFile "http://cdn.jquerytools.org/1.2.5/full/jquery.tools.min.js" e.Script [ &"$('form').validator();" ] ]
and putting it at the bottom of the Wing Beats layout.
All code posted here is part of the sample Figment app.
No comments:
Post a Comment