Source: argos-sdk/src/Fields/DurationField.js

/* Copyright (c) 2010, Sage Software, Inc. All rights reserved.
 *
 * 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.
 */
import declare from 'dojo/_base/declare';
import string from 'dojo/string';
import format from '../Format';
import LookupField from './LookupField';
import FieldManager from '../FieldManager';
import getResource from '../I18n';


const resource = getResource('durationField');

/**
 * @class argos.Fields.DurationField
 * @classdesc The Duration field is a mashup of an auto-complete box and a {@link LookupField LookupField} for handling
 * duration's of: minutes, hours, days, weeks or years. Meaning a user can type directly into the input area the
 * amount of time or press the lookup button and choose from pre-determined list of times.
 *
 * When typing in a value directly, the Duration field only supports one "measurement" meaning if you wanted to
 * have 1 hour and 30 minutes you would need to type in 90 minutes or 1.5 hours.
 *
 * The auto-complete happens on blur, so if a user types in 5m they would need to go to the next field (or press
 * Save) and the field will auto-complete to 5 minute(s), letting the user know it accepted the value. If a value
 * entered is not accepted, 5abc, it will default to the last known measurement, defaulting to minutes.
 *
 * Setting and getting the value is always in minutes as a Number.
 *
 * @example
 *     {
 *         name: 'Duration',
 *         property: 'Duration',
 *         label: this.durationText,
 *         type: 'duration',
 *         view: 'durations_list'
 *     }
 *
 * @extends argos.Fields.LookupField
 * @requires argos.FieldManager
 */
const control = declare('argos.Fields.DurationField', [LookupField], /** @lends argos.Fields.DurationField# */{
  /**
   * Maps various attributes of nodes to setters.
   */
  attributeMap: {
    inputValue: {
      node: 'inputNode',
      type: 'attribute',
      attribute: 'value',
    },
    inputDisabled: {
      node: 'inputNode',
      type: 'attribute',
      attribute: 'disabled',
    },
    autoCompleteContent: {
      node: 'autoCompleteNode',
      type: 'attribute',
      attribute: 'innerHTML',
    },
  },
  /**
   * @property {Simplate}
   * Simplate that defines the fields HTML Markup
   *
   * * `$` => Field instance
   * * `$$` => Owner View instance
   *
   */
  widgetTemplate: new Simplate([
    `<label for="{%= $.name %}">{%: $.label %}</label>
    <div class="field field-control-wrapper">
      <div class="autoComplete-watermark" data-dojo-attach-point="autoCompleteNode"></div>
      <button
        class="button field-control-trigger simpleSubHeaderButton {% if ($$.iconClass) { %} {%: $$.iconClass %} {% } %}"
        data-dojo-attach-event="onclick:navigateToListView"
        aria-label="{%: $.lookupLabelText %}">
        <svg class="icon" focusable="false" aria-hidden="true" role="presentation">
          <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#icon-{%: $.iconClass %}"></use>
        </svg>
      </button>
      <input data-dojo-attach-point="inputNode" data-dojo-attach-event="onkeyup: _onKeyUp, onblur: _onBlur, onfocus: _onFocus" class="" type="{%: $.inputType %}" name="{%= $.name %}" {% if ($.readonly) { %} readonly {% } %}>
    </div>`,
  ]),
  iconClass: 'more',

  // Localization
  /**
   * @property {String}
   * Text used when no value or null is set to the field
   */
  emptyText: resource.emptyText,
  /**
   * @property {String}
   * Text displayed when an invalid input is detected
   */
  invalidDurationErrorText: resource.invalidDurationErrorText,
  /**
   * @property {Object}
   * The auto completed text and their corresponding values in minutes (SData is always minutes)
   *
   * Override ride this object to change the autocomplete units or their localization.
   */
  autoCompleteText: {
    1: resource.minutes,
    60: resource.hours,
    1440: resource.days,
    10080: resource.weeks,
    525960: resource.years,
  },
  /**
   * @property {Boolean}
   * Overrides the {@link LookupField LookupField} default to explicitly set it to false forcing
   * the view to use the currentValue instead of a key/descriptor
   */
  valueKeyProperty: false,
  /**
   * @property {Boolean}
   * Overrides the {@link LookupField LookupField} default to explicitly set it to false forcing
   * the view to use the currentValue instead of a key/descriptor
   */
  valueTextProperty: false,

  /**
   * @property {String}
   * The current unit as detected by the parser
   * @private
   */
  currentKey: null,
  /**
   * @property {Number}
   * The current value, expressed as minutes.
   */
  currentValue: 0,

  /**
   * @property {RegExp}
   * Regular expression for capturing the phrase (text).
   *
   * The first capture group must be non-text part
   * Second capture is the phrase to be used in auto complete
   */
  autoCompletePhraseRE: /^((?:\d+(?:\.\d*)?|\.\d+)\s*?)(.+)/,

  /**
   * @property {RegExp}
   * Regular expression for capturing the value.
   * Only one capture which should correlate to the value portion
   */
  autoCompleteValueRE: /^((?:\d+(?:\.\d*)?|\.\d+))/,

  /**
   * Overrides the parent to skip the connections and alter the base capture RegExp's to account for localization
   */
  init: function init() {
    // do not use lookups connects

    const numberDecimalSeparator = Mobile.CultureInfo.numberFormat.numberDecimalSeparator;

    this.autoCompletePhraseRE = new RegExp(
      string.substitute('^((?:\\d+(?:\\${0}\\d*)?|\\${0}\\d+)\\s*?)(.+)', [numberDecimalSeparator])
    );

    this.autoCompleteValueRE = new RegExp(
      string.substitute('^((?:\\d+(?:\\${0}\\d*)?|\\${0}\\d+))', [numberDecimalSeparator])
    );
  },
  /**
   * Handler for onkeyup on the input. The logic for comparing the matched value and phrase to the autocomplete
   * is done here.
   * @param {Event} evt onkeyup
   * @private
   */
  _onKeyUp: function _onKeyUp(/* evt*/) {
    const val = this.inputNode.value.toString();
    const match = this.autoCompletePhraseRE.exec(val);

    if (!match || val.length < 1) {
      this.hideAutoComplete();
      return true;
    }

    for (const key in this.autoCompleteText) {
      if (this.isWordMatch(match[2], this.autoCompleteText[key])) {
        this.currentKey = this.autoCompleteText[key];
        this.showAutoComplete(match[1] + this.autoCompleteText[key]);
        return true;
      }
    }

    this.hideAutoComplete();
  },
  /**
   * Determines if the two provided values are the same word, ignoring capitalization and length:
   *
   * * h, hour(s) = true
   * * hou, hour(s) = true
   * * minn, minute(s) = false
   * * year, year(s) = true
   *
   * @param {String} val First string to compare
   * @param {String} word Second string to compare
   * @return {Boolean} True if they are equal.
   */
  isWordMatch: function isWordMatch(val, word) {
    let newVal = val;
    let newWord = word;

    if (newVal.length > newWord.length) {
      newVal = newVal.slice(0, newWord.length);
    } else {
      newWord = newWord.slice(0, newVal.length);
    }

    return newVal.toUpperCase() === newWord.toUpperCase();
  },
  /**
   * Shows the auto-complete version of the phrase
   * @param {String} word Text to put in the autocomplete
   */
  showAutoComplete: function showAutoComplete(word) {
    this.set('autoCompleteContent', word);
  },
  /**
   * Clears the autocomplete input
   */
  hideAutoComplete: function hideAutoComplete() {
    this.set('autoCompleteContent', '');
  },
  /**
   * Inputs onblur handler, if an auto complete is matched it fills the text out the full text
   * @param evt
   * @return {Boolean}
   * @private
   */
  _onBlur: function _onBlur(/* evt*/) {
    const val = this.inputNode.value.toString();
    const match = this.autoCompleteValueRE.exec(val);
    const multiplier = this.getMultiplier(this.currentKey);
    let newValue = 0;

    if (val.length < 1) {
      return true;
    }

    if (!match) {
      return true;
    }

    newValue = parseFloat(match[0]) * multiplier;
    this.setValue(newValue);
  },
  /**
   * Returns the corresponding value in minutes to the passed key (currentKey)
   * @return {Number}
   */
  getMultiplier: function getMultiplier(key) {
    let k;
    for (k in this.autoCompleteText) {
      if (this.autoCompleteText.hasOwnProperty(k) && key === this.autoCompleteText[k]) {
        break;
      }
    }
    return k;
  },
  /**
   * Returns the current value in minutes
   * @return {Number}
   */
  getValue: function getValue() {
    return this.currentValue;
  },
  /**
   * Sets the currentValue to the passed value, but sets the displayed value after formatting with {@link #textFormat textFormat}.
   * @param {Number} val Number of minutes
   * @param init
   */
  setValue: function setValue(val = 0/* , init*/) {
    let newVal = val;
    if (newVal === null) {
      newVal = 0;
    }

    this.currentValue = newVal;
    this.set('inputValue', this.textFormat(newVal));
    this.hideAutoComplete();
  },
  /**
   * If used as a Lookup, this is invoked with the value of the lookup item.
   * @param val
   * @param {String/Number} key Number of minutes (will be converted via parseFloat)
   */
  setSelection: function setSelection(val, key) {
    this.setValue(parseFloat(key));
  },
  /**
   * Takes the number of minutes and converts it into a textual representation using the `autoCompleteText`
   * collection as aguide
   * @param {Number} val Number of minutes
   * @return {String}
   */
  textFormat: function textFormat(val) {
    let finalUnit = 1;
    const autoCompleteValues = this.autoCompleteText;

    for (const key in autoCompleteValues) {
      if (autoCompleteValues.hasOwnProperty(key)) {
        const stepValue = parseInt(key, 10);
        if (val === 0 && stepValue === 1) {
          this.currentKey = autoCompleteValues[key];
          break;
        }

        if (val / stepValue >= 1) {
          finalUnit = stepValue;
          this.currentKey = autoCompleteValues[key];
        }
      }
    }

    return this.formatUnit(this.convertUnit(val, finalUnit));
  },
  /**
   * Divides two numbers and fixes the decimal point to two places.
   * @param {Number} val
   * @param {Number} to
   * @return {Number}
   */
  convertUnit: function convertUnit(val, to) {
    return format.fixed(val / to, 2);
  },
  /**
   * Formats the unit with correct decimal separator.
   * @param {Number} unit
   * @return {string}
   */
  formatUnit: function formatUnit(unit) {
    let sval;
    if (isNaN(unit)) {
      sval = '0';
    } else {
      sval = unit.toString().split('.');
      if (sval.length === 1) {
        sval = sval[0];
      } else {
        if (sval[1] === '0') {
          sval = sval[0];
        } else {
          sval = string.substitute('${0}${1}${2}', [
            sval[0],
            Mobile.CultureInfo.numberFormat.currencyDecimalSeparator || '.',
            sval[1],
          ]);
        }
      }
    }
    return `${sval} ${this.currentKey}`;
  },
  /**
   * Extends the {@link LookupField#createNavigationOptions parent implementation} to explicitly set hide search
   * to true and data to `this.data`.
   * @return {Object} Navigation options object to be passed
   */
  createNavigationOptions: function createNavigationOptions() {
    const options = this.inherited(arguments);
    options.hideSearch = true;
    options.data = this.expandExpression(this.data);
    return options;
  },
  /**
   * Validets the field by verifying it matches one of the auto complete text.
   * @return {Boolean} False for no-errors, true for error.
   */
  validate: function validate() {
    const val = this.inputNode.value.toString();
    const phraseMatch = this.autoCompletePhraseRE.exec(val);

    if (!phraseMatch) {
      $(this.containerNode).addClass('row-error');
      return string.substitute(this.invalidDurationErrorText, [val]);
    }

    $(this.containerNode).removeClass('row-error');
    return false;
  },
});

export default FieldManager.register('duration', control);