Index: cms/src/main/java/org/onehippo/forge/selection/frontend/plugin/DynamicMultiSelectPlugin.java IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- cms/src/main/java/org/onehippo/forge/selection/frontend/plugin/DynamicMultiSelectPlugin.java (revision ) +++ cms/src/main/java/org/onehippo/forge/selection/frontend/plugin/DynamicMultiSelectPlugin.java (revision ) @@ -0,0 +1,623 @@ +package org.onehippo.forge.selection.frontend.plugin; + +import java.util.*; + +import org.apache.wicket.WicketRuntimeException; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.form.AjaxFormChoiceComponentUpdatingBehavior; +import org.apache.wicket.ajax.form.AjaxFormComponentUpdatingBehavior; +import org.apache.wicket.ajax.form.OnChangeAjaxBehavior; +import org.apache.wicket.ajax.markup.html.AjaxLink; +import org.apache.wicket.behavior.AttributeAppender; +import org.apache.wicket.extensions.markup.html.form.palette.Palette; +import org.apache.wicket.extensions.markup.html.form.palette.component.Recorder; +import org.apache.wicket.feedback.ContainerFeedbackMessageFilter; +import org.apache.wicket.feedback.IFeedbackMessageFilter; +import org.apache.wicket.markup.head.CssHeaderItem; +import org.apache.wicket.markup.head.IHeaderResponse; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.form.CheckBoxMultipleChoice; +import org.apache.wicket.markup.html.form.IChoiceRenderer; +import org.apache.wicket.markup.html.form.ListMultipleChoice; +import org.apache.wicket.markup.html.list.ListItem; +import org.apache.wicket.markup.html.panel.Fragment; +import org.apache.wicket.markup.repeater.Item; +import org.apache.wicket.markup.repeater.RefreshingView; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.Model; +import org.apache.wicket.model.StringResourceModel; +import org.apache.wicket.request.resource.CssResourceReference; +import org.hippoecm.frontend.PluginRequestTarget; +import org.hippoecm.frontend.editor.ITemplateEngine; +import org.hippoecm.frontend.editor.plugins.field.FieldPluginHelper; +import org.hippoecm.frontend.editor.plugins.fieldhint.FieldHint; +import org.hippoecm.frontend.model.IModelReference; +import org.hippoecm.frontend.model.JcrItemModel; +import org.hippoecm.frontend.model.JcrNodeModel; +import org.hippoecm.frontend.model.event.IObservable; +import org.hippoecm.frontend.model.event.IObserver; +import org.hippoecm.frontend.model.properties.JcrMultiPropertyValueModel; +import org.hippoecm.frontend.model.properties.JcrPropertyModel; +import org.hippoecm.frontend.plugin.IPluginContext; +import org.hippoecm.frontend.plugin.config.IPluginConfig; +import org.hippoecm.frontend.plugins.standards.diff.LCS; +import org.hippoecm.frontend.plugins.standards.diff.LCS.Change; +import org.hippoecm.frontend.service.IEditor; +import org.hippoecm.frontend.service.render.RenderPlugin; +import org.hippoecm.frontend.types.IFieldDescriptor; +import org.hippoecm.frontend.validation.IValidationResult; +import org.hippoecm.frontend.validation.ModelPath; +import org.hippoecm.frontend.validation.ModelPathElement; +import org.hippoecm.frontend.validation.Violation; +import org.onehippo.forge.selection.frontend.model.ValueList; +import org.onehippo.forge.selection.frontend.plugin.sorting.SortHelper; +import org.onehippo.forge.selection.frontend.provider.IValueListProvider; +import org.onehippo.forge.selection.frontend.utils.SelectionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A dynamic multiselect plugin, which is backed by a ValueListProvider service that provides a ValueList object. + *

+ * The default DocumentValueListProvider reads a document of the type 'selection:valuelist', which contains key label + * pairs used to display values and labels in the dropdown. + *

+ * The plugin configuration must then be provided with a source property, which can either be a valid UUID + * of a handle or the path to the document based on the JCR root. + */ +public class DynamicMultiSelectPlugin extends RenderPlugin { + + private final static String CONFIG_TYPE = "multiselect.type"; + private final static String CONFIG_SELECT_MAX_ROWS = "selectlist.maxrows"; + private final static String CONFIG_CHECKBOXES = "checkboxes"; + private final static String CONFIG_PALETTE = "palette"; + private final static String CONFIG_PALETTE_MAX_ROWS = "palette.maxrows"; + private final static String CONFIG_PALETTE_ALLOW_ORDER = "palette.alloworder"; + private final static String CONFIG_VALUELIST_OPTIONS = "valuelist.options"; + private final static String CONFIG_CLUSTER_OPTIONS = "cluster.options"; + + private static final long serialVersionUID = 1L; + + private static final Logger log = LoggerFactory.getLogger(DynamicMultiSelectPlugin.class); + private static final CssResourceReference CSS = new CssResourceReference(DynamicMultiSelectPlugin.class, "DynamicMultiSelectPlugin.css"); + + private final FieldPluginHelper helper; + + private JcrPropertyModel propertyModel; + private IObserver propertyObserver; + + private final SortHelper sortHelper = new SortHelper(); + + private IEditor.Mode mode; + + /** + * Constructor. + */ + public DynamicMultiSelectPlugin(IPluginContext context, IPluginConfig config) { + super(context, config); + + this.mode = IEditor.Mode.fromString(config.getString(ITemplateEngine.MODE, "view")); + + helper = new FieldPluginHelper(context, config); + + subscribe(); + + // use caption for backwards compatibility; i18n should use field name + String captionKey = helper.getField() != null ? helper.getField().getName() : config.getString("caption"); + add(new Label("name", new StringResourceModel(captionKey, this, null, config.getString("caption")))); + + // required + Label required = new Label("required", "*"); + if (helper.getField() != null && !helper.getField().getValidators().contains("required")) { + required.setVisible(false); + } + add(required); + + add(new FieldHint("hint-panel", config.getString("hint"))); + + // configured provider + final IValueListProvider selectedProvider = context.getService(config.getString(IValueListProvider.SERVICE), + IValueListProvider.class); + if (selectedProvider == null) { + log.warn("DynamicMultiSelectPlugin: value list provider can not be found by name '{}'", + config.getString(IValueListProvider.SERVICE)); + + // dummy markup + final Fragment modeFragment = new Fragment("mode", "view", this); + modeFragment.add(new ListView("viewitems", Collections.EMPTY_LIST, null)); + add(modeFragment); + final Fragment unselectFragment = new Fragment("unselectlink", "edit-unselectlink", this); + final AjaxLink unselectLink = new UnselectLink("unselect-link", null, null); + unselectFragment.add(unselectLink); + unselectFragment.setVisibilityAllowed(false); + add(unselectFragment); + return; + } + + final JcrMultiPropertyValueModel model = new JcrMultiPropertyValueModel<>(getPropertyModel().getItemModel()); + + //HIPPLUG-908: Start using cluster.options instead of valuelist.options, maintaining backwards compatibility. + IPluginConfig options = config.getPluginConfig(CONFIG_CLUSTER_OPTIONS); + if (options == null) { + options = config.getPluginConfig(CONFIG_VALUELIST_OPTIONS); + if (options == null) { + throw new WicketRuntimeException("Configuration node '" + CONFIG_CLUSTER_OPTIONS + + "' not found in plugin configuration. " + config.toString()); + } + + log.warn("The configuration node name '{}' is deprecated. Rename it to '{}'. options={}", + new String[] {CONFIG_VALUELIST_OPTIONS, CONFIG_CLUSTER_OPTIONS, options.toString()}); + } + + final Locale locale = SelectionUtils.getLocale(SelectionUtils.getNode(model)); + final ValueList valueList = selectedProvider.getValueList(options.getString(Config.SOURCE), locale); + + sortHelper.sort(valueList, options); + + ArrayList keys = new ArrayList<>(valueList.size()); + for (org.onehippo.forge.selection.frontend.model.ListItem item : valueList) { + keys.add(item.getKey()); + } + final IModel choicesModel = new Model<>(keys); + + Fragment modeFragment; + final String mode = config.getString(ITemplateEngine.MODE); + switch (mode) { + case "edit": + modeFragment = populateEditMode(config, model, valueList, choicesModel); + break; + case "compare": + modeFragment = populateCompareMode(context, config, model, valueList); + break; + default: + modeFragment = populateViewMode(model, valueList); + } + add(modeFragment); + } + + @Override + public void render(final PluginRequestTarget target) { + + if (isActive()) { + if (IEditor.Mode.EDIT == mode) { + IModel validationModel = helper.getValidationModel(); + if (validationModel != null && validationModel.getObject() != null) { + boolean valid = isFieldValid(validationModel.getObject()); + if (!valid) { + target.appendJavaScript("Wicket.$('" + getMarkupId() + "').setAttribute('class', 'invalid');"); + } + } + } + } + + super.render(target); + } + + protected FieldPluginHelper getFieldHelper() { + return helper; + } + + /** + * Checks if a field has any violations attached to it. + * + * @param validation The IValidationResult that contains all violations that occurred for this editor + * @return true if there are no violations present or non of the validation belong to the current field + */ + protected boolean isFieldValid(final IValidationResult validation) { + if (!validation.isValid()) { + IFieldDescriptor field = getFieldHelper().getField(); + if (field == null) { + return false; + } + for (Violation violation : validation.getViolations()) { + Set paths = violation.getDependentPaths(); + for (ModelPath path : paths) { + if (path.getElements().length > 0) { + ModelPathElement first = path.getElements()[0]; + if (first.getField().equals(field)) { + return false; + } + } + } + } + } + IFeedbackMessageFilter filter = new ContainerFeedbackMessageFilter(this); + return !getSession().getFeedbackMessages().hasMessage(filter); + } + + @Override + public void renderHead(final IHeaderResponse response) { + super.renderHead(response); + + response.render(CssHeaderItem.forReference(CSS)); + } + + @Override + protected void onDetach() { + if (this.propertyModel != null) { + this.propertyModel.detach(); + } + super.onDetach(); + } + + @Override + public void onModelChanged() { + unsubscribe(); + subscribe(); + } + + protected Fragment populateViewMode(final JcrMultiPropertyValueModel model, final ValueList valueList) { + final Fragment modeFragment;// show view list + modeFragment = new Fragment("mode", "view", this); + modeFragment.add(new ListView("viewitems", model.getObject(), valueList)); + + // hide dummy fragment + final Fragment unselectFragment = new Fragment("unselectlink", "edit-unselectlink", this); + final AjaxLink unselectLink = new UnselectLink("unselect-link", null, null); + unselectFragment.add(unselectLink); + unselectFragment.setVisibilityAllowed(false); + add(unselectFragment); + return modeFragment; + } + + protected Fragment populateCompareMode(final IPluginContext context, final IPluginConfig config, + final JcrMultiPropertyValueModel model, final ValueList valueList) { + final Fragment modeFragment; + modeFragment = new Fragment("mode", "view", this); + + IModelReference compareToRef = context.getService(config.getString("model.compareTo"), + IModelReference.class); + if (compareToRef != null) { + JcrNodeModel baseNodeModel = (JcrNodeModel) compareToRef.getModel(); + if (baseNodeModel != null && baseNodeModel.getNode() != null) { + IFieldDescriptor field = helper.getField(); + JcrMultiPropertyValueModel baseModel = new JcrMultiPropertyValueModel<>(new JcrItemModel( + baseNodeModel.getItemModel().getPath() + "/" + field.getPath())); + + List baseOptions = baseModel.getObject(); + List currentOptions = model.getObject(); + List> changes = LCS.getChangeSet(baseOptions.toArray(new String[baseOptions.size()]), + currentOptions.toArray(new String[currentOptions.size()])); + + // show view list + modeFragment.add(new CompareView("viewitems", changes, valueList)); + } else { + modeFragment.add(new ListView("viewitems", model.getObject(), valueList)); + } + } else { + modeFragment.add(new ListView("viewitems", model.getObject(), valueList)); + } + + // hide dummy fragment + final Fragment unselectFragment = new Fragment("unselectlink", "edit-unselectlink", this); + final AjaxLink unselectLink = new UnselectLink("unselect-link", null, null); + unselectFragment.add(unselectLink); + unselectFragment.setVisibilityAllowed(false); + add(unselectFragment); + + final Fragment selectAllFragment = new Fragment("selectlink", "edit-selectlink", this); + final AjaxLink selectAllLink = new SelectLink("select-link", null, null); + selectAllFragment.add(selectAllLink); + selectAllFragment.setVisibilityAllowed(false); + add(selectAllFragment); + + return modeFragment; + } + + protected Fragment populateEditMode(final IPluginConfig config, final JcrMultiPropertyValueModel model, + final ValueList valueList, final IModel choicesModel) { + final Fragment modeFragment; + modeFragment = new Fragment("mode", "edit", this); + + Fragment typeFragment; + final String type = config.getString(CONFIG_TYPE); + if (CONFIG_CHECKBOXES.equals(type)) { + typeFragment = addCheckboxes(model, valueList, choicesModel); + } else if (CONFIG_PALETTE.equals(type)) { + typeFragment = addPalette(config, model, valueList, choicesModel); + } else { + typeFragment = addList(config, model, valueList, choicesModel); + } + modeFragment.add(typeFragment); + return modeFragment; + } + + protected Fragment addList(final IPluginConfig config, final JcrMultiPropertyValueModel model, + final ValueList valueList, final IModel choicesModel) { + final Fragment typeFragment; + typeFragment = new Fragment("type", "edit-select", this); + + ListMultipleChoice multiselect = new ListMultipleChoice("multiselect", model, choicesModel, + new ValueListItemRenderer(valueList)); + + // trigger setObject on selection changed + multiselect.add(new OnChangeAjaxBehavior() { + + private static final long serialVersionUID = 1L; + + @Override + protected void onUpdate(AjaxRequestTarget target) { + } + }); + + // set (configured) max rows + final String maxRows = config.getString(CONFIG_SELECT_MAX_ROWS, "8"); + try { + multiselect.setMaxRows(Integer.valueOf(maxRows)); + } catch (NumberFormatException nfe) { + log.warn("The configured value '" + maxRows + "' for " + CONFIG_SELECT_MAX_ROWS + + " is not a valid number. Defaulting to 8."); + multiselect.setMaxRows(8); + } + + typeFragment.add(multiselect); + + final Fragment unselectFragment = new Fragment("unselectlink", "edit-unselectlink", this); + final AjaxLink unselectLink = new UnselectLink("unselect-link", multiselect, model); + unselectFragment.add(unselectLink); + add(unselectFragment); + + final Fragment selectAllFragment = new Fragment("selectlink", "edit-selectlink", this); + final AjaxLink selectAllLink = new SelectLink("select-link", multiselect, model); + selectAllFragment.add(selectAllLink); + add(selectAllFragment); + + return typeFragment; + } + + protected Fragment addPalette(final IPluginConfig config, final JcrMultiPropertyValueModel model, + final ValueList valueList, final IModel choicesModel) { + final Fragment typeFragment; + typeFragment = new Fragment("type", "edit-palette", this); + + // set (configured) max rows + int rows = 10; + final String maxRows = config.getString(CONFIG_PALETTE_MAX_ROWS, "10"); + try { + rows = Integer.valueOf(maxRows); + } catch (NumberFormatException nfe) { + log.warn("The configured value '" + maxRows + "' for " + CONFIG_PALETTE_MAX_ROWS + + " is not a valid number. Defaulting to 10."); + } + + // set (configured) allow order value + final boolean allowOrder = config.getBoolean(CONFIG_PALETTE_ALLOW_ORDER); + + final Palette palette = new Palette("palette", model, choicesModel, + new ValueListItemRenderer(valueList), rows, allowOrder) { + + private static final long serialVersionUID = 1L; + + // FIXME: workaround for WICKET-2843 + @Override + public Collection getModelCollection() { + return new ArrayList(super.getModelCollection()); + } + + // trigger setObject on selection changed + @Override + protected Recorder newRecorderComponent() { + Recorder recorder = super.newRecorderComponent(); + recorder.add(new AjaxFormComponentUpdatingBehavior("onchange") { + + private static final long serialVersionUID = 1L; + + @Override + protected void onUpdate(AjaxRequestTarget target) { + } + + }); + return recorder; + } + }; + + typeFragment.add(palette); + + // hide unselect fragment + final Fragment unselectFragment = new Fragment("unselectlink", "edit-unselectlink", this); + final AjaxLink unselectLink = new UnselectLink("unselect-link", null, null); + unselectFragment.add(unselectLink); + unselectFragment.setVisibilityAllowed(false); + add(unselectFragment); + return typeFragment; + } + + protected Fragment addCheckboxes(final JcrMultiPropertyValueModel model, final ValueList valueList, + final IModel choicesModel) { + final Fragment typeFragment; + typeFragment = new Fragment("type", "edit-checkboxes", this); + + CheckBoxMultipleChoice checkboxes = new CheckBoxMultipleChoice("checkboxes", model, choicesModel, + new ValueListItemRenderer(valueList)); + + // trigger setObject on selection changed + checkboxes.add(new AjaxFormChoiceComponentUpdatingBehavior() { + + private static final long serialVersionUID = 1L; + + @Override + protected void onUpdate(AjaxRequestTarget target) { + } + }); + + typeFragment.add(checkboxes); + + // hide unselect fragment + final Fragment unselectFragment = new Fragment("unselectlink", "edit-unselectlink", this); + final AjaxLink unselectLink = new UnselectLink("unselect-link", null, null); + unselectFragment.add(unselectLink); + unselectFragment.setVisibilityAllowed(false); + add(unselectFragment); + return typeFragment; + } + + protected JcrPropertyModel getPropertyModel() { + return new JcrPropertyModel(helper.getFieldItemModel()); + } + + /** + * Subscribe to a service to get notified of property changes. + */ + protected void subscribe() { + propertyModel = getPropertyModel(); + if (propertyModel != null) { + + getPluginContext().registerService(propertyObserver = new IObserver() { + + private static final long serialVersionUID = 1L; + + public IObservable getObservable() { + return propertyModel; + } + + public void onEvent(Iterator events) { + redraw(); + } + + }, IObserver.class.getName()); + } + } + + /** + * Unsubscribe from the change notification service. + */ + protected void unsubscribe() { + if (propertyModel != null) { + getPluginContext().unregisterService(propertyObserver, IObserver.class.getName()); + propertyModel = null; + } + } + + protected static class ValueListItemRenderer implements IChoiceRenderer { + + private static final long serialVersionUID = 1L; + + private final ValueList valueList; + + public ValueListItemRenderer(final ValueList valueList) { + this.valueList = valueList; + } + + public String getDisplayValue(String object) { + return valueList.getLabel(object); + } + + public String getIdValue(String object, int index) { + return valueList.getKey(object); + } + } + + /** + * Repeating view to show items in view mode. + */ + protected class ListView extends RefreshingView { + + private static final long serialVersionUID = 1L; + private final Collection> models = new ArrayList<>(); + + public ListView(String id, Collection actualValues, ValueList choices) { + super(id); + + // get the choice labels by the actual values/keys + for (Object item : actualValues) { + this.models.add(new Model(choices.getLabel(item))); + } + } + + @Override + protected Iterator> getItemModels() { + return models.iterator(); + } + + @Override + protected void populateItem(Item item) { + item.add(new Label("viewitem", item.getModelObject())); + } + } + + /** + * Repeating view to show items in compare mode. + */ + protected class CompareView extends org.apache.wicket.markup.html.list.ListView> { + + private static final long serialVersionUID = 1L; + private final ValueList choices; + + public CompareView(String id, List> changes, ValueList choices) { + super(id, changes); + this.choices = choices; + } + + @Override + protected void populateItem(ListItem> item) { + Change change = item.getModelObject(); + + Label label = new Label("viewitem", choices.getLabel(change.getValue())); + switch (change.getType()) { + case ADDED: + label.add(new AttributeAppender("class", new Model<>("hippo-diff-added"), " ")); + break; + case REMOVED: + label.add(new AttributeAppender("class", new Model<>("hippo-diff-removed"), " ")); + break; + } + item.add(label); + } + } + + /** + * Link unselect all values from a select list. + */ + protected class UnselectLink extends AjaxLink { + + private static final long serialVersionUID = -2703760847654039423L; + + private ListMultipleChoice multiselect; + private IModel model; + + UnselectLink(String id, ListMultipleChoice multiselect, IModel model) { + super(id); + this.multiselect = multiselect; + this.model = model; + } + + @Override + public void onClick(AjaxRequestTarget target) { + + // clear model + //this.model.setObject(null); + + this.model.setObject(multiselect.getChoices()); + + // make the multiselect update to remove selected items + target.add(this.multiselect); + } + } + + /** + * Link unselect all values from a select list. + */ + protected class SelectLink extends AjaxLink { + + private static final long serialVersionUID = -2703760847654039423L; + + private ListMultipleChoice multiselect; + private IModel model; + + SelectLink(String id, ListMultipleChoice multiselect, IModel model) { + super(id); + this.multiselect = multiselect; + this.model = model; + } + + @Override + public void onClick(AjaxRequestTarget target) { + + // select all options + this.model.setObject(multiselect.getChoices()); + + // make the multiselect update to remove selected items + target.add(this.multiselect); + } + } +} Index: cms/src/main/resources/org/onehippo/forge/selection/frontend/plugin/DynamicMultiSelectPlugin.properties IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>windows-1252 =================================================================== --- cms/src/main/resources/org/onehippo/forge/selection/frontend/plugin/DynamicMultiSelectPlugin.properties (revision ) +++ cms/src/main/resources/org/onehippo/forge/selection/frontend/plugin/DynamicMultiSelectPlugin.properties (revision ) @@ -0,0 +1,2 @@ +unselect=Deselect all +select=Select all \ No newline at end of file Index: cms/src/main/resources/org/onehippo/forge/selection/frontend/plugin/DynamicMultiSelectPlugin.html IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- cms/src/main/resources/org/onehippo/forge/selection/frontend/plugin/DynamicMultiSelectPlugin.html (revision 243202) +++ cms/src/main/resources/org/onehippo/forge/selection/frontend/plugin/DynamicMultiSelectPlugin.html (revision ) @@ -27,6 +27,7 @@ + @@ -50,6 +51,12 @@

+ + + + Index: cms/src/main/resources/org/onehippo/forge/selection/frontend/plugin/DynamicMultiSelectPlugin_de.properties IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>windows-1252 =================================================================== --- cms/src/main/resources/org/onehippo/forge/selection/frontend/plugin/DynamicMultiSelectPlugin_de.properties (revision ) +++ cms/src/main/resources/org/onehippo/forge/selection/frontend/plugin/DynamicMultiSelectPlugin_de.properties (revision ) @@ -0,0 +1,2 @@ +unselect=Auswahl ausheben +select=Alles auswählen \ No newline at end of file