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