001// Copyright 2008, 2009, 2010, 2011, 2012 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 015package org.apache.tapestry5.corelib.components; 016 017import org.apache.tapestry5.*; 018import org.apache.tapestry5.annotations.*; 019import org.apache.tapestry5.corelib.internal.AjaxFormLoopContext; 020import org.apache.tapestry5.internal.services.RequestConstants; 021import org.apache.tapestry5.ioc.annotations.Inject; 022import org.apache.tapestry5.ioc.services.TypeCoercer; 023import org.apache.tapestry5.json.JSONObject; 024import org.apache.tapestry5.services.*; 025import org.apache.tapestry5.services.ajax.AjaxResponseRenderer; 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 * <p/> 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 * <p/> 042 * Works with {@link org.apache.tapestry5.corelib.components.AddRowLink} and 043 * {@link org.apache.tapestry5.corelib.components.RemoveRowLink} components. 044 * <p/> 045 * The addRow event will receive the context specified by the context parameter. 046 * <p/> 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 * @see FormInjector 056 */ 057@Events( 058 {EventConstants.ADD_ROW, EventConstants.REMOVE_ROW}) 059@Import(module = "t5/core/ajaxformloop") 060@SupportsInformalParameters 061public class AjaxFormLoop 062{ 063 /** 064 * The element to render for each iteration of the loop. The default comes from the template, or "div" if the 065 * template did not specify an element. 066 */ 067 @Parameter(defaultPrefix = BindingConstants.LITERAL) 068 @Property(write = false) 069 private String element; 070 071 /** 072 * The objects to iterate over (passed to the internal Loop component). 073 */ 074 @Parameter(required = true, autoconnect = true) 075 private Iterable source; 076 077 /** 078 * The current value from the source. 079 */ 080 @Parameter(required = true) 081 private Object value; 082 083 /** 084 * Name of a function on the client-side Tapestry.ElementEffect object that is invoked to make added content 085 * visible. This is used with the {@link FormInjector} component, when adding a new row to the loop. Leaving as 086 * null uses the default function, "highlight". 087 */ 088 @Parameter(defaultPrefix = BindingConstants.LITERAL) 089 private String show; 090 091 /** 092 * The context for the form loop (optional parameter). This list of values will be converted into strings and 093 * included in the URI. The strings will be coerced back to whatever their values are and made available to event 094 * handler methods. Note that the context is only encoded and available to the {@linkplain EventConstants#ADD_ROW addRow} 095 * event; for the {@linkplain EventConstants#REMOVE_ROW} event, the context passed to event handlers 096 * is simply the decoded value for the row that is to be removed. 097 */ 098 @Parameter 099 private Object[] context; 100 101 /** 102 * A block to render after the loop as the body of the {@link org.apache.tapestry5.corelib.components.FormInjector}. 103 * This typically contains a {@link org.apache.tapestry5.corelib.components.AddRowLink}. 104 */ 105 @Parameter(value = "block:defaultAddRow", defaultPrefix = BindingConstants.LITERAL) 106 @Property(write = false) 107 private Block addRow; 108 109 /** 110 * The block that contains the form injector (it is rendered last, as the "tail" of the AjaxFormLoop). This, in 111 * turn, references the addRow block (from a parameter, or a default). 112 */ 113 @Inject 114 private Block tail; 115 116 /** 117 * A ValueEncoder used to convert server-side objects (provided by the 118 * "source" parameter) into unique client-side strings (typically IDs) and 119 * back. Note: this parameter may be OMITTED if Tapestry is configured to 120 * provide a ValueEncoder automatically for the type of property bound to 121 * the "value" parameter. 122 */ 123 @Parameter(required = true, allowNull = false) 124 private ValueEncoder<Object> encoder; 125 126 @InjectComponent 127 private FormFragment fragment; 128 129 @Inject 130 private Block ajaxResponse; 131 132 @Inject 133 private ComponentResources resources; 134 135 @Environmental 136 private FormSupport formSupport; 137 138 @Environmental 139 private Heartbeat heartbeat; 140 141 @Inject 142 private Environment environment; 143 144 @Inject 145 private JavaScriptSupport jsSupport; 146 147 private Iterator iterator; 148 149 @Inject 150 private TypeCoercer typeCoercer; 151 152 @Inject 153 private ComponentDefaultProvider defaultProvider; 154 155 @Inject 156 private AjaxResponseRenderer ajaxResponseRenderer; 157 158 ValueEncoder defaultEncoder() 159 { 160 return defaultProvider.defaultValueEncoder("value", resources); 161 } 162 163 private final AjaxFormLoopContext formLoopContext = new AjaxFormLoopContext() 164 { 165 public String encodedRowValue() 166 { 167 return encoder.toClient(value); 168 } 169 }; 170 171 String defaultElement() 172 { 173 return resources.getElementName("div"); 174 } 175 176 /** 177 * Action for synchronizing the current element of the loop by recording its client value. 178 */ 179 static class SyncValue implements ComponentAction<AjaxFormLoop> 180 { 181 private final String clientValue; 182 183 public SyncValue(String clientValue) 184 { 185 this.clientValue = clientValue; 186 } 187 188 public void execute(AjaxFormLoop component) 189 { 190 component.syncValue(clientValue); 191 } 192 193 @Override 194 public String toString() 195 { 196 return String.format("AjaxFormLoop.SyncValue[%s]", clientValue); 197 } 198 } 199 200 private static final ComponentAction<AjaxFormLoop> BEGIN_HEARTBEAT = new ComponentAction<AjaxFormLoop>() 201 { 202 public void execute(AjaxFormLoop component) 203 { 204 component.beginHeartbeat(); 205 } 206 207 @Override 208 public String toString() 209 { 210 return "AjaxFormLoop.BeginHeartbeat"; 211 } 212 }; 213 214 @Property(write = false) 215 private final Renderable beginHeartbeat = new Renderable() 216 { 217 public void render(MarkupWriter writer) 218 { 219 formSupport.storeAndExecute(AjaxFormLoop.this, BEGIN_HEARTBEAT); 220 } 221 }; 222 223 private static final ComponentAction<AjaxFormLoop> END_HEARTBEAT = new ComponentAction<AjaxFormLoop>() 224 { 225 public void execute(AjaxFormLoop component) 226 { 227 component.endHeartbeat(); 228 } 229 230 @Override 231 public String toString() 232 { 233 return "AjaxFormLoop.EndHeartbeat"; 234 } 235 }; 236 237 @Property(write = false) 238 private final Renderable endHeartbeat = new Renderable() 239 { 240 public void render(MarkupWriter writer) 241 { 242 formSupport.storeAndExecute(AjaxFormLoop.this, END_HEARTBEAT); 243 } 244 }; 245 246 @Property(write = false) 247 private final Renderable beforeBody = new Renderable() 248 { 249 public void render(MarkupWriter writer) 250 { 251 beginHeartbeat(); 252 syncCurrentValue(); 253 } 254 }; 255 256 @Property(write = false) 257 private final Renderable afterBody = new Renderable() 258 { 259 public void render(MarkupWriter writer) 260 { 261 endHeartbeat(); 262 } 263 }; 264 265 @SuppressWarnings( 266 {"unchecked"}) 267 private void syncValue(String clientValue) 268 { 269 Object value = encoder.toValue(clientValue); 270 271 if (value == null) 272 throw new RuntimeException(String.format( 273 "Unable to convert client value '%s' back into a server-side object.", clientValue)); 274 275 this.value = value; 276 } 277 278 @Property(write = false) 279 private final Renderable syncValue = new Renderable() 280 { 281 public void render(MarkupWriter writer) 282 { 283 syncCurrentValue(); 284 } 285 }; 286 287 private void syncCurrentValue() 288 { 289 String id = toClientValue(); 290 291 // Add the command that restores value from the value clientValue, 292 // when the form is submitted. 293 294 formSupport.store(this, new SyncValue(id)); 295 } 296 297 /** 298 * Uses the {@link org.apache.tapestry5.ValueEncoder} to convert the current server-side value to a client-side 299 * value. 300 */ 301 @SuppressWarnings( 302 {"unchecked"}) 303 private String toClientValue() 304 { 305 return encoder.toClient(value); 306 } 307 308 void setupRender(MarkupWriter writer) 309 { 310 pushContext(); 311 312 iterator = source == null ? Collections.EMPTY_LIST.iterator() : source.iterator(); 313 314 Link removeRowLink = resources.createEventLink("triggerRemoveRow", context); 315 Link injectRowLink = resources.createEventLink("injectRow", context); 316 317 injectRowLink.addParameter(RequestConstants.FORM_CLIENTID_PARAMETER, formSupport.getClientId()); 318 injectRowLink.addParameter(RequestConstants.FORM_COMPONENTID_PARAMETER, formSupport.getFormComponentId()); 319 320 writer.element("div", 321 "data-container-type", "core/AjaxFormLoop", 322 "data-remove-row-url", removeRowLink, 323 "data-inject-row-url", injectRowLink); 324 } 325 326 private void pushContext() 327 { 328 environment.push(AjaxFormLoopContext.class, formLoopContext); 329 } 330 331 boolean beginRender(MarkupWriter writer) 332 { 333 if (!iterator.hasNext()) 334 { 335 return false; 336 } 337 338 value = iterator.next(); 339 340 // Return true: render the body for this value; that ends up being a form-fragment. 341 342 return true; 343 } 344 345 Object afterRender(MarkupWriter writer) 346 { 347 // When out of source items to render, switch over to the addRow block (either the default, 348 // or from the addRow parameter) before proceeding to cleanup render. 349 350 if (!iterator.hasNext()) 351 { 352 return tail; 353 } 354 355 // There's more to come, loop back to begin render. 356 357 return false; 358 } 359 360 // Capture BeginRender event from the formfragment or the addRowWrapper, and render the informal parameters 361 // into the row. 362 boolean onBeginRender(MarkupWriter writer) 363 { 364 resources.renderInformalParameters(writer); 365 366 return true; 367 } 368 369 void cleanupRender(MarkupWriter writer) 370 { 371 writer.end(); 372 373 popContext(); 374 } 375 376 private void popContext() 377 { 378 environment.pop(AjaxFormLoopContext.class); 379 } 380 381 Object onInjectRow(EventContext context) 382 { 383 ComponentEventCallback callback = new ComponentEventCallback() 384 { 385 public boolean handleResult(Object result) 386 { 387 value = result; 388 389 return true; 390 } 391 }; 392 393 resources.triggerContextEvent(EventConstants.ADD_ROW, context, callback); 394 395 if (value == null) 396 throw new IllegalArgumentException(String.format( 397 "Event handler for event 'addRow' from %s should have returned a non-null value.", 398 resources.getCompleteId())); 399 400 ajaxResponseRenderer.addFilter(new PartialMarkupRendererFilter() 401 { 402 public void renderMarkup(MarkupWriter writer, JSONObject reply, PartialMarkupRenderer renderer) 403 { 404 pushContext(); 405 406 renderer.renderMarkup(writer, reply); 407 408 popContext(); 409 } 410 }); 411 412 return ajaxResponse; 413 } 414 415 Object onTriggerRemoveRow(@RequestParameter("t:rowvalue") String encodedValue) 416 { 417 syncValue(encodedValue); 418 419 resources.triggerEvent(EventConstants.REMOVE_ROW, new Object[] 420 {value}, null); 421 422 return new JSONObject(); 423 } 424 425 private void beginHeartbeat() 426 { 427 heartbeat.begin(); 428 } 429 430 private void endHeartbeat() 431 { 432 heartbeat.end(); 433 } 434}