blob: c87c9e1dfec5dd9c7cb989e93bb4eee25436e867 [file] [log] [blame]
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package org.apache.tapestry5.corelib.components;
import org.apache.tapestry5.*;
import org.apache.tapestry5.annotations.*;
import org.apache.tapestry5.corelib.internal.AjaxFormLoopContext;
import org.apache.tapestry5.dom.Element;
import org.apache.tapestry5.internal.services.RequestConstants;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.ioc.services.TypeCoercer;
import org.apache.tapestry5.json.JSONObject;
import org.apache.tapestry5.services.*;
import org.apache.tapestry5.services.ajax.AjaxResponseRenderer;
import org.apache.tapestry5.services.compatibility.DeprecationWarning;
import org.apache.tapestry5.services.javascript.JavaScriptSupport;
import java.util.Collections;
import java.util.Iterator;
/**
* A special form of the {@link org.apache.tapestry5.corelib.components.Loop}
* component that adds Ajax support to handle adding new rows and removing
* existing rows dynamically.
*
* This component expects that the values being iterated over are entities that
* can be identified via a {@link org.apache.tapestry5.ValueEncoder}, therefore
* you must either bind the "encoder" parameter to a ValueEncoder or use an
* entity type for the "value" parameter for which Tapestry can provide a
* ValueEncoder automatically.
*
* Works with {@link org.apache.tapestry5.corelib.components.AddRowLink} and
* {@link org.apache.tapestry5.corelib.components.RemoveRowLink} components.
*
* The addRow event will receive the context specified by the context parameter.
*
* The removeRow event will receive the client-side value for the row being iterated.
*
* @tapestrydoc
* @see EventConstants#ADD_ROW
* @see EventConstants#REMOVE_ROW
* @see AddRowLink
* @see RemoveRowLink
* @see Loop
*/
@Events(
{EventConstants.ADD_ROW, EventConstants.REMOVE_ROW})
@Import(module = "t5/core/ajaxformloop")
@SupportsInformalParameters
public class AjaxFormLoop
{
/**
* The element to render for each iteration of the loop. The default comes from the template, or "div" if the
* template did not specify an element.
*/
@Parameter(defaultPrefix = BindingConstants.LITERAL)
@Property(write = false)
private String element;
/**
* The objects to iterate over (passed to the internal Loop component).
*/
@Parameter(required = true, autoconnect = true)
private Iterable source;
/**
* The current value from the source.
*/
@Parameter(required = true)
private Object value;
/**
* Name of a function on the client-side Tapestry.ElementEffect object that is invoked to make added content
* visible. This was used by the FormInjector component (remove in 5.4), when adding a new row to the loop. Leaving as
* null uses the default function, "highlight".
*
* @deprecated Deprecated in 5.4 with no replacement.
*/
@Parameter(defaultPrefix = BindingConstants.LITERAL)
private String show;
/**
* The context for the form loop (optional parameter). This list of values will be converted into strings and
* included in the URI. The strings will be coerced back to whatever their values are and made available to event
* handler methods. Note that the context is only encoded and available to the {@linkplain EventConstants#ADD_ROW addRow}
* event; for the {@linkplain EventConstants#REMOVE_ROW} event, the context passed to event handlers
* is simply the decoded value for the row that is to be removed.
*/
@Parameter
private Object[] context;
/**
* A block to render after the loo
* This typically contains a {@link org.apache.tapestry5.corelib.components.AddRowLink}.
*/
@Parameter(value = "block:defaultAddRow", defaultPrefix = BindingConstants.LITERAL)
@Property(write = false)
private Block addRow;
/**
* The block that contains the form injector (it is rendered last, as the "tail" of the AjaxFormLoop). This, in
* turn, references the addRow block (from a parameter, or a default).
*/
@Inject
private Block tail;
/**
* A ValueEncoder used to convert server-side objects (provided by the
* "source" parameter) into unique client-side strings (typically IDs) and
* back. Note: this parameter may be OMITTED if Tapestry is configured to
* provide a ValueEncoder automatically for the type of property bound to
* the "value" parameter.
*/
@Parameter(required = true, allowNull = false)
private ValueEncoder<Object> encoder;
@InjectComponent
private FormFragment fragment;
@Inject
private Block ajaxResponse;
@Inject
private ComponentResources resources;
@Environmental
private FormSupport formSupport;
@Environmental
private Heartbeat heartbeat;
@Inject
private Environment environment;
@Inject
private JavaScriptSupport jsSupport;
private Iterator iterator;
private Element wrapper;
@Inject
private TypeCoercer typeCoercer;
@Inject
private ComponentDefaultProvider defaultProvider;
@Inject
private AjaxResponseRenderer ajaxResponseRenderer;
@Inject
private DeprecationWarning deprecationWarning;
void pageLoaded()
{
deprecationWarning.ignoredComponentParameters(resources, "show");
}
ValueEncoder defaultEncoder()
{
return defaultProvider.defaultValueEncoder("value", resources);
}
private final AjaxFormLoopContext formLoopContext = new AjaxFormLoopContext()
{
public String encodedRowValue()
{
return encoder.toClient(value);
}
};
String defaultElement()
{
return resources.getElementName("div");
}
/**
* Action for synchronizing the current element of the loop by recording its client value.
*/
static class SyncValue implements ComponentAction<AjaxFormLoop>
{
private final String clientValue;
public SyncValue(String clientValue)
{
this.clientValue = clientValue;
}
public void execute(AjaxFormLoop component)
{
component.syncValue(clientValue);
}
@Override
public String toString()
{
return String.format("AjaxFormLoop.SyncValue[%s]", clientValue);
}
}
private static final ComponentAction<AjaxFormLoop> BEGIN_HEARTBEAT = new ComponentAction<AjaxFormLoop>()
{
public void execute(AjaxFormLoop component)
{
component.beginHeartbeat();
}
@Override
public String toString()
{
return "AjaxFormLoop.BeginHeartbeat";
}
};
@Property(write = false)
private final Renderable beginHeartbeat = new Renderable()
{
public void render(MarkupWriter writer)
{
formSupport.storeAndExecute(AjaxFormLoop.this, BEGIN_HEARTBEAT);
}
};
private static final ComponentAction<AjaxFormLoop> END_HEARTBEAT = new ComponentAction<AjaxFormLoop>()
{
public void execute(AjaxFormLoop component)
{
component.endHeartbeat();
}
@Override
public String toString()
{
return "AjaxFormLoop.EndHeartbeat";
}
};
@Property(write = false)
private final Renderable endHeartbeat = new Renderable()
{
public void render(MarkupWriter writer)
{
formSupport.storeAndExecute(AjaxFormLoop.this, END_HEARTBEAT);
}
};
@Property(write = false)
private final Renderable beforeBody = new Renderable()
{
public void render(MarkupWriter writer)
{
beginHeartbeat();
syncCurrentValue();
}
};
@Property(write = false)
private final Renderable afterBody = new Renderable()
{
public void render(MarkupWriter writer)
{
endHeartbeat();
}
};
@SuppressWarnings(
{"unchecked"})
private void syncValue(String clientValue)
{
Object value = encoder.toValue(clientValue);
if (value == null)
throw new RuntimeException(String.format(
"Unable to convert client value '%s' back into a server-side object.", clientValue));
this.value = value;
}
@Property(write = false)
private final Renderable syncValue = new Renderable()
{
public void render(MarkupWriter writer)
{
syncCurrentValue();
}
};
private void syncCurrentValue()
{
String id = toClientValue();
// Add the command that restores value from the value clientValue,
// when the form is submitted.
formSupport.store(this, new SyncValue(id));
}
/**
* Uses the {@link org.apache.tapestry5.ValueEncoder} to convert the current server-side value to a client-side
* value.
*/
@SuppressWarnings(
{"unchecked"})
private String toClientValue()
{
return encoder.toClient(value);
}
void setupRender(MarkupWriter writer)
{
pushContext();
iterator = source == null ? Collections.EMPTY_LIST.iterator() : source.iterator();
Link removeRowLink = resources.createEventLink("triggerRemoveRow", context);
Link injectRowLink = resources.createEventLink("injectRow", context);
injectRowLink.addParameter(RequestConstants.FORM_CLIENTID_PARAMETER, formSupport.getClientId());
injectRowLink.addParameter(RequestConstants.FORM_COMPONENTID_PARAMETER, formSupport.getFormComponentId());
// Fix for TAP5-227 - AjaxFormLoop dont work well inside a table tag
Element element = writer.getElement();
this.wrapper = element.getAttribute("data-container-type") != null
|| element.getAttribute("data-remove-row-url") != null
|| element.getAttribute("data-inject-row-url") != null ? writer.element("div") : null;
writer.attributes("data-container-type", "core/AjaxFormLoop",
"data-remove-row-url", removeRowLink,
"data-inject-row-url", injectRowLink);
}
private void pushContext()
{
environment.push(AjaxFormLoopContext.class, formLoopContext);
}
boolean beginRender(MarkupWriter writer)
{
if (!iterator.hasNext())
{
return false;
}
value = iterator.next();
// Return true: render the body for this value; that ends up being a form-fragment.
return true;
}
Object afterRender(MarkupWriter writer)
{
// When out of source items to render, switch over to the addRow block (either the default,
// or from the addRow parameter) before proceeding to cleanup render.
if (!iterator.hasNext())
{
return tail;
}
// There's more to come, loop back to begin render.
return false;
}
// Capture BeginRender event from the formfragment or the addRowWrapper, and render the informal parameters
// into the row.
boolean onBeginRender(MarkupWriter writer)
{
resources.renderInformalParameters(writer);
return true;
}
void cleanupRender(MarkupWriter writer)
{
if (wrapper != null)
writer.end();
popContext();
}
private void popContext()
{
environment.pop(AjaxFormLoopContext.class);
}
Object onInjectRow(EventContext context)
{
ComponentEventCallback callback = new ComponentEventCallback()
{
public boolean handleResult(Object result)
{
value = result;
return true;
}
};
resources.triggerContextEvent(EventConstants.ADD_ROW, context, callback);
if (value == null)
throw new IllegalArgumentException(String.format(
"Event handler for event 'addRow' from %s should have returned a non-null value.",
resources.getCompleteId()));
ajaxResponseRenderer.addFilter(new PartialMarkupRendererFilter()
{
public void renderMarkup(MarkupWriter writer, JSONObject reply, PartialMarkupRenderer renderer)
{
pushContext();
renderer.renderMarkup(writer, reply);
popContext();
}
});
return ajaxResponse;
}
Object onTriggerRemoveRow(@RequestParameter("t:rowvalue") String encodedValue)
{
syncValue(encodedValue);
resources.triggerEvent(EventConstants.REMOVE_ROW, new Object[]
{value}, null);
return new JSONObject();
}
private void beginHeartbeat()
{
heartbeat.begin();
}
private void endHeartbeat()
{
heartbeat.end();
}
}