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 }