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