domingo, 26 de junio de 2011

JSF component state per row for datatables

Components like h:dataTable, ui:repeat and others found in common JSF libraries (in the current moment JUN 2011) does not preserve component state per row. This is an old known problem found in JSF, but fortunately there is an alternative to solve this one.

But first it is necessary to explain the problem with a simple example. The code shown below does not work as users expect:

<h:form id="mainForm">
  <h:dataTable value="#{itemsBean.items}" var="item">
    <h:column>
      <h:outputText value="#{item}"/>
      <h:commandButton value="Change Only this Button To Red" actionListener="#{itemsBean.changeToRed}"/>
    </h:column>
  </h:dataTable>
</h:form>

@ManagedBean(name="itemsBean")
@SessionScoped
public class ItemsBean{

 
    private List<String> items;
   
    public List<String> getItems() {
        if (items == null) {
            items = new ArrayList<String>();
            items.add("A");
            items.add("B");
            items.add("C");
            items.add("D");
        }
        return items;
    }
   
    public void changeToRed(ActionEvent evt) {
        evt.getComponent().

            getAttributes().put(
                "style", "background:red");
    }
}

The previous code renders 4 rows with the item text and a button:

A [Change Only this Button To Red]
B [Change Only this Button To Red]
C [Change Only this Button To Red]
D [Change Only this Button To Red]

What users expect is when a button is clicked, only the button who was clicked changes to red. Variants of this code includes calls to UIComponent.invokeOnComponent, UIComponent.visitTree or UIComponent.findComponent that tries to change something on the component state inside the row and keep it on that scope.

But the real behavior is all buttons change to red. In other scenarios state could get lost.

The reason behind this behavior is tags like h:dataTable or ui:repeat only save properties related with EditableValueHolder interface (value, submittedValue, localValueSet, valid). So, a common hack found to make it work correctly is extend your component from UIInput or use EditableValueHolder interface, and store the state you want to preserve per row inside "value" field. One example of a component using that approach is tomahawk t:collapsiblePanel. But note this only works with simple components, and use that strategy is just workaround.

The most simple solution since JSF 1.2 using facelets is refactor the code to use c:forEach tag:

<h:form id="mainForm">
 <table>
  <c:forEach items="#{itemsBean.items}" var="item">
    <tr>
      <td>
        <h:outputText value="#{item}"/>
        <h:commandButton value="Change Only this Button To Red" actionListener="#{itemsBean.changeToRed}"/>
      </td>
    </tr>
  </c:forEach>
 </table>
</h:form>

It works, because c:forEach is a "build view" tag, or in other words, just traverse the list first time the view is rendered, and create full components per row. So in the component tree we have 4 component buttons instead one "shared" by all rows.

The problem with use c:forEach is that create components makes state bigger and with many rows, view rendering is slower. That's the reason why h:dataTable or ui:repeat create just one component and share it for all rows: to keep state small and view rendering fast.

There are other workarounds to this one, like use EL expressions and store some properties on the model. All those solutions are valid, but isn't exists a clean approach for this one? something that preserve the component state on the component and the model data on the model, without use a proxy pattern?.
  
The good news is exists one cool hack to make it work since JSF 2.1, and a extended version of this hack is available on tomahawk latest snapshot, so it will be in version 1.1.11. This will only work with partial state saving enabled.   

Since JSF 2.1, UIData implementation has a new property called rowStatePreserved. Right now this property does not appear on facelets taglib documentation for h:dataTable, but on the javadoc for UIData there is. So the fix is very simple, just add rowStatePreserved="true" in your h:dataTable tag:

<h:form id="mainForm">
  <h:dataTable value="#{itemsBean.items}" var="item" rowStatePreserved="true">
    <h:column>
      <h:outputText value="#{item}"/>
      <h:commandButton value="Change Only this Button To Red" actionListener="#{itemsBean.changeToRed}"/>
    </h:column>
  </h:dataTable> 
</h:form>

That's it. Simple, isn't it?.

Tomahawk t:dataTable and t:dataList have rowStatePreserved property too, so if you are in JSF 2.0, you can use those tags instead:

<h:form id="mainForm">
  <t:dataTable value="#{itemsBean.items}" var="item" rowStatePreserved="true">
    <h:column>
      <h:outputText value="#{item}"/>
      <h:commandButton value="Change Only this Button To Red" actionListener="#{itemsBean.changeToRed}"/>
    </h:column>
  </t:dataTable>
</h:form>

<h:form id="mainForm">
 <table>
  <t:dataList value="#{itemsBean.items}" var="item" rowStatePreserved="true">
    <tr>
      <td>
        <h:outputText value="#{item}"/>
        <h:commandButton value="Change Only this Button To Red" actionListener="#{itemsBean.changeToRed}"/>
      </td>
    </tr>
  </t:dataList>
  </table>
</h:form>

Additionally t:dataTable and t:dataList are designed to handle complex scenarios like nested combinations. If you need to add/remove rows and keep state per row working in a reliable way you can use "rowKey" property to identify in a unique way a row, so that value will be used later to generate child component clientId and the final effect is your component state will be preserved even when you manipulate the model. But that's for another blog entry ;-).

Please note in current Mojarra implementations (2.0.6 and 2.1.2) there is a problem with composite components that makes state get lost (see JAVASERVERFACES-1985 for details), but this work on MyFaces 2.0.7 and 2.1.1, so give it a try ;-). Anyway, Mojarra guys are working hard to get it out, it is a very difficult problem and I have been helping them with it, but I expect sooner or later this problem will be solved.

1 comentario: