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