    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}