    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 &lt;select&gt; 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    }