001 // Copyright 2007, 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 org.apache.tapestry5.*;
018 import org.apache.tapestry5.annotations.Environmental;
019 import org.apache.tapestry5.annotations.Import;
020 import org.apache.tapestry5.annotations.Parameter;
021 import org.apache.tapestry5.annotations.Property;
022 import org.apache.tapestry5.corelib.base.AbstractField;
023 import org.apache.tapestry5.internal.util.SelectModelRenderer;
024 import org.apache.tapestry5.ioc.annotations.Inject;
025 import org.apache.tapestry5.ioc.annotations.Symbol;
026 import org.apache.tapestry5.ioc.internal.util.CollectionFactory;
027 import org.apache.tapestry5.json.JSONArray;
028 import org.apache.tapestry5.services.ComponentDefaultProvider;
029 import org.apache.tapestry5.services.Request;
030 import org.apache.tapestry5.services.javascript.JavaScriptSupport;
031
032 import java.util.Collections;
033 import java.util.List;
034 import java.util.Map;
035 import java.util.Set;
036
037 import static org.apache.tapestry5.ioc.internal.util.CollectionFactory.newList;
038 import static org.apache.tapestry5.ioc.internal.util.CollectionFactory.newSet;
039
040 /**
041 * Multiple selection component. Generates a UI consisting of two <select> elements configured for multiple
042 * selection; the one on the left is the list of "available" elements, the one on the right is "selected". Elements can
043 * be moved between the lists by clicking a button, or double clicking an option (and eventually, via drag and drop).
044 * <p/>
045 * The items in the available list are kept ordered as per {@link SelectModel} order. When items are moved from the
046 * selected list to the available list, they items are inserted back into their proper positions.
047 * <p/>
048 * The Palette may operate in normal or re-orderable mode, controlled by the reorder parameter.
049 * <p/>
050 * In normal mode, the items in the selected list are kept in the same "natural" order as the items in the available
051 * list.
052 * <p/>
053 * In re-order mode, items moved to the selected list are simply added to the bottom of the list. In addition, two extra
054 * buttons appear to move items up and down within the selected list.
055 * <p/>
056 * Much of the look and feel is driven by CSS, the default Tapestry CSS is used to set up the columns, etc. By default,
057 * the <select> element's widths are 200px, and it is common to override this to a specific value:
058 * <p/>
059 * <p/>
060 * <pre>
061 * <style>
062 * DIV.t-palette SELECT { width: 300px; }
063 * </style>
064 * </pre>
065 * <p/>
066 * You'll want to ensure that both <select> in each column is the same width, otherwise the display will update
067 * poorly as options are moved from one column to the other.
068 * <p/>
069 * Option groups within the {@link SelectModel} will be rendered, but are not supported by many browsers, and are not
070 * fully handled on the client side.
071 * <p/>
072 * For an alternative component that can be used for similar purposes, see
073 * {@link Checklist}.
074 *
075 * @tapestrydoc
076 * @see Form
077 * @see Select
078 */
079 @Import(library = "palette.js")
080 public class Palette extends AbstractField
081 {
082 // These all started as anonymous inner classes, and were refactored out to here.
083 // I was chasing down one of those perplexing bytecode errors.
084
085 private final class AvailableRenderer implements Renderable
086 {
087 public void render(MarkupWriter writer)
088 {
089 writer.element("select", "id", getClientId() + "-avail", "multiple", "multiple", "size", getSize(), "name",
090 getControlName() + "-avail");
091
092 writeDisabled(writer, isDisabled());
093
094 for (Runnable r : availableOptions)
095 r.run();
096
097 writer.end();
098 }
099 }
100
101 private final class OptionGroupEnd implements Runnable
102 {
103 private final OptionGroupModel model;
104
105 private OptionGroupEnd(OptionGroupModel model)
106 {
107 this.model = model;
108 }
109
110 public void run()
111 {
112 renderer.endOptionGroup(model);
113 }
114 }
115
116 private final class OptionGroupStart implements Runnable
117 {
118 private final OptionGroupModel model;
119
120 private OptionGroupStart(OptionGroupModel model)
121 {
122 this.model = model;
123 }
124
125 public void run()
126 {
127 renderer.beginOptionGroup(model);
128 }
129 }
130
131 private final class RenderOption implements Runnable
132 {
133 private final OptionModel model;
134
135 private RenderOption(OptionModel model)
136 {
137 this.model = model;
138 }
139
140 public void run()
141 {
142 renderer.option(model);
143 }
144 }
145
146 private final class SelectedRenderer implements Renderable
147 {
148 public void render(MarkupWriter writer)
149 {
150 writer.element("select", "id", getClientId(), "multiple", "multiple", "size", getSize(), "name",
151 getControlName());
152
153 writeDisabled(writer, isDisabled());
154
155 putPropertyNameIntoBeanValidationContext("selected");
156
157 Palette.this.validate.render(writer);
158
159 removePropertyNameFromBeanValidationContext();
160
161 for (Object value : getSelected())
162 {
163 OptionModel model = valueToOptionModel.get(value);
164
165 renderer.option(model);
166 }
167
168 writer.end();
169 }
170 }
171
172 /**
173 * List of Runnable commands to render the available options.
174 */
175 private List<Runnable> availableOptions;
176
177 /**
178 * The image to use for the deselect button (the default is a left pointing arrow).
179 */
180 @Parameter(value = "asset:deselect.png")
181 @Property(write = false)
182 private Asset deselect;
183
184 /**
185 * A ValueEncoder used to convert server-side objects (provided from the
186 * "source" parameter) into unique client-side strings (typically IDs) and
187 * back. Note: this component does NOT support ValueEncoders configured to
188 * be provided automatically by Tapestry.
189 */
190 @Parameter(required = true, allowNull = false)
191 private ValueEncoder<Object> encoder;
192
193 /**
194 * Model used to define the values and labels used when rendering.
195 */
196 @Parameter(required = true, allowNull = false)
197 private SelectModel model;
198
199 /**
200 * Allows the title text for the available column (on the left) to be modified. As this is a Block, it can contain
201 * conditionals and components. The default is the text "Available".
202 */
203 @Property(write = false)
204 @Parameter(required = true, allowNull = false, value = "message:available-label", defaultPrefix = BindingConstants.LITERAL)
205 private Block availableLabel;
206
207 /**
208 * Allows the title text for the selected column (on the right) to be modified. As this is a Block, it can contain
209 * conditionals and components. The default is the text "Available".
210 */
211 @Property(write = false)
212 @Parameter(required = true, allowNull = false, value = "message:selected-label", defaultPrefix = BindingConstants.LITERAL)
213 private Block selectedLabel;
214
215 /**
216 * The image to use for the move down button (the default is a downward pointing arrow).
217 */
218 @Parameter(value = "asset:move_down.png")
219 @Property(write = false)
220 private Asset moveDown;
221
222 /**
223 * The image to use for the move up button (the default is an upward pointing arrow).
224 */
225 @Parameter(value = "asset:move_up.png")
226 @Property(write = false)
227 private Asset moveUp;
228
229 /**
230 * Used to include scripting code in the rendered page.
231 */
232 @Environmental
233 private JavaScriptSupport javascriptSupport;
234
235 @Environmental
236 private ValidationTracker tracker;
237
238 /**
239 * Needed to access query parameters when processing form submission.
240 */
241 @Inject
242 private Request request;
243
244 @Inject
245 private ComponentDefaultProvider defaultProvider;
246
247 @Inject
248 private ComponentResources componentResources;
249
250 @Inject
251 private FieldValidationSupport fieldValidationSupport;
252
253 private SelectModelRenderer renderer;
254
255 /**
256 * The image to use for the select button (the default is a right pointing arrow).
257 */
258 @Parameter(value = "asset:select.png")
259 @Property(write = false)
260 private Asset select;
261
262 /**
263 * The list of selected values from the {@link org.apache.tapestry5.SelectModel}. This will be updated when the form
264 * is submitted. If the value for the parameter is null, a new list will be created, otherwise the existing list
265 * will be cleared. If unbound, defaults to a property of the container matching this component's id.
266 */
267 @Parameter(required = true, autoconnect = true)
268 private List<Object> selected;
269
270 /**
271 * If true, then additional buttons are provided on the client-side to allow for re-ordering of the values.
272 */
273 @Parameter("false")
274 @Property(write = false)
275 private boolean reorder;
276
277 /**
278 * Used during rendering to identify the options corresponding to selected values (from the selected parameter), in
279 * the order they should be displayed on the page.
280 */
281 private List<OptionModel> selectedOptions;
282
283 private Map<Object, OptionModel> valueToOptionModel;
284
285 /**
286 * Number of rows to display.
287 */
288 @Parameter(value = BindingConstants.SYMBOL + ":" + ComponentParameterConstants.PALETTE_ROWS_SIZE)
289 private int size;
290
291 /**
292 * The object that will perform input validation. The validate binding prefix is generally used to provide
293 * this object in a declarative fashion.
294 *
295 * @since 5.2.0
296 */
297 @Parameter(defaultPrefix = BindingConstants.VALIDATE)
298 @SuppressWarnings("unchecked")
299 private FieldValidator<Object> validate;
300
301 @Inject
302 @Symbol(SymbolConstants.COMPACT_JSON)
303 private boolean compactJSON;
304
305 /**
306 * The natural order of elements, in terms of their client ids.
307 */
308 private List<String> naturalOrder;
309
310 public Renderable getAvailableRenderer()
311 {
312 return new AvailableRenderer();
313 }
314
315 public Renderable getSelectedRenderer()
316 {
317 return new SelectedRenderer();
318 }
319
320 @Override
321 protected void processSubmission(String controlName)
322 {
323 String parameterValue = request.getParameter(controlName + "-values");
324
325 this.tracker.recordInput(this, parameterValue);
326
327 JSONArray values = new JSONArray(parameterValue);
328
329 // Use a couple of local variables to cut down on access via bindings
330
331 List<Object> selected = this.selected;
332
333 if (selected == null)
334 selected = newList();
335 else
336 selected.clear();
337
338 ValueEncoder encoder = this.encoder;
339
340 int count = values.length();
341 for (int i = 0; i < count; i++)
342 {
343 String value = values.getString(i);
344
345 Object objectValue = encoder.toValue(value);
346
347 selected.add(objectValue);
348 }
349
350 putPropertyNameIntoBeanValidationContext("selected");
351
352 try
353 {
354 this.fieldValidationSupport.validate(selected, this.componentResources, this.validate);
355
356 this.selected = selected;
357 } catch (final ValidationException e)
358 {
359 this.tracker.recordError(this, e.getMessage());
360 }
361
362 removePropertyNameFromBeanValidationContext();
363 }
364
365 private void writeDisabled(MarkupWriter writer, boolean disabled)
366 {
367 if (disabled)
368 writer.attributes("disabled", "disabled");
369 }
370
371 void beginRender(MarkupWriter writer)
372 {
373 JSONArray selectedValues = new JSONArray();
374
375 for (OptionModel selected : selectedOptions)
376 {
377
378 Object value = selected.getValue();
379 String clientValue = encoder.toClient(value);
380
381 selectedValues.put(clientValue);
382 }
383
384 JSONArray naturalOrder = new JSONArray();
385
386 for (String value : this.naturalOrder)
387 {
388 naturalOrder.put(value);
389 }
390
391 String clientId = getClientId();
392
393 javascriptSupport.addScript("new Tapestry.Palette('%s', %s, %s);", clientId, reorder, naturalOrder
394 .toString(compactJSON));
395
396 writer.element("input", "type", "hidden", "id", clientId + "-values", "name", getControlName() + "-values",
397 "value", selectedValues);
398 writer.end();
399 }
400
401 /**
402 * Prevent the body from rendering.
403 */
404 boolean beforeRenderBody()
405 {
406 return false;
407 }
408
409 @SuppressWarnings("unchecked")
410 void setupRender(MarkupWriter writer)
411 {
412 valueToOptionModel = CollectionFactory.newMap();
413 availableOptions = CollectionFactory.newList();
414 selectedOptions = CollectionFactory.newList();
415 naturalOrder = CollectionFactory.newList();
416 renderer = new SelectModelRenderer(writer, encoder);
417
418 final Set selectedSet = newSet(getSelected());
419
420 SelectModelVisitor visitor = new SelectModelVisitor()
421 {
422 public void beginOptionGroup(OptionGroupModel groupModel)
423 {
424 availableOptions.add(new OptionGroupStart(groupModel));
425 }
426
427 public void endOptionGroup(OptionGroupModel groupModel)
428 {
429 availableOptions.add(new OptionGroupEnd(groupModel));
430 }
431
432 public void option(OptionModel optionModel)
433 {
434 Object value = optionModel.getValue();
435
436 boolean isSelected = selectedSet.contains(value);
437
438 String clientValue = toClient(value);
439
440 naturalOrder.add(clientValue);
441
442 if (isSelected)
443 {
444 selectedOptions.add(optionModel);
445 valueToOptionModel.put(value, optionModel);
446 return;
447 }
448
449 availableOptions.add(new RenderOption(optionModel));
450 }
451 };
452
453 model.visit(visitor);
454 }
455
456 /**
457 * Computes a default value for the "validate" parameter using
458 * {@link org.apache.tapestry5.services.FieldValidatorDefaultSource}.
459 */
460 Binding defaultValidate()
461 {
462 return this.defaultProvider.defaultValidatorBinding("selected", this.componentResources);
463 }
464
465 // Avoids a strange Javassist bytecode error, c'est lavie!
466 int getSize()
467 {
468 return size;
469 }
470
471 String toClient(Object value)
472 {
473 return encoder.toClient(value);
474 }
475
476 List<Object> getSelected()
477 {
478 if (selected == null)
479 return Collections.emptyList();
480
481 return selected;
482 }
483
484 @Override
485 public boolean isRequired()
486 {
487 return validate.isRequired();
488 }
489 }