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
015package org.apache.tapestry5.corelib.components;
016
017import org.apache.tapestry5.*;
018import org.apache.tapestry5.annotations.Environmental;
019import org.apache.tapestry5.annotations.Import;
020import org.apache.tapestry5.annotations.Parameter;
021import org.apache.tapestry5.annotations.Property;
022import org.apache.tapestry5.corelib.base.AbstractField;
023import org.apache.tapestry5.internal.util.SelectModelRenderer;
024import org.apache.tapestry5.ioc.annotations.Inject;
025import org.apache.tapestry5.ioc.annotations.Symbol;
026import org.apache.tapestry5.ioc.internal.util.CollectionFactory;
027import org.apache.tapestry5.json.JSONArray;
028import org.apache.tapestry5.services.ComponentDefaultProvider;
029import org.apache.tapestry5.services.Request;
030import org.apache.tapestry5.services.javascript.JavaScriptSupport;
031
032import java.util.Collections;
033import java.util.List;
034import java.util.Map;
035import java.util.Set;
036
037import static org.apache.tapestry5.ioc.internal.util.CollectionFactory.newList;
038import 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 &lt;select&gt; element's widths are 200px, and it is common to override this to a specific value:
058 * <p/>
059 * <p/>
060 * <pre>
061 * &lt;style&gt;
062 * DIV.t-palette SELECT { width: 300px; }
063 * &lt;/style&gt;
064 * </pre>
065 * <p/>
066 * You'll want to ensure that both &lt;select&gt; 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")
080public 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}