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 &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")
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    }