Jumat, 20 Juni 2014

Composite component with multiple input fields


Introduction



Composite components are a nice JSF2/Facelets feature. As stated in this stackoverflow.com answer, you can use it to create a reuseable component with a single responsibility based on existing JSF components and/or HTML.



Use Composite Components if you want to create a single and reuseable custom UIComponent with a single responsibility using pure XML. Such a composite component usually consists of a bunch of existing components and/or HTML and get physically rendered as single component. E.g. a component which shows a rating in stars based on a given integer value. An example can be found in our Composite Component wiki page.


The wiki page contains however only an example of a composite component with a pure output function (showing a rating in stars). Creating a composite component based on a bunch of closely related UIInput components is a little tougher, but it's not demonstrated in the wiki page. So, let's write a blog about it.



Back to top


Bind java.util.Date value to 3 day/month/year dropdowns



Although the calendar popup is becoming more popular these days, a not uncommon requirement is to have a date selection by three dropdown lists representing the day, month and year. In JSF terms, you'd thus need three components and a little bit of ajax or even plain vanilla JavaScript in order to get the days right depending on the selected month and year. Not every month has the same amount of days and a particular month has even a different amount of days depending on the year.



Let's start with some XHTML first:




xmlns="http://www.w3.org/1999/xhtml"
xmlns:f="http://java.sun.com/jsf/core"
xmlns:h="http://java.sun.com/jsf/html"
xmlns:ui="http://java.sun.com/jsf/facelets"
xmlns:cc="http://java.sun.com/jsf/composite"
>
componentType="inputDate">
name="value" type="java.util.Date"
shortDescription="The selected Date. Defaults to today." />
name="maxyear" type="java.lang.Integer"
shortDescription="The maximum year. Defaults to current year." />
name="minyear" type="java.lang.Integer"
shortDescription="The minimum year. Defaults to maxyear minus 100." />


id="#{cc.clientId}" style="white-space:nowrap">
id="day" binding="#{cc.day}" converter="javax.faces.Integer">
value="#{cc.days}" />

id="month" binding="#{cc.month}" converter="javax.faces.Integer">
value="#{cc.months}" />
execute="day month" listener="#{cc.updateDaysIfNecessary}" />

id="year" binding="#{cc.year}" converter="javax.faces.Integer">
value="#{cc.years}" />
execute="day year" listener="#{cc.updateDaysIfNecessary}" />





Save it as /resources/components/inputDate.xhtml.



The componentType attribute of the tag is perhaps new to you. It basically allows you to bind the composite component to a so-called backing component. This must be an instance of UIComponent and implement at least the NamingContainer interface (as required by the JSF composite component specification). Given that we basically want to create an input component, we'd like to extend from UIInput. The component type inputDate represents the component type and should be exactly the same value as is been declared in the value of the @FacesComponent annotation. The concrete backing component instance is available by the implicit EL variable #{cc} inside the .



The three components are all via binding attribute bound as UIInput properties of the backing component which allows easy access to the submitted values and the (local) values. The also obtains all available values from the backing component. The listener is also declared in the backing component. The enduser has only to provide a java.util.Date property as composite component value. The backing component does all the heavy lifting job.



Oh, there's also a with the client ID of the composite component. This allows easy referencing in ajax updates from outside as follows:



 id="foo" ... />
...
... render="foo" />


The composite component is by its own client ID available in the JSF component tree and thus accessible for ajax updates, but this client ID is by default nowhere represented by a HTML element and thus JavaScript wouldn't be able to find it in the HTML DOM (via document.getElementById() and so on) in order to update the HTML representation. So you need to supply your own HTML representation. This is in detail explained in the following stackoverflow.com questions:





Back to top


Backing component of the composite component



Here's the necessary Java code!



package com.example;

import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;

import javax.faces.component.FacesComponent;
import javax.faces.component.NamingContainer;
import javax.faces.component.UIInput;
import javax.faces.component.UINamingContainer;
import javax.faces.context.FacesContext;
import javax.faces.convert.ConverterException;
import javax.faces.event.AjaxBehaviorEvent;

@FacesComponent("inputDate")
public class InputDate extends UIInput implements NamingContainer {

// Fields -------------------------------------------------------------------------------------

private UIInput day;
private UIInput month;
private UIInput year;

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

/**
* Returns the component family of {@link UINamingContainer}.
* (that's just required by composite component)
*/

@Override
public String getFamily() {
return UINamingContainer.COMPONENT_FAMILY;
}

/**
* Set the selected and available values of the day, month and year fields based on the model.
*/

@Override
public void encodeBegin(FacesContext context) throws IOException {
Calendar calendar = Calendar.getInstance();
int maxYear = getAttributeValue("maxyear", calendar.get(Calendar.YEAR));
int minYear = getAttributeValue("minyear", maxYear - 100);
Date date = (Date) getValue();

if (date != null) {
calendar.setTime(date);
int year = calendar.get(Calendar.YEAR);

if (year > maxYear || minYear > year) {
throw new IllegalArgumentException(
String.format("Year %d out of min/max range %d/%d.", year, minYear, maxYear));
}
}

day.setValue(calendar.get(Calendar.DATE));
month.setValue(calendar.get(Calendar.MONTH) + 1);
year.setValue(calendar.get(Calendar.YEAR));
setDays(createIntegerArray(1, calendar.getActualMaximum(Calendar.DATE)));
setMonths(createIntegerArray(1, calendar.getActualMaximum(Calendar.MONTH) + 1));
setYears(createIntegerArray(maxYear, minYear));
super.encodeBegin(context);
}

/**
* Returns the submitted value in dd-MM-yyyy format.
*/

@Override
public Object getSubmittedValue() {
return day.getSubmittedValue()
+ "-" + month.getSubmittedValue()
+ "-" + year.getSubmittedValue();
}

/**
* Converts the submitted value to concrete {@link Date} instance.
*/

@Override
protected Object getConvertedValue(FacesContext context, Object submittedValue) {
try {
return new SimpleDateFormat("dd-MM-yyyy").parse((String) submittedValue);
}
catch (ParseException e) {
throw new ConverterException(e); // This is not to be expected in normal circumstances.
}
}

/**
* Update the available days based on the selected month and year, if necessary.
*/

public void updateDaysIfNecessary(AjaxBehaviorEvent event) {
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.DATE, 1);
calendar.set(Calendar.MONTH, (Integer) month.getValue() - 1);
calendar.set(Calendar.YEAR, (Integer) year.getValue());
int maxDay = calendar.getActualMaximum(Calendar.DATE);

if (getDays().length != maxDay) {
setDays(createIntegerArray(1, maxDay));

if ((Integer) day.getValue() > maxDay) {
day.setValue(maxDay); // Fix the selected value if it exceeds new max value.
}

FacesContext context = FacesContext.getCurrentInstance(); // Update day field.
context.getPartialViewContext().getRenderIds().add(day.getClientId(context));
}
}

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

/**
* Return specified attribute value or otherwise the specified default if it's null.
*/

@SuppressWarnings("unchecked")
private T getAttributeValue(String key, T defaultValue) {
T value = (T) getAttributes().get(key);
return (value != null) ? value : defaultValue;
}

/**
* Create an integer array with values from specified begin to specified end, inclusive.
*/

private static Integer[] createIntegerArray(int begin, int end) {
int direction = (begin < end) ? 1 : (begin > end) ? -1 : 0;
int size = Math.abs(end - begin) + 1;
Integer[] array = new Integer[size];

for (int i = 0; i < size; i++) {
array[i] = begin + (i * direction);
}

return array;
}

// Getters/setters ----------------------------------------------------------------------------

public UIInput getDay() {
return day;
}

public void setDay(UIInput day) {
this.day = day;
}

public UIInput getMonth() {
return month;
}

public void setMonth(UIInput month) {
this.month = month;
}

public UIInput getYear() {
return year;
}

public void setYear(UIInput year) {
this.year = year;
}

public Integer[] getDays() {
return (Integer[]) getStateHelper().get("days");
}

public void setDays(Integer[] days) {
getStateHelper().put("days", days);
}

public Integer[] getMonths() {
return (Integer[]) getStateHelper().get("months");
}

public void setMonths(Integer[] months) {
getStateHelper().put("months", months);
}

public Integer[] getYears() {
return (Integer[]) getStateHelper().get("years");
}

public void setYears(Integer[] years) {
getStateHelper().put("years", years);
}

}


The backing component instance has basically a lifetime of exactly one HTTP request. This means that it's recreated on every single HTTP request, like as a request scoped managed bean. So if you ever manually create variables during an encodeXxx() method which you'd like to be available in any of the component's methods during the subsequent postback request (the form submit), then you should not be assigning it as a field of the class. It would get lost by end of initial request and reinitialize to default (e.g. null) during the postback request.



If you've ever developed a custom UIComponent, or looked in the source code of an existing UIComponent, then you have probably already seen the StateHelper which is available by the inherited getStateHelper() method. This basically takes care about the component's state across postbacks. It has basically the same lifetime as a view scoped managed bean. You can use the put() method to store a variable in the component's state. You can use the get() or eval() method to get or EL-evaluate a variable from the component's state. In this particular backing component, this is done so for the dropdown values. Look at their getters/setters, they all delegate directly to StateHelper.



This is not done so for the UIInput properties which represents each of the components. JSF will namely already automatically set them via binding attribute during building/restoring of the view. Even more, you're not supposed to save complete UIComponent instances in component's state. Note that you can also use e.g. UIInput day = (UIInput) findComponent("day"); instead of binding="#{cc.day}" with a day property, but this may result in some boilerplate code as you need this in multiple methods.



When the component is about to be rendered, the encodeBegin() method is invoked which basically obtains the maxyear, minyear and value attributes and initializes the dropdowns. The first two attributes represent the maximum and minimum value of the "year" dropdown, which in turn defaults to respectively the current year and the maximum year minus 100. The input value is as per already expected to be an instance of java.util.Date. Note that the value is obtained by the getValue() method which is inherited from UIInput. After a simple max/min year check, the individual day, month and year fields are obtained from the calendar and set as values of dropdown components. Finally the available values of the dropdown components are filled.



When the form is submitted and the request values have been applied (which is basically what the decode() method of the input component should be doing, but as we're delegating it to the three dropdown components, we actually don't need to override anything here), the getSubmittedValue() method will be invoked in order to obtain the "raw" submitted value which is used for the usual conversion/validation steps. The backing component will return the submitted value as a string in dd-MM-yyyy format. It's important that this value is not null, otherwise JSF will skip the conversion/validation/modelupdate. If you happen to use MyFaces instead of Mojarra, then you need to replace getSubmittedValue() call on child component by getValue():




@Override
public Object getSubmittedValue() {
return day.getValue()
+ "-" + month.getValue()
+ "-" + year.getValue();
}



Or, if you'd like to cover both:




@Override
public Object getSubmittedValue() {
return (day.getSubmittedValue() == null && day.isLocalValueSet() ? day.getValue() : day.getSubmittedValue())
+ "-" + (month.getSubmittedValue() == null && month.isLocalValueSet() ? month.getValue() : month.getSubmittedValue())
+ "-" + (year.getSubmittedValue() == null && year.isLocalValueSet() ? year.getValue() : year.getSubmittedValue());
}



Here, the child component is first checked if it has no submitted value and has its local value set, and if that's the case, then return its local value instead of the submitted value. This is needed because Mojarra and MyFaces don't agree on whether to process the UIInput component itself first before processing its children, or the other way round. Mojarra first processes the UIInput component itself before its children, and therefore needs getSubmittedValue(). MyFaces, on the other hand, first processes the children before the UIInput component itself, and therefore needs getValue().



Shortly after getting the submitted value, JSF will invoke the getConvertedValue() method, passing exactly the submitted value as 2nd argument. Normally this method is not to be overridden and everything is delegated to default JSF Converter mechanisms, but the backing component has it overriden to take the opportunity to convert the submitted value to a concrete java.util.Date instance which will ultimately be updated in the model.



Note that no validation is performed and that's not necessary, because it's impossible for a hacker to provide a different submitted value than shown in the dropdowns (e.g. a day of 33). In any attempt, JSF would simply fail the usual way with Validation Error: Value is not valid on the associated dropdown.



Finally, there's a "proprietary" ajax action listener method updateDaysIfNecessary() which should update the day dropdown depending on the value of the month and year dropdowns, if necessary. It basically determines the maximum day of the given month and year and checks if the available days and currently selected day needs to be altered if the maximum day has been changed. If that's the case, then a programmatic ajax render will be instructed by adding the client ID of the day dropdown to PartialViewContext#getRenderIds().

Usage example



Here's how you can use it in an arbitrary form. First declare the composite component's XML namespace in the top level XML element:



xmlns:my="http://java.sun.com/jsf/composite/components"


The prefix "my" is fully to your choice. The /components part of the path is also fully to your choice, it's basically the name of the subfolder in the /resources folder where you've placed the composite component XHTML file. Given this XML namespace, it's thus available as as follows:





value="#{bean.date1}" />

value="#{bean.date2}" maxyear="2050" />

value="#{bean.date3}" maxyear="2000" minyear="1990" />

value="Submit" action="#{bean.submit}" />




The date1, date2 and date3 properties are all of type java.util.Date. Nothing special, it can even be null, it would default to today's date anyway. See also this little video demo of how the component behaves in the UI.



Source:http://balusc.blogspot.com/2013/01/composite-component-with-multiple-input.html

Tidak ada komentar:

Posting Komentar