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}