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