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