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