001    // Copyright 2008, 2009, 2010, 2011 The Apache Software Foundation
002    //
003    // Licensed under the Apache License, Version 2.0 (the "License");
004    // you may not use this file except in compliance with the License.
005    // You may obtain a copy of the License at
006    //
007    // http://www.apache.org/licenses/LICENSE-2.0
008    //
009    // Unless required by applicable law or agreed to in writing, software
010    // distributed under the License is distributed on an "AS IS" BASIS,
011    // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
012    // See the License for the specific language governing permissions and
013    // limitations under the License.
014    
015    package org.apache.tapestry5.corelib.components;
016    
017    import java.util.Collections;
018    import java.util.Iterator;
019    
020    import org.apache.tapestry5.*;
021    import org.apache.tapestry5.annotations.Environmental;
022    import org.apache.tapestry5.annotations.Events;
023    import org.apache.tapestry5.annotations.InjectComponent;
024    import org.apache.tapestry5.annotations.Log;
025    import org.apache.tapestry5.annotations.Parameter;
026    import org.apache.tapestry5.annotations.Property;
027    import org.apache.tapestry5.corelib.internal.AjaxFormLoopContext;
028    import org.apache.tapestry5.internal.services.PageRenderQueue;
029    import org.apache.tapestry5.ioc.annotations.Inject;
030    import org.apache.tapestry5.ioc.internal.util.InternalUtils;
031    import org.apache.tapestry5.ioc.services.TypeCoercer;
032    import org.apache.tapestry5.json.JSONArray;
033    import org.apache.tapestry5.json.JSONObject;
034    import org.apache.tapestry5.services.ComponentDefaultProvider;
035    import org.apache.tapestry5.services.Environment;
036    import org.apache.tapestry5.services.FormSupport;
037    import org.apache.tapestry5.services.Heartbeat;
038    import org.apache.tapestry5.services.PartialMarkupRenderer;
039    import org.apache.tapestry5.services.PartialMarkupRendererFilter;
040    import org.apache.tapestry5.services.javascript.JavaScriptSupport;
041    
042    /**
043     * A special form of the {@link org.apache.tapestry5.corelib.components.Loop}
044     * component that adds Ajax support to handle adding new rows and removing
045     * existing rows dynamically.
046     * <p/>
047     * This component expects that the values being iterated over are entities that
048     * can be identified via a {@link org.apache.tapestry5.ValueEncoder}, therefore
049     * you must either bind the "encoder" parameter to a ValueEncoder or use an
050     * entity type for the "value" parameter for which Tapestry can provide a
051     * ValueEncoder automatically.
052     * <p/>
053     * Works with {@link org.apache.tapestry5.corelib.components.AddRowLink} and
054     * {@link org.apache.tapestry5.corelib.components.RemoveRowLink} components.
055     * <p/>
056     * The addRow event will receive the context specified by the context parameter.
057     * <p/>
058     * The removeRow event will receive the client-side value for the row being iterated.
059     * 
060     * @see EventConstants#ADD_ROW
061     * @see EventConstants#REMOVE_ROW
062     * @tapestrydoc
063     * @see AddRowLink
064     * @see RemoveRowLink
065     * @see Loop
066     * @see FormInjector
067     */
068    @Events(
069    { EventConstants.ADD_ROW, EventConstants.REMOVE_ROW })
070    public class AjaxFormLoop
071    {
072        /**
073         * The element to render for each iteration of the loop. The default comes from the template, or "div" if the
074         * template did not specify an element.
075         */
076        @Parameter(defaultPrefix = BindingConstants.LITERAL)
077        @Property(write = false)
078        private String element;
079    
080        /**
081         * The objects to iterate over (passed to the internal Loop component).
082         */
083        @Parameter(required = true, autoconnect = true)
084        private Iterable source;
085    
086        /**
087         * The current value from the source.
088         */
089        @Parameter(required = true)
090        private Object value;
091    
092        /**
093         * Name of a function on the client-side Tapestry.ElementEffect object that is invoked to make added content
094         * visible. This is used with the {@link FormInjector} component, when adding a new row to the loop. Leaving as
095         * null uses the default function, "highlight".
096         */
097        @Parameter(defaultPrefix = BindingConstants.LITERAL)
098        private String show;
099    
100        /**
101         * The context for the form loop (optional parameter). This list of values will be converted into strings and
102         * included in the URI. The strings will be coerced back to whatever their values are and made available to event
103         * handler methods.
104         */
105        @Parameter
106        private Object[] context;
107    
108        /**
109         * A block to render after the loop as the body of the {@link org.apache.tapestry5.corelib.components.FormInjector}.
110         * This typically contains a {@link org.apache.tapestry5.corelib.components.AddRowLink}.
111         */
112        @Parameter(value = "block:defaultAddRow", defaultPrefix = BindingConstants.LITERAL)
113        @Property(write = false)
114        private Block addRow;
115    
116        /**
117         * The block that contains the form injector (it is rendered last, as the "tail" of the AjaxFormLoop). This, in
118         * turn, references the addRow block (from a parameter, or a default).
119         */
120        @Inject
121        private Block tail;
122    
123        /**
124         * A ValueEncoder used to convert server-side objects (provided by the
125         * "source" parameter) into unique client-side strings (typically IDs) and
126         * back. Note: this parameter may be OMITTED if Tapestry is configured to
127         * provide a ValueEncoder automatically for the type of property bound to
128         * the "value" parameter. 
129         */
130        @Parameter(required = true, allowNull = false)
131        private ValueEncoder<Object> encoder;
132    
133        @InjectComponent
134        private ClientElement rowInjector;
135    
136        @InjectComponent
137        private FormFragment fragment;
138    
139        @Inject
140        private Block ajaxResponse;
141    
142        @Inject
143        private ComponentResources resources;
144    
145        @Environmental
146        private FormSupport formSupport;
147    
148        @Environmental
149        private Heartbeat heartbeat;
150    
151        @Inject
152        private Environment environment;
153    
154        @Inject
155        private JavaScriptSupport jsSupport;
156    
157        private JSONArray addRowTriggers;
158    
159        private Iterator iterator;
160    
161        @Inject
162        private TypeCoercer typeCoercer;
163    
164        @Inject
165        private ComponentDefaultProvider defaultProvider;
166    
167        @Inject
168        private PageRenderQueue pageRenderQueue;
169    
170        private boolean renderingInjector;
171    
172        ValueEncoder defaultEncoder()
173        {
174            return defaultProvider.defaultValueEncoder("value", resources);
175        }
176    
177        private final AjaxFormLoopContext formLoopContext = new AjaxFormLoopContext()
178        {
179            public void addAddRowTrigger(String clientId)
180            {
181                assert InternalUtils.isNonBlank(clientId);
182                addRowTriggers.put(clientId);
183            }
184    
185            private String currentFragmentId()
186            {
187                ClientElement element = renderingInjector ? rowInjector : fragment;
188    
189                return element.getClientId();
190            }
191    
192            public void addRemoveRowTrigger(String clientId)
193            {
194                Link link = resources.createEventLink("triggerRemoveRow", toClientValue());
195    
196                String asURI = link.toURI();
197    
198                JSONObject spec = new JSONObject();
199                spec.put("link", clientId);
200                spec.put("fragment", currentFragmentId());
201                spec.put("url", asURI);
202    
203                jsSupport.addInitializerCall("formLoopRemoveLink", spec);
204            }
205        };
206    
207        String defaultElement()
208        {
209            return resources.getElementName("div");
210        }
211    
212        /**
213         * Action for synchronizing the current element of the loop by recording its client value.
214         */
215        static class SyncValue implements ComponentAction<AjaxFormLoop>
216        {
217            private final String clientValue;
218    
219            public SyncValue(String clientValue)
220            {
221                this.clientValue = clientValue;
222            }
223    
224            public void execute(AjaxFormLoop component)
225            {
226                component.syncValue(clientValue);
227            }
228    
229            @Override
230            public String toString()
231            {
232                return String.format("AjaxFormLoop.SyncValue[%s]", clientValue);
233            }
234        }
235    
236        private static final ComponentAction<AjaxFormLoop> BEGIN_HEARTBEAT = new ComponentAction<AjaxFormLoop>()
237        {
238            public void execute(AjaxFormLoop component)
239            {
240                component.beginHeartbeat();
241            }
242    
243            @Override
244            public String toString()
245            {
246                return "AjaxFormLoop.BeginHeartbeat";
247            }
248        };
249    
250        @Property(write = false)
251        private final Renderable beginHeartbeat = new Renderable()
252        {
253            public void render(MarkupWriter writer)
254            {
255                formSupport.storeAndExecute(AjaxFormLoop.this, BEGIN_HEARTBEAT);
256            }
257        };
258    
259        private static final ComponentAction<AjaxFormLoop> END_HEARTBEAT = new ComponentAction<AjaxFormLoop>()
260        {
261            public void execute(AjaxFormLoop component)
262            {
263                component.endHeartbeat();
264            }
265    
266            @Override
267            public String toString()
268            {
269                return "AjaxFormLoop.EndHeartbeat";
270            }
271        };
272    
273        @Property(write = false)
274        private final Renderable endHeartbeat = new Renderable()
275        {
276            public void render(MarkupWriter writer)
277            {
278                formSupport.storeAndExecute(AjaxFormLoop.this, END_HEARTBEAT);
279            }
280        };
281    
282        @Property(write = false)
283        private final Renderable beforeBody = new Renderable()
284        {
285            public void render(MarkupWriter writer)
286            {
287                beginHeartbeat();
288                syncCurrentValue();
289            }
290        };
291    
292        @Property(write = false)
293        private final Renderable afterBody = new Renderable()
294        {
295            public void render(MarkupWriter writer)
296            {
297                endHeartbeat();
298            }
299        };
300    
301        @SuppressWarnings(
302        { "unchecked" })
303        @Log
304        private void syncValue(String clientValue)
305        {
306            Object value = encoder.toValue(clientValue);
307    
308            if (value == null)
309                throw new RuntimeException(String.format(
310                        "Unable to convert client value '%s' back into a server-side object.", clientValue));
311    
312            this.value = value;
313        }
314    
315        @Property(write = false)
316        private final Renderable syncValue = new Renderable()
317        {
318            public void render(MarkupWriter writer)
319            {
320                syncCurrentValue();
321            }
322        };
323    
324        private void syncCurrentValue()
325        {
326            String id = toClientValue();
327    
328            // Add the command that restores value from the value clientValue,
329            // when the form is submitted.
330    
331            formSupport.store(this, new SyncValue(id));
332        }
333    
334        /**
335         * Uses the {@link org.apache.tapestry5.ValueEncoder} to convert the current server-side value to a client-side
336         * value.
337         */
338        @SuppressWarnings(
339        { "unchecked" })
340        private String toClientValue()
341        {
342            return encoder.toClient(value);
343        }
344    
345        void setupRender()
346        {
347            addRowTriggers = new JSONArray();
348    
349            pushContext();
350    
351            iterator = source == null ? Collections.EMPTY_LIST.iterator() : source.iterator();
352    
353            renderingInjector = false;
354        }
355    
356        private void pushContext()
357        {
358            environment.push(AjaxFormLoopContext.class, formLoopContext);
359        }
360    
361        boolean beginRender(MarkupWriter writer)
362        {
363            if (!iterator.hasNext())
364                return false;
365    
366            value = iterator.next();
367    
368            return true; // Render body, etc.
369        }
370    
371        Object afterRender(MarkupWriter writer)
372        {
373            // When out of source items to render, switch over to the addRow block (either the default,
374            // or from the addRow parameter) before proceeding to cleanup render.
375    
376            if (!iterator.hasNext())
377            {
378                renderingInjector = true;
379                return tail;
380            }
381    
382            // There's more to come, loop back to begin render.
383    
384            return false;
385        }
386    
387        void cleanupRender()
388        {
389            popContext();
390    
391            JSONObject spec = new JSONObject();
392    
393            spec.put("rowInjector", rowInjector.getClientId());
394            spec.put("addRowTriggers", addRowTriggers);
395    
396            jsSupport.addInitializerCall("ajaxFormLoop", spec);
397        }
398    
399        private void popContext()
400        {
401            environment.pop(AjaxFormLoopContext.class);
402        }
403    
404        /**
405         * When the action event arrives from the FormInjector, we fire our own event, "addRow" to tell the container to add
406         * a new row, and to return that new entity for rendering.
407         */
408        @Log
409        Object onActionFromRowInjector(EventContext context)
410        {
411            ComponentEventCallback callback = new ComponentEventCallback()
412            {
413                public boolean handleResult(Object result)
414                {
415                    value = result;
416    
417                    return true;
418                }
419            };
420    
421            resources.triggerContextEvent(EventConstants.ADD_ROW, context, callback);
422    
423            if (value == null)
424                throw new IllegalArgumentException(String.format(
425                        "Event handler for event 'addRow' from %s should have returned a non-null value.",
426                        resources.getCompleteId()));
427    
428            renderingInjector = true;
429    
430            pageRenderQueue.addPartialMarkupRendererFilter(new PartialMarkupRendererFilter()
431            {
432                public void renderMarkup(MarkupWriter writer, JSONObject reply, PartialMarkupRenderer renderer)
433                {
434                    pushContext();
435    
436                    renderer.renderMarkup(writer, reply);
437    
438                    popContext();
439                }
440            });
441    
442            return ajaxResponse;
443        }
444    
445        @Log
446        Object onTriggerRemoveRow(String rowId)
447        {
448            Object value = encoder.toValue(rowId);
449    
450            resources.triggerEvent(EventConstants.REMOVE_ROW, new Object[]
451            { value }, null);
452    
453            return new JSONObject();
454        }
455    
456        private void beginHeartbeat()
457        {
458            heartbeat.begin();
459        }
460    
461        private void endHeartbeat()
462        {
463            heartbeat.end();
464        }
465    }