/* ` This file belongs to the Servoy development and deployment environment, Copyright (C) 1997-2010 Servoy BV This program is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. 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 Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program; if not, see http://www.gnu.org/licenses or write to the Free Software Foundation,Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 */ package com.servoy.j2db.server.headlessclient.dataui; import java.awt.Color; import java.awt.Font; import java.awt.Insets; import java.util.Collections; import java.util.Iterator; import java.util.List; import org.apache.wicket.Component; import org.apache.wicket.IRequestTarget; import org.apache.wicket.RequestCycle; import org.apache.wicket.Response; import org.apache.wicket.Session; import org.apache.wicket.ajax.AjaxRequestTarget; import org.apache.wicket.behavior.HeaderContributor; import org.apache.wicket.extensions.ajax.markup.html.autocomplete.AutoCompleteBehavior; import org.apache.wicket.extensions.ajax.markup.html.autocomplete.AutoCompleteSettings; import org.apache.wicket.extensions.ajax.markup.html.autocomplete.IAutoCompleteRenderer; import org.apache.wicket.markup.html.IHeaderContributor; import org.apache.wicket.markup.html.IHeaderResponse; import org.apache.wicket.markup.html.resources.CompressedResourceReference; import org.apache.wicket.protocol.http.ClientProperties; import org.apache.wicket.protocol.http.request.WebClientInfo; import org.apache.wicket.util.convert.IConverter; import com.servoy.j2db.IApplication; import com.servoy.j2db.IFormUIInternal; import com.servoy.j2db.dataprocessing.CustomValueList; import com.servoy.j2db.dataprocessing.CustomValueList.DisplayString; import com.servoy.j2db.dataprocessing.IDisplayDependencyData; import com.servoy.j2db.dataprocessing.IDisplayRelatedData; import com.servoy.j2db.dataprocessing.IFoundSetInternal; import com.servoy.j2db.dataprocessing.IRecordInternal; import com.servoy.j2db.dataprocessing.IValueList; import com.servoy.j2db.dataprocessing.LookupListChangeListener; import com.servoy.j2db.dataprocessing.LookupListModel; import com.servoy.j2db.dataprocessing.LookupValueList; import com.servoy.j2db.dataprocessing.SortColumn; import com.servoy.j2db.server.headlessclient.WebClientSession; import com.servoy.j2db.ui.scripting.RuntimeDataLookupField; import com.servoy.j2db.util.Debug; import com.servoy.j2db.util.HtmlUtils; import com.servoy.j2db.util.ScopesUtils; import com.servoy.j2db.util.UIUtils; import com.servoy.j2db.util.Utils; /** * Represents the typeahead/lookup field in the browser. * * @author jcompagner */ public class WebDataLookupField extends WebDataField implements IDisplayRelatedData, IDisplayDependencyData { private static final long serialVersionUID = 1L; private IRecordInternal parentState; private IRecordInternal relatedRecord; private LookupListModel dlm; protected LookupListChangeListener changeListener; public WebDataLookupField(IApplication application, RuntimeDataLookupField scriptable, String id, LookupValueList list) { super(application, scriptable, id, list); createLookupListModel(list); init(); } public WebDataLookupField(IApplication application, RuntimeDataLookupField scriptable, String id, final String serverName, String tableName, String dataProviderID) { super(application, scriptable, id); dlm = new LookupListModel(application, serverName, tableName, dataProviderID); init(); } public WebDataLookupField(IApplication application, RuntimeDataLookupField scriptable, String id, CustomValueList list) { super(application, scriptable, id, list); createCustomListModel(list); init(); } protected void createCustomListModel(CustomValueList vList) { if (dlm != null && changeListener != null) { dlm.getValueList().removeListDataListener(changeListener); } dlm = new LookupListModel(application, vList); if (changeListener == null) changeListener = new LookupListChangeListener(this); vList.addListDataListener(changeListener); } protected void createLookupListModel(LookupValueList vlist) { if (dlm != null && changeListener != null) { dlm.getValueList().removeListDataListener(changeListener); } dlm = new LookupListModel(application, vlist); if (dlm.isShowValues() != dlm.isReturnValues()) { try { if (changeListener == null) changeListener = new LookupListChangeListener(this); vlist.addListDataListener(changeListener); } catch (Exception e) { Debug.error("Error registering table listener for web lookup"); //$NON-NLS-1$ Debug.error(e); } } } /** * @see com.servoy.j2db.server.headlessclient.dataui.WebDataField#setValidationEnabled(boolean) */ @Override public void setValidationEnabled(boolean validation) { if (list != null && list.getFallbackValueList() != null) { IValueList vlist = list; if (!validation) { vlist = list.getFallbackValueList(); } if (vlist instanceof CustomValueList) { createCustomListModel((CustomValueList)vlist); } else { createLookupListModel((LookupValueList)vlist); } } super.setValidationEnabled(validation); } private void init() { add(new HeaderContributor(new IHeaderContributor() { private static final long serialVersionUID = 1L; public void renderHead(IHeaderResponse response) { response.renderCSSReference(new CompressedResourceReference(WebDataLookupField.class, "servoy_lookupfield.css")); //$NON-NLS-1$ } }) { @Override public boolean isEnabled(Component component) { return !getScriptObject().isReadOnly() && getScriptObject().isEnabled(); } }); setOutputMarkupPlaceholderTag(true); AutoCompleteSettings behSettings = new AutoCompleteSettings(); behSettings.setMaxHeightInPx(200); behSettings.setPreselect(true); behSettings.setShowCompleteListOnFocusGain(true); behSettings.setAdjustInputWidth(false); ClientProperties clp = (application.getApplicationType() != IApplication.HEADLESS_CLIENT ? ((WebClientInfo)Session.get().getClientInfo()).getProperties() : null); // in case of batch processors/jsp, we can't get browser info because UI is not given by web client components if (clp != null && (!clp.isBrowserInternetExplorer() || clp.getBrowserVersionMajor() >= 8)) { // smart positioning doesn't work on IE < 8 (probably because of unreliable clientWidth/clientHeight browser element js properties) behSettings.setUseSmartPositioning(true); behSettings.setUseHideShowCoveredIEFix(false); // don't know if the problem this setting is for can still be reproduced (I couldn't reproduce it)... this is true by default and makes fields in IE and Opera appear/dissapear if they would be covered by type-ahead popup } else { behSettings.setUseSmartPositioning(false); behSettings.setUseHideShowCoveredIEFix(true); } behSettings.setThrottleDelay(500); IAutoCompleteRenderer<Object> renderer = new IAutoCompleteRenderer<Object>() { protected String getTextValue(Object object) { String str = ""; //$NON-NLS-1$ if (object instanceof DisplayString) { str = object.toString(); } else if (object != null && !(object instanceof String)) { IConverter con = getConverter(object.getClass()); if (con != null) { str = con.convertToString(object, getLocale()); } else { str = object.toString(); } } else if (object != null) { str = object.toString(); } if (str == null || str.trim().equals("")) str = " "; //$NON-NLS-1$//$NON-NLS-2$ return str; } protected void renderChoice(Object object, Response response, String criteria) { if (IValueList.SEPARATOR_DESIGN_VALUE.equals(object)) return; String renderedObject = getTextValue(object); // escape the markup if it is not html or not just an empty none breaking space (null or empty string object) if (!renderedObject.equals(" ") && !HtmlUtils.hasHtmlTag(renderedObject)) renderedObject = HtmlUtils.escapeMarkup(renderedObject, true, //$NON-NLS-1$ false).toString(); response.write(renderedObject); } /* * (non-Javadoc) * * @see org.apache.wicket.extensions.ajax.markup.html.autocomplete.IAutoCompleteRenderer#render(java.lang.Object, org.apache.wicket.Response, * java.lang.String) */ public void render(Object object, Response response, String criteria) { String textValue = getTextValue(object); if (textValue == null) { throw new IllegalStateException("A call to textValue(Object) returned an illegal value: null for object: " + object.toString()); } textValue = textValue.replaceAll("\\\"", """); response.write("<li textvalue=\"" + textValue + "\""); response.write(">"); renderChoice(object, response, criteria); response.write("</li>"); } /* * (non-Javadoc) * * @see org.apache.wicket.extensions.ajax.markup.html.autocomplete.IAutoCompleteRenderer#renderHeader(org.apache.wicket.Response) */ @SuppressWarnings("nls") public void renderHeader(Response response) { StringBuffer listStyle = new StringBuffer(); listStyle.append("style=\""); String fFamily = "Tahoma, Arial, Helvetica, sans-serif"; String bgColor = "#ffffff"; String fgColor = "#000000"; String fSize = TemplateGenerator.DEFAULT_FONT_SIZE + "px"; String padding = "2px"; String margin = "0px"; if (getFont() != null) { Font f = getFont(); if (f != null) { if (f.getFamily() != null) { fFamily = f.getFamily(); if (fFamily.contains(" ")) fFamily = "'" + fFamily + "'"; } if (f.getName() != null) { String fName = f.getName(); if (fName.contains(" ")) fName = "'" + fName + "'"; fFamily = fName + "," + fFamily; } if (f.isBold()) listStyle.append("font-weight:bold; "); if (f.isItalic()) listStyle.append("font-style:italic; "); fSize = Integer.toString(f.getSize()) + "px"; } } if (getListColor() != null && getListColor().getAlpha() == 255) { // background shouldn't be transparent bgColor = getWebColor(getListColor().getRGB()); } if (getForeground() != null) { fgColor = getWebColor(getForeground().getRGB()); } Insets _padding = getPadding(); if (getPadding() != null) padding = _padding.top + "px " + _padding.right + "px " + _padding.bottom + "px " + _padding.left + "px"; listStyle.append("font-family:" + fFamily + "; "); listStyle.append("background-color: " + bgColor + "; "); listStyle.append("color: " + fgColor + "; "); listStyle.append("font-size:" + fSize + "; "); listStyle.append("min-width:" + (getSize().width - 6) + "px; "); // extract padding and border listStyle.append("margin: " + margin + "; "); listStyle.append("padding: " + padding + "; "); listStyle.append("text-align:" + TemplateGenerator.getHorizontalAlignValue(getHorizontalAlignment())); listStyle.append("\""); response.write("<ul " + listStyle + ">"); } /* * (non-Javadoc) * * @see org.apache.wicket.extensions.ajax.markup.html.autocomplete.IAutoCompleteRenderer#renderFooter(org.apache.wicket.Response) */ public void renderFooter(Response response) { response.write("</ul>"); //$NON-NLS-1$ } /** * Returns web color representation of int rgba color by * removing the alpha value * * @param color int representation of rgba color * @return web color of form #rrggbb */ private String getWebColor(int color) { String webColor = Integer.toHexString(color); int startIdx = webColor.length() - 6; if (startIdx < 0) startIdx = 0; webColor = webColor.substring(startIdx); StringBuilder sb = new StringBuilder(); sb.append('#'); int nrMissing0 = 6 - webColor.length(); for (int i = 0; i < nrMissing0; i++) { sb.append('0'); } sb.append(webColor); return sb.toString(); } }; AutoCompleteBehavior<Object> beh = new AutoCompleteBehavior<Object>(renderer, behSettings) { private static final long serialVersionUID = 1L; /** * @see org.apache.wicket.extensions.ajax.markup.html.autocomplete.AutoCompleteBehavior#getChoices(java.lang.String) */ @Override protected Iterator<Object> getChoices(String input) { String filteredInput = filterInput(input); if (changeListener != null) dlm.getValueList().removeListDataListener(changeListener); try { dlm.fill(parentState, getDataProviderID(), filteredInput, false); return dlm.iterator(); } catch (Exception ex) { Debug.error(ex); } finally { if (changeListener != null) dlm.getValueList().addListDataListener(changeListener); } return Collections.emptyList().iterator(); } /** * filters the input in case of masked input (removes the mask) */ private String filterInput(String input) { String displayFormat = WebDataLookupField.this.parsedFormat.getDisplayFormat(); if (displayFormat != null && displayFormat.length() > 0 && input.length() == displayFormat.length()) { int index = firstBlankSpacePosition(input, displayFormat); if (index == -1) return input; return input.substring(0, index); } return input; } /** * Computes the index of the first space char found in the input and is not ' ' nor '*' in the format * Example: * input '12 - 3 - ' * format '## - ## - #' * returns 6 * @param input * @param displayFormat * @return The index of the first space char found in the input and is not ' ' nor '*' in the format */ private int firstBlankSpacePosition(String input, String displayFormat) { for (int i = 0; i < input.length(); i++) { if ((input.charAt(i) == ' ') && (displayFormat.charAt(i) != ' ') && (displayFormat.charAt(i) != '*')) return i; } return 0; } /** * @see org.apache.wicket.ajax.AbstractDefaultAjaxBehavior#getFailureScript() */ @Override protected CharSequence getFailureScript() { return "onAjaxError();"; //$NON-NLS-1$ } /** * @see org.apache.wicket.ajax.AbstractDefaultAjaxBehavior#getPreconditionScript() */ @Override protected CharSequence getPreconditionScript() { return "onAjaxCall();" + super.getPreconditionScript(); //$NON-NLS-1$ } // need to set this behavior to true (enterHidesWithNoSelection) because otherwise the onKeyDown events // or other events for the component with type ahead would be null in Firefox, and would not execute as // expected on the other browsers... @Override public void renderHead(IHeaderResponse response) { settings.setShowListOnEmptyInput( Boolean.TRUE.equals(UIUtils.getUIProperty(getScriptObject(), application, IApplication.TYPE_AHEAD_SHOW_POPUP_WHEN_EMPTY, Boolean.TRUE))); settings.setShowListOnFocusGain( Boolean.TRUE.equals(UIUtils.getUIProperty(getScriptObject(), application, IApplication.TYPE_AHEAD_SHOW_POPUP_ON_FOCUS_GAIN, Boolean.TRUE))); if (!getScriptObject().isReadOnly() && getScriptObject().isEnabled()) { super.renderHead(response); response.renderJavascript("Wicket.AutoCompleteSettings.enterHidesWithNoSelection = true;", "AutocompleteSettingsID"); //$NON-NLS-1$ //$NON-NLS-2$ } } /** * @see org.apache.wicket.behavior.AbstractBehavior#isEnabled(org.apache.wicket.Component) */ @Override public boolean isEnabled(Component component) { IFormUIInternal< ? > formui = findParent(IFormUIInternal.class); if (formui != null && formui.isDesignMode()) { return false; } return super.isEnabled(component) && WebClientSession.get().useAjax(); } }; add(beh); } @Override protected boolean needsFormatOnchange() { return true; } @Override public void setClientProperty(Object key, Object value) { if ((IApplication.TYPE_AHEAD_SHOW_POPUP_ON_FOCUS_GAIN.equals(key) || IApplication.TYPE_AHEAD_SHOW_POPUP_WHEN_EMPTY.equals(key)) && !Utils.equalObjects(getScriptObject().getClientProperty(key), value)) { getStylePropertyChanges().setChanged(); } super.setClientProperty(key, value); } @Override public void setValueList(IValueList vl) { super.setValueList(vl); if (list instanceof CustomValueList) { createCustomListModel((CustomValueList)list); } converter = null; // clear old converter, so a new one is created for the new list } /** * Maps a trimmed value entered by the user to a value stored in the value list. The value stored in the value list may contain some trailing spaces, while * the value entered by the user is trimmed. * * @param value * @return */ @SuppressWarnings("nls") public String mapTrimmedToNotTrimmed(String value) { // Although the value entered by the user should be trimmed, make sure it is so. String trimmed = value.trim(); try { // this is the   character we set for empty value if ("\u00A0".equals(trimmed) || trimmed.length() == 0) return trimmed; // Grab all values that start with the value entered by the user. String result = matchValueListValue(trimmed, false); if (result == null) { if (changeListener != null) dlm.getValueList().removeListDataListener(changeListener); try { dlm.fill(parentState, getDataProviderID(), trimmed, false); result = matchValueListValue(trimmed, false); if (result == null && list.hasRealValues()) { dlm.fill(parentState, getDataProviderID(), null, false); //if it doesn't have real values, just keep what is typed // now just try to match it be start with matching instead of equals: result = matchValueListValue(trimmed, true); if (result == null && !getEventExecutor().getValidationEnabled()) { result = trimmed; } else { // if this is found then it is a commit of data of a partial string, make sure that the field is updated with the complete value. String displayValue = result == null ? "" : result; if (displayValue != null && !displayValue.equals(trimmed) && RequestCycle.get() != null) { IRequestTarget requestTarget = RequestCycle.get().getRequestTarget(); if (requestTarget instanceof AjaxRequestTarget) { ((AjaxRequestTarget)requestTarget).appendJavascript("if (document.getElementById('" + getMarkupId() + "').value == '" + value + "') document.getElementById('" + getMarkupId() + "').value='" + displayValue + "'"); } } } } } finally { if (changeListener != null) dlm.getValueList().addListDataListener(changeListener); } } // If no match was found then return back the value, otherwise return the found match. return result == null ? trimmed : result; } catch (Exception e) { Debug.error(e); return trimmed; } } public static enum LISTVALUE { NOVALUE } public Object getValueListRealValue(String displayValue) { for (int i = 0; i < dlm.getSize(); i++) { Object display = dlm.getElementAt(i); if ((displayValue != null && display != null && displayValue.equals(display.toString())) || (displayValue == null && display == null)) { return dlm.getRealElementAt(i); } } return LISTVALUE.NOVALUE; } private String matchValueListValue(String trimmed, boolean startsWith) { int size = dlm.getSize(); // Find a match in the value list. String result = null; if (startsWith) trimmed = trimmed.toLowerCase(); for (int i = 0; i < size; i++) { String currentValue = dlm.getElementAt(i).toString(); if (startsWith && currentValue.trim().toLowerCase().startsWith(trimmed)) { result = currentValue; break; } else if (currentValue.trim().equals(trimmed)) { result = currentValue; break; } } return result; } /** * @see com.servoy.j2db.dataprocessing.IDisplayRelatedData#setFoundSet(com.servoy.j2db.dataprocessing.IRecordInternal, * com.servoy.j2db.dataprocessing.IFoundSetInternal, boolean) */ public void setRecord(IRecordInternal parentState, boolean stopEditing) { if (this.parentState == parentState) return; dependencyChanged(parentState); } /* * (non-Javadoc) * * @see com.servoy.j2db.dataprocessing.IDisplayDependencyData#dependencyChanged(com.servoy.j2db.dataprocessing.IRecordInternal) */ @Override public void dependencyChanged(IRecordInternal record) { this.parentState = record; if (list != null) { int index = -1; if (!ScopesUtils.isVariableScope(getDataProviderID()) && (getDataProviderID() != null)) { index = getDataProviderID().lastIndexOf('.'); } if (index == -1 || parentState == null) { list.fill(parentState); } else { IFoundSetInternal relatedFoundSet = parentState.getRelatedFoundSet(getDataProviderID().substring(0, index)); if (relatedFoundSet == null) { this.relatedRecord = parentState.getParentFoundSet().getPrototypeState(); list.fill(relatedRecord); } else if (relatedFoundSet.getSize() == 0) { this.relatedRecord = relatedFoundSet.getPrototypeState(); list.fill(relatedRecord); } else { IRecordInternal relRecord = relatedFoundSet.getRecord(relatedFoundSet.getSelectedIndex()); if (relRecord != relatedRecord) { this.relatedRecord = relRecord; list.fill(relatedRecord); } } } } } public String getSelectedRelationName() { if (relationName == null && list != null) { relationName = list.getRelationName(); } return relationName; } private String relationName = null; public String[] getAllRelationNames() { String selectedRelationName = getSelectedRelationName(); if (selectedRelationName == null) { return new String[0]; } else { return new String[] { selectedRelationName }; } } /** * @see com.servoy.j2db.dataprocessing.IDisplayRelatedData#getDefaultSort() */ public List<SortColumn> getDefaultSort() { return dlm.getDefaultSort(); } public void notifyVisible(boolean b, List<Runnable> invokeLaterRunnables) { //ignore } /** * @see com.servoy.j2db.dataprocessing.IDisplayRelatedData#destroy() */ @Override public void destroy() { if (dlm != null && changeListener != null) { dlm.getValueList().removeListDataListener(changeListener); } super.destroy(); parentState = null; relatedRecord = null; detachModel(); } @Override public void setBackground(Color cbg) { listColor = cbg; super.setBackground(cbg); } private Color listColor = null; private Color getListColor() { return listColor; } public void setListColor(Color listColor) { this.listColor = listColor; } }