SelectBox with multiple selection (edit)


#1

Hi, everybody. This is my first post here, and I’m quite new in the bpmn.io world.

So, I need to make possible to create a multiple selectBox, since I have the need to create a custom extention that allows user to select one or more values from a list. A select with multiple options seemed to me the most reasonable choice. So I tweaked a little the SelectEntryFactory.js a little. That’s the result:

"use strict";

import _ from "lodash";
import {
  domify
} from "min-dom";

import entryFieldDescription from "./EntryFieldDescription";

var isList = function (list) {
  return !(!list || Object.prototype.toString.call(list) !== "[object Array]");
};

var addEmptyParameter = function (list) {
  return list.concat([{
    name: "",
    value: ""
  }]);
};

var createOption = function (option) {
  return '<option value="' + option.value + '">' + option.name + "</option>";
};

/**
 * @param  {Object} options
 * @param  {string} options.id
 * @param  {string} [options.label]
 * @param  {Array<Object>} options.selectOptions
 * @param  {string} options.modelProperty
 * @param  {boolean} options.emptyParameter
 * @param  {boolean} options.multiple
 * @param  {function} options.disabled
 * @param  {function} options.hidden
 * @param  {Object} defaultParameters
 *
 * @return {Object}
 */
var selectbox = function (options, defaultParameters) {
  var resource = defaultParameters,
    label = options.label || resource.id,
    selectOptions = options.selectOptions || [{
      name: "",
      value: ""
    }],
    modelProperty = options.modelProperty,
    emptyParameter = options.emptyParameter,
    canBeDisabled = !!options.disabled && typeof options.disabled === "function",
    canBeHidden = !!options.hidden && typeof options.hidden === "function",
    isMultiple = options.multiple || false,
    description = options.description;

  if (emptyParameter) {
    selectOptions = addEmptyParameter(selectOptions);
  }

  resource.html =
    '<label for="camunda-' +
    resource.id +
    '">' +
    label +
    "</label>" +
    '<select ' +
    (isMultiple ? 'multiple ' : '') +
    'id="camunda-' +
    resource.id +
    '-select" name="' +
    modelProperty +
    '"' +
    (canBeDisabled ? 'data-disable="isDisabled" ' : "") +
    (canBeHidden ? 'data-show="isHidden" ' : "") +
    " data-value >";

  if (isList(selectOptions)) {
    _.forEach(selectOptions, function (option) {
      resource.html +=
        '<option value="' + option.value + '">' + (option.name || "") + "</option>";
    });
  }

  resource.html += "</select>";

  // add description below select box entry field
  if (description && typeof options.showCustomInput !== "function") {
    resource.html += entryFieldDescription(description);
  }

  /**
   * Fill the select box options dynamically.
   *
   * Calls the defined function #selectOptions in the entry to get the
   * values for the options and set the value to the inputNode.
   *
   * @param {djs.model.Base} element
   * @param {HTMLElement} entryNode
   * @param {EntryDescriptor} inputNode
   * @param {Object} inputName
   * @param {Object} newValue
   */
  resource.setControlValue = function (element, entryNode, inputNode, inputName, newValue) {
    if (typeof selectOptions === "function") {
      var options = selectOptions(element, inputNode);

      if (options) {
        // remove existing options
        while (inputNode.firstChild) {
          inputNode.removeChild(inputNode.firstChild);
        }

        // add options
        _.forEach(options, function (option) {
          var template = domify(createOption(option));

          inputNode.appendChild(template);
        });
      }
    }

    // set select value
    if (newValue !== undefined) {
      if (isMultiple) {
        _.forEach(newValue, function (value) {
          const option = _.find(inputNode, node => node.value === value);
          if (option) {
            option.selected = true;
          }
        });
      } else {
        inputNode.value = newValue;
      }
    }
  };

  if (canBeDisabled) {
    resource.isDisabled = function () {
      return options.disabled.apply(resource, arguments);
    };
  }

  if (canBeHidden) {
    resource.isHidden = function () {
      return !options.hidden.apply(resource, arguments);
    };
  }

  resource.cssClasses = ["bpp-dropdown"];

  return resource;
};

export default selectbox;

I just add a new property in options, called multiple. Then, in setControlValue, set the property selected to true to every option selected via click. Then, I created my SomeTaskProps.js

Then I use this in a custom property panel I’ve created. I created get and set custom functions. Seems to work perfectly, adding the extension in the proper place, but I still have a little problem. In case I’ve selected several options, and those get highlighted in my screen, if I click the first selected option (the first one appearing from the top), the set function do not trigger. Mind that this happens only with the first option. The other trigger the function as fine.

So, I’ve many question. Is this the right approach? And so, what am I doing wrong? Creating a mess changing the selectBox or there’s something in my javascript/html code that goes wrong?

Plus: in a case like this, is there a different property field I might use?

I thank anybody could spend a minute to read this and those willing to help me a little to undestand more of this world.


#2

I answer by myself, since this morning I found the solution of this problem.

After writing this message I find out that the Select element created stores as a value only the first choice, even if more options are selected. Just to clarify:

<select name="example-select">
  <option value="1" >One</option>
  <option value="2" selected>Two</option>
  <option value="3" >Three</option>
  <option value="4" selected>Four</option>
</select>

This dropdown will show two selected options (two and four), but stores as a value only the value two. Because of this behaviour, when I click again on the option two, the compiler do not recognize any change, so PropertiesPanel.js do not trigger the applyChanges method, and therefore do not trigger the set method in my custom property.

Since I didn’t want to modify PropertiesPanel.js to trigger the function applyChanges every time, I added a small trick in my SelectBox class. I simply added an option that will always be created as the first one, that will always be hidden and that will always be selected. Therefore, the selectBox will store that option as a selected one (but it won’t be possible to modify) and it will allow me to trigger the set function every time I click on it.

With this trick, I realized what I desired. A dropdown that will allow to select more options. Every option I select, it allow me to create a new extension. When I click on a selected option, it deselect the option and remove the previously created extension. This workaround is fine enough for what I need, in case somebody could need it, I share here the code:
(Search for “NOTE” to find comments in the code area where I used the isMultiple property)

"use strict";

import _ from "lodash";
import {
  domify
} from "min-dom";

import entryFieldDescription from "./EntryFieldDescription";

var isList = function (list) {
  return !(!list || Object.prototype.toString.call(list) !== "[object Array]");
};

var addEmptyParameter = function (list) {
  return list.concat([{
    name: "",
    value: "",
  }, ]);
};

var createOption = function (option) {
  return '<option value="' + option.value + '">' + option.name + "</option>";
};

/**
 * @param  {Object} options
 * @param  {string} options.id
 * @param  {string} [options.label]
 * @param  {Array<Object>} options.selectOptions
 * @param  {string} options.modelProperty
 * @param  {boolean} options.emptyParameter
 * @param  {boolean} options.multiple
 * @param  {function} options.disabled
 * @param  {function} options.hidden
 * @param  {Object} defaultParameters
 *
 * @return {Object}
 */
var selectbox = function (options, defaultParameters) {
  var resource = defaultParameters,
    label = options.label || resource.id,
    selectOptions = options.selectOptions || [{
      name: "",
      value: "",
    }, ],
    modelProperty = options.modelProperty,
    emptyParameter = options.emptyParameter,
    canBeDisabled = !!options.disabled && typeof options.disabled === "function",
    canBeHidden = !!options.hidden && typeof options.hidden === "function",
    isMultiple = options.multiple || false, // NOTE: multiple default value is false
    description = options.description;

  if (emptyParameter) {
    selectOptions = addEmptyParameter(selectOptions);
  }

  resource.html =
    '<label for="camunda-' +
    resource.id +
    '">' +
    label +
    "</label>" +
    "<select " +
    (isMultiple ? "multiple " : "") + // NOTE: in case of multiple, add the proper attribute to the select tag
    'id="camunda-' +
    resource.id +
    '-select" name="' +
    modelProperty +
    '"' +
    (canBeDisabled ? 'data-disable="isDisabled" ' : "") +
    (canBeHidden ? 'data-show="isHidden" ' : "") +
    " data-value >";

  if (isList(selectOptions)) {
    // NOTE: in case of Multiple, add an empty option as a tweak
    if (isMultiple) {
      resource.html += "<option style='display: none' value='empty'></option>";
    }

    _.forEach(selectOptions, function (option) {
      resource.html += '<option value="' + option.value + '">' + (option.name || "") + "</option>";
    });
  }

  resource.html += "</select>";

  // add description below select box entry field
  if (description && typeof options.showCustomInput !== "function") {
    resource.html += entryFieldDescription(description);
  }

  /**
   * Fill the select box options dynamically.
   *
   * Calls the defined function #selectOptions in the entry to get the
   * values for the options and set the value to the inputNode.
   *
   * @param {djs.model.Base} element
   * @param {HTMLElement} entryNode
   * @param {EntryDescriptor} inputNode
   * @param {Object} inputName
   * @param {Object} newValue
   */
  resource.setControlValue = function (element, entryNode, inputNode, inputName, newValue) {
    if (typeof selectOptions === "function") {
      var options = selectOptions(element, inputNode);

      if (options) {
        // remove existing options
        while (inputNode.firstChild) {
          inputNode.removeChild(inputNode.firstChild);
        }

        // add options

        // NOTE: in case of multiple selectBox, an empty option is created as a tweak.
        if (isMultiple) {
          var template = domify(createOption({
            value: "empty",
            name: ""
          }));
          template.hidden = true;
          inputNode.appendChild(template);
        }

        _.forEach(options, function (option) {
          var template = domify(createOption(option));
          inputNode.appendChild(template);
        });
      }
    }

    // set select value
    // NOTE: in case of multiple selectBox, use the tweak, then set the selected option to true
    if (newValue !== undefined) {
      if (isMultiple) {
        inputNode.value = "empty";
        _.forEach(newValue, function (value) {
          const option = _.find(inputNode, node => node.value === value);
          if (option) {
            option.selected = true;
          }
        });
      } else {
        inputNode.value = newValue;
      }
    }
  };

  if (canBeDisabled) {
    resource.isDisabled = function () {
      return options.disabled.apply(resource, arguments);
    };
  }

  if (canBeHidden) {
    resource.isHidden = function () {
      return !options.hidden.apply(resource, arguments);
    };
  }

  resource.cssClasses = ["bpp-dropdown"];

  return resource;
};

export default selectbox;

(DISCLAIMER: I like lodash a lot)

I’m also considering to refine this code and propose it in GitHub. It could be pretty useful in case of modeler with custom extension with property isMany set to true, where values are taken from a list of possible choices.

Obviously, any kind of suggestion is still welcome.


#3

Glad you figured out a solution to your issue.