/*
* RHQ Management Platform
* Copyright (C) 2005-2008 Red Hat, Inc.
* All rights reserved.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 2 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
*/
package org.rhq.core.gui.configuration.helper;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import javax.el.ValueExpression;
import javax.faces.application.FacesMessage;
import javax.faces.component.NamingContainer;
import javax.faces.component.UIComponent;
import javax.faces.component.UIInput;
import javax.faces.component.UIOutput;
import javax.faces.component.UISelectItem;
import javax.faces.component.UISelectOne;
import javax.faces.component.html.HtmlInputSecret;
import javax.faces.component.html.HtmlInputText;
import javax.faces.component.html.HtmlInputTextarea;
import javax.faces.component.html.HtmlSelectBooleanCheckbox;
import javax.faces.component.html.HtmlSelectOneMenu;
import javax.faces.component.html.HtmlSelectOneRadio;
import javax.faces.context.FacesContext;
import javax.faces.validator.ValidatorException;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.rhq.core.domain.configuration.DynamicConfigurationPropertyValue;
import org.rhq.core.domain.configuration.Property;
import org.rhq.core.domain.configuration.PropertyDefinitionDynamic;
import org.rhq.core.domain.configuration.PropertyDynamicType;
import org.rhq.core.domain.configuration.PropertySimple;
import org.rhq.core.domain.configuration.definition.PropertyDefinition;
import org.rhq.core.domain.configuration.definition.PropertyDefinitionEnumeration;
import org.rhq.core.domain.configuration.definition.PropertyDefinitionSimple;
import org.rhq.core.domain.configuration.definition.PropertySimpleType;
import org.rhq.core.gui.configuration.CssStyleClasses;
import org.rhq.core.gui.converter.PropertySimpleValueConverter;
import org.rhq.core.gui.util.FacesComponentUtility;
import org.rhq.core.gui.util.FacesContextUtility;
import org.rhq.core.gui.util.PropertyIdGeneratorUtility;
import org.rhq.core.gui.validator.PropertySimpleValueValidator;
import org.rhq.core.template.TemplateEngine;
/**
* A class that provides methods for rendering RHQ {@link Property}s.
*
* @author Ian Springer
*/
public class PropertyRenderingUtility {
private static final int INPUT_TEXT_COMPONENT_WIDTH = 30;
private static final int INPUT_TEXTAREA_COMPONENT_ROWS = 4;
private static final String ERROR_MSG_STYLE_CLASS = "error-msg";
/**
* Enums with a size equal to or greater than this threshold will be rendered as list boxes, rather than radios.
*/
private static final int LISTBOX_THRESHOLD_ENUM_SIZE = 6;
private static final String UNIQUE_ID_PREFIX = "rhq_id";
/**
* Holds a mapping from dynamic property backing type (i.e. database, server-side-plugin) to the
* {@link DynamicPropertyRetriever} instance that should be called to retrieve the values. Code using
* this class as a core UI dependency
*/
private static final Map<PropertyDynamicType, DynamicPropertyRetriever> DYNAMIC_PROPERTY_RETRIEVERS =
new HashMap<PropertyDynamicType, DynamicPropertyRetriever>();
@NotNull
public static UIInput createInputForSimpleProperty(PropertyDefinitionSimple propertyDefinitionSimple,
PropertySimple propertySimple, ValueExpression propertyValueExpression, Integer listIndex,
boolean isGroupConfig, boolean configReadOnly, boolean configFullyEditable, boolean prevalidate, TemplateEngine templateEngine) {
UIInput input = createInput(propertyDefinitionSimple);
if (propertySimple != null)
addTitleAttribute(input, propertySimple.getStringValue());
boolean isUnset = isUnset(propertyDefinitionSimple.isRequired(), propertySimple, isGroupConfig);
boolean isReadOnly = isReadOnly(propertyDefinitionSimple.isReadOnly(), propertyDefinitionSimple.isRequired(),
propertySimple, configReadOnly, configFullyEditable);
String propertyId = PropertyIdGeneratorUtility.getIdentifier(propertySimple, listIndex);
input.setId(propertyId);
input.setValueExpression("value", propertyValueExpression);
FacesComponentUtility.setUnset(input, isUnset);
FacesComponentUtility.setReadonly(input, isReadOnly);
addValidatorsAndConverter(input, propertyDefinitionSimple, configReadOnly, templateEngine);
addErrorMessages(input, propertyDefinitionSimple, propertySimple, prevalidate);
return input;
}
public static UIInput createInputForDynamicProperty(PropertyDefinitionDynamic propertyDefinitionDynamic,
PropertySimple property, ValueExpression valueExpression, Integer listIndex, boolean isGroupConfig,
boolean configReadOnly, boolean configFullyEditable, boolean prevalidate) {
// Simply use a drop down in all cases
UISelectOne input = FacesComponentUtility.createComponent(HtmlSelectOneMenu.class, null);
// Determine where to retrieve the values from
PropertyDynamicType propertyType = propertyDefinitionDynamic.getDynamicType();
DynamicPropertyRetriever retriever = DYNAMIC_PROPERTY_RETRIEVERS.get(propertyType);
if (retriever == null) {
throw new IllegalStateException("Attempt to render a dynamic property but no retrievers are " +
"configured for the type. Dynamic property type: " + propertyType + ", Definition: " +
propertyDefinitionDynamic);
}
// Retrieve the values and load them into UI options
List<DynamicConfigurationPropertyValue> values = retriever.loadValues(propertyDefinitionDynamic);
for (DynamicConfigurationPropertyValue option : values) {
UISelectItem selectItem = FacesComponentUtility.createComponent(UISelectItem.class, null);
selectItem.setItemLabel(option.getDisplay());
selectItem.setItemValue(option.getValue());
input.getChildren().add(selectItem);
}
boolean isUnset = isUnset(propertyDefinitionDynamic.isRequired(), property, isGroupConfig);
boolean isReadOnly = isReadOnly(propertyDefinitionDynamic.isReadOnly(), propertyDefinitionDynamic.isRequired(),
property, configReadOnly, configFullyEditable);
String propertyId = PropertyIdGeneratorUtility.getIdentifier(property, listIndex);
input.setId(propertyId);
// Revert to the previously selected value
input.setValueExpression("value", valueExpression);
FacesComponentUtility.setUnset(input, isUnset);
FacesComponentUtility.setReadonly(input, isReadOnly);
// If there is a PC-detected error associated with the property, associate it with the input.
addPluginContainerDetectedErrorMessage(input, property);
return input;
}
/**
* Sets the object used to retrieve property values for dynamic properties of the given type.
*
* @param type each type in the enum will only have one retriever associated with it at any given time
* @param retriever may not be <code>null</code>
*/
public static void putDynamicPropertyRetriever(PropertyDynamicType type, DynamicPropertyRetriever retriever) {
DYNAMIC_PROPERTY_RETRIEVERS.put(type, retriever);
}
public static UIInput createInput(PropertyDefinitionSimple propertyDefinitionSimple) {
UIInput input;
PropertySimpleType type = (propertyDefinitionSimple != null) ? propertyDefinitionSimple.getType()
: PropertySimpleType.STRING;
switch (type) {
case BOOLEAN: {
input = createInputForBooleanProperty();
break;
}
case LONG_STRING: {
input = createInputForLongStringProperty();
break;
}
case PASSWORD: {
input = createInputForPasswordProperty();
break;
}
default: {
if (propertyDefinitionSimple != null && isEnum(propertyDefinitionSimple)) {
input = createInputForEnumProperty(propertyDefinitionSimple);
} else {
input = createInputForStringProperty();
}
}
}
input.setId(UNIQUE_ID_PREFIX + UUID.randomUUID());
if (propertyDefinitionSimple != null) {
// It's important to set the label attribute, since we include it in our validation error messages.
input.getAttributes().put("label", propertyDefinitionSimple.getDisplayName());
// The below adds an inert attribute to the input that contains the name of the associated property - useful
// for debugging (i.e. when viewing source of the page or using a JavaScript debugger).
input.getAttributes().put("ondblclick", "//" + propertyDefinitionSimple.getName());
}
return input;
}
@NotNull
public static UIInput createInputForSimpleProperty(PropertySimple propertySimple, ValueExpression valueExpression,
boolean readOnly) {
UIInput input = createInputForStringProperty();
input.setValueExpression("value", valueExpression);
FacesComponentUtility.setReadonly(input, readOnly);
addTitleAttribute(input, propertySimple.getStringValue());
return input;
}
public static void addMessageComponentForInput(UIComponent parent, UIInput input) {
// <h:message for="#{input-component-id}" showDetail="true" errorClass="error-msg" />
FacesComponentUtility.addMessage(parent, null, input.getId(), ERROR_MSG_STYLE_CLASS);
// TODO: specify a component id
}
public static HtmlSelectBooleanCheckbox addUnsetControl(UIComponent parent,
boolean isRequired, boolean isReadOnly, PropertySimple propertySimple, Integer listIndex,
UIInput valueInput, boolean isGroupConfig, boolean configReadOnly, boolean configFullyEditable) {
HtmlSelectBooleanCheckbox unsetCheckbox = FacesComponentUtility.createComponent(
HtmlSelectBooleanCheckbox.class, null);
if (propertySimple != null) {
String unsetCheckboxId = PropertyIdGeneratorUtility.getIdentifier(propertySimple, listIndex, "Unset");
unsetCheckbox.setId(unsetCheckboxId);
}
parent.getChildren().add(unsetCheckbox);
unsetCheckbox.setValue(isUnset(isRequired, propertySimple, isGroupConfig));
if (isReadOnly(isReadOnly, isRequired, propertySimple, configReadOnly, configFullyEditable)
|| isGroupConfigWithDifferingValues(propertySimple, isGroupConfig)) {
FacesComponentUtility.setDisabled(unsetCheckbox, true);
} else {
// Add JavaScript that will disable/enable the corresponding input element when the unset checkbox is
// checked/unchecked.
// IMPORTANT: We must use document.formName.inputName, rather than document.getElementById('inputId'),
// to reference the HTML DOM element, because the id of the HTML DOM input element is not the same as the
// id of the corresponding JSF input component in some cases (e.g. radio buttons). However, the
// name property that JSF renders on the HTML DOM input element does always match the JSF
// component id. (ips, 05/31/07)
StringBuilder onclick = new StringBuilder();
for (String htmlDomReference : getHtmlDomReferences(valueInput)) {
onclick.append("setInputUnset(").append(htmlDomReference).append(", this.checked);");
}
unsetCheckbox.setOnclick(onclick.toString());
}
return unsetCheckbox;
}
public static void addInitInputsJavaScript(UIComponent parent, String componentId, boolean configFullyEditable,
boolean postBack) {
List<UIInput> inputs = FacesComponentUtility.getDescendantsOfType(parent, UIInput.class);
List<UIInput> overrideInputs = new ArrayList<UIInput>();
List<UIInput> unsetInputs = new ArrayList<UIInput>();
List<UIInput> readOnlyInputs = new ArrayList<UIInput>();
for (UIInput input : inputs) {
// readOnly components can not be overridden - by this point, the override
// status should have only been set if the component *was not* readOnly
//if (FacesComponentUtility.isOverride(input)) {
// overrideInputs.add(input);
//}
if (postBack) {
boolean inputIsNull = PropertySimpleValueConverter.NULL_INPUT_VALUE.equals(input.getSubmittedValue());
FacesComponentUtility.setUnset(input, inputIsNull);
}
if (FacesComponentUtility.isUnset(input)) {
unsetInputs.add(input);
}
if (!configFullyEditable && FacesComponentUtility.isReadonly(input)) {
readOnlyInputs.add(input);
}
}
StringBuilder script = new StringBuilder();
if (!overrideInputs.isEmpty()) {
script.append("var overrideInputArray = new Array(");
for (UIInput input : overrideInputs) {
for (String htmlDomReference : getHtmlDomReferences(input)) {
script.append(htmlDomReference).append(", ");
}
}
script.delete(script.length() - 2, script.length()); // chop off the extra ", "
script.append(");\n");
// do it this way instead of DISABLED attribute via code, because if DISABLED is used and
// even if javascript later enables them, JSF won't submit them as part of the component
script.append("setInputsOverride(overrideInputArray, false);\n");
}
if (!unsetInputs.isEmpty()) {
script.append("var unsetInputArray = new Array(");
for (UIInput input : unsetInputs) {
for (String htmlDomReference : getHtmlDomReferences(input)) {
script.append(htmlDomReference).append(", ");
}
}
script.delete(script.length() - 2, script.length()); // chop off the extra ", "
script.append(");\n");
// do it this way instead of DISABLED attribute via code, because if DISABLED is used and
// even if javascript later enables them, JSF won't submit them as part of the component
script.append("unsetInputs(unsetInputArray);\n");
}
if (!readOnlyInputs.isEmpty()) {
script.append("var readOnlyInputArray = new Array(");
for (UIInput input : readOnlyInputs) {
for (String htmlDomReference : getHtmlDomReferences(input)) {
script.append(htmlDomReference).append(", ");
}
}
script.delete(script.length() - 2, script.length()); // chop off the extra ", "
script.append(");\n");
script.append("writeProtectInputs(readOnlyInputArray);\n");
}
UIOutput uiOutput = FacesComponentUtility.addJavaScript(parent, null, null, script);
uiOutput.setId(componentId);
}
public static List<String> getHtmlDomReferences(UIComponent component) {
List<String> htmlDomReferences = new ArrayList<String>();
if (component instanceof HtmlSelectOneRadio) {
String clientId = component.getClientId(FacesContext.getCurrentInstance());
int selectItemCount = 0;
for (UIComponent child : component.getChildren()) {
if (child instanceof UISelectItem) {
String selectItemClientId = clientId + NamingContainer.SEPARATOR_CHAR + selectItemCount++;
htmlDomReferences.add(getHtmlDomReference(selectItemClientId));
} else {
throw new IllegalStateException(
"HtmlSelectOneRadio component has a child that is not a UISelectItem.");
}
}
} else {
String clientId = component.getClientId(FacesContext.getCurrentInstance());
htmlDomReferences.add(getHtmlDomReference(clientId));
}
return htmlDomReferences;
}
// <h:outputLabel value="DISPLAY_NAME" styleClass="..." />
public static void addPropertyDisplayName(UIComponent parent, PropertyDefinition propertyDefinition,
Property property, boolean configReadOnly) {
String displayName = (propertyDefinition != null) ? propertyDefinition.getDisplayName() : property.getName();
FacesComponentUtility.addOutputText(parent, null, displayName, CssStyleClasses.PROPERTY_DISPLAY_NAME_TEXT);
if (!configReadOnly && propertyDefinition != null &&
propertyDefinition.isRequired() &&
(propertyDefinition instanceof PropertyDefinitionSimple ||
propertyDefinition instanceof PropertyDefinitionDynamic) ) {
// Print a required marker next to required simples.
// Ignore the required field for maps and lists, as it is has no significance for them.
FacesComponentUtility.addOutputText(parent, null, " * ", CssStyleClasses.REQUIRED_MARKER_TEXT);
}
}
public static void addPropertyDescription(UIComponent parent, PropertyDefinition propertyDefinition) {
// <span class="description">DESCRIPTION</span>
FacesComponentUtility.addOutputText(parent, null, propertyDefinition.getDescription(),
CssStyleClasses.DESCRIPTION);
}
static String getHtmlDomReference(String clientId) {
return "document.getElementById('" + clientId + "')";
}
private static UIInput createInputForBooleanProperty() {
// <h:selectOneRadio id="#{identifier}" value="#{beanValue}" layout="pageDirection" styleClass="radiolabels">
// <f:selectItems value="#{itemValues}"></f:selectItems>
// </h:selectOneRadio>
HtmlSelectOneRadio selectOneRadio = FacesComponentUtility.createComponent(HtmlSelectOneRadio.class, null);
selectOneRadio.setLayout("lineDirection");
// TODO: We may want to use CSS to get less space between the radio buttons
// (see http://jira.jboss.com/jira/browse/JBMANCON-21).
UISelectItem selectItem = FacesComponentUtility.createComponent(UISelectItem.class, null);
selectItem.setItemLabel("Yes");
selectItem.setItemValue("true");
selectOneRadio.getChildren().add(selectItem);
selectItem = FacesComponentUtility.createComponent(UISelectItem.class, null);
selectItem.setItemLabel("No");
selectItem.setItemValue("false");
selectOneRadio.getChildren().add(selectItem);
return selectOneRadio;
}
// <h:selectOneRadio id="#{identifier}" value="#{beanValue}" layout="pageDirection" styleClass="radiolabels">
// <f:selectItems value="#{itemValues}"></f:selectItems>
// </h:selectOneRadio>
private static UIInput createInputForEnumProperty(PropertyDefinitionSimple propertyDefinitionSimple) {
UISelectOne selectOne;
if (propertyDefinitionSimple.getEnumeratedValues().size() >= LISTBOX_THRESHOLD_ENUM_SIZE) {
// Use a drop down menu for larger enums...
HtmlSelectOneMenu menu = FacesComponentUtility.createComponent(HtmlSelectOneMenu.class, null);
// TODO: Use CSS to set the width of the menu.
selectOne = menu;
} else {
// ...and a radio for smaller ones.
HtmlSelectOneRadio radio = FacesComponentUtility.createComponent(HtmlSelectOneRadio.class, null);
radio.setLayout("pageDirection");
// TODO: We may want to use CSS to get less space between the radio buttons
// (see http://jira.jboss.com/jira/browse/JBMANCON-21).
selectOne = radio;
}
List<PropertyDefinitionEnumeration> options = propertyDefinitionSimple.getEnumeratedValues();
for (PropertyDefinitionEnumeration option : options) {
UISelectItem selectItem = FacesComponentUtility.createComponent(UISelectItem.class, null);
selectItem.setItemLabel(option.getName());
selectItem.setItemValue(option.getValue());
selectOne.getChildren().add(selectItem);
}
return selectOne;
}
private static UIInput createInputForStringProperty() {
HtmlInputText inputText = FacesComponentUtility.createComponent(HtmlInputText.class, null);
//TODO: check if this has units, then apply the correct style
inputText.setStyleClass(CssStyleClasses.PROPERTY_VALUE_INPUT);
// inputText.setStyle(INPUT_TEXT_WIDTH_STYLE_WITH_UNITS);
inputText.setMaxlength(PropertySimple.MAX_VALUE_LENGTH);
// Disable browser auto-completion.
inputText.setAutocomplete("off");
return inputText;
}
private static UIInput createInputForPasswordProperty() {
HtmlInputSecret inputSecret = FacesComponentUtility.createComponent(HtmlInputSecret.class, null);
inputSecret.setStyleClass(CssStyleClasses.PROPERTY_VALUE_INPUT);
inputSecret.setMaxlength(PropertySimple.MAX_VALUE_LENGTH);
// TODO: Remove the below line, as it's not secure, and improve support for displaying/validating password fields.
inputSecret.setRedisplay(true);
// Disable browser auto-completion.
inputSecret.setAutocomplete("off");
return inputSecret;
}
private static UIInput createInputForLongStringProperty() {
HtmlInputTextarea inputTextarea = FacesComponentUtility.createComponent(HtmlInputTextarea.class, null);
inputTextarea.setRows(INPUT_TEXTAREA_COMPONENT_ROWS);
inputTextarea.setStyleClass(CssStyleClasses.PROPERTY_VALUE_INPUT);
return inputTextarea;
}
private static boolean isEnum(PropertyDefinitionSimple simplePropertyDefinition) {
return !simplePropertyDefinition.getEnumeratedValues().isEmpty();
}
private static void addValidatorsAndConverter(UIInput input, PropertyDefinitionSimple propertyDefinitionSimple,
boolean readOnly,TemplateEngine templateEngine ) {
if (!readOnly) {
input.setRequired(propertyDefinitionSimple.isRequired());
input.addValidator(new PropertySimpleValueValidator(propertyDefinitionSimple));
input.setConverter(new PropertySimpleValueConverter(templateEngine));
}
}
private static void addErrorMessages(UIInput input, @Nullable PropertyDefinitionSimple propertyDefinitionSimple,
PropertySimple propertySimple, boolean prevalidate) {
if (prevalidate) {
// Pre-validate the property's value, in case the PC sent us an invalid live config.
PropertySimpleValueValidator validator = new PropertySimpleValueValidator(propertyDefinitionSimple);
//PropertySimple propertySimple = this.propertyMap.getSimple(propertyDefinitionSimple.getName());
prevalidatePropertyValue(input, propertySimple, validator);
}
// If there is a PC-detected error associated with the property, associate it with the input.
addPluginContainerDetectedErrorMessage(input, propertySimple);
}
private static void prevalidatePropertyValue(UIInput propertyValueInput, PropertySimple propertySimple,
PropertySimpleValueValidator validator) {
FacesContext facesContext = FacesContextUtility.getFacesContext();
try {
String value = (propertySimple != null) ? propertySimple.getStringValue() : null;
validator.validate(facesContext, propertyValueInput, value);
} catch (ValidatorException e) {
// NOTE: It's vital to pass the client id, *not* the component id, to addMessage().
facesContext.addMessage(propertyValueInput.getClientId(facesContext), e.getFacesMessage());
}
}
private static void addPluginContainerDetectedErrorMessage(UIInput input, PropertySimple propertySimple) {
String errorMsg = (propertySimple != null) ? propertySimple.getErrorMessage() : null;
if ((errorMsg != null) && !errorMsg.equals("")) {
FacesContext facesContext = FacesContextUtility.getFacesContext();
FacesMessage facesMsg = new FacesMessage(FacesMessage.SEVERITY_ERROR, errorMsg, null);
// NOTE: It's vital to pass the client id, *not* the component id, to addMessage().
facesContext.addMessage(input.getClientId(facesContext), facesMsg);
}
}
private static void addTitleAttribute(UIInput input, String propertyValue) {
if (input instanceof HtmlInputText) {
// TODO: will this still work now that we use the style def'n "width:185px;" to define the input field width?
// For text inputs with values that are too long to fit in the input text field, add a "title" attribute set to
// the value, so the user can see the untruncated value via a tooltip.
// (see http://jira.jboss.com/jira/browse/JBNADM-1608)
HtmlInputText inputText = (HtmlInputText) input;
if ((propertyValue != null) && (propertyValue.length() > INPUT_TEXT_COMPONENT_WIDTH))
inputText.setTitle(propertyValue);
inputText.setOnchange("setInputTitle(this)");
}
}
private static boolean isUnset(boolean isRequired, PropertySimple propertySimple,
boolean isGroupConfig) {
if (isGroupConfigWithDifferingValues(propertySimple, isGroupConfig))
// Properties from group configs that have differing values should not be marked unset.
return false;
if (propertySimple == null)
return false;
return (!isRequired && propertySimple.getStringValue() == null);
}
public static boolean isReadOnly(boolean isReadOnly, boolean isRequired, PropertySimple propertySimple,
boolean configReadOnly, boolean configFullyEditable) {
// A fully editable config overrides any other means of setting read-only.
return (!configFullyEditable && //
(configReadOnly || //
isReadOnly && //
(propertySimple == null || //
!isInvalidRequiredProperty(isRequired, propertySimple))));
}
public static boolean isGroupConfigWithDifferingValues(PropertySimple propertySimple, boolean isGroupConfig) {
return isGroupConfig && (propertySimple.getOverride() == null || !propertySimple.getOverride());
}
private static boolean isInvalidRequiredProperty(boolean isRequired, PropertySimple propertySimple) {
boolean isInvalidRequiredProperty = false;
if (isRequired) {
String errorMessage = propertySimple.getErrorMessage();
if ((null == propertySimple.getStringValue()) || "".equals(propertySimple.getStringValue())
|| ((null != errorMessage) && (!"".equals(errorMessage.trim())))) {
// Required properties with no value, or an invalid value (assumed if we see an error message) should
// never be set to read-only, otherwise the user will have no way to give the property a value and
// thereby get things to a valid state.
isInvalidRequiredProperty = true;
}
}
return isInvalidRequiredProperty;
}
}