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
015package org.apache.tapestry5.corelib.components;
016
017import java.util.Collections;
018import java.util.Iterator;
019
020import org.apache.tapestry5.*;
021import org.apache.tapestry5.annotations.Environmental;
022import org.apache.tapestry5.annotations.Events;
023import org.apache.tapestry5.annotations.InjectComponent;
024import org.apache.tapestry5.annotations.Log;
025import org.apache.tapestry5.annotations.Parameter;
026import org.apache.tapestry5.annotations.Property;
027import org.apache.tapestry5.corelib.internal.AjaxFormLoopContext;
028import org.apache.tapestry5.internal.services.PageRenderQueue;
029import org.apache.tapestry5.ioc.annotations.Inject;
030import org.apache.tapestry5.ioc.internal.util.InternalUtils;
031import org.apache.tapestry5.ioc.services.TypeCoercer;
032import org.apache.tapestry5.json.JSONArray;
033import org.apache.tapestry5.json.JSONObject;
034import org.apache.tapestry5.services.ComponentDefaultProvider;
035import org.apache.tapestry5.services.Environment;
036import org.apache.tapestry5.services.FormSupport;
037import org.apache.tapestry5.services.Heartbeat;
038import org.apache.tapestry5.services.PartialMarkupRenderer;
039import org.apache.tapestry5.services.PartialMarkupRendererFilter;
040import 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 })
070public 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}