    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}