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