Bläddra i källkod

AMBARI-10158. Implement time-interval spinner widget for config (onechiporenko)

Oleg Nechiporenko 10 år sedan
förälder
incheckning
499b0d9704

+ 1 - 0
ambari-web/app/assets/test/tests.js

@@ -234,6 +234,7 @@ var files = ['test/init_model_test',
   'test/views/common/configs/service_configs_by_category_view_test',
   'test/views/common/configs/service_configs_by_category_view_test',
   'test/views/common/configs/custom_category_views/notification_configs_view_test',
   'test/views/common/configs/custom_category_views/notification_configs_view_test',
   'test/views/common/controls_view_test',
   'test/views/common/controls_view_test',
+  'test/views/common/configs/widgets/time_interval_spinner_view_test',
   'test/views/wizard/step3/hostLogPopupBody_view_test',
   'test/views/wizard/step3/hostLogPopupBody_view_test',
   'test/views/wizard/step3/hostWarningPopupBody_view_test',
   'test/views/wizard/step3/hostWarningPopupBody_view_test',
   'test/views/wizard/step3/hostWarningPopupFooter_view_test',
   'test/views/wizard/step3/hostWarningPopupFooter_view_test',

+ 5 - 6
ambari-web/app/messages.js

@@ -250,6 +250,11 @@ Em.I18n.translations = {
   'common.stdout': "stdout",
   'common.stdout': "stdout",
   'common.stderr': "stderr",
   'common.stderr': "stderr",
   'common.fileName': 'File name',
   'common.fileName': 'File name',
+  'common.days': "Days",
+  'common.hours': "Hours",
+  'common.minutes': "Minutes",
+  'common.seconds': "Seconds",
+  'common.milliseconds': "Milliseconds",
 
 
   'models.alert_instance.tiggered.verbose': "Occured on {0} <br> Checked on {1}",
   'models.alert_instance.tiggered.verbose': "Occured on {0} <br> Checked on {1}",
   'models.alert_definition.triggered.verbose': "Occured on {0}",
   'models.alert_definition.triggered.verbose': "Occured on {0}",
@@ -333,12 +338,6 @@ Em.I18n.translations = {
   'popup.invalid.KDC.admin.principal': 'Admin principal',
   'popup.invalid.KDC.admin.principal': 'Admin principal',
   'popup.invalid.KDC.admin.password': 'Admin password',
   'popup.invalid.KDC.admin.password': 'Admin password',
 
 
-  'popup.dependent.configs.header': 'Dependent Properties',
-  'popup.dependent.configs.title': 'Properties that was changed has dependent properties. It\'s recommended to update these properties!',
-  'popup.dependent.configs.table.saveProperty': 'Save property',
-  'popup.dependent.configs.table.currentValue': 'Current value',
-  'popup.dependent.configs.table.recommendedValue': 'Recommended value',
-
   'login.header':'Sign in',
   'login.header':'Sign in',
   'login.username':'Username',
   'login.username':'Username',
   'login.loginButton':'Sign in',
   'login.loginButton':'Sign in',

+ 41 - 0
ambari-web/app/styles/widgets.less

@@ -70,4 +70,45 @@
     border-radius: 11px;
     border-radius: 11px;
     box-shadow: none;
     box-shadow: none;
   }
   }
+}
+
+.spinner-input-widget {
+
+  .spinner-input {
+    display: inline-block;
+    padding: 0 5px;
+    line-height: normal;
+    float: left;
+
+    .input-append.input-prepend {
+       margin: 0;
+    }
+
+    input {
+      width: 20px;
+      font-size: 12px;
+      text-align: center;
+    }
+
+    .btn {
+      &:first-child {
+        padding-left: 4px;
+        padding-right: 2px;
+      }
+
+      &:last-child {
+        padding-left: 2px;
+        padding-right: 4px;
+      }
+    }
+
+    .spinner-input-label {
+      font-size: 11px;
+    }
+  }
+
+  .restore-btn {
+    padding: 2px 10px;
+    float: left;
+  }
 }
 }

+ 33 - 0
ambari-web/app/templates/common/configs/widgets/time_interval_spinner.hbs

@@ -0,0 +1,33 @@
+{{!
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you 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.
+}}
+
+{{#each spinnerContent in view.content}}
+  {{view App.SpinnerInputView contentBinding="spinnerContent" disabledBinding="view.disabled"}}
+{{/each}}
+{{#if view.valueIsChanged}}
+  <div class="restore-btn">
+    <a class="btn btn-small" href="#" {{action "restoreValue" target="view"}}>
+      <i class="icon-undo"></i>
+    </a>
+  </div>
+{{/if}}
+{{#if view.errorMessage}}
+  <div class="clearfix"></div>
+  <p class="text-error">{{view.errorMessage}}</p>
+{{/if}}
+<div class="clearfix"></div>

+ 26 - 0
ambari-web/app/templates/common/form/spinner_input.hbs

@@ -0,0 +1,26 @@
+{{!
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you 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.
+}}
+
+<div class="input-prepend input-append">
+  <button class="btn" {{bindAttr disabled="view.disabled"}} {{action decrementValue target="view"}}><span class="icon icon-caret-left"></span></button>
+  {{view Em.TextField valueBinding="view.content.value" disabledBinding="view.disabled"}}
+  <button class="btn" {{bindAttr disabled="view.disabled"}} {{action incrementValue target="view"}}><span class="icon icon-caret-right"></span></button>
+</div>
+{{#if view.content.label}}
+  <span class="spinner-input-label">{{view.content.label}}</span>
+{{/if}}

+ 2 - 0
ambari-web/app/views.js

@@ -40,6 +40,7 @@ require('views/common/select_custom_date_view');
 require('views/common/metric');
 require('views/common/metric');
 require('views/common/time_range');
 require('views/common/time_range');
 require('views/common/form/field');
 require('views/common/form/field');
+require('views/common/form/spinner_input_view');
 require('views/common/quick_view_link_view');
 require('views/common/quick_view_link_view');
 require('views/common/configs/services_config');
 require('views/common/configs/services_config');
 require('views/common/configs/service_config_container_view');
 require('views/common/configs/service_config_container_view');
@@ -53,6 +54,7 @@ require('views/common/configs/custom_category_views/notification_configs_view');
 require('views/common/configs/widgets/config_widget_view');
 require('views/common/configs/widgets/config_widget_view');
 require('views/common/configs/widgets/list_config_widget_view');
 require('views/common/configs/widgets/list_config_widget_view');
 require('views/common/configs/widgets/slider_config_widget_view');
 require('views/common/configs/widgets/slider_config_widget_view');
+require('views/common/configs/widgets/time_interval_spinner_view');
 require('views/common/filter_combobox');
 require('views/common/filter_combobox');
 require('views/common/filter_combo_cleanable');
 require('views/common/filter_combo_cleanable');
 require('views/common/table_view');
 require('views/common/table_view');

+ 288 - 0
ambari-web/app/views/common/configs/widgets/time_interval_spinner_view.js

@@ -0,0 +1,288 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+var App = require('app');
+
+App.TimeIntervalSpinnerView = App.ConfigWidgetView.extend({
+  templateName: require('templates/common/configs/widgets/time_interval_spinner'),
+  classNames: ['spinner-input-widget'],
+
+  /**
+   * @property isValid
+   * @type {Boolean}
+   */
+  isValid: true,
+
+  /**
+   * @property disabled
+   * @type {Boolean}
+   */
+  disabled: false,
+
+  /**
+   * @property valueIsChanged
+   * @type {Boolean}
+   */
+  valueIsChanged: false,
+
+  /**
+   * Default property value in widget format.
+   *
+   * @property defaultValue
+   * @type {Object[]}
+   */
+  defaultValue: null,
+
+  /**
+   * Maximum property value in widget format.
+   *
+   * @property maxValue
+   * @type {Object[]}
+   */
+  maxValue: null,
+
+  /**
+   * Minimum property value in widget format.
+   *
+   * @property minValue
+   * @type {Object[]}
+   */
+  minValue: null,
+
+  /**
+   * Map with units lookup used for convertation.
+   *
+   * @property timeConvertMap
+   * @type {Object}
+   */
+  timeConvertMap: {
+    milliseconds: [],
+    seconds: [1000],
+    minutes: [60, 1000],
+    hours: [60, 60, 1000],
+    days: [24, 60, 60, 1000]
+  },
+
+  /**
+   * Map with maximum value per unit.
+   *
+   * @property timeMaxValueOverflow
+   * @type {Object}
+   */
+  timeMaxValueOverflow: {
+    milliseconds: 999,
+    seconds: 59,
+    minutes: 59,
+    hours: 23,
+    days: 365
+  },
+
+  didInsertElement: function () {
+    this.prepareContent();
+    this._super();
+  },
+
+  /**
+   * Content setter.
+   * Affects to view attributes:
+   *  @see propertyUnit
+   *  @see defaultValue
+   *  @see minValue
+   *  @see maxValue
+   *       content
+   */
+  prepareContent: function() {
+    var self = this;
+    var property = this.get('config');
+    var propertyUnit = property.get('stackConfigProperty.valueAttributes').unit;
+
+    Em.run.once(function() {
+      self.set('propertyUnit', propertyUnit);
+      self.set('minValue', self.generateWidgetValue(property.get('stackConfigProperty.valueAttributes.minimum')));
+      self.set('maxValue', self.generateWidgetValue(property.get('stackConfigProperty.valueAttributes.maximum')));
+      self.set('content', self.generateWidgetValue(property.get('value')));
+    });
+  },
+
+  /**
+   * Generate formatted value for widget.
+   *
+   * @param {String|Number} value
+   * @returns {Object[]}
+   */
+  generateWidgetValue: function(value) {
+    var property = this.get('config');
+    var widgetUnits = property.get('stackConfigProperty.widget.units.firstObject.unit');
+    var propertyUnit = property.get('stackConfigProperty.valueAttributes').unit;
+    return this.convertToWidgetUnits(value, propertyUnit, widgetUnits);
+  },
+  /**
+   * Convert property value to widget format units.
+   *
+   * @param {String|Number} input - time to convert
+   * @param {String} inputUnitType - type of input value e.g. "milliseconds"
+   * @param {String|String[]} desiredUnits - units to convert input value e.g. ['days', 'hours', 'minutes']
+   *   or 'days,hours,minutes'
+   * @return {Object[]} - converted values according to desiredUnits order. Returns object
+   *   contains unit, value, label, minValue, maxValue, invertOnOverflow attributes according to desiredUnits array
+   *   For example:
+   *   <code>
+   *     [
+   *       {unit: 'days', label: 'Days', value: 2, minValue: 0, maxValue: 23},
+   *       {unit: 'hours', label: 'Hours', value: 3, minValue: 0, maxValue: 59}
+   *     ]
+   *   </code>
+   */
+  convertToWidgetUnits: function(input, inputUnitType, desiredUnits) {
+    var self = this;
+    var time = parseInt(input);
+    var msUnitMap = this.generateUnitsTable(desiredUnits, inputUnitType);
+    if (typeof desiredUnits == 'string') {
+      desiredUnits = desiredUnits.split(',');
+    }
+
+    return desiredUnits.map(function(item) {
+      var unitMs = msUnitMap[item];
+      var unitValue = Math.floor(time/unitMs);
+      time = time - unitValue*unitMs;
+
+      return {
+        label: Em.I18n.t('common.' + item),
+        unit: item,
+        value: unitValue,
+        minValue: 0,
+        maxValue: Em.get(self, 'timeMaxValueOverflow.' + item),
+        invertOnOverflow: true
+      };
+    });
+  },
+
+  /**
+   * Convert widget value to config property format.
+   *
+   * @param {Object[]} widgetValue - formatted value for widget
+   * @param {String} propertyUnit - config property unit to convert
+   * @return {Number}
+   */
+  convertToPropertyUnit: function(widgetValue, propertyUnit) {
+    var widgetUnitNames = widgetValue.mapProperty('unit');
+    var msUnitMap = this.generateUnitsTable(widgetUnitNames, propertyUnit);
+    return widgetUnitNames.map(function(item) {
+      return parseInt(Em.get(widgetValue.findProperty('unit', item), 'value')) * msUnitMap[item];
+    }).reduce(Em.sum);
+  },
+
+  /**
+   * Generate convertion map with specified unit.
+   *
+   * @param {String|String[]} units - units to convert
+   * @param {String} convertationUnit - unit factor name e.g 'milliseconds', 'minutes', 'seconds', etc.
+   */
+  generateUnitsTable: function(units, convertationUnit) {
+    var msUnitMap = $.extend({}, this.get('timeConvertMap'));
+    if (typeof units == 'string') {
+      units = units.split(',');
+    }
+    units.forEach(function(unit) {
+      var keys = Em.keys(msUnitMap);
+      // check the convertion level
+      var distance = keys.indexOf(unit) - keys.indexOf(convertationUnit);
+      var convertPath = msUnitMap[unit].slice(0, distance);
+      // set unit value of the property it always 1
+      if (distance === 0) {
+        msUnitMap[unit] = 1;
+      }
+      if (convertPath.length) {
+        // reduce convert path values to value
+        msUnitMap[unit] = convertPath.reduce(function(p,c) {
+          return p * c;
+        });
+      }
+    });
+
+    return msUnitMap;
+  },
+
+  /**
+   * Subscribe for value changes.
+   */
+  valueObserver: function() {
+    if (!this.get('content')) return;
+    var self = this;
+    Em.run.once(function() {
+      self.checkModified();
+      self.checkErrors();
+      self.setConfigValue();
+    });
+  }.observes('content.@each.value'),
+
+  /**
+   * Check for property modification.
+   */
+  checkModified: function() {
+    this.set('valueIsChanged', this.convertToPropertyUnit(this.get('content'), this.get('propertyUnit')) != parseInt(this.get('config.defaultValue')));
+  },
+
+  /**
+   * Check for validation errors like minimum or maximum required value.
+   */
+  checkErrors: function() {
+    var convertedValue = this.convertToPropertyUnit(this.get('content'), this.get('propertyUnit'));
+    var errorMessage = false;
+    if (convertedValue < parseInt(this.get('config.stackConfigProperty.valueAttributes.minimum'))) {
+      errorMessage = Em.I18n.t('number.validate.lessThanMinumum').format(this.dateToText(this.get('minValue')));
+    }
+    else if (convertedValue > parseInt(this.get('config.stackConfigProperty.valueAttributes.maximum'))) {
+      errorMessage = Em.I18n.t('number.validate.moreThanMaximum').format(this.dateToText(this.get('maxValue')));
+    }
+    this.set('isValid', !errorMessage);
+    this.set('errorMessage', errorMessage);
+  },
+
+  /**
+   * set appropriate attribute for configProperty model
+   */
+  setConfigValue: function() {
+    this.set('config.value', this.convertToPropertyUnit(this.get('content'), this.get('propertyUnit')));
+  },
+
+  /**
+   * Convert value to readable format using widget value.
+   *
+   * @param {Object[]} widgetFormatValue - value formatted for widget @see convertToWidgetUnits
+   * @return {String}
+   */
+  dateToText: function(widgetFormatValue) {
+    return widgetFormatValue.map(function(item) {
+      if (Em.get(item, 'value') > 0) {
+        return Em.get(item, 'value') + ' ' + Em.get(item, 'label');
+      }
+      else {
+        return null;
+      }
+    }).compact().join(' ');
+  },
+
+  /**
+   * Restore value to default.
+   */
+  restoreValue: function() {
+    this._super();
+    this.set('content', this.generateWidgetValue(this.get('config.defaultValue')));
+  }
+});

+ 133 - 0
ambari-web/app/views/common/form/spinner_input_view.js

@@ -0,0 +1,133 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+var App = require('app');
+
+App.SpinnerInputView = Em.View.extend({
+    templateName: require('templates/common/form/spinner_input'),
+    classNames: ['spinner-input'],
+
+    /**
+     * Bind content directly from Handlebars template or any another way.
+     * For example:
+     * {{view App.SpinnerInputView contentBinding="someContent"}}
+     *
+     * @property content
+     * @type {Object}
+     */
+    content: {
+
+      /**
+       * Minimal value that can be set. (not required)
+       *
+       * @property [minValue]
+       * @type {Number}
+       */
+      minValue: null,
+
+      /**
+       * Maximum value that can be set. (not required)
+       *
+       * @property [maxValue]
+       * @type {Number}
+       */
+      maxValue: null,
+
+      /**
+       * Input value. (required)
+       *
+       * @property value
+       * @type {String|Number}
+       */
+      value: null,
+
+      /**
+       * Config display name. Will be shown beneath the input. (not required)
+       *
+       * @type {String}
+       * @property [label]
+       */
+      label: null,
+
+      /**
+       * If set to true value will toggle with max|min on property exceed value.
+       *
+       * @property [invertOnOverflow]
+       * @type {Boolean}
+       */
+      invertOnOverflow: false
+    },
+
+    incrementValue: function() {
+      this.setValue(true);
+    },
+
+    decrementValue: function() {
+      this.setValue();
+    },
+
+    setValue: function(increment) {
+      var value = parseInt(this.get('content.value')) + ((increment) ? 1 : -1);
+      // if minimal required value is specified and decremented property value is less than minimal
+      if (!Em.isEmpty(this.get('content.minValue')) && !increment && (value < this.get('content.minValue') )) {
+        // set maximum value if value invert accepted
+        value = (this.get('content.invertOnOverflow')) ? this.get('content.maxValue') : this.get('content.minValue');
+      }
+      // if maximum require value is specified and incremented value is more than maximum
+      else if (!Em.isEmpty(this.get('content.maxValue')) && increment && (value > this.get('content.maxValue'))) {
+        // set minimum if value invert accepted
+        value = (this.get('content.invertOnOverflow')) ? this.get('content.minValue') : this.get('content.maxValue');
+      }
+      this.set('content.value', value);
+    },
+
+    /**
+     * Handle keyboard event. Allow digits only and some key maps.
+     */
+    keyDown: function(e) {
+      var charCode = (e.charCode) ? e.charCode : e.which;
+      if ([46, 8, 9, 27, 13, 110, 190].contains(charCode) ||
+        (charCode == 65 && e.ctrlKey === true) ||
+        (charCode == 67 && e.ctrlKey === true) ||
+        (charCode == 88 && e.ctrlKey === true) ||
+        (charCode >= 35 && charCode <= 39)) {
+          return;
+        }
+      if ((e.shiftKey || (charCode < 48 || charCode > 57)) && (charCode < 96 || charCode > 105)) {
+          e.preventDefault();
+      }
+    },
+
+    /**
+     * Change value to maximum if exceeded.
+     */
+    keyUp: function(e) {
+      if (!Em.isEmpty(this.get('content.maxValue')) && this.get('content.value') > this.get('content.maxValue')) {
+        this.set('content.value', this.get('content.maxValue'));
+      }
+    },
+
+    /**
+     * Set value to 0 if user removed it and changed focus to another element.
+     */
+    focusOut: function(e) {
+      if (Em.isEmpty(this.get('content.value'))) {
+        this.set('content.value', 0);
+      }
+    }
+});

+ 142 - 0
ambari-web/test/views/common/configs/widgets/time_interval_spinner_view_test.js

@@ -0,0 +1,142 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+var App = require('app');
+
+describe('App.TimeIntervalSpinnerView', function() {
+  describe('#convertToWidgetUnits', function(){
+    beforeEach(function() {
+      this.view = App.TimeIntervalSpinnerView.create({});
+    });
+    var tests = [
+      {
+        input: 60000,
+        inputType: 'milliseconds',
+        desiredUnits: "days,hours,minutes",
+        e: [
+          { label: 'Days', value: 0},
+          { label: 'Hours', value: 0},
+          { label: 'Minutes', value: 1}
+        ]
+      },
+      {
+        input: "2592000000",
+        inputType: 'milliseconds',
+        desiredUnits: "days,hours,minutes",
+        e: [
+          { label: 'Days', value: 30},
+          { label: 'Hours', value: 0},
+          { label: 'Minutes', value: 0}
+        ]
+      },
+      {
+        input: "604800000",
+        inputType: 'milliseconds',
+        desiredUnits: "days,hours,minutes",
+        e: [
+          { label: 'Days', value: 7},
+          { label: 'Hours', value: 0},
+          { label: 'Minutes', value: 0}
+        ]
+      },
+      {
+        input: "804820200",
+        inputType: 'milliseconds',
+        desiredUnits: "days,hours,minutes",
+        e: [
+          { label: 'Days', value: 9},
+          { label: 'Hours', value: 7},
+          { label: 'Minutes', value: 33}
+        ]
+      },
+      {
+        input: "70000",
+        inputType: 'milliseconds',
+        desiredUnits: "minutes",
+        e: [
+          { label: 'Minutes', value: 1}
+        ]
+      },
+      {
+        input: "140",
+        inputType: 'minutes',
+        desiredUnits: "hours,minutes",
+        e: [
+          { label: 'Hours', value: 2},
+          { label: 'Minutes', value: 20}
+        ]
+      },
+      {
+        input: "2",
+        inputType: 'hours',
+        desiredUnits: "hours",
+        e: [
+          { label: 'Hours', value: 2},
+        ]
+      }
+    ];
+
+    tests.forEach(function(test) {
+      it('should convert {0} {1} to {2}'.format(test.input, test.inputType, JSON.stringify(test.e)), function() {
+        var result = this.view.convertToWidgetUnits(test.input, test.inputType, test.desiredUnits).map(function(item) {
+          // remove unneccessary keys
+          return App.permit(item, ['label', 'value']);
+        });
+        expect(result).to.eql(test.e);
+      });
+    });
+  });
+
+  describe('#convertToPropertyUnit', function(){
+    beforeEach(function() {
+      this.view = App.TimeIntervalSpinnerView.create({});
+    });
+    var tests = [
+      {
+        widgetUnits: [
+          { unit: 'minutes', value: 10 },
+          { unit: 'seconds', value: 2 }
+        ],
+        inputUnit: "seconds",
+        e: 602
+      },
+      {
+        widgetUnits: [
+          { unit: 'minutes', value: 13 },
+          { unit: 'seconds', value: 10 }
+        ],
+        inputUnit: "milliseconds",
+        e: 790000
+      },
+      {
+        widgetUnits: [
+          { unit: 'days', value: 1 },
+          { unit: 'hours', value: 2 }
+        ],
+        inputUnit: "milliseconds",
+        e: 93600000
+      }
+    ];
+
+    tests.forEach(function(test) {
+      it('should convert {0} to {1} {2}'.format(JSON.stringify(test.widgetUnits), test.e, test.inputUnit), function() {
+        expect(this.view.convertToPropertyUnit(test.widgetUnits, test.inputUnit)).to.equal(test.e);
+      });
+    });
+  });
+});