null

PrimeFaces Spinner и точность представления вещественных чисел

PrimeFaces Spinner - очень удобный компонент, если хочется дать возможность пользователю вводить некие числа, но при этом не дать ему возможности в этих числах "накосячить", введя что-то заведомо некорректное. Он позволяет удобно задать диапазон допустимых для ввода значений и шаг изменения по клику по кнопке, но, как выяснилось, временами ведёт себя достаточно непредсказуемо. В этих случаях, чтобы заставить его работать правильно, как обычно, приходится немного пошаманить.

Исходные данные

В одном из наших проектов Spinner используется для ввода сумм платежей. Очень удобно - пользователь не введёт заведомую ерунду, при отсутствии физической клавиатуры (к примеру, на планшете) он может "накликать" нужную сумму кнопками управления виджетом, и так далее. В исходном варианте виджет показывал и позволял изменить сумму платежа с точностью до копейки и работал прекрасно:

Виджет позволяет ввести сумму с точностью до копейки

Код xhtml-страницы при этом выглядел следующим образом:

<p:spinner id="paymentamount" value="#{payment.amount}" required="true"
    converterMessage="#{i18n['payment.amount.validator.message']}"
    validatorMessage="#{i18n['payment.amount.validator.message']}"
    requiredMessage="#{i18n['payment.amount.required']}"
    min="-99999.99" max="99999.99" maxlength="8" stepFactor="0.01" >
    <f:validator validatorId="custom.notZeroValidator" />
</p:spinner>
<p:message for="paymentamount"/>

Наиболее важный атрибут здесь - stepFactor; он определяет шаг спиннера.

Теперь посмотрим код свойства amount на уровне управляемого бина:

//...
private BigDecimal amount = new BigDecimal(0);
//...
public BigDecimal getAmount() {
    return amount;
}

public void setAmount(BigDecimal amount) {
    this.amount = amount;
}
//...

Ничего особенного - просто поле типа BigDecimal и get- и set-методы к нему.

Проблема

Как уже было упомянуто выше, до определённого момента компонент работал прекрасно и никаких проблем с ним не было. Проблемы начались тогда, когда заказчик попросил немного изменить логику его работы. Действительно, шаг ввода в одну копейку достаточно неудобен - чаще всего люди вносят на счета сумму в целых рублях и кликать 100 раз ради изменения её всего на один рубль довольно утомительно. Поэтому было предложено изменить шаг изменения суммы с копейки до рубля - если сумма нецелая, копейки можно ввести и вручную. Доводы заказчика показались разумными, в силу чего код xhtml был изменён на такой:

<p:spinner id="paymentamount" value="#{payment.amount}" required="true"
    converterMessage="#{i18n['payment.amount.validator.message']}"
    validatorMessage="#{i18n['payment.amount.validator.message']}"
    requiredMessage="#{i18n['payment.amount.required']}"
    min="-99999.99" max="99999.99" maxlength="8" stepFactor="1.00" >
    <f:validator validatorId="custom.notZeroValidator" />
</p:spinner>
<p:message for="paymentamount"/>

Как видим, поменялось только значение атрибута stepFactor. Казалось бы, изменения самые незначительные, однако компонент почему-то начал округлять числа до целых, не давая ввести копейки (за исключением предельных положительных и отрицательных значений):

Значение в поле округляется

Ищем причину

Естественно, что заказчика такой вариант "оптимизации" устроить не мог, поэтому пришлось искать пути решения проблемы. Опытным путём было установлено, что округление начинается для всех значений шага спиннера, больших или равных единице. К примеру, при stepFactor=0.99 ещё можно было ввести и сохранить сумму с копейками, а при stepFactor=1.00 - уже нет. С этой вводной я полез копаться в исходниках PrimeFaces в поисках того, что же за магия происходит при достижении шагом спиннера значения, равного 1.

Так как вся логика работы спиннера реализована на стороне клиента (де-факто это компонент Spinner из JQuery UI), меня интересовала только клиентская часть компонента, то есть, JavaScript-функция. В использовавшейся версии PrimeFaces (4.0) эта функция выглядит примерно так:

PrimeFaces.widget.Spinner = PrimeFaces.widget.BaseWidget.extend({
  init: function (a) {
    this._super(a);
    this.input = this.jq.children('.ui-spinner-input');
    this.upButton = this.jq.children('a.ui-spinner-up');
    this.downButton = this.jq.children('a.ui-spinner-down');
    this.cfg.step = this.cfg.step || 1;
    if (parseInt(this.cfg.step) === 0) {
      this.cfg.precision = this.cfg.step.toString().split(/[,]|[.]/) [1].length
    }
    this.initValue();
    this.addARIA();
    if (this.input.prop('disabled') || this.input.prop('readonly')) {
      return
    }
    this.bindEvents();
    this.input.data(PrimeFaces.CLIENT_ID_DATA, this.id);
    PrimeFaces.skinInput(this.input)
  },
  bindEvents: function () {
    var a = this;
    this.jq.children('.ui-spinner-button').on('mouseover.spinner', function () {
      $(this).addClass('ui-state-hover')
    }).on('mouseout.spinner', function () {
      $(this).removeClass('ui-state-hover ui-state-active');
      if (a.timer) {
        clearInterval(a.timer)
      }
    }).on('mouseup.spinner', function () {
      clearInterval(a.timer);
      $(this).removeClass('ui-state-active').addClass('ui-state-hover');
      a.input.trigger('change')
    }).on('mousedown.spinner', function (d) {
      var c = $(this),
      b = c.hasClass('ui-spinner-up') ? 1 : - 1;
      c.removeClass('ui-state-hover').addClass('ui-state-active');
      if (a.input.is(':not(:focus)')) {
        a.input.focus()
      }
      a.repeat(null, b);
      d.preventDefault()
    });
    this.input.on('keydown.spinner', function (c) {
      var b = $.ui.keyCode;
      switch (c.which) {
        case b.UP:
          a.spin(1);
          break;
        case b.DOWN:
          a.spin( - 1);
          break;
        default:
          break
      }
    }).on('keyup.spinner', function () {
      a.updateValue()
    }).on('blur.spinner', function () {
      a.format()
    }).on('focus.spinner', function () {
      if (a.value !== null) {
        a.input.val(a.value)
      }
    }).on('mousewheel.spinner', function (b, c) {
      if (a.input.is(':focus')) {
        if (c > 0) {
          a.spin(1)
        } else {
          a.spin( - 1)
        }
        return false
      }
    }); if (this.cfg.behaviors) {
      PrimeFaces.attachBehaviors(this.input, this.cfg.behaviors)
    }
  },
  repeat: function (a, b) {
    var d = this,
    c = a || 500;
    clearTimeout(this.timer);
    this.timer = setTimeout(function () {
      d.repeat(40, b)
    }, c);
    this.spin(b)
  },
  toFixed: function (c, a) {
    var b = Math.pow(10, a || 0);
    return String(Math.round(c * b) / b)
  },
  spin: function (a) {
    var c = this.cfg.step * a,
    b = this.value ? this.value : 0,
    d = null;
    if (this.cfg.precision) {
      d = parseFloat(this.toFixed(b + c, this.cfg.precision))
    } else {
      d = parseInt(b + c)
    }
    if (this.cfg.min !== undefined && d < this.cfg.min) {
      d = this.cfg.min
    }
    if (this.cfg.max !== undefined && d > this.cfg.max) {
      d = this.cfg.max
    }
    this.input.val(d);
    this.value = d;
    this.input.attr('aria-valuenow', d)
  },
  updateValue: function () {
    var a = this.input.val();
    if ($.trim(a) === '') {
      if (this.cfg.min !== undefined) {
        this.value = this.cfg.min
      } else {
        this.value = null
      }
    } else {
      if (this.cfg.precision) {
        a = parseFloat(a)
      } else {
        a = parseInt(a)
      }
      if (!isNaN(a)) {
        if (this.cfg.max !== undefined && a > this.cfg.max) {
          a = this.cfg.max
        }
        if (this.cfg.min !== undefined && a < this.cfg.min) {
          a = this.cfg.min
        }
        this.value = a
      }
    }
  },
  initValue: function () {
    var a = this.input.val();
    if ($.trim(a) === '') {
      if (this.cfg.min !== undefined) {
        this.value = this.cfg.min
      } else {
        this.value = null
      }
    } else {
      if (this.cfg.prefix) {
        a = a.split(this.cfg.prefix) [1]
      }
      if (this.cfg.suffix) {
        a = a.split(this.cfg.suffix) [0]
      }
      if (this.cfg.precision) {
        this.value = parseFloat(a)
      } else {
        this.value = parseInt(a)
      }
    }
  },
  format: function () {
    if (this.value !== null) {
      var a = this.value;
      if (this.cfg.prefix) {
        a = this.cfg.prefix + a
      }
      if (this.cfg.suffix) {
        a = a + this.cfg.suffix
      }
      this.input.val(a)
    }
  },
  addARIA: function () {
    this.input.attr('role', 'spinner');
    this.input.attr('aria-multiline', false);
    this.input.attr('aria-valuenow', this.value);
    if (this.cfg.min !== undefined) {
      this.input.attr('aria-valuemin', this.cfg.min)
    }
    if (this.cfg.max !== undefined) {
      this.input.attr('aria-valuemax', this.cfg.max)
    }
    if (this.input.prop('disabled')) {
      this.input.attr('aria-disabled', true)
    }
    if (this.input.prop('readonly')) {
      this.input.attr('aria-readonly', true)
    }
  }
});


Обратим внимание на функцию spin (строки 89-107) и конфигурационный параметр precision - получается, что этот параметр определяет, с какой точностью округляется значение, получившееся при очередном шаге спиннера. Теперь внимательно посмотрим, как этот параметр вычисляется (строки 8-10):

if (parseInt(this.cfg.step) === 0) {
  this.cfg.precision = this.cfg.step.toString().split(/[,]|[.]/) [1].length
}

То есть, если шаг спиннера меньше нуля, то этот точность определяется количеством разрядов в дробной части шага, а если больше - то этот параметр вообще не задаётся! А если точность не задана, то итоговое значение округляется до целого (строки 93-97):

if (this.cfg.precision) {
  d = parseFloat(this.toFixed(b + c, this.cfg.precision))
} else {
  d = parseInt(b + c)
}

Решение

В общем, причина проблемы понятна, осталось найти решение. Проблема в том, что precision является конфигурационным параметром компонента JQuery UI, а у компонента PrimeFaces такого атрибута нет, в результате чего точность всегда вычисляется автоматически на базе размера шага спиннера. Можно, конечно, пропатчить PrimeFaces, сделав собственный вариант компонента Spinner, который поддерживает задание произвольной точности, но сборка форка всей библиотеки и загрузка его в репозиторий Maven ради одного злосчастного атрибута, всё-таки, кажется нецелесообразной - хочется разрулить эту проблему какими-то более простыми средствами. На счастье, такие средства есть - это атрибут widgetVar компонента Spinner, позволяющий обращаться напрямую к JavaScript-функции. Попробуем с его помощью поправить конфигурацию компонента JQuery UI "в обход" PrimeFaces:

<p:spinner id="paymentamount" value="#{payment.amount}" required="true"
    converterMessage="#{i18n['payment.amount.validator.message']}"
    validatorMessage="#{i18n['payment.amount.validator.message']}"
    requiredMessage="#{i18n['payment.amount.required']}"
    min="-99999.99" max="99999.99" maxlength="8" stepFactor="1" 
    widgetVar="paymentamountWidget">
    <f:validator validatorId="custom.notZeroValidator" />
</p:spinner>
<script>
    paymentamountWidget.cfg.precision = 2;
</script>
<p:message for="paymentamount"/>

В этом варианте в теге компонента задействован новый атрибут - widgetVar и появилась новая функция, которая обращается к этому виджету и "вручную" указывает нужную нам точность. Проверяем, будет ли работать такой вариант:

Теперь спиннер работает правильно - значение округляется до двух знаков после запятой

Будет! Проблема решена.

Коротко о себе:

Работаю ведущим программистом в компании Tune IT и ассистентом кафедры Вычислительной техники в Университете ИТМО .

Занимаюсь проектами, связанными с разработкой разного рода веб-приложений (порталы, CRM-системы, системы электронного документооборота), а также, в рамках научной работы на кафедре, изучаю возможности применения семантического анализа в задачах САПР.