3.5. Nested conversations: extending the Hotel Booking example

3.5.1. Introduction

Long-running conversations allow you to easily maintain state consistency in an application, even in the face of multi-window operation and back-buttoning. However, simply beginning and ending a long-running conversation is not always enough. Depending on the requirements of the application, there can be inconsistency between user expectations and the application state.
The nested booking application extends the features of the hotel booking application to incorporate room selection. Each hotel has a list of available rooms from which you can select a room. This requires the addition of a room selection page in the hotel reservation flow.
You can now select an available room to be included in the booking. If room selection is left in the same conversation context, it can lead to issues with state consistency — if a conversation variable changes, it affects all windows operating within the same conversation context.
For example, you clone the room selection screen in a new window. You then select the Wonderful Room and proceed to the confirmation screen. To check the cost of a more expensive room, you return to the original window, select the Fantastic Suite for booking, and again proceed to confirmation. After reviewing the total cost, you return to the window showing Wonderful Room to confirm.
In this scenario, if all the states are stored in the conversation, flexibility for multi-window operation within the same conversation are limited. Nested conversations allow you to achieve correct behavior even when contexts vary within the same conversation.

3.5.2. Understanding Nested Conversations

The following code shows the behavior of the hotel booking application with intended behavior for nested conversations. Think of the code as a set of steps to be read in a sequence.

Example 3.19. RoomPreferenceAction.java

@Stateful
@Name("roomPreference")
@Restrict("#{identity.loggedIn}")
public class RoomPreferenceAction implements RoomPreference 
{

    @Logger 
        private Log log;

    @In private Hotel hotel;                                                                  1
   
    @In private Booking booking;

    @DataModel(value="availableRooms")
    private List<Room> availableRooms;

    @DataModelSelection(value="availableRooms")
    private Room roomSelection;
    
    @In(required=false, value="roomSelection")
    @Out(required=false, value="roomSelection")
    private Room room;

    @Factory("availableRooms")
    public void loadAvailableRooms()
    {
        availableRooms = hotel.getAvailableRooms(booking.getCheckinDate(), 
                                                 booking.getCheckoutDate());
        log.info("Retrieved #0 available rooms", availableRooms.size());
    }

    public BigDecimal getExpectedPrice()
    {
        log.info("Retrieving price for room #0", roomSelection.getName());
      
        return booking.getTotal(roomSelection);
    }

    @Begin(nested=true)                                                                       2
    public String selectPreference()
    {
        log.info("Room selected");
      
        this.room = this.roomSelection;                                                       3
      
        return "payment";
    }

    public String requestConfirmation()
    {
        // all validations are performed through the s:validateAll, so checks are
        // already performed
        log.info("Request confirmation from user");
      
        return "confirm";
    }

    @End(beforeRedirect=true)                                                                 4
    public String cancel()
    {
        log.info("ending conversation");

        return "cancel";
    }

    @Destroy @Remove                                                                      
        public void destroy() {}    
}

1

The hotel instance is injected from the conversation context. The hotel is loaded through an extended persistence context so that the entity remains managed throughout the conversation. This allows you to load the availableRooms through a @Factory method by simply walking the association.

2

When @Begin(nested=true) is encountered, a nested conversation is pushed onto the conversation stack. When executing within a nested conversation, components still have access to all outer conversation states. However, setting values in the nested conversation’s state container does not affect the outer conversation. In addition, nested conversations can exist concurrently stacked on the same outer conversation, allowing independent state for each.

3

The roomSelection is outjected to the conversation based on the @DataModelSelection. Note that because nested conversation has an independent context, the roomSelection is only set into the new nested conversation. If you select a different preference in another window or tab a new nested conversation is started.

4

The @End annotation pops the conversation stack and resumes the outer conversation. The roomSelection is destroyed along with the conversation context.
When you begin a nested conversation, it is pushed onto the conversation stack. In the nestedbooking example, the conversation stack consists of the external long-running conversation (the booking) and each of the nested conversations (room selections).

Example 3.20. rooms.xhtml

<div class="section">
  <h1>Room Preference</h1>
</div>

<div class="section">
  <h:form id="room_selections_form">
    <div class="section">
      <h:outputText styleClass="output" 
         value="No rooms available for the dates selected: " 
         rendered="#{availableRooms != null and availableRooms.rowCount == 0}"/>
      <h:outputText styleClass="output" 
         value="Rooms available for the dates selected: " 
         rendered="#{availableRooms != null and availableRooms.rowCount > 0}"/>
      
      <h:outputText styleClass="output" value="#{booking.checkinDate}"/>
      <h:outputText styleClass="output" value="#{booking.checkoutDate}"/>
      
      <br/><br/>
      
      <h:dataTable value="#{availableRooms}" var="room"                             1
         rendered="#{availableRooms.rowCount > 0}">
        <h:column>
          <f:facet name="header">Name</f:facet>
          #{room.name}
        </h:column>
        <h:column>
          <f:facet name="header">Description</f:facet>
          #{room.description}
        </h:column>
        <h:column>
          <f:facet name="header">Per Night</f:facet>
          <h:outputText value="#{room.price}">
            <f:convertNumber type="currency" currencySymbol="$"/>
          </h:outputText>
        </h:column>
        <h:column>
          <f:facet name="header">Action</f:facet>
          <s:link id="selectRoomPreference" 
            action="#{roomPreference.selectPreference}">Select</s:link>             2
        </h:column>
      </h:dataTable>   
    </div>
    <div class="entry">
      <div class="label">&#160;</div>
      <div class="input">
        <s:button id="cancel" value="Revise Dates" view="/book.xhtml"/>             3
      </div>
    </div>    
  </h:form>
</div>

1

When requested from EL, the #{availableRooms} are loaded by the @Factory method defined in RoomPreferenceAction. The @Factory method is executed only once to load the values into the current context as a @DataModel instance.

2

Invoking the #{roomPreference.selectPreference} action results in the row being selected and set into the @DataModelSelection. This value is outjected to the nested conversation context.

3

Revising the dates simply return to the /book.xhtml. Note that you have not yet nested a conversation (no room preference has been selected), so the current conversation can be resumed. The <s:button> component propagates the current conversation when displaying the /book.xhtml view.
The following code shows how you can confirm the booking of a selected room by extending the behavior of the HotelBookingAction.

Example 3.21. HotelBookingAction.java

@Stateful
@Name("hotelBooking")
@Restrict("#{identity.loggedIn}")
public class HotelBookingAction implements HotelBooking
{
   
   @PersistenceContext(type=EXTENDED)
   private EntityManager em;
   
   @In 
   private User user;
   
   @In(required=false) @Out
   private Hotel hotel;
   
   @In(required=false) 
   @Out(required=false)
   private Booking booking;
   
   @In(required=false)
   private Room roomSelection;
   
   @In
   private FacesMessages facesMessages;
      
   @In
   private Events events;
   
   @Logger 
   private Log log;
   
   @Begin
   public void selectHotel(Hotel selectedHotel)
   {
      log.info("Selected hotel #0", selectedHotel.getName());
      hotel = em.merge(selectedHotel);
   }
   
   public String setBookingDates()
   {
      // the result will indicate whether or not to begin the nested conversation
      // as well as the navigation.  if a null result is returned, the nested
      // conversation will not begin, and the user will be returned to the current
      // page to fix validation issues
      String result = null;

      Calendar calendar = Calendar.getInstance();
      calendar.add(Calendar.DAY_OF_MONTH, -1);

      // validate what we have received from the user so far
      if ( booking.getCheckinDate().before( calendar.getTime() ) )
      {
         facesMessages.addToControl("checkinDate", 
                                    "Check in date must be a future date");
      }
      else if ( !booking.getCheckinDate().before( booking.getCheckoutDate() ) )
      {
         facesMessages.addToControl("checkoutDate", 
                                    "Check out date must be later than check in date");
      }
      else
      {
         result = "rooms";
      }

      return result;
   }
   
   public void bookHotel()
   {      
      booking = new Booking(hotel, user);
      Calendar calendar = Calendar.getInstance();
      booking.setCheckinDate( calendar.getTime() );
      calendar.add(Calendar.DAY_OF_MONTH, 1);
      booking.setCheckoutDate( calendar.getTime() );
   }
   
   @End(root=true)                                                                          1
   public void confirm()
   {
      // on confirmation we set the room preference in the booking.  the room preference
      // will be injected based on the nested conversation we are in.
      booking.setRoomPreference(roomSelection);                                             2

      em.persist(booking);
      facesMessages.add("Thank you, #{user.name}, your confimation number" +
                        " for #{hotel.name} is #{booking.id}");
      log.info("New booking: #{booking.id} for #{user.username}");
      events.raiseTransactionSuccessEvent("bookingConfirmed");
   }
   
   @End(root=true, beforeRedirect=true)                                                     3
   public void cancel() {}
   
   @Destroy @Remove
   public void destroy() {}
}

1

Annotating an action with @End(root=true) ends the root conversation which effectively destroys the entire conversation stack. When a conversation is ended, its nested conversations are also ended. As root is the conversation that started the nested conversation, this is a simple way to destroy and release all the states associated with a workspace once the booking is confirmed.

2

The roomSelection is associated with the booking only on user confirmation. Outjecting values to the nested conversation context does not impact the outer conversation, and objects injected from the outer conversation are injected by reference. Therefore, any change in these objects is reflected in the parent conversation and other concurrent nested conversations.

3

By annotating the cancellation action with @End(root=true, beforeRedirect=true)you can destroy and release all the states associated with the workspace, before redirecting the user back to the hotel selection view.
Confirming a booking will always result in the correct hotel and room preference with the nested conversation model.