Home Reference Source

src/components/select/Select.js

import Choices from '../../utils/ChoicesWrapper';
import _ from 'lodash';
import Formio from '../../Formio';
import Field from '../_classes/field/Field';
import Form from '../../Form';
import NativePromise from 'native-promise-only';
import { getRandomComponentId, boolValue } from '../../utils/utils';

export default class SelectComponent extends Field {
  static schema(...extend) {
    return Field.schema({
      type: 'select',
      label: 'Select',
      key: 'select',
      idPath: 'id',
      data: {
        values: [],
        json: '',
        url: '',
        resource: '',
        custom: ''
      },
      clearOnRefresh: false,
      limit: 100,
      dataSrc: 'values',
      valueProperty: '',
      lazyLoad: true,
      filter: '',
      searchEnabled: true,
      searchField: '',
      minSearch: 0,
      readOnlyValue: false,
      authenticate: false,
      template: '<span>{{ item.label }}</span>',
      selectFields: '',
      searchThreshold: 0.3,
      uniqueOptions: false,
      tableView: true,
      fuseOptions: {
        include: 'score',
        threshold: 0.3,
      },
      customOptions: {},
      useExactSearch: false,
    }, ...extend);
  }

  static get builderInfo() {
    return {
      title: 'Select',
      group: 'basic',
      icon: 'th-list',
      weight: 70,
      documentation: '/userguide/#select',
      schema: SelectComponent.schema()
    };
  }

  init() {
    super.init();
    this.validators = this.validators.concat(['select', 'onlyAvailableItems']);

    // Trigger an update.
    let updateArgs = [];
    const triggerUpdate = _.debounce((...args) => {
      updateArgs = [];
      return this.updateItems.apply(this, args);
    }, 100);
    this.triggerUpdate = (...args) => {
      if (args.length) {
        updateArgs = args;
      }
      return triggerUpdate(...updateArgs);
    };

    // Keep track of the select options.
    this.selectOptions = [];

    if (this.isInfiniteScrollProvided) {
      this.isFromSearch = false;

      this.searchServerCount = null;
      this.defaultServerCount = null;

      this.isScrollLoading = false;

      this.searchDownloadedResources = [];
      this.defaultDownloadedResources = [];
    }

    // If this component has been activated.
    this.activated = false;

    // Determine when the items have been loaded.
    this.itemsLoaded = new NativePromise((resolve) => {
      this.itemsLoadedResolve = resolve;
    });
  }

  get dataReady() {
    return this.itemsLoaded;
  }

  get defaultSchema() {
    return SelectComponent.schema();
  }

  get emptyValue() {
    if (this.component.multiple) {
      return [];
    }
    // if select has JSON data source type, we are defining if empty value would be an object or a string by checking JSON's first item
    if (this.component.dataSrc === 'json' && this.component.data.json) {
      const firstItem = this.component.data.json[0];
      let firstValue;
      if (this.valueProperty) {
        firstValue = _.get(firstItem, this.valueProperty);
      }
      else {
        firstValue = firstItem;
      }
      if (firstValue && typeof firstValue === 'string') {
        return '';
      }
      else {
        return {};
      }
    }
    if (this.valueProperty) {
      return '';
    }
    return {};
  }

  get overlayOptions() {
    return this.parent && this.parent.component && this.parent.component.type === 'table';
  }

  get valueProperty() {
    if (this.component.valueProperty) {
      return this.component.valueProperty;
    }
    // Force values datasource to use values without actually setting it on the component settings.
    if (this.component.dataSrc === 'values') {
      return 'value';
    }

    return '';
  }

  get inputInfo() {
    const info = super.elementInfo();
    info.type = 'select';
    info.changeEvent = 'change';
    return info;
  }

  get isSelectResource() {
    return this.component.dataSrc === 'resource';
  }

  get isSelectURL() {
    return this.component.dataSrc === 'url';
  }

  get isInfiniteScrollProvided() {
    return this.isSelectResource || this.isSelectURL;
  }

  get shouldDisabled() {
    return super.shouldDisabled || this.parentDisabled;
  }

  isEntireObjectDisplay() {
    return this.component.dataSrc === 'resource' && this.valueProperty === 'data';
  }

  itemTemplate(data) {
    if (_.isEmpty(data)) {
      return '';
    }

    // If they wish to show the value in read only mode, then just return the itemValue here.
    if (this.options.readOnly && this.component.readOnlyValue) {
      return this.itemValue(data);
    }

    // Perform a fast interpretation if we should not use the template.
    if (data && !this.component.template) {
      const itemLabel = data.label || data;
      return (typeof itemLabel === 'string') ? this.t(itemLabel) : itemLabel;
    }
    if (typeof data === 'string') {
      return this.t(data);
    }

    if (data.data) {
      // checking additional fields in the template for the selected Entire Object option
      const hasNestedFields = /item\.data\.\w*/g.test(this.component.template);
      data.data = this.isEntireObjectDisplay() && _.isObject(data.data) && !hasNestedFields
        ? JSON.stringify(data.data)
        : data.data;
    }
    const template = this.sanitize(this.component.template ? this.interpolate(this.component.template, { item: data }) : data.label);
    if (template) {
      const label = template.replace(/<\/?[^>]+(>|$)/g, '');
      if (!label || !this.t(label)) return;
      return template.replace(label, this.t(label));
    }
    else {
      return JSON.stringify(data);
    }
  }

  /**
   * Adds an option to the select dropdown.
   *
   * @param value
   * @param label
   */
  addOption(value, label, attrs = {}, id = getRandomComponentId()) {
    if (_.isNil(label)) return;
    const idPath = this.component.idPath
      ? this.component.idPath.split('.').reduceRight((obj, key) => ({ [key]: obj }), id)
      : {};
    const option = {
      value: this.getOptionValue(value),
      label,
      ...idPath
    };

    const skipOption = this.component.uniqueOptions
      ? !!this.selectOptions.find((selectOption) => _.isEqual(selectOption.value, option.value))
      : false;

    if (skipOption) {
      return;
    }

    if (value) {
      this.selectOptions.push(option);
    }

    if (this.refs.selectContainer && (this.component.widget === 'html5')) {
      // Add element to option so we can reference it later.
      const div = document.createElement('div');
      div.innerHTML = this.sanitize(this.renderTemplate('selectOption', {
        selected: _.isEqual(this.dataValue, option.value),
        option,
        attrs,
        id,
        useId: (this.valueProperty === '') && _.isObject(value) && id,
      })).trim();

      option.element = div.firstChild;
      this.refs.selectContainer.appendChild(option.element);
    }
  }

  addValueOptions(items) {
    items = items || [];
    let added = false;
    if (!this.selectOptions.length) {
      // Add the currently selected choices if they don't already exist.
      const currentChoices = Array.isArray(this.dataValue) ? this.dataValue : [this.dataValue];
      added = this.addCurrentChoices(currentChoices, items);
      if (!added && !this.component.multiple) {
        this.addPlaceholder();
      }
    }
    return added;
  }

  disableInfiniteScroll() {
    if (!this.downloadedResources) {
      return;
    }

    this.downloadedResources.serverCount = this.downloadedResources.length;
    this.serverCount = this.downloadedResources.length;
  }

  /* eslint-disable max-statements */
  setItems(items, fromSearch) {
    // If the items is a string, then parse as JSON.
    if (typeof items == 'string') {
      try {
        items = JSON.parse(items);
      }
      catch (err) {
        console.warn(err.message);
        items = [];
      }
    }

    // Allow js processing (needed for form builder)
    if (this.component.onSetItems && typeof this.component.onSetItems === 'function') {
      const newItems = this.component.onSetItems(this, items);
      if (newItems) {
        items = newItems;
      }
    }

    if (!this.choices && this.refs.selectContainer) {
      if (this.loading) {
        // this.removeChildFrom(this.refs.input[0], this.selectContainer);
      }

      this.empty(this.refs.selectContainer);
    }

    // If they provided select values, then we need to get them instead.
    if (this.component.selectValues) {
      items = _.get(items, this.component.selectValues, items) || [];
    }

    let areItemsEqual;

    if (this.isInfiniteScrollProvided) {
      areItemsEqual = this.isSelectURL ? _.isEqual(items, this.downloadedResources) : false;

      const areItemsEnded = this.component.limit > items.length;
      const areItemsDownloaded = areItemsEqual
        && this.downloadedResources
        && this.downloadedResources.length === items.length;

      if (areItemsEnded) {
        this.disableInfiniteScroll();
      }
      else if (areItemsDownloaded) {
        this.selectOptions = [];
      }
      else {
        this.serverCount = items.serverCount;
      }
    }

    if (this.isScrollLoading && items) {
      if (!areItemsEqual) {
        this.downloadedResources = this.downloadedResources
          ? this.downloadedResources.concat(items)
          : items;
      }

      this.downloadedResources.serverCount = items.serverCount || this.downloadedResources.serverCount;
    }
    else {
      this.downloadedResources = items || [];
      this.selectOptions = [];
      // If there is new select option with same id as already selected, set the new one
      if (!_.isEmpty(this.dataValue) && this.component.idPath) {
        const selectedOptionId = _.get(this.dataValue, this.component.idPath, null);
        const newOptionWithSameId = !_.isNil(selectedOptionId) && items.find(item => {
          const itemId = _.get(item, this.component.idPath);

          return itemId === selectedOptionId;
        });

        if (newOptionWithSameId) {
          this.setValue(newOptionWithSameId);
        }
      }
    }

    // Add the value options.
    if (!fromSearch) {
      this.addValueOptions(items);
    }

    if (this.component.widget === 'html5' && !this.component.placeholder) {
      this.addOption(null, '');
    }

    // Iterate through each of the items.
    _.each(items, (item, index) => {
      // preventing references of the components inside the form to the parent form when building forms
      if (this.root && this.root.options.editForm && this.root.options.editForm._id && this.root.options.editForm._id === item._id) return;
      this.addOption(this.itemValue(item), this.itemTemplate(item), {}, _.get(item, this.component.idPath, String(index)));
    });

    if (this.choices) {
      this.choices.setChoices(this.selectOptions, 'value', 'label', true);

      if (this.overlayOptions) {
        const { element: optionsDropdown } = this.choices.dropdown;

        optionsDropdown.style.position = 'fixed';

        const recalculatePosition = () => {
          const { top, height, width } = this.element.getBoundingClientRect();

          optionsDropdown.style.top = `${top + height}px`;
          optionsDropdown.style.width = `${width}px`;
        };

        recalculatePosition();

        ['scroll', 'resize'].forEach(
          eventType => this.addEventListener(window, eventType, recalculatePosition)
        );
      }
    }
    else if (this.loading) {
      // Re-attach select input.
      // this.appendTo(this.refs.input[0], this.selectContainer);
    }

    // We are no longer loading.
    this.isScrollLoading = false;
    this.loading = false;

    // If a value is provided, then select it.
    if (this.dataValue) {
      this.setValue(this.dataValue, {
        noUpdateEvent: true
      });
    }
    else {
      // If a default value is provided then select it.
      const defaultValue = this.multiple ? this.defaultValue || [] : this.defaultValue;
      if (defaultValue) {
        this.setValue(defaultValue);
      }
    }

    // Say we are done loading the items.
    this.itemsLoadedResolve();
  }
  /* eslint-enable max-statements */

  loadItems(url, search, headers, options, method, body) {
    options = options || {};

    // See if they have not met the minimum search requirements.
    const minSearch = parseInt(this.component.minSearch, 10);
    if (
      this.component.searchField &&
      (minSearch > 0) &&
      (!search || (search.length < minSearch))
    ) {
      // Set empty items.
      return this.setItems([]);
    }

    // Ensure we have a method and remove any body if method is get
    method = method || 'GET';
    if (method.toUpperCase() === 'GET') {
      body = null;
    }

    const limit = this.component.limit || 100;
    const skip = this.isScrollLoading ? this.selectOptions.length : 0;
    const query = (this.component.dataSrc === 'url') ? {} : {
      limit,
      skip,
    };

    // Allow for url interpolation.
    url = this.interpolate(url, {
      formioBase: Formio.getBaseUrl(),
      search,
      limit,
      skip,
      page: Math.abs(Math.floor(skip / limit))
    });

    // Add search capability.
    if (this.component.searchField && search) {
      if (Array.isArray(search)) {
        query[`${this.component.searchField}`] = search.join(',');
      }
      else {
        query[`${this.component.searchField}`] = search;
      }
    }

    // If they wish to return only some fields.
    if (this.component.selectFields) {
      query.select = this.component.selectFields;
    }

    // Add sort capability
    if (this.component.sort) {
      query.sort = this.component.sort;
    }

    if (!_.isEmpty(query)) {
      // Add the query string.
      url += (!url.includes('?') ? '?' : '&') + Formio.serialize(query, (item) => this.interpolate(item));
    }

    // Add filter capability
    if (this.component.filter) {
      url += (!url.includes('?') ? '?' : '&') + this.interpolate(this.component.filter);
    }

    // Make the request.
    options.header = headers;
    this.loading = true;

    Formio.makeRequest(this.options.formio, 'select', url, method, body, options)
      .then((response) => {
        this.loading = false;
        this.setItems(response, !!search);
      })
      .catch((err) => {
        if (this.isInfiniteScrollProvided) {
          this.setItems([]);
          this.disableInfiniteScroll();
        }

        this.isScrollLoading = false;
        this.loading = false;
        this.itemsLoadedResolve();
        this.emit('componentError', {
          component: this.component,
          message: err.toString(),
        });
        console.warn(`Unable to load resources for ${this.key}`);
      });
  }

  /**
   * Get the request headers for this select dropdown.
   */
  get requestHeaders() {
    // Create the headers object.
    const headers = new Formio.Headers();

    // Add custom headers to the url.
    if (this.component.data && this.component.data.headers) {
      try {
        _.each(this.component.data.headers, (header) => {
          if (header.key) {
            headers.set(header.key, this.interpolate(header.value));
          }
        });
      }
      catch (err) {
        console.warn(err.message);
      }
    }

    return headers;
  }

  getCustomItems() {
    return this.evaluate(this.component.data.custom, {
      values: []
    }, 'values');
  }

  updateCustomItems() {
    this.setItems(this.getCustomItems() || []);
  }

  refresh(value, { instance }) {
    if (this.component.clearOnRefresh && (instance && !instance.pristine)) {
      this.setValue(this.emptyValue);
    }

    if (this.component.lazyLoad) {
      this.activated = false;
      this.loading = true;
      this.setItems([]);
      return;
    }

    this.updateItems(null, true);
  }

  get additionalResourcesAvailable() {
    return _.isNil(this.serverCount) || (this.serverCount > this.downloadedResources.length);
  }

  get serverCount() {
    if (this.isFromSearch) {
      return this.searchServerCount;
    }

    return this.defaultServerCount;
  }

  set serverCount(value) {
    if (this.isFromSearch) {
      this.searchServerCount = value;
    }
    else {
      this.defaultServerCount = value;
    }
  }

  get downloadedResources() {
    if (this.isFromSearch) {
      return this.searchDownloadedResources;
    }

    return this.defaultDownloadedResources;
  }

  set downloadedResources(value) {
    if (this.isFromSearch) {
      this.searchDownloadedResources = value;
    }
    else {
      this.defaultDownloadedResources = value;
    }
  }

  /* eslint-disable max-statements */
  updateItems(searchInput, forceUpdate) {
    this.itemsLoaded = new NativePromise((resolve) => {
      this.itemsLoadedResolve = resolve;
    });
    if (!this.component.data) {
      console.warn(`Select component ${this.key} does not have data configuration.`);
      this.itemsLoadedResolve();
      return;
    }

    // Only load the data if it is visible.
    if (!this.checkConditions()) {
      this.itemsLoadedResolve();
      return;
    }

    switch (this.component.dataSrc) {
      case 'values':
        this.setItems(this.component.data.values);
        break;
      case 'json':
        this.setItems(this.component.data.json);
        break;
      case 'custom':
        this.updateCustomItems();
        break;
      case 'resource': {
        // If there is no resource, or we are lazyLoading, wait until active.
        if (!this.component.data.resource || (!forceUpdate && !this.active)) {
          return;
        }

        let resourceUrl = this.options.formio ? this.options.formio.formsUrl : `${Formio.getProjectUrl()}/form`;
        resourceUrl += (`/${this.component.data.resource}/submission`);

        if (forceUpdate || this.additionalResourcesAvailable || !this.serverCount) {
          try {
            this.loadItems(resourceUrl, searchInput, this.requestHeaders);
          }
          catch (err) {
            console.warn(`Unable to load resources for ${this.key}`);
          }
        }
        else {
          this.setItems(this.downloadedResources);
        }
        break;
      }
      case 'url': {
        if (!forceUpdate && !this.active && !this.calculatedValue) {
          // If we are lazyLoading, wait until activated.
          return;
        }
        let { url } = this.component.data;
        let method;
        let body;

        if (url.startsWith('/')) {
          // if URL starts with '/project', we should use base URL to avoid issues with URL formed like <base_url>/<project_name>/project/<project_id>/...
          const baseUrl = url.startsWith('/project') ? Formio.getBaseUrl() : Formio.getProjectUrl() || Formio.getBaseUrl();
          url = baseUrl + url;
        }

        if (!this.component.data.method) {
          method = 'GET';
        }
        else {
          method = this.component.data.method;
          if (method.toUpperCase() === 'POST') {
            body = this.component.data.body;
          }
          else {
            body = null;
          }
        }
        const options = this.component.authenticate ? {} : { noToken: true };
        this.loadItems(url, searchInput, this.requestHeaders, options, method, body);
        break;
      }
      case 'indexeddb': {
        if (!window.indexedDB) {
          window.alert("Your browser doesn't support current version of indexedDB");
        }

        if (this.component.indexeddb && this.component.indexeddb.database && this.component.indexeddb.table) {
          const request = window.indexedDB.open(this.component.indexeddb.database);

          request.onupgradeneeded = (event) => {
            if (this.component.customOptions) {
              const db = event.target.result;
              const objectStore = db.createObjectStore(this.component.indexeddb.table, { keyPath: 'myKey', autoIncrement: true });
              objectStore.transaction.oncomplete = () => {
                const transaction = db.transaction(this.component.indexeddb.table, 'readwrite');
                this.component.customOptions.forEach((item) => {
                  transaction.objectStore(this.component.indexeddb.table).put(item);
                });
              };
            }
          };

          request.onerror = () => {
            window.alert(request.errorCode);
          };

          request.onsuccess = (event) => {
            const db = event.target.result;
            const transaction = db.transaction(this.component.indexeddb.table, 'readwrite');
            const objectStore = transaction.objectStore(this.component.indexeddb.table);
            new NativePromise((resolve) => {
              const responseItems = [];
              objectStore.getAll().onsuccess = (event) => {
                event.target.result.forEach((item) => {
                  responseItems.push(item);
                });
                resolve(responseItems);
              };
            }).then((items) => {
              if (!_.isEmpty(this.component.indexeddb.filter)) {
                items = _.filter(items, this.component.indexeddb.filter);
              }
              this.setItems(items);
            });
          };
        }
      }
    }
  }
  /* eslint-enable max-statements */

  addPlaceholder() {
    if (!this.component.placeholder) {
      return;
    }

    this.addOption('', this.component.placeholder, { placeholder: true });
  }

  /**
   * Activate this select control.
   */
  activate() {
    if (this.loading || !this.active) {
      this.setLoadingItem();
    }
    if (this.active) {
      return;
    }
    this.activated = true;
    this.triggerUpdate();
  }

  setLoadingItem(addToCurrentList = false) {
    if (this.choices) {
      if (addToCurrentList) {
        this.choices.setChoices([{
          value: `${this.id}-loading`,
          label: 'Loading...',
          disabled: true,
        }], 'value', 'label');
      }
      else {
        this.choices.setChoices([{
          value: '',
          label: `<i class="${this.iconClass('refresh')}" style="font-size:1.3em;"></i>`,
          disabled: true,
        }], 'value', 'label', true);
      }
    }
    else if (this.component.dataSrc === 'url' || this.component.dataSrc === 'resource') {
      this.addOption('', this.t('loading...'));
    }
  }

  get active() {
    return !this.component.lazyLoad || this.activated || this.options.readOnly;
  }

  render() {
    const info = this.inputInfo;
    const styles = this.overlayOptions
      ? {
        position: 'fixed',
        display: 'block',
        width: '400px',
        height: '100%',
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        'z-index': 2
      }
      : null;
    info.attr = info.attr || {};
    info.multiple = this.component.multiple;
    return super.render(this.wrapElement(this.renderTemplate('select', {
      input: info,
      selectOptions: '',
      styles,
      index: null,
    })));
  }

  wrapElement(element) {
    return this.component.addResource && !this.options.readOnly
      ? (
        this.renderTemplate('resourceAdd', {
          element
        })
      )
      : element;
  }

  choicesOptions() {
    const useSearch = this.component.hasOwnProperty('searchEnabled') ? this.component.searchEnabled : true;
    const placeholderValue = this.t(this.component.placeholder);
    let customOptions = this.component.customOptions || {};
    if (typeof customOptions == 'string') {
      try {
        customOptions = JSON.parse(customOptions);
      }
      catch (err) {
        console.warn(err.message);
        customOptions = {};
      }
    }

    return {
      removeItemButton: this.component.disabled ? false : _.get(this.component, 'removeItemButton', true),
      itemSelectText: '',
      classNames: {
        containerOuter: 'choices form-group formio-choices',
        containerInner: this.transform('class', 'form-control ui fluid selection dropdown')
      },
      addItemText: false,
      placeholder: !!this.component.placeholder,
      placeholderValue: placeholderValue,
      noResultsText: this.t('No results found'),
      noChoicesText: this.t('No choices to choose from'),
      searchPlaceholderValue: this.t('Type to search'),
      shouldSort: false,
      position: (this.component.dropdown || 'auto'),
      searchEnabled: useSearch,
      searchChoices: !this.component.searchField,
      searchFields: _.get(this, 'component.searchFields', ['label']),
      fuseOptions: this.component.useExactSearch
        ? {}
        : Object.assign(
        {},
        _.get(this, 'component.fuseOptions', {}),
        {
          include: 'score',
          threshold: _.get(this, 'component.searchThreshold', 0.3),
        }
      ),
      valueComparer: _.isEqual,
      resetScrollPosition: false,
      ...customOptions,
    };
  }

  /* eslint-disable max-statements */
  attach(element) {
    const superAttach = super.attach(element);
    this.loadRefs(element, {
      selectContainer: 'single',
      addResource: 'single',
      autocompleteInput: 'single'
    });
    //enable autocomplete for select
    const autocompleteInput = this.refs.autocompleteInput;
    if (autocompleteInput) {
      this.addEventListener(autocompleteInput, 'change', (event) => {
        this.setValue(event.target.value);
      });
    }
    const input = this.refs.selectContainer;
    if (!input) {
      return;
    }
    this.addEventListener(input, this.inputInfo.changeEvent, () => this.updateValue(null, {
      modified: true
    }));
    this.attachRefreshOnBlur();

    if (this.component.widget === 'html5') {
      this.triggerUpdate(null, true);
      this.setItems(this.selectOptions || []);
      this.focusableElement = input;
      this.addEventListener(input, 'focus', () => this.update());
      this.addEventListener(input, 'keydown', (event) => {
        const { key } = event;

        if (['Backspace', 'Delete'].includes(key)) {
          this.setValue(this.emptyValue);
        }
      });

      return;
    }

    const tabIndex = input.tabIndex;
    this.addPlaceholder();
    input.setAttribute('dir', this.i18next.dir());
    if (this.choices) {
      this.choices.destroy();
    }

    const choicesOptions = this.choicesOptions();
    this.choices = new Choices(input, choicesOptions);

    if (this.selectOptions && this.selectOptions.length) {
      this.choices.setChoices(this.selectOptions, 'value', 'label', true);
    }

    if (this.component.multiple) {
      this.focusableElement = this.choices.input.element;
    }
    else {
      this.focusableElement = this.choices.containerInner.element;
      this.choices.containerOuter.element.setAttribute('tabIndex', '-1');
      if (choicesOptions.searchEnabled) {
        this.addEventListener(this.choices.containerOuter.element, 'focus', () => this.focusableElement.focus());
      }
    }

    if (this.isInfiniteScrollProvided) {
      this.scrollList = this.choices.choiceList.element;
      this.addEventListener(this.scrollList, 'scroll', () => this.onScroll());
    }

    this.focusableElement.setAttribute('tabIndex', tabIndex);

    // If a search field is provided, then add an event listener to update items on search.
    if (this.component.searchField) {
      // Make sure to clear the search when no value is provided.
      if (this.choices && this.choices.input && this.choices.input.element) {
        this.addEventListener(this.choices.input.element, 'input', (event) => {
          this.isFromSearch = !!event.target.value;

          if (!event.target.value) {
            this.triggerUpdate();
          }
          else {
            this.serverCount = null;
            this.downloadedResources = [];
          }
        });
      }

      this.addEventListener(input, 'choice', () => {
        if (this.component.multiple && this.component.dataSrc === 'resource' && this.isFromSearch) {
          this.triggerUpdate();
        }
        this.isFromSearch = false;
      });
      this.addEventListener(input, 'search', (event) => this.triggerUpdate(event.detail.value));
      this.addEventListener(input, 'stopSearch', () => this.triggerUpdate());
      this.addEventListener(input, 'hideDropdown', () => {
        this.choices.input.element.value = '';
        this.updateItems(null, true);
      });
    }

    this.addEventListener(input, 'showDropdown', () => this.update());

    if (choicesOptions.placeholderValue && this.choices._isSelectOneElement) {
      this.addPlaceholderItem(choicesOptions.placeholderValue);

      this.addEventListener(input, 'removeItem', () => {
        this.addPlaceholderItem(choicesOptions.placeholderValue);
      });
    }

    // Add value options.
    this.addValueOptions();
    this.setChoicesValue(this.dataValue);

    if (this.isSelectResource && this.refs.addResource) {
      this.addEventListener(this.refs.addResource, 'click', (event) => {
        event.preventDefault();

        const formioForm = this.ce('div');
        const dialog = this.createModal(formioForm);

        const projectUrl = _.get(this.root, 'formio.projectUrl', Formio.getBaseUrl());
        const formUrl = `${projectUrl}/form/${this.component.data.resource}`;
        new Form(formioForm, formUrl, {}).ready
          .then((form) => {
            form.on('submit', (submission) => {
              // If valueProperty is set, replace the submission with the corresponding value
              let value = this.valueProperty ? _.get(submission, this.valueProperty) : submission;

              if (this.component.multiple) {
                value = [...this.dataValue, value];
              }
              this.setValue(value);
              this.triggerUpdate();
              dialog.close();
            });
          });
      });
    }

    // Force the disabled state with getters and setters.
    this.disabled = this.shouldDisabled;
    this.triggerUpdate();
    return superAttach;
  }

  get isLoadingAvailable() {
    return !this.isScrollLoading && this.additionalResourcesAvailable;
  }

  onScroll() {
    if (this.isLoadingAvailable) {
      this.isScrollLoading = true;
      this.setLoadingItem(true);
      this.triggerUpdate(this.choices.input.element.value);
    }
  }

  attachRefreshOnBlur() {
    if (this.component.refreshOnBlur) {
      this.on('blur', (instance) => {
        this.checkRefreshOn([{ instance, value: instance.dataValue }], { fromBlur: true });
      });
    }
  }

  addPlaceholderItem(placeholderValue) {
    const items = this.choices._store.activeItems;
    if (!items.length) {
      this.choices._addItem({
        value: placeholderValue,
        label: placeholderValue,
        choiceId: 0,
        groupId: -1,
        customProperties: null,
        placeholder: true,
        keyCode: null
      });
    }
  }

  /* eslint-enable max-statements */

  update() {
    if (this.component.dataSrc === 'custom') {
      this.updateCustomItems();
    }

    // Activate the control.
    this.activate();
  }

  set disabled(disabled) {
    super.disabled = disabled;
    if (!this.choices) {
      return;
    }
    if (disabled) {
      this.setDisabled(this.choices.containerInner.element, true);
      this.focusableElement.removeAttribute('tabIndex');
      this.choices.disable();
    }
    else {
      this.setDisabled(this.choices.containerInner.element, false);
      this.focusableElement.setAttribute('tabIndex', this.component.tabindex || 0);
      this.choices.enable();
    }
  }

  get disabled() {
    return super.disabled;
  }

  set visible(value) {
    // If we go from hidden to visible, trigger a refresh.
    if (value && (!this._visible !== !value)) {
      this.triggerUpdate();
    }
    super.visible = value;
  }

  get visible() {
    return super.visible;
  }

  /**
   * @param {*} value
   * @param {Array} items
   */
  addCurrentChoices(values, items, keyValue) {
    if (!values) {
      return false;
    }
    const notFoundValuesToAdd = [];
    const added = values.reduce((defaultAdded, value) => {
      if (!value || _.isEmpty(value)) {
        return defaultAdded;
      }
      let found = false;

      // Make sure that `items` and `this.selectOptions` points
      // to the same reference. Because `this.selectOptions` is
      // internal property and all items are populated by
      // `this.addOption` method, we assume that items has
      // 'label' and 'value' properties. This assumption allows
      // us to read correct value from the item.
      const isSelectOptions = items === this.selectOptions;
      if (items && items.length) {
        _.each(items, (choice) => {
          if (choice._id && value._id && (choice._id === value._id)) {
            found = true;
            return false;
          }
          const itemValue = keyValue ? choice.value : this.itemValue(choice, isSelectOptions);
          found |= _.isEqual(itemValue, value);
          return found ? false : true;
        });
      }

      // Add the default option if no item is found.
      if (!found) {
        notFoundValuesToAdd.push({
          value: this.itemValue(value),
          label: this.itemTemplate(value)
        });
        return true;
      }
      return found || defaultAdded;
    }, false);

    if (notFoundValuesToAdd.length) {
      if (this.choices) {
        this.choices.setChoices(notFoundValuesToAdd, 'value', 'label');
      }
      else {
        notFoundValuesToAdd.map(notFoundValue => {
          this.addOption(notFoundValue.value, notFoundValue.label);
        });
      }
    }
    return added;
  }

  getValueAsString(data) {
    return (this.component.multiple && Array.isArray(data))
      ? data.map(this.asString.bind(this)).join(', ')
      : this.asString(data);
  }

  getValue() {
    // If the widget isn't active.
    if (
      this.viewOnly || this.loading
      || (!this.component.lazyLoad && !this.selectOptions.length)
      || !this.element
    ) {
      return this.dataValue;
    }

    let value = this.emptyValue;
    if (this.choices) {
      value = this.choices.getValue(true);

      // Make sure we don't get the placeholder
      if (
        !this.component.multiple &&
        this.component.placeholder &&
        (value === this.t(this.component.placeholder))
      ) {
        value = this.emptyValue;
      }
    }
    else if (this.refs.selectContainer) {
      value = this.refs.selectContainer.value;

      if (this.valueProperty === '') {
        if (value === '') {
          return {};
        }

        const option = this.selectOptions[value];
        if (option && _.isObject(option.value)) {
          value = option.value;
        }
      }
    }
    else {
      value = this.dataValue;
    }
    // Choices will return undefined if nothing is selected. We really want '' to be empty.
    if (value === undefined || value === null) {
      value = '';
    }
    return value;
  }

  redraw() {
    const done = super.redraw();
    this.triggerUpdate();
    return done;
  }

  normalizeSingleValue(value) {
    if (_.isNil(value)) {
      return;
    }
    //check if value equals to default emptyValue
    if (_.isObject(value) && Object.keys(value).length === 0) {
      return value;
    }
    const displayEntireObject = this.isEntireObjectDisplay();
    const dataType = this.component.dataType || 'auto';
    const normalize = {
      value,

      number() {
        const numberValue = Number(this.value);
        const isEquivalent = value.toString() === numberValue.toString();

        if (!Number.isNaN(numberValue) && Number.isFinite(numberValue) && value !== '' && isEquivalent) {
          this.value = numberValue;
        }

        return this;
      },

      boolean() {
        if (
          _.isString(this.value)
          && (this.value.toLowerCase() === 'true'
            || this.value.toLowerCase() === 'false')
        ) {
          this.value = (this.value.toLowerCase() === 'true');
        }

        return this;
      },

      string() {
        this.value = String(this.value);
        return this;
      },

      object() {
        if (_.isObject(this.value) && displayEntireObject) {
          this.value = JSON.stringify(this.value);
        }

        return this;
      },

      auto() {
        if (_.isObject(this.value)) {
          this.value = this.object().value;
        }
        else {
          this.value = this.string().number().boolean().value;
        }

        return this;
      }
    };

    try {
      return normalize[dataType]().value;
    }
    catch (err) {
      console.warn('Failed to normalize value', err);
      return value;
    }
  }

  /**
   * Normalize values coming into updateValue.
   *
   * @param value
   * @return {*}
   */
  normalizeValue(value) {
    if (this.component.multiple && Array.isArray(value)) {
      return value.map((singleValue) => this.normalizeSingleValue(singleValue));
    }

    return super.normalizeValue(this.normalizeSingleValue(value));
  }

  setValue(value, flags = {}) {
    const previousValue = this.dataValue;
    const changed = this.updateValue(value, flags);
    value = this.dataValue;
    const hasPreviousValue = Array.isArray(previousValue) ? previousValue.length : previousValue;
    const hasValue = Array.isArray(value) ? value.length : value;

    // Undo typing when searching to set the value.
    if (this.component.multiple && Array.isArray(value)) {
      value = value.map(value => {
        if (typeof value === 'boolean' || typeof value === 'number') {
          return value.toString();
        }
        return value;
      });
    }
    else {
      if (typeof value === 'boolean' || typeof value === 'number') {
        value = value.toString();
      }
    }

    // Do not set the value if we are loading... that will happen after it is done.
    if (this.loading) {
      return changed;
    }

    // Determine if we need to perform an initial lazyLoad api call if searchField is provided.
    if (this.isInitApiCallNeeded(hasValue)) {
      this.loading = true;
      this.lazyLoadInit = true;
      const searchProperty = this.component.searchField || this.component.valueProperty;
      this.triggerUpdate(_.get(value.data || value, searchProperty, value), true);
      return changed;
    }

    // Add the value options.
    this.addValueOptions();
    this.setChoicesValue(value, hasPreviousValue, flags);
    return changed;
  }

  isInitApiCallNeeded(hasValue) {
    return this.component.lazyLoad &&
      !this.lazyLoadInit &&
      !this.active &&
      !this.selectOptions.length &&
      hasValue &&
      this.visible && (this.component.searchField || this.component.valueProperty);
  }

  setChoicesValue(value, hasPreviousValue, flags = {}) {
    const hasValue = Array.isArray(value) ? value.length : value;
    hasPreviousValue = (hasPreviousValue === undefined) ? true : hasPreviousValue;
    if (this.choices) {
      // Now set the value.
      if (hasValue) {
        this.choices.removeActiveItems();
        // Add the currently selected choices if they don't already exist.
        const currentChoices = Array.isArray(value) ? value : [value];
        if (!this.addCurrentChoices(currentChoices, this.selectOptions, true)) {
          this.choices.setChoices(this.selectOptions, 'value', 'label', true);
        }
        this.choices.setChoiceByValue(value);
      }
      else if (hasPreviousValue || flags.resetValue) {
        this.choices.removeActiveItems();
      }
    }
    else {
      if (hasValue) {
        const values = Array.isArray(value) ? value : [value];
        _.each(this.selectOptions, (selectOption) => {
          _.each(values, (val) => {
            if (_.isEqual(val, selectOption.value) && selectOption.element) {
              selectOption.element.selected = true;
              selectOption.element.setAttribute('selected', 'selected');
              return false;
            }
          });
        });
      }
      else {
        _.each(this.selectOptions, (selectOption) => {
          if (selectOption.element) {
            selectOption.element.selected = false;
            selectOption.element.removeAttribute('selected');
          }
        });
      }
    }
  }

  validateValueAvailability(setting, value) {
    if (!boolValue(setting) || !value) {
      return true;
    }

    const values = this.getOptionsValues();

    if (values) {
      if (_.isObject(value)) {
        const compareComplexValues = (optionValue) => {
          const normalizedOptionValue = this.normalizeSingleValue(optionValue);

          if (!_.isObject(normalizedOptionValue)) {
            return false;
          }

          try {
            return (JSON.stringify(normalizedOptionValue) === JSON.stringify(value));
          }
          catch (err) {
            console.warn.error('Error while comparing items', err);
            return false;
          }
        };

        return values.findIndex((optionValue) => compareComplexValues(optionValue)) !== -1;
      }

      return values.findIndex((optionValue) => this.normalizeSingleValue(optionValue) === value) !== -1;
    }
    return false;
  }

  /**
   * Performs required transformations on the initial value to use in selectOptions
   * @param {*} value
   */
  getOptionValue(value) {
    return _.isObject(value) && this.isEntireObjectDisplay()
    ? this.normalizeSingleValue(value)
    : _.isObject(value)
      ? value
      : _.isNull(value)
        ? this.emptyValue
        : String(this.normalizeSingleValue(value));
  }

  /**
   * If component has static values (values, json) or custom values, returns an array of them
   * @returns {Array<*>|undefiened}
   */
  getOptionsValues() {
    let rawItems = [];
    switch (this.component.dataSrc) {
      case 'values':
        rawItems = this.component.data.values;
        break;
      case 'json':
        rawItems = this.component.data.json;
        break;
      case 'custom':
        rawItems = this.getCustomItems();
        break;
    }

    if (typeof rawItems === 'string') {
      try {
        rawItems = JSON.parse(rawItems);
      }
      catch (err) {
        console.warn(err.message);
        rawItems = [];
      }
    }

    if (!Array.isArray(rawItems)) {
      return;
    }

    return rawItems.map((item) => this.getOptionValue(this.itemValue(item)));
  }

  /**
   * Deletes the value of the component.
   */
  deleteValue() {
    this.setValue('', {
      noUpdateEvent: true
    });
    this.unset();
  }

  /**
   * Check if a component is eligible for multiple validation
   *
   * @return {boolean}
   */
  validateMultiple() {
    // Select component will contain one input when flagged as multiple.
    return false;
  }

  /**
   * Output this select dropdown as a string value.
   * @return {*}
   */

  isBooleanOrNumber(value) {
    return typeof value === 'number' || typeof value === 'boolean';
  }

  getNormalizedValues() {
    if (!this.component || !this.component.data || !this.component.data.values) {
      return;
    }
    return this.component.data.values.map(
      value => ({ label: value.label, value: String(this.normalizeSingleValue(value.value)) })
    );
  }

  asString(value) {
    value = value || this.getValue();
    //need to convert values to strings to be able to compare values with available options that are strings
    const convertToString = (data, valueProperty) => {
      if (valueProperty) {
        if (Array.isArray(data)) {
          data.forEach((item) => item[valueProperty] = item[valueProperty].toString());
        }
        else {
          data[valueProperty] = data[valueProperty].toString();
        }
        return data;
      }

      if (this.isBooleanOrNumber(data)) {
        data = data.toString();
      }

      if (Array.isArray(data) && data.some(item => this.isBooleanOrNumber(item))) {
        data = data.map(item => {
          if (this.isBooleanOrNumber(item)) {
            item = item.toString();
          }
        });
      }

      return data;
    };

    value = convertToString(value);

    if (['values', 'custom'].includes(this.component.dataSrc)) {
      const {
        items,
        valueProperty,
      } = this.component.dataSrc === 'values'
          ? {
            items: convertToString(this.getNormalizedValues(), 'value'),
            valueProperty: 'value',
          }
          : {
            items: convertToString(this.getCustomItems(), this.valueProperty),
            valueProperty: this.valueProperty,
          };

      value = (this.component.multiple && Array.isArray(value))
        ? _.filter(items, (item) => value.includes(item.value))
        : valueProperty
          ? _.find(items, [valueProperty, value])
          : value;
    }

    if (_.isString(value)) {
      return value;
    }

    if (Array.isArray(value)) {
      const items = [];
      value.forEach(item => items.push(this.itemTemplate(item)));
      return items.length > 0 ? items.join('<br />') : '-';
    }

    return !_.isNil(value)
      ? this.itemTemplate(value)
      : '-';
  }

  detach() {
    super.detach();
    if (this.choices) {
      this.choices.destroy();
      this.choices = null;
    }
  }

  focus() {
    if (this.focusableElement) {
      super.focus.call(this);
      this.focusableElement.focus();
    }
  }

  setErrorClasses(elements, dirty, hasError) {
    super.setErrorClasses(elements, dirty, hasError);
    if (this.choices) {
      super.setErrorClasses([this.choices.containerInner.element], dirty, hasError);
    }
    else {
      super.setErrorClasses([this.refs.selectContainer], dirty, hasError);
    }
  }
}