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 }