001 package org.apache.tapestry.scriptaculous; 002 003 import java.text.ParseException; 004 import java.util.Arrays; 005 import java.util.HashMap; 006 import java.util.Iterator; 007 import java.util.List; 008 import java.util.Map; 009 010 import org.apache.hivemind.ApplicationRuntimeException; 011 import org.apache.hivemind.util.Defense; 012 import org.apache.tapestry.IActionListener; 013 import org.apache.tapestry.IDirect; 014 import org.apache.tapestry.IForm; 015 import org.apache.tapestry.IMarkupWriter; 016 import org.apache.tapestry.IRequestCycle; 017 import org.apache.tapestry.IScript; 018 import org.apache.tapestry.PageRenderSupport; 019 import org.apache.tapestry.TapestryUtils; 020 import org.apache.tapestry.coerce.ValueConverter; 021 import org.apache.tapestry.engine.DirectServiceParameter; 022 import org.apache.tapestry.engine.IEngineService; 023 import org.apache.tapestry.engine.ILink; 024 import org.apache.tapestry.form.AbstractFormComponent; 025 import org.apache.tapestry.form.TranslatedField; 026 import org.apache.tapestry.form.TranslatedFieldSupport; 027 import org.apache.tapestry.form.ValidatableFieldSupport; 028 import org.apache.tapestry.json.JSONLiteral; 029 import org.apache.tapestry.json.JSONObject; 030 import org.apache.tapestry.link.DirectLink; 031 import org.apache.tapestry.listener.ListenerInvoker; 032 import org.apache.tapestry.services.ResponseBuilder; 033 import org.apache.tapestry.util.SizeRestrictingIterator; 034 import org.apache.tapestry.valid.ValidatorException; 035 036 /** 037 * Implementation of the <a href="http://wiki.script.aculo.us/scriptaculous/show/Ajax.Autocompleter">Ajax.Autocompleter</a> in 038 * the form of a {@link org.apache.tapestry.form.TextField} like component with the additional ability to dynamically suggest 039 * values via XHR requests. 040 * 041 * <p> 042 * This component will use the html element tag name defined in your html template to include it to determine whether or not 043 * to render a TextArea or TextField style input element. For example, specifying a component definition such as: 044 * </p> 045 * 046 * <pre><input jwcid="@Suggest" value="literal:A default value" /></pre> 047 * 048 * <p> 049 * would render something looking like: 050 * </p> 051 * 052 * <pre><input type="text" name="suggest" id="suggest" autocomplete="off" value="literal:A default value" /></pre> 053 * 054 * <p>while a defintion of</p> 055 * 056 * <pre><textarea jwcid="@Suggest" value="literal:A default value" /></pre> 057 * 058 * <p>would render something like:</p> 059 * 060 * <pre> 061 * <textarea name="suggest" id="suggest" >A default value<textarea/> 062 * </pre> 063 * 064 */ 065 public abstract class Suggest extends AbstractFormComponent implements TranslatedField, IDirect { 066 067 /** 068 * Keys that should be treated as javascript literals when contructing the 069 * options json. 070 */ 071 private static final String[] LITERAL_KEYS = new String[] 072 {"onFailure", "updateElement", "afterUpdateElement", "callback"}; 073 074 075 /** 076 * Injected service used to invoke whatever listeners people have setup to handle 077 * changing value from this field. 078 * 079 * @return The invoker. 080 */ 081 public abstract ListenerInvoker getListenerInvoker(); 082 083 /** 084 * Injected response builder for doing specific XHR things. 085 * 086 * @return ResponseBuilder for this request. 087 */ 088 public abstract ResponseBuilder getResponse(); 089 090 /** 091 * Associated javascript template. 092 * 093 * @return The script template. 094 */ 095 public abstract IScript getScript(); 096 097 /** 098 * Used to convert form input values. 099 * 100 * @return The value converter to use. 101 */ 102 public abstract ValueConverter getValueConverter(); 103 104 /** 105 * Injected. 106 * 107 * @return Service used to validate input. 108 */ 109 public abstract ValidatableFieldSupport getValidatableFieldSupport(); 110 111 /** 112 * Injected. 113 * 114 * @return Translation service. 115 */ 116 public abstract TranslatedFieldSupport getTranslatedFieldSupport(); 117 118 /** 119 * Injected. 120 * 121 * @return The {@link org.apache.tapestry.engine.DirectService} engine. 122 */ 123 public abstract IEngineService getEngineService(); 124 125 //////////////////////////////////////////////////////// 126 // Parameters 127 //////////////////////////////////////////////////////// 128 129 public abstract Object getValue(); 130 public abstract void setValue(Object value); 131 132 public abstract ListItemRenderer getListItemRenderer(); 133 public abstract void setListItemRenderer(ListItemRenderer renderer); 134 135 public abstract IActionListener getListener(); 136 137 public abstract Object getListSource(); 138 public abstract void setListSource(Object value); 139 140 public abstract int getMaxResults(); 141 142 public abstract Object getParameters(); 143 144 public abstract String getOptions(); 145 146 public abstract String getUpdateElementClass(); 147 148 /** 149 * Used internally to track listener invoked searches versus 150 * normal rendering requests. 151 * 152 * @return True if search was triggered, false otherwise. 153 */ 154 public abstract boolean isSearchTriggered(); 155 public abstract void setSearchTriggered(boolean value); 156 157 public boolean isRequired() 158 { 159 return getValidatableFieldSupport().isRequired(this); 160 } 161 162 protected void renderComponent(IMarkupWriter writer, IRequestCycle cycle) 163 { 164 // render search triggered response instead of normal render if 165 // listener was invoked 166 167 IForm form = TapestryUtils.getForm(cycle, this); 168 setForm(form); 169 170 if (form.wasPrerendered(writer, this)) 171 return; 172 173 if (!form.isRewinding() && !cycle.isRewinding() 174 && getResponse().isDynamic() && isSearchTriggered()) 175 { 176 setName(form); 177 178 // do nothing if it wasn't for this instance - such as in a loop 179 180 if (cycle.getParameter(getClientId()) == null) 181 return; 182 183 renderList(writer, cycle); 184 return; 185 } 186 187 // defer to super if normal render 188 189 super.renderComponent(writer, cycle); 190 } 191 192 /** 193 * Invoked only when a search has been triggered to render out the <li> list of 194 * dynamic suggestion options. 195 * 196 * @param writer 197 * The markup writer. 198 * @param cycle 199 * The associated request. 200 */ 201 public void renderList(IMarkupWriter writer, IRequestCycle cycle) 202 { 203 Defense.notNull(getListSource(), "listSource for Suggest component."); 204 205 Iterator values = (Iterator)getValueConverter().coerceValue(getListSource(), Iterator.class); 206 207 if (isParameterBound("maxResults")) 208 { 209 values = new SizeRestrictingIterator(values, getMaxResults()); 210 } 211 212 getListItemRenderer().renderList(writer, cycle, values); 213 } 214 215 protected void renderFormComponent(IMarkupWriter writer, IRequestCycle cycle) 216 { 217 String value = getTranslatedFieldSupport().format(this, getValue()); 218 boolean isTextArea = getTemplateTagName().equalsIgnoreCase("textarea"); 219 220 renderDelegatePrefix(writer, cycle); 221 222 if (isTextArea) 223 writer.begin(getTemplateTagName()); 224 else 225 writer.beginEmpty(getTemplateTagName()); 226 227 // only render input attributes if not a textarea 228 if (!isTextArea) 229 { 230 writer.attribute("type", "text"); 231 writer.attribute("autocomplete", "off"); 232 } 233 234 renderIdAttribute(writer, cycle); 235 writer.attribute("name", getName()); 236 237 if (isDisabled()) 238 writer.attribute("disabled", "disabled"); 239 240 renderInformalParameters(writer, cycle); 241 renderDelegateAttributes(writer, cycle); 242 243 getTranslatedFieldSupport().renderContributions(this, writer, cycle); 244 getValidatableFieldSupport().renderContributions(this, writer, cycle); 245 246 if (value != null) 247 { 248 if (!isTextArea) 249 writer.attribute("value", value); 250 else 251 writer.print(value); 252 } 253 254 if (!isTextArea) 255 writer.closeTag(); 256 else 257 writer.end(); 258 259 renderDelegateSuffix(writer, cycle); 260 261 // render update element 262 263 writer.begin("div"); 264 writer.attribute("id", getClientId() + "choices"); 265 writer.attribute("class", getUpdateElementClass()); 266 writer.end(); 267 268 // render javascript 269 270 JSONObject json = null; 271 String options = getOptions(); 272 273 try { 274 275 json = options != null ? new JSONObject(options) : new JSONObject(); 276 277 } catch (ParseException ex) 278 { 279 throw new ApplicationRuntimeException(ScriptaculousMessages.invalidOptions(options, ex), this.getBinding("options").getLocation(), ex); 280 } 281 282 // bind onFailure client side function if not already defined 283 284 if (!json.has("onFailure")) 285 { 286 json.put("onFailure", "tapestry.error"); 287 } 288 289 if (!json.has("encoding")) 290 { 291 json.put("encoding", cycle.getEngine().getOutputEncoding()); 292 } 293 294 for (int i=0; i<LITERAL_KEYS.length; i++) 295 { 296 String key = LITERAL_KEYS[i]; 297 if (json.has(key)) 298 { 299 json.put(key, new JSONLiteral(json.getString(key))); 300 } 301 } 302 303 Map parms = new HashMap(); 304 parms.put("inputId", getClientId()); 305 parms.put("updateId", getClientId() + "choices"); 306 parms.put("options", json.toString()); 307 308 Object[] specifiedParams = DirectLink.constructServiceParameters(getParameters()); 309 Object[] listenerParams = null; 310 if (specifiedParams != null) 311 { 312 listenerParams = new Object[specifiedParams.length + 1]; 313 System.arraycopy(specifiedParams, 0, listenerParams, 1, specifiedParams.length); 314 } else { 315 316 listenerParams = new Object[1]; 317 } 318 319 listenerParams[0] = getClientId(); 320 321 ILink updateLink = getEngineService().getLink(isStateful(), new DirectServiceParameter(this, listenerParams)); 322 parms.put("updateUrl", updateLink.getURL()); 323 324 PageRenderSupport pageRenderSupport = TapestryUtils.getPageRenderSupport(cycle, this); 325 getScript().execute(this, cycle, pageRenderSupport, parms); 326 } 327 328 /** 329 * Rewinds the component, doing translation, validation and binding. 330 */ 331 protected void rewindFormComponent(IMarkupWriter writer, IRequestCycle cycle) 332 { 333 String value = cycle.getParameter(getName()); 334 try 335 { 336 Object object = getTranslatedFieldSupport().parse(this, value); 337 getValidatableFieldSupport().validate(this, writer, cycle, object); 338 339 setValue(object); 340 } catch (ValidatorException e) 341 { 342 getForm().getDelegate().recordFieldInputValue(value); 343 getForm().getDelegate().record(e); 344 } 345 } 346 347 /** 348 * Triggers the listener. The parameters passed are the current text 349 * and those specified in the parameters parameter of the component. 350 * If the listener parameter is not bound, attempt to locate an implicit 351 * listener named by the capitalized component id, prefixed by "do". 352 */ 353 public void trigger(IRequestCycle cycle) 354 { 355 IActionListener listener = getListener(); 356 if (listener == null) 357 listener = getContainer().getListeners().getImplicitListener(this); 358 359 Object[] params = cycle.getListenerParameters(); 360 361 // replace the first param with the correct value 362 String inputId = (String)params[0]; 363 params[0] = cycle.getParameter(inputId); 364 365 cycle.setListenerParameters(params); 366 367 setSearchTriggered(true); 368 369 getListenerInvoker().invokeListener(listener, this, cycle); 370 } 371 372 public List getUpdateComponents() 373 { 374 return Arrays.asList(new Object[] { getClientId() }); 375 } 376 377 public boolean isAsync() 378 { 379 return true; 380 } 381 382 public boolean isJson() 383 { 384 return false; 385 } 386 387 /** 388 * Sets the default {@link ListItemRenderer} for component, to be overriden as 389 * necessary by component parameters. 390 */ 391 protected void finishLoad() 392 { 393 setListItemRenderer(DefaultListItemRenderer.SHARED_INSTANCE); 394 } 395 }