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