| // Copyright 2008, 2009, 2010 The Apache Software Foundation |
| // |
| // 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 java.util.Collections; |
| import java.util.Iterator; |
| |
| import org.apache.tapestry5.*; |
| import org.apache.tapestry5.annotations.Environmental; |
| import org.apache.tapestry5.annotations.Events; |
| import org.apache.tapestry5.annotations.InjectComponent; |
| import org.apache.tapestry5.annotations.Log; |
| import org.apache.tapestry5.annotations.Parameter; |
| import org.apache.tapestry5.annotations.Property; |
| import org.apache.tapestry5.corelib.internal.AjaxFormLoopContext; |
| import org.apache.tapestry5.internal.services.PageRenderQueue; |
| import org.apache.tapestry5.ioc.annotations.Inject; |
| import org.apache.tapestry5.ioc.internal.util.InternalUtils; |
| import org.apache.tapestry5.ioc.services.TypeCoercer; |
| import org.apache.tapestry5.json.JSONArray; |
| import org.apache.tapestry5.json.JSONObject; |
| import org.apache.tapestry5.services.ComponentDefaultProvider; |
| import org.apache.tapestry5.services.Environment; |
| import org.apache.tapestry5.services.FormSupport; |
| import org.apache.tapestry5.services.Heartbeat; |
| import org.apache.tapestry5.services.PartialMarkupRenderer; |
| import org.apache.tapestry5.services.PartialMarkupRendererFilter; |
| import org.apache.tapestry5.services.javascript.JavaScriptSupport; |
| |
| /** |
| * 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. Expects that the values being iterated over are |
| * entities that can be identified via a {@link org.apache.tapestry5.ValueEncoder}. |
| * <p/> |
| * Works with {@link org.apache.tapestry5.corelib.components.AddRowLink} and |
| * {@link org.apache.tapestry5.corelib.components.RemoveRowLink} components. |
| * <p/> |
| * The addRow event will receive the context specified by the context parameter. |
| * <p/> |
| * The removeRow event will receive the client-side value for the row being iterated. |
| * |
| * @see EventConstants#ADD_ROW |
| * @see EventConstants#REMOVE_ROW |
| */ |
| @Events( |
| { EventConstants.ADD_ROW, EventConstants.REMOVE_ROW }) |
| 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 is used with the {@link FormInjector} component, when adding a new row to the loop. Leaving as |
| * null uses the default function, "highlight". |
| */ |
| @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. |
| */ |
| @Parameter |
| private Object[] context; |
| |
| /** |
| * A block to render after the loop as the body of the {@link org.apache.tapestry5.corelib.components.FormInjector}. |
| * 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; |
| |
| /** |
| * Required parameter used to convert server-side objects (provided from the source) into client-side ids and back. |
| * A default encoder may be calculated from the type of property bound to the value parameter. |
| */ |
| @Parameter(required = true, allowNull = false) |
| private ValueEncoder<Object> encoder; |
| |
| @InjectComponent |
| private ClientElement rowInjector; |
| |
| @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 JSONArray addRowTriggers; |
| |
| private Iterator iterator; |
| |
| @Inject |
| private TypeCoercer typeCoercer; |
| |
| @Inject |
| private ComponentDefaultProvider defaultProvider; |
| |
| @Inject |
| private PageRenderQueue pageRenderQueue; |
| |
| private boolean renderingInjector; |
| |
| ValueEncoder defaultEncoder() |
| { |
| return defaultProvider.defaultValueEncoder("value", resources); |
| } |
| |
| private final AjaxFormLoopContext formLoopContext = new AjaxFormLoopContext() |
| { |
| public void addAddRowTrigger(String clientId) |
| { |
| assert InternalUtils.isNonBlank(clientId); |
| addRowTriggers.put(clientId); |
| } |
| |
| private String currentFragmentId() |
| { |
| ClientElement element = renderingInjector ? rowInjector : fragment; |
| |
| return element.getClientId(); |
| } |
| |
| public void addRemoveRowTrigger(String clientId) |
| { |
| Link link = resources.createEventLink("triggerRemoveRow", toClientValue()); |
| |
| String asURI = link.toURI(); |
| |
| JSONObject spec = new JSONObject(); |
| spec.put("link", clientId); |
| spec.put("fragment", currentFragmentId()); |
| spec.put("url", asURI); |
| |
| jsSupport.addInitializerCall("formLoopRemoveLink", spec); |
| } |
| }; |
| |
| 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" }) |
| @Log |
| 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() |
| { |
| addRowTriggers = new JSONArray(); |
| |
| pushContext(); |
| |
| iterator = source == null ? Collections.EMPTY_LIST.iterator() : source.iterator(); |
| |
| renderingInjector = false; |
| } |
| |
| private void pushContext() |
| { |
| environment.push(AjaxFormLoopContext.class, formLoopContext); |
| } |
| |
| boolean beginRender(MarkupWriter writer) |
| { |
| if (!iterator.hasNext()) |
| return false; |
| |
| value = iterator.next(); |
| |
| return true; // Render body, etc. |
| } |
| |
| 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()) |
| { |
| renderingInjector = true; |
| return tail; |
| } |
| |
| // There's more to come, loop back to begin render. |
| |
| return false; |
| } |
| |
| void cleanupRender() |
| { |
| popContext(); |
| |
| JSONObject spec = new JSONObject(); |
| |
| spec.put("rowInjector", rowInjector.getClientId()); |
| spec.put("addRowTriggers", addRowTriggers); |
| |
| jsSupport.addInitializerCall("ajaxFormLoop", spec); |
| } |
| |
| private void popContext() |
| { |
| environment.pop(AjaxFormLoopContext.class); |
| } |
| |
| /** |
| * When the action event arrives from the FormInjector, we fire our own event, "addRow" to tell the container to add |
| * a new row, and to return that new entity for rendering. |
| */ |
| @Log |
| Object onActionFromRowInjector(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())); |
| |
| renderingInjector = true; |
| |
| pageRenderQueue.addPartialMarkupRendererFilter(new PartialMarkupRendererFilter() |
| { |
| public void renderMarkup(MarkupWriter writer, JSONObject reply, PartialMarkupRenderer renderer) |
| { |
| pushContext(); |
| |
| renderer.renderMarkup(writer, reply); |
| |
| popContext(); |
| } |
| }); |
| |
| return ajaxResponse; |
| } |
| |
| @Log |
| Object onTriggerRemoveRow(String rowId) |
| { |
| Object value = encoder.toValue(rowId); |
| |
| resources.triggerEvent(EventConstants.REMOVE_ROW, new Object[] |
| { value }, null); |
| |
| return new JSONObject(); |
| } |
| |
| private void beginHeartbeat() |
| { |
| heartbeat.begin(); |
| } |
| |
| private void endHeartbeat() |
| { |
| heartbeat.end(); |
| } |
| } |