Grails Webflows
Since Grails 0.6 Grails supports the creation of web flows built on the
Spring Web Flow project. A web flow is a conversation that spans multiple requests and retains state for the scope of the flow. A web flow also has a defined start and end state.
Since of Grails 1.2 the WebFlow plugin is not installed by default (see GRAILS-5185). Use 'grails install-plugin webflow' to install the plugin and enable this feature.
Creating a flow
To create a flow create a regular Grails
controller and then add an action that ends with the convention "Flow". For example:
class BookController {
def index = {
redirect(action:'shoppingCart')
}
def shoppingCartFlow = {
..
}
}
Notice when redirecting or referring to the flow as an action we omit the "Flow" suffix. In otherwords the name of the action of the above flow is 'shoppingCart'.
Defining Start and End states
As mentioned before a flow has a defined start and end state. The start state of A Grails flow is the first node within the flow. For example:
class BookController {
…
def shoppingCartFlow = {
showCart {
on("checkout").to "enterPersonalDetails"
on("continueShopping").to "displayCatalogue"
}
…
displayCatalogue {
redirect(controller:"catalogue", action:"show")
}
displayInvoice()
}
}
Here the {{showCart}} node is the start state of the flow. Since it defines only event handlers by convention Grails assumes it is a view state and looks for a view called {{grails-app/views/book/shoppingCart/showCart.gsp}}.
This flow also has two possible end states. The first is {{displayCatalogue}} which performs an external redirect to another controller and action, thus exiting the flow. The second is {{displayInvoice}} which is an end state as it has no events at all and will simply render a view called {{grails-app/views/book/shoppingCart/displayInvoice.gsp}} whilst ending the flow at the same time.
Trigger flow execution events
From a view state
As mentioned previously the start state of the flow in the previous code listing deals with two possible events. A {{checkout}} event and a {{continueShopping}} event. How do we trigger these events from a GSP view? Simply define a form that has two submit buttons:
<g:form action="shoppingCart">
<g:submitButton name="continueShopping" value="Continue Shopping"></g:submitButton>
<g:submitButton name="checkout" value="Checkout"></g:submitButton>
</g:form>The form must submit back to the {{shoppingCart}} flow. The {{name}} attribute of each {{<g:submitButton>}} signals which event will be triggered. If you don't have a form you can also trigger an event with the {{<g:link>}} tag as follows:
<g:link action="shoppingCart" event="checkout" />
From an action
To trigger an event from an action you need to invoke a method. For example there is the built in error() and success() methods. The example below triggers a the error() event on validation failure in a transition action:
enterPersonalDetails {
on("submit") {
def p = new Person(params)
flow.person = p
if(!p.validate())return error()
}.to "enterShipping"
on("return").to "showCart"
}In this case because of the error the transition action will make the flow go back to the enterPersonalDetails state.
With an action state you can also trigger events to redirect flow:
shippingNeeded {
action {
if(params.shippingRequired) return yes()
else return no()
}
on("yes").to "enterShipping"
on("no").to "enterPayment"
}Data binding and validation
The start state from Listing 1 trigger a transition to the {{enterPersonalDetails}} state. This state renders a view and waits for the user to enter the required information.
enterPersonalDetails {
on("submit").to "enterShipping"
on("return").to "showCart"
}The view contains a form with two submit buttons that either trigger the {{submit}} event or the {{return}} event. However, what about the capturing this information? To to capture the form info we can use a flow transition action:
enterPersonalDetails {
on("submit") {
def p = new Person(params)
flow.person = p
if(!p.validate())return error()
}.to "enterShipping"
on("return").to "showCart"
}Notice how we perform data binding from request parameters and the use the {{ctx}} object to place the person within {{flow scope}}. Also interesting is that we perform validation and invoke the {{error()}} method. This signals to the flow that the transition should halt and return to the "enterPersonalDetails" view so valid entries can be entered by the user.
NOTE: Data is not persisted to the database until the flow exits. This is to preserve the atomic nature of a webflow.
Flow scopes
You'll notice from the previous example that we used a special object called {{flow}} to store the person object within "flow scope". Grails flows have 5 different scope you can utilize:
- request - Stores an object for the scope of the current request
- flash - Stores the object for the current and next request only
- flow - Stores objects for the scope of the flow, removing them when the flow reaches an end state
- conversation - Stores objects for the scope of the conversation including the root flow and nested subflows
- session - Stores objects inside the users session
Also returning a model map from an action will automatically result in the model being placed in flow scope. If no validation is required the previous example could be written as:
enterPersonalDetails {
on("submit") {
[person:new Person(params)]
}.to "enterShipping"
on("return").to "showCart"
}Be aware that a new request is always created for each state, so an object placed in request scope in an action state (for example) will not be available in a subsequent view state. Use one of the other scopes to pass objects from one state to another.
Also note that Web Flow:
1) moves objects from flash scope to request scope upon transition between states;
2) merges objects from the flow and conversation scopes into the view model before rendering. This means you shouldn't include a scope prefix when referencing these objects within a view, e.g. GSP pages. For instance, you would reference flash.message as simply ${message} in the GSP.
When placing objects in flash, flow or conversation scope they must implement java.io.Serializable otherwise you will get an error. Note: You could argue that objects placed into the session should also implement java.io.Serializable as if you do any kind of http session clustering this will be a requirement.
Scoped Service classes
For information on how to implement rich conversations with Grails service classes see the
Services page
Action States
An action state is a state that executes code but does not render any view. The result of the action is used to dictate flow transition. To create an action state you need to define an action to to be executed:
getBooks {
action { [ bookList:Book.list() ]}
on("success").to "showCatalogue"
on(Exception).to "handleError"
}
As you can see an action looks very similar to a controller action and in fact you can re-use controller actions if you want. If the action successfully returns with no errors the {{success}} event will be triggered. Here we also use an exception handler to deal with errors.
In this case since we return a map, this is regarded as the "model" and is automatically placed in "flow scope". You can write more complex actions that interact with the flow request context:
processPurchaseOrder {
action {
def a = flow.address
def p = flow.person
def pd = flow.paymentDetails
def cartItems = flow.cartItems
flow.clear() def o = new Order(person:p, shippingAddress:a, paymentDetails:pd)
o.invoiceNumber = new Random().nextInt(9999999)
cartItems.each { o.addToItems(it) }
[order:o]
}
on("error").to "confirmPurchase"
on(Exception).to "confirmPurchase"
on("success").to "displayInvoice"
}
Here is a more complex action that gathers all the information accumulated from the flow scope and creates an Order object. It then returns the order as the model. The important thing to note here is the interaction with the request context and "flow scope"
View states and rendering custom views
A view state is a one that doesn't define an action state. So for example the below is a view state:
enterPersonalDetails {
on("submit").to "enterShipping"
on("return").to "showCart"
}
It will look for a view called {{grails-app/views/book/shoppingCart/enterPersonalDetails.gsp}} by default. If you want to change the view to be rendered you can do so with the render method:
enterPersonalDetails {
render(view:"enterDetailsView")
on("submit").to "enterShipping"
on("return").to "showCart"
}
Now it will look for {{grails-app/views/book/shoppingCart/enterDetailsView.gsp}}. If you want to use a shared view, start with a / in view argument:
enterPersonalDetails {
render(view:"/shared/enterDetailsView")
on("submit").to "enterShipping"
on("return").to "showCart"
}
Now it will look for {{grails-app/views/shared/enterDetailsView.gsp}}
Flow sublows and Conversation scope
Grails' webflow integration also supports subflows. A subflow is like a flow within a flow. For example take this search flow:
def searchFlow = {
displaySearchForm {
on("submit").to "executeSearch"
}
executeSearch {
action {
[results:searchService.executeSearch(params.q)]
}
on("success").to "displayResults"
on("error").to "displaySearchForm"
}
displayResults {
on("searchDeeper").to "extendedSearch"
on("searchAgain").to "displaySearchForm"
}
extendedSearch {
subflow(extendedSearchFlow) // <--- extended search subflow
on("moreResults").to "displayMoreResults"
on("noResults").to "displayNoMoreResults"
}
displayMoreResults()
displayNoMoreResults()
}
It references a subflow in the {{extendedSearch}} phase. The subflow is another flow entirely:
def extendedSearchFlow = {
startExtendedSearch {
on("findMore").to "searchMore"
on("searchAgain").to "noResults"
}
searchMore {
action {
def results = searchService.deepSearch(ctx.conversation.query)
if(!results)return error()
conversation.extendedResults = results
}
on("success").to "moreResults"
on("error").to "noResults"
}
moreResults()
noResults()
}
Notice how it places the extendedResults in conversation scope. This scope differs to flow scope as it allows you to share state that spans the whole conversation not just the flow. Also notice that the end state (either {{moreResults}} or {{noResults}} of the subflow triggers the events in the main flow:
extendedSearch {
subflow(extendedSearchFlow) // <--- extended search subflow
on("moreResults").to "displayMoreResults"
on("noResults").to "displayNoMoreResults"
}
Error handling in webflows
You can write a handler to catch any errors that get thrown during your webflow. From the example already mentioned above, you can see how to use "on(Exception)" to catch an exception:
getBooks {
action { [ bookList:Book.list() ]}
on("success").to "showCatalogue"
on(Exception).to "handleError"
}Then your "handleError" function could look something like this:
handleError(){
action{
log.error("Webflow Exception occurred: ${flash.stateException}", flash.stateException)
}
on("back").to "startOver"
}This exception handler can use any of the static properties defined in TransitionExecutingStateExceptionHandler -
http://static.springframework.org/spring-webflow/docs/1.0.x/api/org/springframework/webflow/engine/support/TransitionExecutingStateExceptionHandler.htmlAlternatively, Grails will log the exception for you using the logger "org.codehaus.groovy.grails.webflow.engine.builder". You just need to configure log4j appropriately.
Testing webflows
There is a special test harness you can use to test webflows during integration test - you extend "grails.test.WebFlowTestCase",
which sub classes Spring Web Flow's AbstractFlowExecutionTests class.
This will allow you to programatically interact with the webflow.
So, if we had the webflow from above:
class BookController {
…
def shoppingCartFlow = {
showCart {
on("checkout").to "enterPersonalDetails"
on("continueShopping").to "displayCatalogue"
}
…
displayCatalogue {
redirect(controller:"catalogue", action:"show")
}
displayInvoice() enterPersonalDetails {
render(view:"enterDetailsView")
on("submit").to "enterShipping"
on("return").to "showCart"
}
…
}
}First, you have to override the getFlow() function to point to the flow you want to test.
So, using the shoppingCartFlow above:
def getFlow() { new BookController().shoppingCartFlow }Then you would use the startFlow() command to get things going.
This will give you back a ViewDescriptor, which is a lot like Spring MVC's ModelAndView construct.
/**
* Grails 1.1.x
*/class ShoppingCartFlowTests extends grails.test.WebFlowTestCase {
def getFlow() { return new BookController().shoppingCartFlow }
void testShoppingCartFlow() {
startFlow() // void method
/**
* Tests for the current execution state, not the view returned.
* signalEvent('<eventid>') returns an implementation of
* org.springframework.webflow.context.ExternalContext,
* not ViewDescriptor. However,
* assertResponseWrittenEquals('showCart', context) doesn't
* work for some reason.
*/
assertCurrentStateEquals('showCart')
//set some test parameters
flow.params.address = new Address(...)
flow.params.person = new Person(...)
flow.params.cartItems = [...] //signal a submission from the show cart page
context = signalEvent("checkout")
assertCurrentStateEquals "enterDetailsView" //check anything put in flash.message
def message = getFlowExecution().getFlashScope().get("message")
assertTrue message.contains("saved.") //test your data to see that stuff worked
//
//assertModelAttributeNotNull('cart',viewSelection)
def cart = getFlowAttribute('cart')
//another way to get stuff
def person = viewSelection.model.cart
}/**
* Grails 1.0.x
*/
class ShoppingCartFlowTests extends grails.test.WebFlowTestCase {
def getFlow() { new BookController().shoppingCartFlow } void testShoppingCartFlow(){ def viewSelection = startFlow()
//test that you get the correct view to start
assertEquals "showCart", viewSelection.viewName //set some test parameters
flow.params.address = new Address(...)
flow.params.person = new Person(...)
flow.params.cartItems = [...] //signal a submission from the show cart page
viewSelection = signalEvent("checkout")
assertEquals "enterDetailsView", viewSelection.viewName //check anything put in flash.message
def message = getFlowExecution().getFlashScope().get("message")
assertTrue message.contains("saved.") //test your data to see that stuff worked
assertModelAttributeNotNull('cart',viewSelection)
def cart = getFlowAttribute('cart')
//another way to get stuff
def person = viewSelection.model.cart
}
}
See the reference documentation on Integration Testing for more details:
http://grails.org/doc/1.0.x/guide/single.htmlYou can also look at the Spring Documentation on AbstractFlowExceutionTests for more functions:
http://static.springframework.org/spring-webflow/docs/pr5/api/org/springframework/webflow/test/AbstractFlowExecutionTests.html
Known Issues and Quirks
Objects placed in flash/flow/conversation scope MUST implement java.io.Serializable
This is more of a quirk, but don't forget to make all objects you put in flash/flow/conversation scope implement java.io.Serializable as Web Flow stores objects in serialized form
Transactional service classes cannot be placed into flash/flow/conversation scope
Currently you cannot put a transactional service class in flash/flow/conversation scope as an AOP proxy/serialization error will occur. The workaround is to disable transactional demarcation by doing:
static transactional = false
And then if you still need transations to use programmatic transaction management:
Book.withTransaction { status ->
// code here
}
Or alternatively you can use dependency injection to inject another transactional service into the service within flow scope:
…
def transactionalService
def saveBook(book) {
transactionalService.doSomeAtomicOperation(book)
}
...
Pending issue:
http://opensource.atlassian.com/projects/spring/browse/SWF-353If a class has an instance in flash/flow/conversation scope and it is reloaded then you will get an error
Currently if you change and reload a class that has an instance if flash/flow/conversation scope you will get an error and will need to restart Grails. This is due to web flow storing classes in serialized form and then attempting to deserialize, but the class has changed. There is no current workaround for this.
Pending issue:
http://opensource.atlassian.com/projects/spring/browse/SWF-354All function calls are interpreted as events.
If you try and call a function of your controller class from a webFlow, it will be interpreted as an event. So if you were trying to write a function that did some work and then returned an object, you would actually get a flow event reference back because it would be interpreted as a flow event. The workaround is to preface all function calls with this - ie this.function()
Issue:
http://jira.codehaus.org/browse/GRAILS-3510