Initial work on rewriting Palette component
Project: http://git-wip-us.apache.org/repos/asf/tapestry-5/repo Commit: http://git-wip-us.apache.org/repos/asf/tapestry-5/commit/c435bd2a Tree: http://git-wip-us.apache.org/repos/asf/tapestry-5/tree/c435bd2a Diff: http://git-wip-us.apache.org/repos/asf/tapestry-5/diff/c435bd2a Branch: refs/heads/5.4-js-rewrite Commit: c435bd2a5ac445dfdd882db708f8884baded691b Parents: 97f89ae Author: Howard M. Lewis Ship <[email protected]> Authored: Tue Nov 6 11:47:11 2012 -0800 Committer: Howard M. Lewis Ship <[email protected]> Committed: Tue Nov 6 13:33:01 2012 -0800 ---------------------------------------------------------------------- 54_RELEASE_NOTES.txt | 6 +- .../META-INF/modules/core/palette.coffee | 174 ++++++++++ .../tapestry5/corelib/components/Palette.java | 251 ++------------ .../resources/META-INF/assets/core/palette.css | 47 +++ .../tapestry5/corelib/components/Palette.tml | 46 ++- tapestry-core/src/test/app1/PaletteDemo.tml | 41 ++- .../integration/app1/pages/PaletteDemo.java | 27 +- 7 files changed, 330 insertions(+), 262 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/c435bd2a/54_RELEASE_NOTES.txt ---------------------------------------------------------------------- diff --git a/54_RELEASE_NOTES.txt b/54_RELEASE_NOTES.txt index 2c103eb..b5d42da 100644 --- a/54_RELEASE_NOTES.txt +++ b/54_RELEASE_NOTES.txt @@ -142,4 +142,8 @@ still exists, but the methods do nothing, and the service and interface will be In prior releases, adding _any_ JavaScript to the application would implicitly import the `core` JavaScript stack. This no longer occurs; in most cases, the core stack is provided automatically by other components. -You may want to consider adding `@Import(stack="core")` to your applications' main layout component. \ No newline at end of file +You may want to consider adding `@Import(stack="core")` to your applications' main layout component. + +## Palette Component + +The selected property is now type `Collection`, not specifically type `List`. It is no longer allowed to be null. \ No newline at end of file http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/c435bd2a/tapestry-core/src/main/coffeescript/META-INF/modules/core/palette.coffee ---------------------------------------------------------------------- diff --git a/tapestry-core/src/main/coffeescript/META-INF/modules/core/palette.coffee b/tapestry-core/src/main/coffeescript/META-INF/modules/core/palette.coffee new file mode 100644 index 0000000..c257eeb --- /dev/null +++ b/tapestry-core/src/main/coffeescript/META-INF/modules/core/palette.coffee @@ -0,0 +1,174 @@ +# Copyright 2012 The Apache Software Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http:#www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ##core/palette +# +# Support for the `core/Palette` component. +define ["core/dom", "_"], + (dom, _) -> + class PaletteController + + constructor: (id) -> + @selected = (dom id) + container = @selected.findContainer ".t-palette" + @available = container.findFirst ".t-palette-available select" + @hidden = container.findFirst "input[type=hidden]" + + @select = container.findFirst "[data-action=select]" + @deselect = container.findFirst "[data-action=deselect]" + + @moveUp = container.findFirst "[data-action=move-up]" + @moveDown = container.findFirst "[data-action=move-down]" + + # Track where reorder is allowed based on whether the buttons actually exist + @reorder = @moveUp isnt null + + @valueToOrderIndex = {} + + _.each @available.element.options, (option, i) => + @valueToOrderIndex[option.value] = i + + # This occurs even when the palette is disabled, to present the + # values correctly. Otherwise it looks like nothing is selected. + @initialTransfer() + + unless @selected.element.disabled + @updateButtons() + @bindEvents() + + initialTransfer: -> + # Get the values for options that should move over + values = JSON.parse @hidden.value() + valueToPosition = {} + + _.each values, (v, i) -> valueToPosition[v] = i + + e = @available.element + + movers = [] + + for i in [(e.options.length - 1)..0] by -1 + option = e.options[i] + value = option.value + pos = valueToPosition[value] + unless pos is undefined + movers[pos] = option + e.remove i + + for option in movers + @selected.element.add option + + updateHidden: -> + values = _.pluck(this.selected, "value") + hidden.value JSON.stringify values + + bindEvents: -> + @select.on "click", => + @doSelect() + return false + + @deselect.on "click", => + @doDeselect() + return false + + updateButtons: -> + @select.element.disabled = @available.element.selectedIndex < 0 + + nothingSelected = @selected.element.selectedIndex < 0 + + @deselect.element.disabled = nothingSelected + + if @reorder + @moveUp.disabled = nothingSelected or @allSelectionsAtTop() + @moveDown.disabled = nothingSelected or @allSelectionsAtBottom() + + transferOptions: (from, to, atEnd) -> + if from.element.selectedIndex is -1 + return + + _(to.element.options).each (o) -> o.selected = false + + movers = @removeSelectedOptions from + + @moveOptions movers, to, atEnd + + removeSelectedOptions: (select) -> + movers = [] + e = select.element + options = e.options + + for i in [(e.length - 1)..(e.selectedIndex)] by -1 + o = options[i] + if o.selected + select.remove i + movers.unshift o + + return movers + + moveOptions: (movers, to, atEnd) -> + _.each movers, (o) => + @moveOption o, to, atEnd + + @updateHidden() + @updateButtons() + + moveOptions: (option, to, atEnd) -> + before = null + + unless atEnd + optionOrder = @valueToOrderIndex[option.value] + candidate = _.find to.element.options, (o) => @valueToOrderIndex[o.value] > optionOrder + if candidate + before = candidate + + @addOption to, option, before + + addOption: (to, option, before) -> + try + to.element.add option, before + catch ex + if before is null + # IE throws an exception about type mismatch; here's the fix: + to.add option + else + to.add option, before.index + + indexOfLastSelection: (select) -> + e = select.element + if e.selectedIndex < 0 + return -1 + + for i in [(e.options.length -1)..(e.selectedIndex + 1)] by -1 + if e.options[i].selected + return i + + return -1 + + allSelectionsAtTop: -> + last = @indexOfLastSelection @selected + options = @selected.options + + _(options[0..last]).all (o) -> o.selected + + allSelectionsAtBottom: -> + last = @selected.element.selectedIndex + + _(options[last..]).all (o) -> o.selected + + + initialize = (id) -> + new PaletteController(id) + + # Export just the initialize function + return initialize \ No newline at end of file http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/c435bd2a/tapestry-core/src/main/java/org/apache/tapestry5/corelib/components/Palette.java ---------------------------------------------------------------------- diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/corelib/components/Palette.java b/tapestry-core/src/main/java/org/apache/tapestry5/corelib/components/Palette.java index 6c614ec..e3dc476 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/corelib/components/Palette.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/corelib/components/Palette.java @@ -22,16 +22,11 @@ import org.apache.tapestry5.corelib.base.AbstractField; import org.apache.tapestry5.internal.util.SelectModelRenderer; import org.apache.tapestry5.ioc.annotations.Inject; import org.apache.tapestry5.ioc.annotations.Symbol; -import org.apache.tapestry5.ioc.internal.util.CollectionFactory; import org.apache.tapestry5.json.JSONArray; -import java.util.Collections; +import java.util.Collection; import java.util.List; import java.util.Map; -import java.util.Set; - -import static org.apache.tapestry5.ioc.internal.util.CollectionFactory.newList; -import static org.apache.tapestry5.ioc.internal.util.CollectionFactory.newSet; /** * Multiple selection component. Generates a UI consisting of two <select> elements configured for multiple @@ -52,7 +47,6 @@ import static org.apache.tapestry5.ioc.internal.util.CollectionFactory.newSet; * Much of the look and feel is driven by CSS, the default Tapestry CSS is used to set up the columns, etc. By default, * the <select> element's widths are 200px, and it is common to override this to a specific value: * <p/> - * <p/> * <pre> * <style> * DIV.t-palette SELECT { width: 300px; } @@ -72,104 +66,9 @@ import static org.apache.tapestry5.ioc.internal.util.CollectionFactory.newSet; * @see Form * @see Select */ -@Import(library = "palette.js") +@Import(stylesheet = "Palette.css") public class Palette extends AbstractField { - // These all started as anonymous inner classes, and were refactored out to here. - // I was chasing down one of those perplexing bytecode errors. - - private final class AvailableRenderer implements Renderable - { - public void render(MarkupWriter writer) - { - writer.element("select", "id", getClientId() + "-avail", "multiple", "multiple", "size", getSize(), "name", - getControlName() + "-avail"); - - writeDisabled(writer, isDisabled()); - - for (Runnable r : availableOptions) - r.run(); - - writer.end(); - } - } - - private final class OptionGroupEnd implements Runnable - { - private final OptionGroupModel model; - - private OptionGroupEnd(OptionGroupModel model) - { - this.model = model; - } - - public void run() - { - renderer.endOptionGroup(model); - } - } - - private final class OptionGroupStart implements Runnable - { - private final OptionGroupModel model; - - private OptionGroupStart(OptionGroupModel model) - { - this.model = model; - } - - public void run() - { - renderer.beginOptionGroup(model); - } - } - - private final class RenderOption implements Runnable - { - private final OptionModel model; - - private RenderOption(OptionModel model) - { - this.model = model; - } - - public void run() - { - renderer.option(model); - } - } - - private final class SelectedRenderer implements Renderable - { - public void render(MarkupWriter writer) - { - writer.element("select", "id", getClientId(), "multiple", "multiple", "size", getSize(), "name", - getControlName()); - - writeDisabled(writer, isDisabled()); - - putPropertyNameIntoBeanValidationContext("selected"); - - Palette.this.validate.render(writer); - - removePropertyNameFromBeanValidationContext(); - - for (Object value : getSelected()) - { - OptionModel model = valueToOptionModel.get(value); - - renderer.option(model); - } - - writer.end(); - } - } - - /** - * List of Runnable commands to render the available options. - */ - private List<Runnable> availableOptions; - /** * The image to use for the deselect button (the default is a left pointing arrow). */ @@ -222,8 +121,6 @@ public class Palette extends AbstractField @Property(write = false) private Asset moveUp; - private SelectModelRenderer renderer; - /** * The image to use for the select button (the default is a right pointing arrow). */ @@ -235,12 +132,17 @@ public class Palette extends AbstractField * The list of selected values from the {@link org.apache.tapestry5.SelectModel}. This will be updated when the form * is submitted. If the value for the parameter is null, a new list will be created, otherwise the existing list * will be cleared. If unbound, defaults to a property of the container matching this component's id. + * <p/> + * Prior to Tapestry 5.4, this allowed null, and a list would be created when the form was submitted. Starting + * with 5.4, the selected list may not be null, and may not be a list (it may be, for example, a set). */ - @Parameter(required = true, autoconnect = true) - private List<Object> selected; + @Parameter(required = true, autoconnect = true, allowNull = false) + private Collection<Object> selected; /** * If true, then additional buttons are provided on the client-side to allow for re-ordering of the values. + * This is only useful when the selected parameter is bound to a {@code List}, rather than a {@code Set} or other + * unordered collection. */ @Parameter("false") @Property(write = false) @@ -257,6 +159,7 @@ public class Palette extends AbstractField /** * Number of rows to display. */ + @Property(write = false) @Parameter(value = BindingConstants.SYMBOL + ":" + ComponentParameterConstants.PALETTE_ROWS_SIZE) private int size; @@ -274,41 +177,41 @@ public class Palette extends AbstractField @Symbol(SymbolConstants.COMPACT_JSON) private boolean compactJSON; - /** - * The natural order of elements, in terms of their client ids. - */ - private List<String> naturalOrder; - public Renderable getAvailableRenderer() + public final Renderable mainRenderer = new Renderable() { - return new AvailableRenderer(); - } + @Override + public void render(MarkupWriter writer) + { + SelectModelRenderer visitor = new SelectModelRenderer(writer, encoder); + + model.visit(visitor); + } + }; - public Renderable getSelectedRenderer() + public String getInitialJSON() { - return new SelectedRenderer(); + return new JSONArray().toString(compactJSON); } + @Override protected void processSubmission(String controlName) { - String parameterValue = request.getParameter(controlName + "-values"); - - validationTracker.recordInput(this, parameterValue); + String parameterValue = request.getParameter(controlName); JSONArray values = new JSONArray(parameterValue); // Use a couple of local variables to cut down on access via bindings - List<Object> selected = this.selected; + Collection<Object> selected = this.selected; - if (selected == null) - selected = newList(); - else - selected.clear(); + selected.clear(); ValueEncoder encoder = this.encoder; + // TODO: Validation error if the model does not contain a value. + int count = values.length(); for (int i = 0; i < count; i++) { @@ -334,40 +237,13 @@ public class Palette extends AbstractField removePropertyNameFromBeanValidationContext(); } - private void writeDisabled(MarkupWriter writer, boolean disabled) - { - if (disabled) - writer.attributes("disabled", "disabled"); - } - - void beginRender(MarkupWriter writer) + void beginRender() { - JSONArray selectedValues = new JSONArray(); - - for (OptionModel selected : selectedOptions) - { - - Object value = selected.getValue(); - String clientValue = encoder.toClient(value); - - selectedValues.put(clientValue); - } - - JSONArray naturalOrder = new JSONArray(); - - for (String value : this.naturalOrder) - { - naturalOrder.put(value); - } - String clientId = getClientId(); - javaScriptSupport.addScript("new Tapestry.Palette('%s', %s, %s);", clientId, reorder, naturalOrder - .toString(compactJSON)); - - writer.element("input", "type", "hidden", "id", clientId + "-values", "name", getControlName() + "-values", - "value", selectedValues); - writer.end(); + // The client side just need to know the id of the selected (right column) select; + // it can take it from there. + javaScriptSupport.require("core/palette").with(clientId); } /** @@ -378,53 +254,6 @@ public class Palette extends AbstractField return false; } - @SuppressWarnings("unchecked") - void setupRender(MarkupWriter writer) - { - valueToOptionModel = CollectionFactory.newMap(); - availableOptions = CollectionFactory.newList(); - selectedOptions = CollectionFactory.newList(); - naturalOrder = CollectionFactory.newList(); - renderer = new SelectModelRenderer(writer, encoder); - - final Set selectedSet = newSet(getSelected()); - - SelectModelVisitor visitor = new SelectModelVisitor() - { - public void beginOptionGroup(OptionGroupModel groupModel) - { - availableOptions.add(new OptionGroupStart(groupModel)); - } - - public void endOptionGroup(OptionGroupModel groupModel) - { - availableOptions.add(new OptionGroupEnd(groupModel)); - } - - public void option(OptionModel optionModel) - { - Object value = optionModel.getValue(); - - boolean isSelected = selectedSet.contains(value); - - String clientValue = toClient(value); - - naturalOrder.add(clientValue); - - if (isSelected) - { - selectedOptions.add(optionModel); - valueToOptionModel.put(value, optionModel); - return; - } - - availableOptions.add(new RenderOption(optionModel)); - } - }; - - model.visit(visitor); - } - /** * Computes a default value for the "validate" parameter using * {@link org.apache.tapestry5.services.FieldValidatorDefaultSource}. @@ -434,28 +263,20 @@ public class Palette extends AbstractField return this.defaultProvider.defaultValidatorBinding("selected", this.resources); } - // Avoids a strange Javassist bytecode error, c'est lavie! - int getSize() - { - return size; - } - String toClient(Object value) { return encoder.toClient(value); } - List<Object> getSelected() - { - if (selected == null) - return Collections.emptyList(); - - return selected; - } @Override public boolean isRequired() { return validate.isRequired(); } + + public String getDisabledValue() + { + return disabled ? "disabled" : null; + } } http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/c435bd2a/tapestry-core/src/main/resources/META-INF/assets/core/palette.css ---------------------------------------------------------------------- diff --git a/tapestry-core/src/main/resources/META-INF/assets/core/palette.css b/tapestry-core/src/main/resources/META-INF/assets/core/palette.css new file mode 100644 index 0000000..14a18d1 --- /dev/null +++ b/tapestry-core/src/main/resources/META-INF/assets/core/palette.css @@ -0,0 +1,47 @@ +DIV.t-palette { + display: inline; +} + +DIV.t-palette SELECT { + margin-bottom: 2px; + width: 200px; +} + +DIV.t-palette-title { + color: white; + background-color: #809FFF; + text-align: center; + font-weight: bold; + margin-bottom: 3px; + display: block; +} + +DIV.t-palette-available { + float: left; +} + +DIV.t-palette-controls { + margin: 5px 5px; + float: left; +} + +DIV.t-palette-controls > DIV { + margin-top: 5px; +} + +DIV.t-palette-controls > DIV:first-child { + margin-top: 0; +} + +DIV.t-palette-selected { + float: left; + clear: right; +} + +DIV.t-palette-spacer { + clear: left; +} + +DIV.t-palette-title { + width: 200px; +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/c435bd2a/tapestry-core/src/main/resources/org/apache/tapestry5/corelib/components/Palette.tml ---------------------------------------------------------------------- diff --git a/tapestry-core/src/main/resources/org/apache/tapestry5/corelib/components/Palette.tml b/tapestry-core/src/main/resources/org/apache/tapestry5/corelib/components/Palette.tml index aff9513..1042188 100644 --- a/tapestry-core/src/main/resources/org/apache/tapestry5/corelib/components/Palette.tml +++ b/tapestry-core/src/main/resources/org/apache/tapestry5/corelib/components/Palette.tml @@ -1,31 +1,51 @@ <div class="t-palette" xml:space="default" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_3.xsd"> + <t:remove>Contains the array of option ids for selected elements:</t:remove> + <input type="hidden" name="${controlName}" value="${initialJSON}" disabled="${disabledValue}"/> + <div class="t-palette-available"> <div class="t-palette-title"> <t:delegate to="availableLabel"/> </div> - <t:delegate to="availableRenderer"/> + <select multiple="multiple" size="${size}" disabled="${disabledValue}"> + <t:remove> + All the options, in their "natural" order, are rendered here, then selected elements + are transferred to the other select. + </t:remove> + <t:delegate to="mainRenderer"/> + </select> </div> + <div class="t-palette-controls"> - <button id="${clientId}-select" disabled="disabled"> - <img src="${select}" alt="${message:core-palette-select-label}"/> - </button> - <button id="${clientId}-deselect" disabled="disabled"> - <img src="${deselect}" alt="${message:core-palette-deselect-label}"/> - </button> - <t:if test="reorder"> - <button id="${clientId}-up" disabled="disabled"> - <img src="${moveUp}" alt="${message:core-palette-up-label}"/> + <div> + <button data-action="select" class="btn" disabled="${disabledValue}"> + <img src="${select}" alt="${message:core-palette-select-label}"/> </button> - <button id="${clientId}-down" disabled="disabled"> - <img src="${moveDown}" alt="${message:core-palette-down-label}"/> + </div> + <div> + <button data-action="deselect" class="btn" disabled="${disabledValue}"> + <img src="${deselect}" alt="${message:core-palette-deselect-label}"/> </button> + </div> + <t:if test="reorder"> + <div> + <button data-action="move-up" class="btn" disabled="${disabledValue}"> + <img src="${moveUp}" alt="${message:core-palette-up-label}"/> + </button> + </div> + <div> + <button data-action="move-down" class="btn" disabled="${disabledValue}"> + <img src="${moveDown}" alt="${message:core-palette-down-label}"/> + </button> + </div> </t:if> </div> <div class="t-palette-selected"> <div class="t-palette-title"> <t:delegate to="selectedLabel"/> </div> - <t:delegate to="selectedRenderer"/> + <select id="${clientId}" multiple="multiple" size="${size}" disabled="${disabledValue}"> + <t:remove>Starts empty, populated on the client side.</t:remove> + </select> </div> <div class="t-palette-spacer"/> </div> http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/c435bd2a/tapestry-core/src/test/app1/PaletteDemo.tml ---------------------------------------------------------------------- diff --git a/tapestry-core/src/test/app1/PaletteDemo.tml b/tapestry-core/src/test/app1/PaletteDemo.tml index 0c5acca..6d65f74 100644 --- a/tapestry-core/src/test/app1/PaletteDemo.tml +++ b/tapestry-core/src/test/app1/PaletteDemo.tml @@ -3,28 +3,37 @@ <h1>Palette Demo</h1> -<t:form> - <div> - <label class="checkbox"> - <t:checkbox t:id="reorder"/> - Enable Reorder - </label> +<t:form t:id="demo" class="form-horizontal"> + <div class="control-group"> + <div class="controls"> + + <label class="checkbox"> + <t:checkbox t:id="reorder"/> + Enable Reorder + </label> + </div> + </div> - <div> + <div class="control-group"> + + <t:label for="languages"/> + + <div class="controls"> - <t:palette t:id="languages" model="languageModel" reorder="reorder" encoder="languageEncoder" - availableLabel="Languages Offered" validate="required"> - <t:parameter name="selectedLabel" xml:space="default"> - Selected - <t:if test="reorder">/ Ranked</t:if> - Languages - </t:parameter> - </t:palette> + <t:palette t:id="languages" model="languageModel" reorder="reorder" encoder="languageEncoder" + availableLabel="Languages Offered" validate="required"> + <t:parameter name="selectedLabel" xml:space="default"> + Selected + <t:if test="reorder">/ Ranked</t:if> + Languages + </t:parameter> + </t:palette> + </div> </div> - <div> + <div class="form-actions"> <input type="submit" class="btn btn-primary"/> </div> http://git-wip-us.apache.org/repos/asf/tapestry-5/blob/c435bd2a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/PaletteDemo.java ---------------------------------------------------------------------- diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/PaletteDemo.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/PaletteDemo.java index 96c0d60..7cf5dba 100644 --- a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/PaletteDemo.java +++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/PaletteDemo.java @@ -1,4 +1,4 @@ -// Copyright 2007 The Apache Software Foundation +// Copyright 2007, 2012 The Apache Software Foundation // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,12 +18,14 @@ import org.apache.tapestry5.ComponentResources; import org.apache.tapestry5.SelectModel; import org.apache.tapestry5.ValueEncoder; import org.apache.tapestry5.annotations.Persist; +import org.apache.tapestry5.annotations.Property; import org.apache.tapestry5.integration.app1.data.ProgrammingLanguage; import org.apache.tapestry5.ioc.annotations.Inject; import org.apache.tapestry5.ioc.services.TypeCoercer; import org.apache.tapestry5.util.EnumSelectModel; import org.apache.tapestry5.util.EnumValueEncoder; +import java.util.ArrayList; import java.util.List; public class PaletteDemo @@ -32,32 +34,23 @@ public class PaletteDemo private ComponentResources resources; @Persist + @Property private List<ProgrammingLanguage> languages; @Persist + @Property private boolean reorder; @Inject private TypeCoercer typeCoercer; - public boolean isReorder() - { - return reorder; - } - - public void setReorder(boolean reorder) - { - this.reorder = reorder; - } - - public List<ProgrammingLanguage> getLanguages() - { - return languages; - } - public void setLanguages(List<ProgrammingLanguage> selected) + void onPrepareFromDemo() { - languages = selected; + if (languages == null) + { + languages = new ArrayList<ProgrammingLanguage>(); + } } public SelectModel getLanguageModel()
