Java tutorial
/******************************************************************************* * Licensed 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 name.cphillipson.experimental.gwt.client.module.common.widget.suggest; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import com.google.gwt.event.dom.client.KeyUpEvent; import com.google.gwt.event.dom.client.KeyUpHandler; import com.google.gwt.event.logical.shared.SelectionEvent; import com.google.gwt.event.logical.shared.SelectionHandler; import com.google.gwt.http.client.RequestBuilder; import com.google.gwt.http.client.RequestCallback; import com.google.gwt.http.client.RequestException; import com.google.gwt.http.client.Response; import com.google.gwt.http.client.URL; import com.google.gwt.json.client.JSONArray; import com.google.gwt.json.client.JSONObject; import com.google.gwt.json.client.JSONParser; import com.google.gwt.json.client.JSONValue; import com.google.gwt.user.client.Timer; import com.google.gwt.user.client.ui.Composite; import com.google.gwt.user.client.ui.FlowPanel; import com.google.gwt.user.client.ui.Focusable; import com.google.gwt.user.client.ui.SuggestBox; import com.google.gwt.user.client.ui.SuggestOracle; import com.google.gwt.user.client.ui.SuggestOracle.Callback; import com.google.gwt.user.client.ui.SuggestOracle.Request; import com.google.gwt.user.client.ui.SuggestOracle.Suggestion; import com.google.gwt.user.client.ui.TextArea; import com.google.gwt.user.client.ui.TextBox; import com.google.gwt.user.client.ui.TextBoxBase; /** * A SuggestBox that uses REST and allows for multiple values, autocomplete and browsing * * @author Bess Siegal <bsiegal@novell.com> */ public class MultivalueSuggestBox extends Composite implements SelectionHandler<Suggestion>, Focusable, KeyUpHandler { private SuggestBox m_field; private Map<String, String> m_valueMap; private int m_indexFrom = 0; private int m_indexTo = 0; private int m_findExactMatchesTotal = 0; private int m_findExactMatchesFound = 0; private ArrayList<String> m_findExactMatchesNot = new ArrayList<String>(); private static final String DISPLAY_SEPARATOR = ", "; private static final String VALUE_DELIM = ";"; private static final int PAGE_Size = 15; private static final int DELAY = 1000; private static final int FIND_EXACT_MATCH_QUERY_LIMIT = 20; private FormFeedback m_feedback; private boolean m_isMultivalued = false; private String m_restEndpointUrl; /** * Constructor. * * @param restEndpointUrl * URL for the REST endpoint. This URL should accept the parameters q (for query), indexFrom and indexTo * @param isMultivalued * - true for allowing multiple values */ public MultivalueSuggestBox(String restEndpointUrl, boolean isMultivalued) { m_restEndpointUrl = restEndpointUrl; m_isMultivalued = isMultivalued; final FlowPanel panel = new FlowPanel(); TextBoxBase textfield; if (isMultivalued) { panel.addStyleName("textarearow"); textfield = new TextArea(); } else { panel.addStyleName("textfieldrow"); textfield = new TextBox(); } // Create our own SuggestOracle that queries REST endpoint final SuggestOracle oracle = new RestSuggestOracle(); // intialize the SuggestBox m_field = new SuggestBox(oracle, textfield); if (isMultivalued) { // have to do this here b/c gwt suggest box wipes // style name if added in previous if textfield.addStyleName("multivalue"); } m_field.addStyleName("wideTextField"); m_field.addSelectionHandler(this); m_field.addKeyUpHandler(this); panel.add(m_field); m_feedback = new FormFeedback(); panel.add(m_feedback); initWidget(panel); /* * Create a Map that holds the values that should be stored. It will be keyed on "display value", so that any * time a "display value" is added or removed the valueMap can be updated. */ m_valueMap = new HashMap<String, String>(); resetPageIndices(); } private void resetPageIndices() { m_indexFrom = 0; m_indexTo = m_indexFrom + PAGE_Size - 1; } /** * Convenience method to set the status and tooltip of the FormFeedback * * @param status * - a FormFeedback status * @param tooltip * - a String tooltip */ public void updateFormFeedback(int status, String tooltip) { m_feedback.setStatus(status); if (tooltip != null) { m_feedback.setTitle(tooltip); } final TextBoxBase textBox = m_field.getTextBox(); if (FormFeedback.LOADING == status) { textBox.setEnabled(false); } else { textBox.setEnabled(true); textBox.setFocus(false); // Blur then focus b/c of a strange problem with the cursor or selection highlights // no longer visible within the textfield (this is a workaround) textBox.setFocus(true); } } private void putValue(String key, String value) { System.out.println("putting key = " + key + "; value = " + value); m_valueMap.put(key, value); } /** * Get the value(s) as a String. If allowing multivalues, separated by the VALUE_DELIM * * @return value(s) as a String */ public String getValue() { // String together all the values in the valueMap // based on the display values shown in the field final String text = m_field.getText(); String values = ""; String invalids = ""; String newKeys = ""; if (m_isMultivalued) { final String[] keys = text.split(DISPLAY_SEPARATOR); for (String key : keys) { key = key.trim(); if (!key.isEmpty()) { final String v = m_valueMap.get(key); System.out.println("getValue for key = " + key + " is v = " + v); if (null != v) { values += v + VALUE_DELIM; // rebuild newKeys removing invalids and dups newKeys += key + DISPLAY_SEPARATOR; } else { invalids += key + DISPLAY_SEPARATOR; } } } values = trimLastDelimiter(values, VALUE_DELIM); // set the new display values m_field.setText(newKeys); } else { values = m_valueMap.get(text); } // if there were any invalid show warning if (!invalids.isEmpty()) { // trim last separator invalids = trimLastDelimiter(invalids, DISPLAY_SEPARATOR); updateFormFeedback(FormFeedback.ERROR, "Invalids: " + invalids); } return values; } /** * Get the value map * * @return value map */ public Map<String, String> getValueMap() { return m_valueMap; } /** * If there is more than one key in the text field, check that every key has a value in the map. For any that do * not, try to find its exact match. */ private void findExactMatches() { final String text = m_field.getText(); final String[] keys = text.split(DISPLAY_SEPARATOR.trim()); final int len = keys.length; if (len < 2) { // do not continue. if there's 1, it is the last one, and getSuggestions can handle it return; } m_findExactMatchesTotal = 0; m_findExactMatchesFound = 0; m_findExactMatchesNot.clear(); for (int pos = 0; pos < len; pos++) { final String key = keys[pos].trim(); if (!key.isEmpty()) { final String v = m_valueMap.get(key); if (null == v) { m_findExactMatchesTotal++; } } } // then loop through again and try to find them /* * We may have invalid values due to a multi-value copy-n-paste, or going back and messing with a middle or * first key; so for each invalid value, try to find an exact match. * */ for (int pos = 0; pos < len; pos++) { final String key = keys[pos].trim(); if (!key.isEmpty()) { final String v = m_valueMap.get(key); if (null == v) { findExactMatch(key, pos); } } } } private void findExactMatch(final String displayValue, final int position) { updateFormFeedback(FormFeedback.LOADING, null); queryOptions(displayValue, 0, FIND_EXACT_MATCH_QUERY_LIMIT, // return a relatively small amount in case wanted // "Red" and "Brick Red" is the first thing returned new OptionQueryCallback() { @Override public void error(Throwable exception) { // an exact match couldn't be found, just increment not found m_findExactMatchesNot.add(displayValue); finalizeFindExactMatches(); } @Override public void success(OptionResultSet optResults) { final int totSize = optResults.getTotalSize(); if (totSize == 1) { // an exact match was found, so place it in the value map final Option option = optResults.getOptions()[0]; extactMatchFound(position, option); } else { // try to find the exact matches within the results boolean found = false; for (final Option option : optResults.getOptions()) { if (displayValue.equalsIgnoreCase(option.getName())) { extactMatchFound(position, option); found = true; break; } } if (!found) { m_findExactMatchesNot.add(displayValue); System.out.println("RestExactMatchCallback -- exact match not found for displ = " + displayValue); } } finalizeFindExactMatches(); } private void extactMatchFound(final int position, Option option) { putValue(option.getName(), option.getValue()); System.out.println("extactMatchFound ! exact match found for displ = " + displayValue); // and replace the text final String text = m_field.getText(); final String[] keys = text.split(DISPLAY_SEPARATOR.trim()); keys[position] = option.getName(); String join = ""; for (final String n : keys) { join += n.trim() + DISPLAY_SEPARATOR; } join = trimLastDelimiter(join, DISPLAY_SEPARATOR); m_field.setText(join); m_findExactMatchesFound++; } private void finalizeFindExactMatches() { if (m_findExactMatchesFound + m_findExactMatchesNot.size() == m_findExactMatchesTotal) { // when the found + not = total, we're done if (m_findExactMatchesNot.size() > 0) { String join = ""; for (final String val : m_findExactMatchesNot) { join += val.trim() + DISPLAY_SEPARATOR; } join = trimLastDelimiter(join, DISPLAY_SEPARATOR); updateFormFeedback(FormFeedback.ERROR, "Invalid:" + join); } else { updateFormFeedback(FormFeedback.VALID, null); } } } }); } /** * Returns a String without the last delimiter * * @param s * - String to trim * @param delim * - the delimiter * @return the String without the last delimter */ private static String trimLastDelimiter(String s, String delim) { if (s.length() > 0) { s = s.substring(0, s.length() - delim.length()); } return s; } @Override public void onSelection(SelectionEvent<Suggestion> event) { final Suggestion suggestion = event.getSelectedItem(); if (suggestion instanceof OptionSuggestion) { final OptionSuggestion osugg = (OptionSuggestion) suggestion; // if NEXT or PREVIOUS were selected, requery but bypass the timer final String value = osugg.getValue(); if (OptionSuggestion.NEXT_VALUE.equals(value)) { m_indexFrom += PAGE_Size; m_indexTo += PAGE_Size; final RestSuggestOracle oracle = (RestSuggestOracle) m_field.getSuggestOracle(); oracle.getSuggestions(); } else if (OptionSuggestion.PREVIOUS_VALUE.equals(value)) { m_indexFrom -= PAGE_Size; m_indexTo -= PAGE_Size; final RestSuggestOracle oracle = (RestSuggestOracle) m_field.getSuggestOracle(); oracle.getSuggestions(); } else { // made a valid selection updateFormFeedback(FormFeedback.VALID, null); // add the option's value to the value map putValue(osugg.getName(), value); // put the focus back into the textfield so user // can enter more m_field.setFocus(true); } } } private String getFullReplaceText(String displ, String replacePre) { // replace the last bit after the last comma if (replacePre.lastIndexOf(DISPLAY_SEPARATOR) > 0) { replacePre = replacePre.substring(0, replacePre.lastIndexOf(DISPLAY_SEPARATOR)) + DISPLAY_SEPARATOR; } else { replacePre = ""; } // then add a comma if (m_isMultivalued) { return replacePre + displ + DISPLAY_SEPARATOR; } else { return displ; } } @Override public int getTabIndex() { return m_field.getTabIndex(); } @Override public void setAccessKey(char key) { m_field.setAccessKey(key); } @Override public void setFocus(boolean focused) { m_field.setFocus(focused); } @Override public void setTabIndex(int index) { m_field.setTabIndex(index); } @Override public void onKeyUp(KeyUpEvent event) { /* * Because SuggestOracle.requestSuggestions does not get called when the text field is empty this key up handler * is necessary for handling the case when there is an empty text field... Here, the FormFeedback is reset. */ updateFormFeedback(FormFeedback.NONE, null); } /** * Retrieve Options (name-value pairs) that are suggested from the REST endpoint * * @param query * - the String search term * @param from * - the 0-based begin index int * @param to * - the end index inclusive int * @param callback * - the OptionQueryCallback to handle the response */ private void queryOptions(final String query, final int from, final int to, final OptionQueryCallback callback) { final RequestBuilder builder = new RequestBuilder(RequestBuilder.GET, URL.encode(m_restEndpointUrl + "?q=" + query + "&indexFrom=" + from + "&indexTo=" + to)); // Set our headers builder.setHeader("Accept", "application/json"); builder.setHeader("Accept-Charset", "UTF-8"); builder.setCallback(new RequestCallback() { @Override public void onResponseReceived(com.google.gwt.http.client.Request request, Response response) { final JSONValue val = JSONParser.parse(response.getText()); final JSONObject obj = val.isObject(); final int totSize = (int) obj.get(OptionResultSet.TOTAL_SIZE).isNumber().doubleValue(); final OptionResultSet options = new OptionResultSet(totSize); final JSONArray optionsArray = obj.get(OptionResultSet.OPTIONS).isArray(); if (options.getTotalSize() > 0 && optionsArray != null) { for (int i = 0; i < optionsArray.size(); i++) { if (optionsArray.get(i) == null) { /* * This happens when a JSON array has an invalid trailing comma */ continue; } final JSONObject jsonOpt = optionsArray.get(i).isObject(); final Option option = new Option(); option.setName(jsonOpt.get(OptionResultSet.DISPLAY_NAME).isString().stringValue()); option.setValue(jsonOpt.get(OptionResultSet.VALUE).isString().stringValue()); options.addOption(option); } } callback.success(options); } @Override public void onError(com.google.gwt.http.client.Request request, Throwable exception) { callback.error(exception); } }); try { builder.send(); } catch (final RequestException e) { updateFormFeedback(FormFeedback.ERROR, "Error: " + e.getMessage()); } } /* * Some custom inner classes for our SuggestOracle */ /** * A custom Suggest Oracle */ private class RestSuggestOracle extends SuggestOracle { private SuggestOracle.Request m_request; private SuggestOracle.Callback m_callback; private Timer m_timer; RestSuggestOracle() { m_timer = new Timer() { @Override public void run() { /* * The reason we check for empty string is found at http://development.lombardi.com/?p=39 -- * paraphrased, if you backspace quickly the contents of the field are emptied but a query for a * single character is still executed. Workaround for this is to check for an empty string field * here. */ if (!m_field.getText().trim().isEmpty()) { if (m_isMultivalued) { // calling this here in case a user is trying to correct the "kev" value of Allison Andrews, // Kev, Josh Nolan or pasted in multiple values findExactMatches(); } getSuggestions(); } } }; } @Override public void requestSuggestions(SuggestOracle.Request request, SuggestOracle.Callback callback) { // This is the method that gets called by the SuggestBox whenever some types into the text field m_request = request; m_callback = callback; // reset the indexes (b/c NEXT and PREV call getSuggestions directly) resetPageIndices(); // If the user keeps triggering this event (e.g., keeps typing), cancel and restart the timer m_timer.cancel(); m_timer.schedule(DELAY); } private void getSuggestions() { String query = m_request.getQuery(); // find the last thing entered up to the last separator // and use that as the query if (m_isMultivalued) { final int sep = query.lastIndexOf(DISPLAY_SEPARATOR); if (sep > 0) { query = query.substring(sep + DISPLAY_SEPARATOR.length()); } } query = query.trim(); // do not query if it's just an empty String // also do not get suggestions you've already got an exact match for this string in the m_valueMap if (query.length() > 0 && m_valueMap.get(query) == null) { // JSUtil.println("getting Suggestions for: " + query); updateFormFeedback(FormFeedback.LOADING, null); queryOptions(query, m_indexFrom, m_indexTo, new RestSuggestCallback(m_request, m_callback, query)); } } @Override public boolean isDisplayStringHTML() { return true; } } /** * A custom callback that has the original SuggestOracle.Request and SuggestOracle.Callback */ private class RestSuggestCallback extends OptionQueryCallback { private SuggestOracle.Request m_request; private SuggestOracle.Callback m_callback; private String m_query; // this may be different from m_request.getQuery when multivalued it's only the // substring after the last delimiter RestSuggestCallback(Request request, Callback callback, String query) { m_request = request; m_callback = callback; m_query = query; } @Override public void success(OptionResultSet optResults) { final SuggestOracle.Response resp = new SuggestOracle.Response(); final List<OptionSuggestion> suggs = new ArrayList<OptionSuggestion>(); final int totSize = optResults.getTotalSize(); if (totSize < 1) { // if there were no suggestions, then it's an invalid value updateFormFeedback(FormFeedback.ERROR, "Invalid: " + m_query); } else if (totSize == 1) { // it's an exact match, so do not bother with showing suggestions, final Option o = optResults.getOptions()[0]; final String displ = o.getName(); // remove the last bit up to separator m_field.setText(getFullReplaceText(displ, m_request.getQuery())); System.out.println("RestSuggestCallback.success! exact match found for displ = " + displ); // it's valid! updateFormFeedback(FormFeedback.VALID, null); // set the value into the valueMap putValue(displ, o.getValue()); } else { // more than 1 so show the suggestions // if not at the first page, show PREVIOUS if (m_indexFrom > 0) { final OptionSuggestion prev = new OptionSuggestion(OptionSuggestion.PREVIOUS_VALUE, m_request.getQuery()); suggs.add(prev); } // show the suggestions for (final Option o : optResults.getOptions()) { final OptionSuggestion sugg = new OptionSuggestion(o.getName(), o.getValue(), m_request.getQuery(), m_query); suggs.add(sugg); } // if there are more pages, show NEXT if (m_indexTo < totSize) { final OptionSuggestion next = new OptionSuggestion(OptionSuggestion.NEXT_VALUE, m_request.getQuery()); suggs.add(next); } // nothing has been picked yet, so let the feedback show an error (unsaveable) updateFormFeedback(FormFeedback.ERROR, "Invalid: " + m_query); } // it's ok (and good) to pass an empty suggestion list back to the suggest box's callback method // the list is not shown at all if the list is empty. resp.setSuggestions(suggs); m_callback.onSuggestionsReady(m_request, resp); } @Override public void error(Throwable exception) { updateFormFeedback(FormFeedback.ERROR, "Invalid: " + m_query); } } /** * A bean to serve as a custom suggestion so that the value is available and the replace will look like it is * supporting multivalues */ private class OptionSuggestion implements SuggestOracle.Suggestion { private String m_display; private String m_replace; private String m_value; private String m_name; static final String NEXT_VALUE = "NEXT"; static final String PREVIOUS_VALUE = "PREVIOUS"; /** * Constructor for navigation options * * @param nav * - next or previous value * @param currentTextValue * - the current contents of the text box */ OptionSuggestion(String nav, String currentTextValue) { if (NEXT_VALUE.equals(nav)) { m_display = "<div class=\"autocompleterNext\" title=\"Next\"></div>"; } else { m_display = "<div class=\"autocompleterPrev\" title=\"Previous\"></div>"; } m_replace = currentTextValue; m_value = nav; } /** * Constructor for regular options * * @param displ * - the name of the option * @param val * - the value of the option * @param replacePre * - the current contents of the text box * @param query * - the query */ OptionSuggestion(String displ, String val, String replacePre, String query) { m_name = displ; final int begin = displ.toLowerCase().indexOf(query.toLowerCase()); if (begin >= 0) { final int end = begin + query.length(); final String match = displ.substring(begin, end); m_display = displ.replaceFirst(match, "<b>" + match + "</b>"); } else { // may not necessarily be a part of the query, for example if "*" was typed. m_display = displ; } m_replace = getFullReplaceText(displ, replacePre); m_value = val; } @Override public String getDisplayString() { return m_display; } @Override public String getReplacementString() { return m_replace; } /** * Get the value of the option * * @return value */ public String getValue() { return m_value; } /** * Get the name of the option. (when not multivalued, this will be the same as getReplacementString) * * @return name */ public String getName() { return m_name; } } /** * An abstract class that handles success and error conditions from the REST call */ private abstract class OptionQueryCallback { abstract void success(OptionResultSet optResults); abstract void error(Throwable exception); } /** * Bean for name-value pairs */ private class Option { private String m_name; private String m_value; /** * No argument constructor */ public Option() { } /** * @return Returns the name. */ public String getName() { return m_name; } /** * @param name * The name to set. */ public void setName(String name) { m_name = name; } /** * @return Returns the value. */ public String getValue() { return m_value; } /** * @param value * The value to set. */ public void setValue(String value) { m_value = value; } } /** * Bean for total size and options */ private class OptionResultSet { /** JSON key for Options */ public static final String OPTIONS = "Options"; /** JSON key for DisplayName */ public static final String DISPLAY_NAME = "DisplayName"; /** JSON key for Value */ public static final String VALUE = "Value"; /** JSON key for the size of the Results */ public static final String TOTAL_SIZE = "TotalSize"; private final List<Option> m_options = new ArrayList<Option>(); private int m_totalSize; /** * Constructor. Must pass in the total size. * * @param totalSize * the total size of the template */ public OptionResultSet(int totalSize) { setTotalSize(totalSize); } /** * Add an option * * @param option * - the Option to add */ public void addOption(Option option) { m_options.add(option); } /** * @return an array of Options */ public Option[] getOptions() { return m_options.toArray(new Option[m_options.size()]); } /** * @param totalSize * The totalSize to set. */ public void setTotalSize(int totalSize) { m_totalSize = totalSize; } /** * @return Returns the totalSize. */ public int getTotalSize() { return m_totalSize; } } }