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.
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) yes()
else 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.
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 (so you shouldn't include a scope prefix when referencing these objects within a view, e.g. GSP pages).
|
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"
}
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-353
If 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-354