Wednesday, October 17, 2007

Populate child menu's

WARNING - OUTDATED CONTENT!

This article is targeted on JSF 1.2. For JSF 2.0, populating a child menu has become so much easier using <f:ajax>. See also this post for a code snippet.

Introduction

Having multiple h:selectOneMenu instances in one form which depends on each other and of which its values have to be obtained from the backing bean can drive JSF developers nuts. Especially if those are to be implemented in a form with at least one required field or if they are even required themselves. Validation errors ('Value not valid'), IllegalArgumentExceptions (at SelectItemsIterator#next()), unexpected submits (missing or wrong values), etcetera are flying around. You would almost become suicidal.

Back to top

Onchange, valueChangeListener, immediate, renderResponse and binding

To populate a child menu of which its contents is to be determined based on the value of the parent menu, you have to submit the form to the server on change of the parent menu. This can easily be achieved using the onchange attribute where you can specify some Javascript which have to be invoked when the menu is changed. Submitting the current form to the server using Javascript is simple, specifying "this.form.submit()" ought to be enough, or just submit() if you're lazy in typing.

You can use a valueChangeListener to retrieve the new value of the menu. But you of course want to prevent validation on the other required fields. This can technically be achieved by adding immediate="true" to all menu's so that all converters, validators and valuechange events of the menu's gets fired in the APPLY_REQUEST_VALUES phase instead of the PROCESS_VALIDATIONS phase and by adding the following line to the end of the valueChangeListener method:


FacesContext.getCurrentInstance().renderResponse();

This line will force a phase shift of the current phase to the RENDER_RESPONSE phase. When a valueChangeListener is invoked with immediate="true", then this line will cause the PROCESS_VALIDATIONS, UPDATE_MODEL_VALUES and INVOKE_APPLICATION being skipped, so that the other components which doesn't have immediate="true" set won't be converted, validated, applied nor invoked. Also see the former article Debug JSF lifecycle for more insights in the JSF lifecycle.

One concern is that the skipping of the UPDATE_MODEL_VALUES will also cause that the new values of the menu's which have immediate="true" set won't be set in the backing bean. This can partly be fixed by getting the new value from the ValueChangeEvent inside the valueChangeListener method and assign it to the appropriate property. But this won't work for other menu's of which the valueChangeListener isn't been invoked. This would cause problems if you select a child menu value and then select the parent menu back to null and then reselect it to same value again, the child menu which will show up again would remain the same selection instead of null while its child will not be rendered! To solve this we need to bind the menu's to the backing bean so that we can use UIInput#setValue() and UIInput#getValue() to set and get the actual values. The JSF lifecycle will set and get them in the RESTORE_VIEW and RENDER_RESPONSE phases respectively.

Back to top

Basic JSF code example

Here is a basic JSF code example which demonstrates three h:selectOneMenu components of which the listing of the next menu depends on the selection of the current menu. The next menu will be hidden until the selection of the current menu has a valid value. In this example we'll use a basic tree structure of area's (countries, cities and streets), which would make the most sense. There is also another required input field added to demonstrate the menu's working flawlessly in conjunction with other components.

One important detail to be mentioned is that the requireness of this menu group is set in a valueless h:inputHidden component instead of in the last menu. This is done so because of the fact that the last menu would not be rendered when its parent menu doesn't have a valid selection, so it would be pointless to set a required attribute on the last menu.

This example is developed and tested using JSF 1.2_05 in a Java EE 5.0 environment with a GlassFish V2 application server.

<h:form>
    <h:panelGrid columns="2">
        <h:outputText value="Choose area" />
        <h:panelGroup>
            <h:selectOneMenu
                binding="#{myBean.countryMenu}" converter="areaMenuConverter"
                onchange="this.form.submit();" valueChangeListener="#{myBean.changeCountryMenu}"
                immediate="true">
                <f:selectItem itemLabel="Please select country" />
                <f:selectItems value="#{myBean.countryItems}" />
            </h:selectOneMenu>
            <h:selectOneMenu
                binding="#{myBean.cityMenu}" converter="areaMenuConverter"
                onchange="this.form.submit();" valueChangeListener="#{myBean.changeCityMenu}"
                immediate="true" rendered="#{myBean.countryMenu.value != null}">
                <f:selectItem itemLabel="Please select city" />
                <f:selectItems value="#{myBean.cityItems}" />
            </h:selectOneMenu>
            <h:selectOneMenu
                binding="#{myBean.streetMenu}" converter="areaMenuConverter" 
                rendered="#{myBean.cityMenu.value != null}">
                <f:selectItem itemLabel="Please select street" />
                <f:selectItems value="#{myBean.streetItems}" />
            </h:selectOneMenu>
            <h:inputHidden
                required="#{myBean.streetMenu.value == null}" requiredMessage="Area is required." />
        </h:panelGroup>

        <h:outputText value="Enter input" />
        <h:inputText
            value="#{myBean.input}" 
            required="true" requiredMessage="Input is required." />

        <h:panelGroup />
        <h:commandButton value="Submit" action="#{myBean.submit}" />
    </h:panelGrid>

    <h:messages infoStyle="color: green;" errorStyle="color: red;" />
</h:form>

And here is the appropriate backing bean:

package mypackage;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;

import javax.faces.application.FacesMessage;
import javax.faces.component.html.HtmlSelectOneMenu;
import javax.faces.context.FacesContext;
import javax.faces.event.ValueChangeEvent;
import javax.faces.model.SelectItem;

public class MyBean {

    // Init ---------------------------------------------------------------------------------------

    private static World world = new World();
    private List<SelectItem> countryItems = new ArrayList<SelectItem>();
    private List<SelectItem> cityItems = new ArrayList<SelectItem>();
    private List<SelectItem> streetItems = new ArrayList<SelectItem>();
    private HtmlSelectOneMenu countryMenu;
    private HtmlSelectOneMenu cityMenu;
    private HtmlSelectOneMenu streetMenu;
    private String input;

    {
        // Prefill country menu.
        fillAreaItems(countryItems, world.getAreas());
    }

    // Actions ------------------------------------------------------------------------------------

    public void submit() {
        // Show selection and input results as informal message.
        FacesContext.getCurrentInstance().addMessage(null,
            new FacesMessage("You have chosen: " + countryMenu.getValue() + ", "
                + cityMenu.getValue() + ", " + streetMenu.getValue()
                + " and you have entered: " + input));
    }

    // Changers -----------------------------------------------------------------------------------

    public void changeCountryMenu(ValueChangeEvent event) {
        // Get selected country.
        Country country = (Country) event.getNewValue();

        if (country != null) {
            // Fill city menu.
            fillAreaItems(cityItems, country.getAreas());
        }

        // Reset child menu's. This is only possible when using component binding.
        cityMenu.setValue(null);
        streetMenu.setValue(null);

        // Skip validation of non-immediate components and invocation of the submit() method.
        FacesContext.getCurrentInstance().renderResponse();
    }

    public void changeCityMenu(ValueChangeEvent event) {
        // Get selected city.
        City city = (City) event.getNewValue();

        if (city != null) {
            // Fill street menu.
            fillAreaItems(streetItems, city.getAreas());
        }

        // Reset child menu. This is only possible when using component binding.
        streetMenu.setValue(null);

        // Skip validation of non-immediate components and invocation of the submit() method.
        FacesContext.getCurrentInstance().renderResponse();
    }

    // Fillers ------------------------------------------------------------------------------------

    private static <A extends Area<?>> void fillAreaItems(List<SelectItem> areaItems, Set<A> areas) {
        areaItems.clear();
        for (A area : areas) {
            areaItems.add(new SelectItem(area, area.getName()));
        }
    }

    // Getters ------------------------------------------------------------------------------------

    public List<SelectItem> getCountryItems() {
        return countryItems;
    }

    public List<SelectItem> getCityItems() {
        return cityItems;
    }

    public List<SelectItem> getStreetItems() {
        return streetItems;
    }

    public HtmlSelectOneMenu getCountryMenu() {
        return countryMenu;
    }

    public HtmlSelectOneMenu getCityMenu() {
        return cityMenu;
    }

    public HtmlSelectOneMenu getStreetMenu() {
        return streetMenu;
    }

    public String getInput() {
        return input;
    }

    // Setters ------------------------------------------------------------------------------------

    public void setCountryMenu(HtmlSelectOneMenu countryMenu) {
        this.countryMenu = countryMenu;
    }

    public void setCityMenu(HtmlSelectOneMenu cityMenu) {
        this.cityMenu = cityMenu;
    }

    public void setStreetMenu(HtmlSelectOneMenu streetMenu) {
        this.streetMenu = streetMenu;
    }

    public void setInput(String input) {
        this.input = input;
    }

}

The relevant part of the faces-config.xml file look like:

<converter>
    <converter-id>areaMenuConverter</converter-id>
    <converter-class>mypackage.AreaMenuConverter</converter-class>
</converter>
<managed-bean>
    <managed-bean-name>myBean</managed-bean-name>
    <managed-bean-class>mypackage.MyBean</managed-bean-class>
    <managed-bean-scope>session</managed-bean-scope>
</managed-bean>

If you want to keep the bean in request scope (which is a very reasonable requirement), then you need to install Tomahawk and 'cache' the bean for the next request only using <t:saveState />.

Back to top

Menu structure

As said earlier, we're demonstrating the working of the menu's using a tree structure of area's: countries, cities and streets, because that would make the most sense. It is not necessary to take over exactly such a structure for your menu's. Just do whatever you find the best and easiest way to use, access and maintain menu items. At least I like the following relatively simple parent-child structure and the converter.

Here is the abstract class Area (please note the implementation of equals() and hashCode(), this is very important for JSF, also see Objects in h:selectOneMenu):

package mypackage;

import java.util.Comparator;
import java.util.Set;
import java.util.TreeSet;

public abstract class Area<A extends Area<?>> {

    // Init ---------------------------------------------------------------------------------------

    private String name;
    private Set<A> areas;
    private Area<?> parent;

    // Constructors -------------------------------------------------------------------------------

    protected Area(String name, A[] areas) {
        this.name = name;
        this.areas = new TreeSet<A>(new AreaComparator<A>());
        for (A area : areas) {
            area.parent = this;
            this.areas.add(area);
        }
    }

    // Getters ------------------------------------------------------------------------------------

    public String getName() {
        return name;
    }

    public Set<A> getAreas() {
        return areas;
    }

    public Area<?> getParent() {
        return parent;
    }

    // Helpers ------------------------------------------------------------------------------------

    public boolean equals(Object other) {
        return other instanceof Area
            && this.getClass().equals(other.getClass())
            && name.equals(((Area<?>) other).name);
    }

    public int hashCode() {
        return this.getClass().hashCode() + name.hashCode();
    }
    
    public String toString() {
        return name;
    }

}

class AreaComparator<A extends Area<?>> implements Comparator<A> {

    // Invokes natural sorting on area name.
    public int compare(A a1, A a2) {
        return a1.getName().compareTo(a2.getName());
    }

}

And here is the class representing the World which is nothing more or less than a placeholder of the tree structure of area's. Note the stub constructor, it prefills the tree structure.

package mypackage;

public class World extends Area<Country> {

    // Constructors -------------------------------------------------------------------------------

    public World(String name, Country[] countries) {
        super(name, countries);
    }

    public World() {
        // Stub constructor.
        // May be replaced by DAO, configuration files, or wherever you want to store this stuff.
        this("Earth", new Country[] {
            new Country("The Netherlands", new City[] {
                new City("Haarlem", new Street[] {
                    new Street("Palamedesstraat"),
                    new Street("Vergierdeweg"),
                    new Street("Marsstraat")
                }),
                new City("Amsterdam", new Street[] {
                    new Street("Gyroscoopstraat"),
                    new Street("Albert Cuypstraat"),
                    new Street("De Boelelaan")
                }),
                new City("Almere", new Street[] {
                    new Street("Tarantellastraat"),
                    new Street("Salsastraat"),
                    new Street("Hollywoodlaan")
                })
            }),
            new Country("United States", new City[] {
                new City("New York", new Street[] {
                    new Street("Central Park West"),
                    new Street("Park Avenue"),
                    new Street("Amsterdam Avenue")
                }),
                new City("Los Angeles", new Street[] {
                    new Street("Main Street"),
                    new Street("Broadway"),
                    new Street("Olympic Boulevard")
                }),
                new City("Miami", new Street[] {
                    new Street("Miami Avenue"),
                    new Street("Biscayne Boulevard"),
                    new Street("Venetian Way")
                })
            }),
            new Country("France", new City[] {
                new City("Paris", new Street[] {
                    new Street("Avenue des Champs Elysees"),
                    new Street("Quai d'Orsay"),
                    new Street("Rue La Fayette")
                }),
                new City("Lyon", new Street[] {
                    new Street("Cours Lafayette"),
                    new Street("Quai Victor Augagneur"),
                    new Street("Rue Garibaldi")
                }),
                new City("Marseille", new Street[] {
                    new Street("Boulevard Longchamp"),
                    new Street("Rue de Rome"),
                    new Street("Cours Lieutaud")
                })
            })
        });
    }

}

The Country class:

package mypackage;

public class Country extends Area<City> {

    // Constructors -------------------------------------------------------------------------------

    public Country(String name, City[] cities) {
        super(name, cities);
    }

}

The City class:

package mypackage;

public class City extends Area<Street> {

    // Constructors -------------------------------------------------------------------------------

    public City(String name, Street[] streets) {
        super(name, streets);
    }

}

The Street class:

package mypackage;

public class Street extends Area<Area<?>> { 

    // Constructors -------------------------------------------------------------------------------

    public Street(String name) {
        super(name, new Area[0]);
    }

}

And finally the Converter to be used in menu's to convert between the Area type and String type and vice versa:

package mypackage;

import java.util.Set;

import javax.faces.component.UIComponent;
import javax.faces.context.FacesContext;
import javax.faces.convert.Converter;

public class AreaMenuConverter implements Converter {

    // Init ---------------------------------------------------------------------------------------

    private static final String AREAS = "AreaMenuConverter.areas";

    // Actions ------------------------------------------------------------------------------------

    public String getAsString(FacesContext context, UIComponent component, Object value) {
        if (value != null) {
            // Cast back to Area.
            Area<?> area = (Area<?>) value;

            // Store the areas as component attribute so that they are available in getAsObject().
            // Those represents the same values as those in f:selectItems.
            component.getAttributes().put(AREAS, area.getParent().getAreas());

            // Return String representation of area.
            return area.getName();
        }

        return null; // Value is null.
    }

    @SuppressWarnings("unchecked")
    public Object getAsObject(FacesContext context, UIComponent component, String value) {
        if (value != null) {
            // Get the areas back which were stored as component attribute in getAsString().
            Set<Area<?>> areas = (Set<Area<?>>) component.getAttributes().get(AREAS);

            // Compare name of each area with selected value.
            for (Area<?> area : areas) {
                if (area.getName().equals(value)) {
                    // Return matched area object.
                    return area;
                }
            }
        }

        return null; // Value is null or doesn't have any match.
    }

}

That was it! You can in fact just copypaste and run it all without any changes and then play/experiment with it further.

Back to top

Copyright - There is no copyright on the code. You can copy, change and distribute it freely. Just mentioning this site should be fair.

(C) October 2007, BalusC