/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.apache.cocoon.forms.formmodel; import java.util.Locale; import org.apache.cocoon.environment.Request; import org.apache.cocoon.forms.FormContext; import org.apache.cocoon.forms.FormsConstants; import org.apache.cocoon.forms.FormsRuntimeException; import org.apache.cocoon.forms.datatype.Datatype; import org.apache.cocoon.forms.datatype.SelectionList; import org.apache.cocoon.forms.datatype.convertor.ConversionResult; import org.apache.cocoon.forms.event.DeferredValueChangedEvent; import org.apache.cocoon.forms.event.ValueChangedEvent; import org.apache.cocoon.forms.event.ValueChangedListener; import org.apache.cocoon.forms.event.ValueChangedListenerEnabled; import org.apache.cocoon.forms.event.WidgetEvent; import org.apache.cocoon.forms.event.WidgetEventMulticaster; import org.apache.cocoon.forms.util.I18nMessage; import org.apache.cocoon.forms.validation.ValidationError; import org.apache.cocoon.forms.validation.ValidationErrorAware; import org.apache.cocoon.xml.AttributesImpl; import org.apache.cocoon.xml.XMLUtils; import org.apache.commons.lang.ObjectUtils; import org.apache.commons.lang.StringUtils; import org.xml.sax.ContentHandler; import org.xml.sax.SAXException; /** * A general-purpose Widget that can hold one value. A Field widget can be associated * with a {@link org.apache.cocoon.forms.datatype.Datatype Datatype}, and thus * a Field widget can be used to edit different kinds of data, such as strings, * numbers and dates. A Datatype can also have an associated SelectionList, so * that the value for the Field can be selected from a list, rather than being * entered in a textbox. The validation of the field is delegated to its associated * Datatype. * * @version $Id$ */ public class Field extends AbstractWidget implements ValidationErrorAware, DataWidget, SelectableWidget, ValueChangedListenerEnabled { /** * If the field was rendered as a suggestion-list and the user chose one of the suggestions, * the field's value is the chosen item's value and the <code>SUGGESTED_LABEL_ATTR</code> field * attribute contains the chosen item's label. * * @see #isSuggested() * @since 2.1.9 */ public static final String SUGGESTED_LABEL_ATTR = "suggested-label"; /** * Value state indicating that a new value has been read from the request, * but has not yet been parsed. */ protected static final int VALUE_UNPARSED = 0; /** * Value state indicating that a value has been parsed, but needs to be * validated (that must occur before the value is given to the application) */ protected static final int VALUE_PARSED = 1; /** * Value state indicating that a parse error was encountered but should not * yet be displayed. */ protected static final int VALUE_PARSE_ERROR = 2; /** * Value state indicating that validate() has been called when state was * VALUE_PARSE_ERROR. This makes the error visible on output. */ protected static final int VALUE_DISPLAY_PARSE_ERROR = 3; /** * Transient value state indicating that validation is going on. * * @see #validate() */ protected static final int VALUE_VALIDATING = 4; /** * Value state indicating that validation has occured, but that any error should not * yet be displayed. */ protected static final int VALUE_VALIDATED = 5; /** * Value state indicating that value validation has occured, and the * validation error, if any, should be displayed. */ protected static final int VALUE_DISPLAY_VALIDATION = 6; private static final String FIELD_EL = "field"; private static final String VALUE_EL = "value"; private static final String VALIDATION_MSG_EL = "validation-message"; /** * Definition of the field. */ private final FieldDefinition definition; /** * Overrides selection list defined in FieldDefinition, if any. */ protected SelectionList selectionList; /** * Overrides sugestion list defined in FieldDefinition, if any. */ protected SelectionList suggestionList; /** * Additional listeners to those defined as part of the widget definition (if any). */ private ValueChangedListener listener; protected String enteredValue; protected Object value; protected boolean required; /** * Transient widget processing state indicating that the widget is currently validating * (used to avoid endless loops when a validator calls getValue). */ protected int valueState = VALUE_PARSED; protected ValidationError validationError; public Field(FieldDefinition fieldDefinition) { super(fieldDefinition); this.definition = fieldDefinition; this.listener = fieldDefinition.getValueChangedListener(); /* * At startup, we have no value to parse (both enteredValue and value are null), * but still need to validate (e.g. error if field is required), so initial value * is set to {@link #VALUE_PARSED}. */ this.valueState = VALUE_PARSED; } public WidgetDefinition getDefinition() { return this.definition; } public final FieldDefinition getFieldDefinition() { return this.definition; } public void initialize() { Object value = this.definition.getInitialValue(); if (value != null) { setValue(value); } this.selectionList = this.definition.getSelectionList(); this.suggestionList = this.definition.getSuggestionList(); this.required = this.definition.isRequired(); super.initialize(); } /** * If this field has a selection-list, indicates if the value comes from that list * or if a new value was input by the user. * * @since 2.1.9 * @return true if the user has chosen a suggested value */ public boolean isSuggested() { return this.getAttribute(SUGGESTED_LABEL_ATTR) != null; } /** * Set the suggestion label associated to the widget's current value. This is used to initialize * a combobox's rendering. If not such label exists, the widget's value is used. * * @since 2.1.9 */ public void setSuggestionLabel(String label) { if (this.definition.getSuggestionList() == null) { throw new FormsRuntimeException("Field '" + getRequestParameterName() + "' has no suggestion list.", getLocation()); } setAttribute(SUGGESTED_LABEL_ATTR, label); } /** * If the user has chosen an item in a suggestion list, returns that item's label. * * @since 2.1.9 * @return the item's label, or <code>null</code> if the user entered a new value or * if there's not suggestion list. */ public String getSuggestionLabel() { return (String) getAttribute(SUGGESTED_LABEL_ATTR); } public Object getValue() { // if getValue() is called on this field while we're validating, then it's because a validation // rule called getValue(), so then we just return the parsed (but not VALUE_VALIDATED) value to avoid an endless loop if (this.valueState == VALUE_VALIDATING) { return this.value; } ValidationError oldError = this.validationError; // Parse the value if (this.valueState == VALUE_UNPARSED) { doParse(); } // Validate the value if it was successfully parsed if (this.valueState == VALUE_PARSED) { doValidate(); } if (oldError != null && this.validationError == null) { // The parsing process removed an existing validation error. This happens // mainly when a required field is given a value. getForm().addWidgetUpdate(this); } return this.validationError == null ? this.value : null; } public void setValue(Object newValue) { if (newValue != null && !getDatatype().getTypeClass().isAssignableFrom(newValue.getClass())) { throw new FormsRuntimeException("Incorrect value type for '" + getRequestParameterName() + "'. Expected " + getDatatype().getTypeClass() + ", got " + newValue.getClass() + ").", getLocation()); } // Is it a new value? boolean changed; if (this.valueState == VALUE_UNPARSED) { // Current value was not parsed changed = true; } else if (this.value == null) { // Is current value not null? changed = (newValue != null); } else { // Is current value different? changed = !this.value.equals(newValue); } // Do something only if value is different or null // (null allows to reset validation error) if (changed || newValue == null) { // Do we need to call listeners? If yes, keep (and parse if needed) old value. boolean callListeners = changed && (hasValueChangedListeners() || this.getForm().hasFormHandler()); Object oldValue = callListeners ? getValue() : null; this.value = newValue; this.validationError = null; // Force validation, even if set by the application this.valueState = VALUE_PARSED; if (newValue != null) { this.enteredValue = getDatatype().convertToString(newValue, getForm().getLocale()); } else { this.enteredValue = null; } if (callListeners) { getForm().addWidgetEvent(new ValueChangedEvent(this, oldValue, newValue)); } getForm().addWidgetUpdate(this); } } public void readFromRequest(FormContext formContext) { if (!getCombinedState().isAcceptingInputs()) { return; } String paramName = getRequestParameterName(); Request request = formContext.getRequest(); String newEnteredValue = request.getParameter(paramName); if (this.definition.getSuggestionList() != null) { // The Dojo ComboBox sends the typed value or the chosen item's label in the // request parameter and sends an additional "*_selected" parameter containing // the value of the chosen item (if any). // So if *_selected exists, use String selectedValue = request.getParameter(paramName + "_selected"); if (StringUtils.isNotEmpty(selectedValue)) { setSuggestionLabel(newEnteredValue); newEnteredValue = selectedValue; } else { this.removeAttribute(SUGGESTED_LABEL_ATTR); } } // FIXME: Should we consider only non-null values? // Although distinguishing an empty value (input present but blank) from a null value // (input not present in the form) is possible, this distinction is not possible for // several other kinds of widgets such as BooleanField or MultiValueField. So we keep // it consistent with other widgets. //if (newEnteredValue != null) { readFromRequest(newEnteredValue); //} } protected void readFromRequest(String newEnteredValue) { // whitespace & empty field handling newEnteredValue = applyWhitespaceTrim(newEnteredValue); // Only convert if the text value actually changed. Otherwise, keep the old value // and/or the old validation error (allows to keep errors when clicking on actions) boolean changed; if (enteredValue == null) { changed = (newEnteredValue != null); } else { changed = !enteredValue.equals(newEnteredValue); } if (changed) { ValidationError oldError = this.validationError; // If we have some value-changed listeners, we must make sure the current value has been // parsed, to fill the event. Otherwise, we don't need to spend that extra CPU time. boolean hasListeners = hasValueChangedListeners() || this.getForm().hasFormHandler(); Object oldValue = hasListeners ? getValue() : null; enteredValue = newEnteredValue; validationError = null; value = null; this.valueState = VALUE_UNPARSED; if (hasListeners) { // Throw an event that will hold the old value and // will lazily compute the new value only if needed. getForm().addWidgetEvent(new DeferredValueChangedEvent(this, oldValue)); } if (oldError != null) { // There was a validation error, and the user entered a new value: refresh // the widget, because the previous error was cleared getForm().addWidgetUpdate(this); } } } protected String applyWhitespaceTrim(String value) { if (value != null) { Whitespace trim = this.definition.getWhitespaceTrim(); if(trim == null || trim == Whitespace.TRIM) { value = value.trim(); } else if(trim == Whitespace.PRESERVE) { // do nothing. } else if(trim == Whitespace.TRIM_START) { value = StringUtils.stripStart(value, null); } else if(trim == Whitespace.TRIM_END) { value = StringUtils.stripEnd(value, null); } // treat empty strings as null if (value.length() == 0) { value = null; } } return value; } /** * @see org.apache.cocoon.forms.formmodel.Widget#validate() */ public boolean validate() { if (!getCombinedState().isValidatingValues()) { this.wasValid = true; return true; } if (this.valueState == VALUE_UNPARSED) { doParse(); } // Force validation on already validated values (but keep invalid parsings) if (this.valueState >= VALUE_VALIDATED) { this.valueState = VALUE_PARSED; } if (this.valueState == VALUE_PARSED) { doValidate(); this.valueState = VALUE_DISPLAY_VALIDATION; if (this.validationError != null) { getForm().addWidgetUpdate(this); } } else if (this.valueState == VALUE_PARSE_ERROR) { this.valueState = VALUE_DISPLAY_PARSE_ERROR; getForm().addWidgetUpdate(this); } this.wasValid = this.validationError == null; return this.wasValid; } /** * Parse the value that has been read from the request. * Should be called when valueState is VALUE_UNPARSED. * On exit, valueState is set to either: * - VALUE_PARSED: successful parsing or null value. Value is set and ValidationError * is cleared. * - VALUE_PARSE_ERROR: datatype parsing error. In that case, value is null and * validationError is set. */ private void doParse() { if (this.valueState != VALUE_UNPARSED) { throw new IllegalStateException("Field is not in UNPARSED state (" + this.valueState + ")"); } // Clear value, it will be recomputed this.value = null; this.validationError = null; if (this.enteredValue != null) { // Parse the value ConversionResult conversionResult = getDatatype().convertFromString(this.enteredValue, getForm().getLocale()); if (conversionResult.isSuccessful()) { this.value = conversionResult.getResult(); this.valueState = VALUE_PARSED; } else { // Conversion failed this.validationError = conversionResult.getValidationError(); // No need for further validation (and need to keep the above error) this.valueState = VALUE_PARSE_ERROR; } } else { // No value: needs to be validated this.valueState = VALUE_PARSED; } } /** * Validate the value once it has been parsed. * Should be called when valueState is VALUE_PARSED. * On exit, valueState is set to VALUE_VALIDATED, and validationError is set if * validation failed. */ private void doValidate() { if (this.valueState != VALUE_PARSED) { throw new IllegalStateException("Field is not in PARSED state (" + this.valueState + ")"); } // Go to transient validating state this.valueState = VALUE_VALIDATING; // reset validation errot this.validationError = null; try { if (this.value == null && this.required) { // Field is required this.validationError = new ValidationError(new I18nMessage("general.field-required", FormsConstants.I18N_CATALOGUE)); } else if (!super.validate()) { // New-style validators failed. } else if (this.value != null) { // Check the old-style ones. this.validationError = getDatatype().validate(this.value, new ExpressionContextImpl(this)); } } finally { // Consider validation finished even in case of exception this.valueState = VALUE_VALIDATED; } } /** * Returns the validation error, if any. There will always be a validation error in case the * {@link #validate} method returned false. * * <br>This method does not cause parsing to take effect, use {@link #getValue} if value * is not parsed yet. */ public ValidationError getValidationError() { return this.validationError; } /** * Set a validation error on this field. This allows fields to be externally marked as invalid by * application logic. * * @param error the validation error */ public void setValidationError(ValidationError error) { if (this.valueState >= VALUE_VALIDATED) { this.valueState = VALUE_DISPLAY_VALIDATION; } if (!ObjectUtils.equals(this.validationError, error)) { this.validationError = error; getForm().addWidgetUpdate(this); } } public boolean isRequired() { return this.required; } public void setRequired(boolean required) { this.required = required; getForm().addWidgetUpdate(this); } /** * @return "field" */ public String getXMLElementName() { return FIELD_EL; } /** * Adds the @required attribute */ public AttributesImpl getXMLElementAttributes() { AttributesImpl attrs = super.getXMLElementAttributes(); attrs.addCDATAAttribute("required", String.valueOf(isRequired())); return attrs; } public void generateItemSaxFragment(ContentHandler contentHandler, Locale locale) throws SAXException { if (locale == null) { locale = getForm().getLocale(); } if (enteredValue != null || value != null) { contentHandler.startElement(FormsConstants.INSTANCE_NS, VALUE_EL, FormsConstants.INSTANCE_PREFIX_COLON + VALUE_EL, XMLUtils.EMPTY_ATTRIBUTES); String stringValue; if (value != null) { stringValue = getDatatype().convertToString(value, locale); } else { stringValue = enteredValue; } contentHandler.characters(stringValue.toCharArray(), 0, stringValue.length()); contentHandler.endElement(FormsConstants.INSTANCE_NS, VALUE_EL, FormsConstants.INSTANCE_PREFIX_COLON + VALUE_EL); } // Suggested label, if any String suggestedLabel = getSuggestionLabel(); if (suggestedLabel != null) { contentHandler.startElement(FormsConstants.INSTANCE_NS, "suggestion", FormsConstants.INSTANCE_PREFIX_COLON + "suggestion", XMLUtils.EMPTY_ATTRIBUTES); contentHandler.characters(suggestedLabel.toCharArray(), 0, suggestedLabel.length()); contentHandler.endElement(FormsConstants.INSTANCE_NS, "suggestion", FormsConstants.INSTANCE_PREFIX_COLON + "suggestion"); } // validation message element: only present if the value is not valid if (validationError != null && (this.valueState == VALUE_DISPLAY_VALIDATION || this.valueState == VALUE_DISPLAY_PARSE_ERROR)) { contentHandler.startElement(FormsConstants.INSTANCE_NS, VALIDATION_MSG_EL, FormsConstants.INSTANCE_PREFIX_COLON + VALIDATION_MSG_EL, XMLUtils.EMPTY_ATTRIBUTES); validationError.generateSaxFragment(contentHandler); contentHandler.endElement(FormsConstants.INSTANCE_NS, VALIDATION_MSG_EL, FormsConstants.INSTANCE_PREFIX_COLON + VALIDATION_MSG_EL); } // generate selection list, if any if (selectionList != null) { selectionList.generateSaxFragment(contentHandler, locale); } // include some info about the datatype definition.getDatatype().generateSaxFragment(contentHandler, locale); } /** * Set this field's selection list. * @param selectionList The new selection list. */ public void setSelectionList(SelectionList selectionList) { if (selectionList != null && selectionList.getDatatype() != null && selectionList.getDatatype() != getDatatype()) { throw new RuntimeException("Tried to assign a SelectionList that is not associated with this widget's datatype."); } this.selectionList = selectionList; getForm().addWidgetUpdate(this); } /** * Set this field's suggestion list. * @param suggestionList The new suggestion list. * * @since 2.1.12 */ public void setSuggestionList(SelectionList suggestionList) { if (suggestionList != null && suggestionList.getDatatype() != null && suggestionList.getDatatype() != getDatatype()) { throw new RuntimeException("Tried to assign a SuggestionList that is not associated with this widget's datatype."); } this.suggestionList = suggestionList; getForm().addWidgetUpdate(this); } /** * Read this field's selection list from an external source. * All Cocoon-supported protocols can be used. * The format of the XML produced by the source should be the * same as in case of inline specification of the selection list, * thus the root element should be a <code>fd:selection-list</code> * element. * @param uri The URI of the source. */ public void setSelectionList(String uri) { setSelectionList(getFieldDefinition().buildSelectionList(uri)); } /** * Set this field's selection list using values from an in-memory * object. The <code>object</code> parameter should point to a collection * (Java collection or array, or Javascript array) of objects. Each object * belonging to the collection should have a <em>value</em> property and a * <em>label</em> property, whose values are used to specify the <code>value</code> * attribute and the contents of the <code>fd:label</code> child element * of every <code>fd:item</code> in the list. * <p>Access to the values of the above mentioned properties is done * via <a href="http://jakarta.apache.org/commons/jxpath/users-guide.html">XPath</a> expressions. * @param model The collection used as a model for the selection list. * @param valuePath An XPath expression referring to the attribute used * to populate the values of the list's items. * @param labelPath An XPath expression referring to the attribute used * to populate the labels of the list's items. */ public void setSelectionList(Object model, String valuePath, String labelPath) { setSelectionList(getFieldDefinition().buildSelectionListFromModel(model, valuePath, labelPath)); } /** * Read this field's selection list from an external source. * All Cocoon-supported protocols can be used. * The format of the XML produced by the source should be the * same as in case of inline specification of the selection list, * thus the root element should be a <code>fd:selection-list</code> * element. * @param uri The URI of the source. * * @since 2.1.12 * */ public void setSuggestionList(String uri) { setSuggestionList(getFieldDefinition().buildSelectionList(uri)); } public SelectionList getSuggestionList() { return this.suggestionList; } public Datatype getDatatype() { return getFieldDefinition().getDatatype(); } /** * Adds a ValueChangedListener to this widget instance. Listeners defined * on the widget instance will be executed in addtion to any listeners * that might have been defined in the widget definition. */ public void addValueChangedListener(ValueChangedListener listener) { this.listener = WidgetEventMulticaster.add(this.listener, listener); } public void removeValueChangedListener(ValueChangedListener listener) { this.listener = WidgetEventMulticaster.remove(this.listener, listener); } public boolean hasValueChangedListeners() { return this.listener != null; } public void broadcastEvent(WidgetEvent event) { if (event instanceof ValueChangedEvent) { if (this.listener != null) { this.listener.valueChanged((ValueChangedEvent)event); } } else { // Other kinds of events super.broadcastEvent(event); } } }