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