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>&lt;input jwcid="@Suggest" value="literal:A default value" /&gt;</pre>
047     *
048     * <p>
049     * would render something looking like:
050     * </p>
051     *
052     * <pre>&lt;input type="text" name="suggest" id="suggest" autocomplete="off" value="literal:A default value" /&gt;</pre>
053     *
054     * <p>while a defintion of</p>
055     *
056     * <pre>&lt;textarea jwcid="@Suggest" value="literal:A default value" /&gt;</pre>
057     *
058     * <p>would render something like:</p>
059     *
060     * <pre>
061     *  &lt;textarea name="suggest" id="suggest" &gt;A default value&lt;textarea/&gt;
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 &lt;li&gt; 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    }