As a follow-up to Why are there no decent examples of CompositeCell in use within a CellTable?
I am trying to add-on JSR-303 validation support. I have followed Koma's config advice here: How to install gwt-validation with gwt-2.4.0 (Note: I'm using GWT 2.4's built-in validation, not GWT-Validation).
Again, in order to get some re-use I crafted a pair of classes, ValidatableInputCell and AbstractValidatableColumn. I got inspiration for them from:
Let's have a look at 'em...
public class ValidatableInputCell extends AbstractInputCell<String, ValidatableInputCell.ValidationData> {
interface Template extends SafeHtmlTemplates {
@Template("<input type=\"text\" value=\"{0}\" size=\"{1}\" style=\"{2}\" tabindex=\"-1\"></input>")
SafeHtml input(String value, String width, SafeStyles color);
}
private static Template template;
/**
* The error message to be displayed as a pop-up near the field
*/
private String errorMessage;
private static final int DEFAULT_INPUT_SIZE = 15;
/**
* Specifies the width, in characters, of the <input> element contained within this cell
*/
private int inputSize = DEFAULT_INPUT_SIZE;
public ValidatableInputCell() {
super("change", "keyup");
if (template == null) {
template = GWT.create(Template.class);
}
}
public void setInputSize(int inputSize) {
this.inputSize = inputSize;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = SafeHtmlUtils.htmlEscape(errorMessage);
}
@Override
public void onBrowserEvent(Context context, Element parent, String value,
NativeEvent event, ValueUpdater<String> valueUpdater) {
super.onBrowserEvent(context, parent, value, event, valueUpdater);
// Ignore events that don't target the input.
final InputElement input = (InputElement) getInputElement(parent);
final Element target = event.getEventTarget().cast();
if (!input.isOrHasChild(target)) {
return;
}
final Object key = context.getKey();
final String eventType = event.getType();
if ("change".equals(eventType)) {
finishEditing(parent, value, key, valueUpdater);
} else if ("keyup".equals(eventType)) {
// Mark cell as containing a pending change
input.getStyle().setColor("blue");
ValidationData viewData = getViewData(key);
// Save the new value in the view data.
if (viewData == null) {
viewData = new ValidationData();
setViewData(key, viewData);
}
final String newValue = input.getValue();
viewData.setValue(newValue);
finishEditing(parent, newValue, key, valueUpdater);
// Update the value updater, which updates the field updater.
if (valueUpdater != null) {
valueUpdater.update(newValue);
}
}
}
@Override
public void render(Context context, String value, SafeHtmlBuilder sb) {
// Get the view data.
final Object key = context.getKey();
ValidationData viewData = getViewData(key);
if (viewData != null && viewData.getValue().equals(value)) {
// Clear the view data if the value is the same as the current value.
clearViewData(key);
viewData = null;
}
/*
* If viewData is null, just paint the contents black. If it is non-null,
* show the pending value and paint the contents red if they are known to
* be invalid.
*/
final String pendingValue = viewData == null ? null : viewData.getValue();
final boolean invalid = viewData == null ? false : viewData.isInvalid();
final String color = pendingValue != null ? invalid ? "red" : "blue" : "black";
final SafeStyles safeColor = SafeStylesUtils.fromTrustedString("color: " + color + ";");
sb.append(template.input(pendingValue != null ? pendingValue : value, String.valueOf(inputSize), safeColor));
}
@Override
protected void onEnterKeyDown(Context context, Element parent, String value,
NativeEvent event, ValueUpdater<String> valueUpdater) {
final Element target = event.getEventTarget().cast();
if (getInputElement(parent).isOrHasChild(target)) {
finishEditing(parent, value, context.getKey(), valueUpdater);
} else {
super.onEnterKeyDown(context, parent, value, event, valueUpdater);
}
}
@Override
protected void finishEditing(Element parent, String value, Object key,
ValueUpdater<String> valueUpdater) {
final ValidationData viewData = getViewData(key);
final String pendingValue = viewData == null ? null : viewData.getValue();
final boolean invalid = viewData == null ? false : viewData.isInvalid();
if (invalid) {
final DecoratedPopupPanel errorMessagePopup = new DecoratedPopupPanel(true);
final VerticalPanel messageContainer = new VerticalPanel();
messageContainer.setWidth("200px");
final Label messageTxt = new Label(errorMessage, true);
messageTxt.setStyleName(UiResources.INSTANCE.style().error());
messageContainer.add(messageTxt);
errorMessagePopup.setWidget(messageContainer);
// Reposition the popup relative to input field
final int left = parent.getAbsoluteRight() + 25;
final int top = parent.getAbsoluteTop();
errorMessagePopup.setPopupPositionAndShow(new PopupPanel.PositionCallback() {
@Override
public void setPosition(int offsetWidth, int offsetHeight) {
errorMessagePopup.setPopupPosition(left, top);
}
});
}
// XXX let user continue or force focus until value is valid? for now the former is implemented
super.finishEditing(parent, pendingValue, key, valueUpdater);
}
/**
* The ViewData used by {@link ValidatableInputCell}.
*/
static class ValidationData {
private boolean invalid;
private String value;
public String getValue() {
return value;
}
public boolean isInvalid() {
return invalid;
}
public void setInvalid(boolean invalid) {
this.invalid = invalid;
}
public void setValue(String value) {
this.value = value;
}
}
}
and
public abstract class AbstractValidatableColumn<T> implements HasCell<T, String> {
private ValidatableInputCell cell = new ValidatableInputCell();
private CellTable<T> table;
public AbstractValidatableColumn(int inputSize, CellTable<T> table) {
cell.setInputSize(inputSize);
this.table = table;
}
@Override
public Cell<String> getCell() {
return cell;
}
@Override
public FieldUpdater<T, String> getFieldUpdater() {
return new FieldUpdater<T, String>() {
@Override
public void update(int index, T dto, String value) {
final Set<ConstraintViolation<T>> violations = validate(dto);
final ValidationData viewData = cell.getViewData(dto);
if (!violations.isEmpty()) { // invalid
final StringBuffer errorMessage = new StringBuffer();
for (final ConstraintViolation<T> constraintViolation : violations) {
errorMessage.append(constraintViolation.getMessage());
}
viewData.setInvalid(true);
cell.setErrorMessage(errorMessage.toString());
table.redraw();
} else { // valid
viewData.setInvalid(false);
cell.setErrorMessage(null);
doUpdate(index, dto, value);
}
}
};
}
protected abstract void doUpdate(int index, T dto, String value);
protected Set<ConstraintViolation<T>> validate(T dto) {
final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
final Set<ConstraintViolation<T>> violations = validator.validate(dto);
return violations;
}
}
I use the AbstractValidatableColumn like so...
protected HasCell<ReserveOfferDTO, String> generatePriceColumn(DisplayMode currentDisplayMode) {
HasCell<ReserveOfferDTO, String> priceColumn;
if (isInEditMode(currentDisplayMode)) {
priceColumn = new AbstractValidatableColumn<ReserveOfferDTO>(5, this) {
@Override
public String getValue(ReserveOfferDTO reserveOffer) {
return obtainPriceValue(reserveOffer);
}
@Override
protected void doUpdate(int index, ReserveOfferDTO reserveOffer, String value) {
// number format exceptions should be caught and handled by event bus's handle method
final double valueAsDouble = NumberFormat.getDecimalFormat().parse(value);
final BigDecimal price = BigDecimal.valueOf(valueAsDouble);
reserveOffer.setPrice(price);
}
};
} else {
priceColumn = new Column<ReserveOfferDTO, String>(new TextCell()) {
@Override
public String getValue(ReserveOfferDTO reserveOffer) {
return obtainPriceValue(reserveOffer);
}
};
}
return priceColumn;
}
Oh! And here's the DTO with JSR-303 annotations...
public class ReserveOfferDTO extends DateComparable implements Serializable {
private static final long serialVersionUID = 1L;
@NotNull @Digits(integer=6, fraction=2)
private BigDecimal price;
@NotNull @Digits(integer=6, fraction=2)
private BigDecimal fixedMW;
private String dispatchStatus;
private String resourceName;
private String dateTime;
private String marketType;
private String productType;
...
}
Dropping a breakpoint in onBrowserEvent I would expect to have the validation trigger on each key stroke and/or after cell loses focus. It never gets invoked. I can enter whatever I like in the cell. Any clues as to an approach to fix?
My early thoughts... a) AbstractValidatableColumn#getFieldUpdater is never getting invoked and b) the logic in either ValidatableInputCell#onBrowserEvent or ValidatableInputCell#render needs an overhaul.
Ultimately, I'd like to see a popup appearing next to each cell that violates a constraint, and of course see that the appropriate coloring is applied.
Coming up for air here. I finally figured out a solution! I opted to employ the GWT Validation library, see http://code.google.com/p/gwt-validation/wiki/GWT_Validation_2_0 (the code below is known to work with the 2.1 SNAPSHOT).
The trick when performing validation for a cell is to call validateValue rather than validate (the latter triggers validation for all entity's fields). As well, all input cell values are String, and converted to the respective entity field type before being validated. (Even works for nested entity fields).
Here's the revised impls for both AbstractValidatableColumn (AVC) and ValidatableInputCell.
/**
* A {@link Column} implementation that encapsulates a {@link ValidatableInputCell}.
* Performs JSR-303 validation on a field (or nested field) of the type.
* @author cphillipson
*
* @param <T> the type
* @param <O> the owning type of the field to be validated; in many cases T may have only primitive or wrapper types, therefore O will be the same type as T
*/
public abstract class AbstractValidatableColumn<T, O> extends Column<T, String> {
/**
* Preferred constructor.
* Allows for definition of tabIndex but uses a default for the input cell size.
* @param tabIndex the <code>tabindex</code> attribute's value for the input cell
* @param table the grid instance
*/
public AbstractValidatableColumn(int tabIndex, final AbstractHasData<T> table) {
this(App.INSTANCE.defaultValidatableInputCellSize(), tabIndex, table);
}
/**
* Overloaded constructor.
* Allows for definition of tabIndex and allows for an override to the default for the input cell size.
* @param inputSize the <code>size</code> attribute's value for the input cell
* @param tabIndex the <code>tabindex</code> attribute's value for the input cell
* @param table the grid instance
*/
public AbstractValidatableColumn(int inputSize, int tabIndex, final AbstractHasData<T> table) {
super(new ValidatableInputCell());
getCell().setInputSize(inputSize);
getCell().setTabIndex(tabIndex);
init(table);
}
// meat and potatoes
private void init(final AbstractHasData<T> table) {
setFieldUpdater(new FieldUpdater<T, String>() {
@Override
public void update(int index, T dto, String newValue) {
final ConversionResult cr = attemptValueConversion(newValue);
final ValidationData viewData = getCell().getViewData(dto);
if (cr.wasConvertedSuccessfully()) {
final Set<ConstraintViolation<O>> violations = validate(cr.getValue());
if (!violations.isEmpty()) { // invalid
final StringBuffer errorMessage = new StringBuffer();
for (final ConstraintViolation<O> constraintViolation : violations) {
errorMessage.append(constraintViolation.getMessage());
}
viewData.setInvalid(true);
getCell().setErrorMessage(errorMessage.toString());
} else { // valid
viewData.setInvalid(false);
getCell().setErrorMessage("");
doUpdate(index, dto, newValue);
}
} else { // conversion exception
viewData.setInvalid(true);
getCell().setErrorMessage(UiMessages.INSTANCE.improper_input_format());
}
}
});
}
/**
* Attempts conversion of a String value into another type
* Instances are responsible for the conversion logic as it may vary from type to type
* @param value a String value to be converted into an owning class's property type
* @return a ConversionResult
*/
protected abstract ConversionResult attemptValueConversion(String value);
@Override
public ValidatableInputCell getCell() {
return (ValidatableInputCell) super.getCell();
}
/**
* Template method for updating a field (or nested field) value within a DTO
* @param index the row index for the instance of the DTO within the grid
* @param dto the object whose field we wish to update
* @param value the new value that will be set on a field (or nested field) of the DTO
*/
protected abstract void doUpdate(int index, T dto, String value);
/**
* Template method for specifying the property name of an owning class
* @return the field name of the owning class whose value is to be updated
*/
protected abstract String getPropertyName();
/**
* Template method for specifying the owning class
* @return the owning class of the field whose value is to be updated
*/
protected abstract Class<O> getPropertyOwner();
/**
* Validates a value against a set of constraints (i.e., JSR-303 annotations on a field)
* @param newValue the value to be validated
* @return the set of constraint violations induced by an inappropriate value
*/
protected Set<ConstraintViolation<O>> validate(Object newValue) {
final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
final Set<ConstraintViolation<O>> violations = validator.validateValue(getPropertyOwner(), getPropertyName(), newValue);
return violations;
}
}
/**
* <p>A cell that will update its styling and provide feedback upon a validation constraint violation.</p>
* <p>Implementation based upon GWT Showcase's <a href="http://gwt.google.com/samples/Showcase/Showcase.html#!CwCellValidation">Cell Validation</a> example.</p>
* @author cphillipson
*
*/
public class ValidatableInputCell extends AbstractInputCell<String, ValidatableInputCell.ValidationData> {
interface Template extends SafeHtmlTemplates {
@Template("<input type=\"text\" value=\"{0}\" size=\"{1}\" style=\"{2}\" tabindex=\"{3}\"></input>")
SafeHtml input(String value, String width, SafeStyles color, String tabIndex);
}
private static Template template;
/**
* The error message to be displayed as a pop-up near the field
*/
private String errorMessage;
private static final int DEFAULT_INPUT_SIZE = App.INSTANCE.defaultValidatableInputCellSize();
/**
* Specifies the width, in characters, of the <input> element contained within this cell
*/
private int inputSize = DEFAULT_INPUT_SIZE;
/**
* Specifies the tab index for this cell
*/
private int tabIndex = -1;
public ValidatableInputCell() {
// since onBrowserEvent method is overridden, we must register all events that handled in overridden method impl
super("change", "keyup", "focus", "blur", "keydown");
if (template == null) {
template = GWT.create(Template.class);
}
}
public void setInputSize(int inputSize) {
this.inputSize = inputSize;
}
public void setTabIndex(int index) {
tabIndex = index;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = SafeHtmlUtils.fromSafeConstant(errorMessage).asString();
}
@Override
public void onBrowserEvent(Context context, Element parent, String value,
NativeEvent event, ValueUpdater<String> valueUpdater) {
super.onBrowserEvent(context, parent, value, event, valueUpdater);
final InputElement input = (InputElement) getInputElement(parent);
final Object key = context.getKey();
final String eventType = event.getType();
if ("keyup".equals(eventType)) {
ValidationData viewData = getViewData(key);
// Save the new value in the view data.
if (viewData == null) {
viewData = new ValidationData();
setViewData(key, viewData);
}
final String newValue = input.getValue();
viewData.setValue(newValue);
finishEditing(parent, newValue, key, valueUpdater);
}
}
@Override
public void render(Context context, String value, SafeHtmlBuilder sb) {
// Get the view data.
final Object key = context.getKey();
ValidationData viewData = getViewData(key);
if (viewData != null && viewData.getValue().equals(value)) {
// Clear the view data if the value is the same as the current value.
clearViewData(key);
viewData = null;
}
/*
* If viewData is null, just paint the contents black. If it is non-null,
* show the pending value and paint the contents red if they are known to
* be invalid.
*/
final String pendingValue = viewData == null ? null : viewData.getValue();
final boolean invalid = viewData == null ? false : viewData.isInvalid();
final String color = pendingValue != null ? invalid ? App.INSTANCE.invalidCellInputTextColor() : App.INSTANCE.pendingCellInputTextColor() : App.INSTANCE.defaultCellInputTextColor();
final String backgroundColor = pendingValue != null ? invalid ? App.INSTANCE.invalidCellInputTextBackgroundColor() : App.INSTANCE.pendingCellInputTextBackgroundColor() : App.INSTANCE.defaultCellInputTextBackgroundColor();
final SafeStyles style = SafeStylesUtils.fromTrustedString("color: " + color + "; background-color: " + backgroundColor + ";");
sb.append(template.input(pendingValue != null ? pendingValue : value, String.valueOf(inputSize), style, String.valueOf(tabIndex)));
}
/*
@Override
protected void onEnterKeyDown(Context context, Element parent, String value,
NativeEvent event, ValueUpdater<String> valueUpdater) {
final Element target = event.getEventTarget().cast();
if (getInputElement(parent).isOrHasChild(target)) {
finishEditing(parent, value, context.getKey(), valueUpdater);
} else {
super.onEnterKeyDown(context, parent, value, event, valueUpdater);
}
}
*/
@Override
protected void onEnterKeyDown(Context context, Element parent, String value,
NativeEvent event, ValueUpdater<String> valueUpdater) {
// do nothing
}
@Override
protected void finishEditing(Element parent, String value, Object key,
ValueUpdater<String> valueUpdater) {
// Update the value updater, which updates the field updater.
if (valueUpdater != null) {
valueUpdater.update(value);
}
final InputElement input = (InputElement) getInputElement(parent);
final ValidationData viewData = getViewData(key);
/*
* If viewData is null, just paint the contents black. If it is non-null,
* show the pending value and paint the contents red if they are known to
* be invalid.
*/
final String pendingValue = viewData == null ? null : viewData.getValue();
final boolean invalid = viewData == null ? false : viewData.isInvalid();
final String color = pendingValue != null ? invalid ? App.INSTANCE.invalidCellInputTextColor() : App.INSTANCE.pendingCellInputTextColor() : App.INSTANCE.defaultCellInputTextColor();
final String backgroundColor = pendingValue != null ? invalid ? App.INSTANCE.invalidCellInputTextBackgroundColor() : App.INSTANCE.pendingCellInputTextBackgroundColor() : App.INSTANCE.defaultCellInputTextBackgroundColor();
input.getStyle().setColor(color);
input.getStyle().setBackgroundColor(backgroundColor);
if (invalid) {
final DecoratedPopupPanel errorMessagePopup = new DecoratedPopupPanel(true);
final FlowPanel messageContainer = new FlowPanel();
messageContainer.setWidth(App.INSTANCE.errorMessagePopupWidth());
final Label messageTxt = new Label(errorMessage, true);
messageTxt.setStyleName(UiResources.INSTANCE.style().error());
messageContainer.add(messageTxt);
errorMessagePopup.setWidget(messageContainer);
// Reposition the popup relative to input field
final int left = parent.getAbsoluteRight() +5;
final int top = parent.getAbsoluteTop() - 5;
errorMessagePopup.setPopupPositionAndShow(new PopupPanel.PositionCallback() {
@Override
public void setPosition(int offsetWidth, int offsetHeight) {
errorMessagePopup.setPopupPosition(left, top);
}
});
}
}
/**
* The ViewData used by {@link ValidatableInputCell}.
*/
static class ValidationData {
private boolean invalid;
private String value;
public String getValue() {
return value;
}
public boolean isInvalid() {
return invalid;
}
public void setInvalid(boolean invalid) {
this.invalid = invalid;
}
public void setValue(String value) {
this.value = value;
}
}
}
Variants of AVC might look like...
/**
* A variant of {@link AbstractValidatableColumn} that works with {@link BigDecimal} field types.
* @author cphillipson
*
* @param <T> the type
* @param <O> the owning type of the field to be validated; in many cases T may have only primitive or wrapper types, therefore O will be the same type as T
*/
public abstract class BigDecimalValidatableColumn<T, O> extends AbstractValidatableColumn<T, O> {
public BigDecimalValidatableColumn(int tabIndex, AbstractHasData table) {
super(tabIndex, table);
}
public BigDecimalValidatableColumn(int inputSize, int tabIndex, final AbstractHasData<T> table) {
super(inputSize, tabIndex, table);
}
@Override
protected ConversionResult attemptValueConversion(String value) {
return doConversion(value);
}
public static ConversionResult doConversion(String value) {
ConversionResult result = null;
try {
final Double dblValue = Double.valueOf(value);
final BigDecimal convertedValue = BigDecimal.valueOf(dblValue);
result = ConversionResult.converted(convertedValue);
} catch (final NumberFormatException nfe) {
result = ConversionResult.not_converted();
}
return result;
}
}
A ConversionResult is consulted by a Column's fieldUpdater. Here's what it looks like...
/**
* An attempted conversion result.
* Returns both the converted value (from <code>String</code>) and whether or not the conversion was successful.
* E.g., if you tried to convert from a <code>String</code> to a <code>Number</code>, in the failure case this would result in a <code>NumberFormatException</code>.
* On failure, the boolean would be false and the value would be null.
* On success, the boolean would be true and the value would be of the type needed to continue validation against a set of constraints
* @author cphillipson
*
*/
public class ConversionResult {
private Object value;
private boolean convertedSuccessfully;
private ConversionResult () {}
/**
* Use this method when a successful conversion is made to return a result
* @param value the convertedValue
* @return the result of the conversion containing the converted value and a success flag
*/
public static ConversionResult converted(Object value) {
final ConversionResult result = new ConversionResult();
result.setConvertedSuccessfully(true);
result.setValue(value);
return result;
}
/**
* Use this method when an attempt to convert a String value failed
* @return the result of a failed conversion
*/
public static ConversionResult not_converted() {
return new ConversionResult();
}
private void setValue(Object value) {
this.value = value;
}
public Object getValue() {
return value;
}
private void setConvertedSuccessfully(boolean flag) {
convertedSuccessfully = flag;
}
public boolean wasConvertedSuccessfully() {
return convertedSuccessfully;
}
}
Finally, here's how you might spec a column in a grid
new BigDecimalValidatableColumn<EnergyOfferDTO, OfferPriceMwPairDTO>(nextTabIndex(), getGrid()) {
@Override
public String getValue(EnergyOfferDTO energyOffer) {
return obtainPriceValue(colIndex, energyOffer, false);
}
@Override
public void doUpdate(int index, EnergyOfferDTO energyOffer, String value) {
if (value != null && !value.isEmpty()) {
// number format exceptions should be caught and handled by event bus's handle method
final double valueAsDouble = NumberFormat.getDecimalFormat().parse(value);
final BigDecimal price = BigDecimal.valueOf(valueAsDouble);
final List<OfferPriceMwPairDTO> offerPriceCurve = energyOffer.getCurve();
final OfferPriceMwPairDTO offerPriceMwPairDTO = offerPriceCurve.get(colIndex);
if (offerPriceMwPairDTO == null) { // we have a new price value
newOfferPriceMwPair.setPrice(price);
offerPriceCurve.add(newOfferPriceMwPair);
} else {
offerPriceMwPairDTO.setPrice(price);
}
}
}
@Override
protected String getPropertyName() {
return "price";
}
@Override
protected Class<OfferPriceMwPairDTO> getPropertyOwner() {
return OfferPriceMwPairDTO.class;
}
};
Note the DTO's in the example above have their fields JSR-303 constraint annotated (e.g., with @Digits, @NotNull).
The above took some doing, and it may just be the most comprehensive solution out on the net at the moment. Enjoy!