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