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 &lt;select&gt; element's widths are 200px, and  it is common to override this to a specific value:
054     * <p/>
055     * <pre>
056     * &lt;style&gt;
057     * DIV.t-palette SELECT { width: 300px; }
058     * &lt;/style&gt;
059     * </pre>
060     * <p/>
061     * You'll want to ensure that both &lt;select&gt; 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    }