The FormBean library is a great way to render HTML forms, but unless you're an dab hand with CSS, plain forms can look rather dull.
With lots of web apps turning to the popular Bootstrap v3.3 CSS framework to brighten up their designs, this article looks at how to create a Bootstrap skin for your FormBeans...
The new Fantom Pod Repository powered by Eggbox uses a vanilla Bootstrap theme and uses the technique presented here to renders its forms...
This article will take a simple data class that records your favourite beer:
class BeerDetails { @HtmlInput { type="text"; required=true} Str name @HtmlInput { type="select"; required=true} Beer beer @HtmlInput { type="checkbox"} Bool iAmRobot new make(|This|in) { in(this) } } enum class Beer { bitter, stout, lager, gingerBeer }
The basic rendering doesn't supply any CSS styling and the form is rendered thus:
Taking the name field as an example, it is rendered with the following HTML:
<div class="formBean-row inputRow name"> <label for="name">Name</label> <input type="text" id="name" name="name" required value=""> </div>
Which is fine, although standard Bootstrap fields require the following class names:
<div class="form-group"> <label for="name">Name</label> <input type="text" class="form-control" id="name" name="name" required value=""> </div>
Skinning the Text Field
We could add form-control
to the HtmlInput
facet:
@HtmlInput { type="text"; css="form-control"; required=true} Str name
But we still need to use form-group
in the outer div
. So lets kill two birds with one stone and create a Bootstrap FormBean skin by extending InputSkin:
const class BootstrapTextSkin : InputSkin { override Str render(SkinCtx skinCtx) { html := Str.defVal attrs := skinCtx.renderAttributes(["class":"form-control"]) html += """<div class="form-group">""" html += """ <label for="${skinCtx.name}">${skinCtx.label}</label>""" html += """ <input ${attrs} type="${skinCtx.formField.type}" value="${skinCtx.value}">""" html += """</div>""" return html + "\n" } }
We use skinCtx.renderAttributes(...)
because that also renders any validation attributes.
To use the new BootstrapTextSkin
, override the default skin in AppModule
:
@Contribute { serviceType=InputSkins# } static Void contributeInputSkins(Configuration config) { config.overrideValue("text", BootstrapTextSkin()) }
Our text box now looks like:
Adding Hints
Bootstrap also lets you define hints for your fields, so lets add one to the name
field:
@HtmlInput { type="text"; required=true; hint="It's on your birth certificate!" } Str name
Now let's optionally render it in the skin:
const mixin BootstrapSkin : InputSkin { Str renderFormGroup(SkinCtx skinCtx, |Str->Str| inputStr) { html := Str.defVal hint := skinCtx.formField.hint ?: skinCtx.msg("field.${skinCtx.name}.hint") attMap := ["class":"form-control"] if (hint != null) attMap["aria-describedby"] = "${skinCtx.name}-helpBlock" attrs := skinCtx.renderAttributes(attMap) html += """<div class="form-group">""" html += """<label for="${skinCtx.name}">${skinCtx.label}</label>""" html += inputStr(attrs) if (hint != null) html += """<span id="${skinCtx.name}-helpBlock" class="help-block">${hint}</span>""" html += """</div>""" return html + "\n" } }
Skinning Errors
The basic FormBean error rendering is just a plain list:
To skin this we extend ErrorSkin and add it as a service. The FormBean
class then picks this service up should it exist.
We'll render our errors in an danger themed alert.
const class BootstrapErrorSkin : ErrorSkin { override Str render(FormBean formBean) { if (!formBean.hasErrors) return Str.defVal banner := formBean.messages["errors.banner"] html := "" html += """<div class="alert alert-danger" role="alert">""" html += banner html += """<ul>""" formBean.errorMsgs.each { html += """<li>${it}</li>""" } formBean.formFields.vals.each { if (it.errMsg != null) html += """<li>${it.errMsg}</li>""" } html += """</ul>""" html += """</div>""" return html } }
And the service definition in AppModule
:
static Void defineServices(RegistryBuilder bob) { bob.addService(ErrorSkin#, BootstrapErrorSkin#) }
To give our text box a danger hue when it's in error, the BootstrapTextSkin
needs to optionaly add the has-error
class to the form-group
div
.
const class BootstrapTextSkin : BootstrapSkin { override Str render(SkinCtx skinCtx) { ... errCss := skinCtx.fieldInvalid ? " has-error" : Str.defVal ... html += """<div class="form-group${errCss}">""" ... return html } }
Our errors now look like this:
Putting It All Together
What's left is styling the select
, checkbox
and button
inputs. The skin code for the select
and checkbox
are in the completed code, but I'll not bore you with the details here. They're done in a similar vein as the default text
input.
The FormBean.renderSubmit()
method can't be skinned, but it does so little it doesn't matter, we can render our own button:
<button class='btn btn-primary' type='submit'>Submit</button>
Stick it all together and we have:
The complete example code below has been tested with:
To use, save the code as Example.fan
and run it as a Fantom script:
C:\fan Example.fan
Then view your work at http:/localhost:8080/
Have fun!
using afIoc using afBedSheet using afFormBean// ---- The BedSheet Web Application ------------------------------------------class Main { static Void main() { BedSheetBuilder(AppModule#).startWisp(8069) } }// @SubModule only needed because this example is run as a script@SubModule { modules=[FormBeanModule#] } const class AppModule { static Void defineServices(RegistryBuilder bob) { bob.addService(ErrorSkin#, BootstrapErrorSkin#) } @Contribute { serviceType=Routes# } static Void contributeRoutes(Configuration conf) { conf.add(Route(`/`, BeerPage#render, "GET")) conf.add(Route(`/`, BeerPage#onPost, "POST")) } @Contribute { serviceType=InputSkins# } static Void contributeInputSkins(Configuration config) { config.overrideValue("email", BootstrapTextSkin()) config.overrideValue("text", BootstrapTextSkin()) config.overrideValue("url", BootstrapTextSkin()) config.overrideValue("password", BootstrapTextSkin()) config.overrideValue("checkbox", BootstrapCheckboxSkin()) config.overrideValue("textarea", BootstrapTextAreaSkin()) config.set ("static", BootstrapStaticSkin()) config.overrideValue("select", config.build(BootstrapSelectSkin#)) } } class BeerPage { @Inject HttpRequest? httpRequest @Inject { type=BeerDetails# } FormBean? formBean Text render() { Text.fromHtml( "<!DOCTYPE html> <html> <head> <title>FormBean Bootstrap Skin Demo</title> <link rel='stylesheet' href='https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css' /> </head> <body class='container'> <h2>Beer</h2> <form action='/' method='POST' novalidate=''> ${ formBean.renderErrors() } ${ formBean.renderBean(null) } <button class='btn btn-primary' type='submit'>Submit</button> </form> </body> </html>") } Text onPost() {// perform server side validation// if invalid, re-render the page and show the errorsif (!formBean.validateForm(httpRequest.body.form)) return render// create an instance of our form objectbeerDetails := (BeerDetails) formBean.createBean// display a simple messagereturn Text.fromPlain("Thank you ${beerDetails.name}.") } }// ---- Entities --------------------------------------------------------------class BeerDetails { @HtmlInput { type="text"; required=true; hint="It's on your birth certificate!" } Str name @HtmlInput { type="select"; required=true; hint="It'd better not be Kool Aid!" } Beer beer @HtmlInput { type="checkbox"; hint="Tick if you're a robot" } Bool iAmRobot new make(|This|in) { in(this) } } enum class Beer { bitter, stout, lager, gingerBeer }// ---- Bootstrap FormBean Skins ----------------------------------------------const mixin BootstrapSkin : InputSkin { Str renderFormGroup(SkinCtx skinCtx, |Str->Str| inputStr) { html := Str.defVal errCss := skinCtx.fieldInvalid ? " has-error" : Str.defVal hint := skinCtx.formField.hint ?: skinCtx.msg("field.${skinCtx.name}.hint") attMap := ["class":"form-control"] if (hint != null) attMap["aria-describedby"] = "${skinCtx.name}-helpBlock" attrs := skinCtx.renderAttributes(attMap) html += """<div class="form-group${errCss}">""" html += """<label for="${skinCtx.name}">${skinCtx.label}</label>""" html += inputStr(attrs) if (hint != null) html += """<span id="${skinCtx.name}-helpBlock" class="help-block">${hint}</span>""" html += """</div>""" return html + "\n" } } const class BootstrapTextSkin : BootstrapSkin { override Str render(SkinCtx skinCtx) { renderFormGroup(skinCtx) |attrs| { """<input ${attrs} type="${skinCtx.formField.type}" value="${skinCtx.value}">""" } } } const class BootstrapTextAreaSkin : BootstrapSkin { override Str render(SkinCtx skinCtx) { renderFormGroup(skinCtx) |attrs| { """<textarea ${attrs}>${skinCtx.value}</textarea>""" } } } const class BootstrapStaticSkin : BootstrapSkin { override Str render(SkinCtx skinCtx) { renderFormGroup(skinCtx) |attrs| { """<p ${attrs}>${skinCtx.value.toXml}</p>""" } } } const class BootstrapSelectSkin : BootstrapSkin { @Inject private const ValueEncoders valueEncoders @Inject private const OptionsProviders optionsProviders new make(|This| in) { in(this) } override Str render(SkinCtx skinCtx) { renderFormGroup(skinCtx) |attrs| { html := "<select ${attrs}>" formField := skinCtx.formField optionsProvider := formField.optionsProvider ?: optionsProviders.find(skinCtx.field.type) showBlank := formField.showBlank ?: optionsProvider.showBlank(formField) if (showBlank) { blankLabel := formField.blankLabel ?: optionsProvider.blankLabel(formField) html += """<option value="">${blankLabel?.toXml}</option>""" } optionsProvider.options(formField, skinCtx.bean).each |value, label| { optLabel := skinCtx.msg("option.${label}.label") ?: label optValue := skinCtx.toClient(value) optSelec := (optValue.equalsIgnoreCase(skinCtx.value)) ? " selected" : Str.defVal html += """<option value="${optValue}"${optSelec}>${optLabel}</option>""" } html += "</select>" return html } } } const class BootstrapCheckboxSkin : InputSkin { override Str render(SkinCtx skinCtx) { hint := skinCtx.formField.hint ?: skinCtx.msg("field.${skinCtx.name}.hint") checked := (skinCtx.value == "true" || skinCtx.value == "on") ? " checked" : Str.defVal attMap := hint == null ? Str:Str[:] : ["aria-describedby" : "${skinCtx.name}-helpBlock"] attrs := skinCtx.renderAttributes(attMap) html := Str.defVal html += """<div class="checkbox">""" html += """<label>""" html += """<input type="checkbox" ${attrs}${checked}> ${skinCtx.label}""" html += """</label>""" if (hint != null) html += """<span id="${skinCtx.name}-helpBlock" class="help-block">${hint}</span>""" html += """</div>""" return html + "\n" } } const class BootstrapErrorSkin : ErrorSkin { override Str render(FormBean formBean) { if (!formBean.hasErrors) return Str.defVal banner := formBean.messages["errors.banner"] html := "" html += """<div class="alert alert-danger" role="alert">""" html += banner html += """<ul>""" formBean.errorMsgs.each { html += """<li>${it}</li>""" } formBean.formFields.vals.each { if (it.errMsg != null) html += """<li>${it.errMsg}</li>""" } html += """</ul>""" html += """</div>""" return html } }
Edits
- 31 Jul 2016 - Updated example scripts to use FormBean 1.1, BedSheet 1.5, and IoC 3.0.
- 3 Jul 2015 - Original article.