[MERGE] hr_timesheet: log timesheet in any time UoM

Since uom management has been extract in a separated module, we
can now add the possibility to use it without product.

This commit brings the ability to record the timesheets in
an different UoM than 'Hours'. The user can now set an encoding
UoM for its timesheet on the company. This will display the
timesheet field `unit_amount` with a different widget.
For instance, timesheeting in hours will still use
the traditionnal float_time widget, when timesheeting in 'Days'
will displayed a toggle button with a range of defined possible
values.
The widget used by UoM is customizable on the UoM. The `unit_amount`
field should now be decorated with the 'timesheet_uom' widget.
This one will bind the correct widget (float_time, float_toggle, ...).
If the encoding UoM has no widget associated, the fallback will
be 'float_factor', using the uoM converstion factor.

Everything will still be stored in the database in 'Hours' (the
UoM set on the `project_mode_time_id` field on the company).

For more details, please have a look at the subcommits.

Task #39079
Closes #24893
This commit is contained in:
jem-odoo
2018-08-13 14:40:28 +02:00
29 changed files with 563 additions and 64 deletions
+2 -1
View File
@@ -20,10 +20,11 @@ It is completely integrated with the cost accounting module. It allows you to se
up a management by affair.
""",
'website': 'https://www.odoo.com/page/timesheet-mobile-app',
'depends': ['hr', 'analytic', 'project'],
'depends': ['hr', 'analytic', 'project', 'uom'],
'data': [
'security/hr_timesheet_security.xml',
'security/ir.model.access.csv',
'views/assets.xml',
'views/hr_timesheet_views.xml',
'views/res_config_settings_views.xml',
'views/project_views.xml',
+11 -1
View File
@@ -8,9 +8,19 @@
<field name="user_id" ref='base.user_root'/>
</record>
<!-- Set the JS widget -->
<record id="uom.product_uom_day" model="uom.uom">
<field name="timesheet_widget">float_toggle</field>
</record>
<record id="uom.product_uom_hour" model="uom.uom">
<field name="timesheet_widget">float_time</field>
</record>
<!-- Force Analytic account creation for projects allowing timesheet (default is True) -->
<function id="_init_data_analytic_account"
model="project.project"
name="_init_data_analytic_account"
eval="[]"/>
</odoo>
</odoo>
+3
View File
@@ -4,6 +4,9 @@
from . import analytic_account
from . import hr
from . import hr_timesheet
from . import ir_http
from . import res_company
from . import res_config_settings
from . import project
from . import res_users
from . import uom
@@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from lxml import etree
import json
from odoo import api, fields, models, _
from odoo.exceptions import UserError
@@ -46,6 +49,10 @@ class AccountAnalyticLine(models.Model):
for line in self:
line.department_id = line.employee_id.department_id
# ----------------------------------------------------
# ORM overrides
# ----------------------------------------------------
@api.model
def create(self, values):
# compute employee only for timesheet lines, makes no sense for other lines
@@ -70,6 +77,28 @@ class AccountAnalyticLine(models.Model):
self.filtered(lambda t: t.project_id)._timesheet_postprocess(values)
return result
@api.model
def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
""" Set the correct label for `unit_amount`, depending on company UoM """
result = super(AccountAnalyticLine, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
result['arch'] = self._apply_timesheet_label(result['arch'])
return result
@api.model
def _apply_timesheet_label(self, view_arch):
doc = etree.XML(view_arch)
encoding_uom = self.env.user.company_id.timesheet_encode_uom_id
# Here, we select only the unit_amount field having no string set to give priority to
# custom inheretied view stored in database. Even if normally, no xpath can be done on
# 'string' attribute.
for node in doc.xpath("//field[@name='unit_amount'][@widget='timesheet_uom'][not(@string)]"):
node.set('string', _('Duration (%s)') % (encoding_uom.name))
return etree.tostring(doc, encoding='unicode')
# ----------------------------------------------------
# Business Methods
# ----------------------------------------------------
def _timesheet_preprocess(self, vals):
""" Deduce other field values from the one given.
Overrride this to compute on the fly some field that can not be computed fields.
+21
View File
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class Http(models.AbstractModel):
_inherit = 'ir.http'
def session_info(self):
""" The widget 'timesheet_uom' needs to know which UoM conversion factor and which javascript
widget to apply, depending on th ecurrent company.
"""
result = super(Http, self).session_info()
company = self.env.user.company_id
encoding_uom = company.timesheet_encode_uom_id
result['timesheet_uom'] = encoding_uom.read(['name', 'rounding', 'timesheet_widget'])[0]
result['timesheet_uom_factor'] = company.project_time_mode_id._compute_quantity(1.0, encoding_uom, round=False) # convert encoding uom into stored uom to get conversion factor
return result
+7
View File
@@ -151,3 +151,10 @@ class Task(models.Model):
'company_id': account_id.company_id.id,
})
return result
@api.model
def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
""" Set the correct label for `unit_amount`, depending on company UoM """
result = super(Task, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
result['arch'] = self.env['account.analytic.line']._apply_timesheet_label(result['arch'])
return result
@@ -16,8 +16,21 @@ class ResCompany(models.Model):
uom = self.env['uom.uom'].search([('measure_type', '=', 'time')], limit=1)
return uom
@api.model
def _default_timesheet_encode_uom_id(self):
uom = self.env.ref('uom.product_uom_hour', raise_if_not_found=False)
if not uom:
uom = self.env['uom.uom'].search([('measure_type', '=', 'time'), ('uom_type', '=', 'reference')], limit=1)
if not uom:
uom = self.env['uom.uom'].search([('measure_type', '=', 'time')], limit=1)
return uom
project_time_mode_id = fields.Many2one('uom.uom', string='Project Time Unit',
default=_default_project_time_mode_id, domain=[('measure_type', '=', 'time')],
help="This will set the unit of measure used in projects and tasks.\n"
"If you use the timesheet linked to projects, don't "
"forget to setup the right unit of measure in your employees.")
timesheet_encode_uom_id = fields.Many2one('uom.uom', string="Timesheet Encoding Unit",
default=_default_timesheet_encode_uom_id, domain=[('measure_type', '=', 'time')], required=True,
help="""This will set the unit of measure used to encode timesheet. This will simply provide tools
and widgets to help the encoding. All reporting will still be expressed in hours (default value).""")
@@ -9,3 +9,12 @@ class ResConfigSettings(models.TransientModel):
module_project_timesheet_synchro = fields.Boolean("Awesome Timesheet")
module_project_timesheet_holidays = fields.Boolean("Leaves")
project_time_mode_id = fields.Many2one(
'uom.uom', related='company_id.project_time_mode_id', string='Project Time Unit',
help="This will set the unit of measure used in projects and tasks.\n"
"If you use the timesheet linked to projects, don't "
"forget to setup the right unit of measure in your employees.")
timesheet_encode_uom_id = fields.Many2one('uom.uom', string="Encoding Unit",
related='company_id.timesheet_encode_uom_id',
help="""This will set the unit of measure used to encode timesheet. This will simply provide tools
and widgets to help the encoding. All reporting will still be expressed in hours (default value).""")
+10
View File
@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class Uom(models.Model):
_inherit = 'uom.uom'
timesheet_widget = fields.Char("Widget", help="Widget used in the webclient when this unit is the one used to encode timesheets.")
@@ -6,8 +6,6 @@ access_account_analytic_account_user,account.analytic.account,analytic.model_acc
access_account_analytic_account_manager,account.analytic.account,analytic.model_account_analytic_account,project.group_project_manager,1,1,1,1
access_account_analytic_account_portal,account_analytic_account,analytic.model_account_analytic_account,base.group_portal,1,0,0,0
access_account_analytic_line_project,account.analytic.line project,analytic.model_account_analytic_line,project.group_project_manager,1,1,1,1
access_product_product_user,product.product.timesheet.user,product.model_product_product,hr_timesheet.group_hr_timesheet_user,1,1,1,0
access_product_template_timesheet_user,product.template.timesheet.user,product.model_product_template,hr_timesheet.group_hr_timesheet_user,1,1,1,0
access_uom_uom_hr_timesheet,uom.uom.timesheet.user,uom.model_uom_uom,hr_timesheet.group_hr_timesheet_user,1,0,0,0
access_project_project,project.project.timesheet.user,model_project_project,hr_timesheet.group_hr_timesheet_user,1,0,0,0
access_project_task,project.task.timesheet.user,model_project_task,hr_timesheet.group_hr_timesheet_user,1,1,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
6 access_account_analytic_account_manager account.analytic.account analytic.model_account_analytic_account project.group_project_manager 1 1 1 1
7 access_account_analytic_account_portal account_analytic_account analytic.model_account_analytic_account base.group_portal 1 0 0 0
8 access_account_analytic_line_project account.analytic.line project analytic.model_account_analytic_line project.group_project_manager 1 1 1 1
access_product_product_user product.product.timesheet.user product.model_product_product hr_timesheet.group_hr_timesheet_user 1 1 1 0
access_product_template_timesheet_user product.template.timesheet.user product.model_product_template hr_timesheet.group_hr_timesheet_user 1 1 1 0
9 access_uom_uom_hr_timesheet uom.uom.timesheet.user uom.model_uom_uom hr_timesheet.group_hr_timesheet_user 1 0 0 0
10 access_project_project project.project.timesheet.user model_project_project hr_timesheet.group_hr_timesheet_user 1 0 0 0
11 access_project_task project.task.timesheet.user model_project_task hr_timesheet.group_hr_timesheet_user 1 1 0 0
@@ -0,0 +1,86 @@
odoo.define('hr_timesheet.timesheet_uom', function (require) {
'use strict';
var AbstractField = require('web.AbstractField');
var basicFields = require('web.basic_fields');
var fieldRegistry = require('web.field_registry');
var session = require('web.session');
/**
* Extend the float factor widget to set default value for timesheet
* use case. The 'factor' is forced to be the UoM timesheet
* conversion from the session info.
**/
var FieldTimesheetFactor = basicFields.FieldFloatFactor.extend({
formatType: 'float_factor',
/**
* Override init to tweak options depending on the session_info
*
* @constructor
* @override
*/
init: function(parent, name, record, options) {
this._super(parent, name, record, options);
// force factor in format and parse options
if (session.timesheet_uom_factor) {
this.nodeOptions.factor = session.timesheet_uom_factor;
this.parseOptions.factor = session.timesheet_uom_factor;
}
},
});
/**
* Extend the float toggle widget to set default value for timesheet
* use case. The 'range' is different from the default one of the
* native widget, and the 'factor' is forced to be the UoM timesheet
* conversion.
**/
var FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({
formatType: 'float_factor',
/**
* Override init to tweak options depending on the session_info
*
* @constructor
* @override
*/
init: function(parent, name, record, options) {
options = options || {};
var fieldsInfo = record.fieldsInfo[options.viewType || 'default'];
var attrs = options.attrs || (fieldsInfo && fieldsInfo[name]) || {};
var hasRange = _.contains(_.keys(attrs.options || {}), 'range');
this._super(parent, name, record, options);
// Set the timesheet widget options: the range can be customized
// by setting the option on the field in the view. The factor
// is forced to be the UoM conversion factor.
if (!hasRange) {
this.nodeOptions.range = [0.00, 1.00, 0.50];
}
this.nodeOptions.factor = session.timesheet_uom_factor;
},
});
/**
* Binding depending on Company Preference
*
* determine wich widget will be the timesheet one.
* Simply match the 'timesheet_uom' widget key with the correct
* implementation (float_time, float_toggle, ...). The default
* value will be 'float_factor'.
**/
var FieldTimesheetUom = FieldTimesheetFactor;
var widgetName = 'timesheet_uom' in session ?
session.timesheet_uom.timesheet_widget : 'float_factor';
var FieldTimesheetUom = widgetName === 'float_toggle' ?
FieldTimesheetToggle : (fieldRegistry.get(widgetName) || FieldTimesheetFactor);
fieldRegistry.add('timesheet_uom', FieldTimesheetUom);
return FieldTimesheetUom;
});
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0"?>
<odoo>
<template id="assets_backend" name="timesheet assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/hr_timesheet/static/src/js/timesheet_uom.js"></script>
</xpath>
</template>
</odoo>
@@ -43,8 +43,8 @@
<field name="date"/>
<field name="name"/>
<field name="project_id" required="1"/>
<field name="task_id" context="{'default_project_id': project_id}"/>
<field name="unit_amount" string="Time (Hours)" sum="Total time" widget="float_time"/>
<field name="task_id" context="{'default_project_id': project_id}" domain="[('project_id', '=', project_id)]"/>
<field name="unit_amount" widget="timesheet_uom"/>
</tree>
</field>
</record>
@@ -70,7 +70,7 @@
<pivot string="Timesheet">
<field name="employee_id" type="row"/>
<field name="date" interval="month" type="col"/>
<field name="unit_amount" type="measure"/>
<field name="unit_amount" type="measure" widget="timesheet_uom"/>
</pivot>
</field>
</record>
@@ -93,7 +93,7 @@
</group>
<group>
<field name="amount"/>
<field name="unit_amount"/>
<field name="unit_amount" widget="timesheet_uom"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
@@ -184,7 +184,7 @@
<t t-esc="record.date.value"/>
</span>
<span class="float-right">
<strong>Duration: </strong><field name="unit_amount" widget="float_time"/>
<strong>Duration: </strong><field name="unit_amount" widget="timesheet_uom"/>
</span>
</div>
</t>
@@ -201,7 +201,9 @@
<field name="res_model">account.analytic.line</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('project_id', '!=', False), ('user_id', '=', uid)]</field>
<field name="context">{"search_default_week":1}</field>
<field name="context">{
"search_default_week":1,
}</field>
<field name="search_view_id" ref="hr_timesheet_line_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
@@ -256,7 +258,7 @@
'search_default_week':1,
'search_default_groupby_employee':1,
'search_default_groupby_project':1,
'search_default_groupby_task':1
'search_default_groupby_task':1,
}</field>
</record>
+14
View File
@@ -13,6 +13,20 @@
}</field>
</record>
<record id="timesheet_action_view_from_employee_list" model="ir.actions.act_window.view">
<field name="sequence" eval="5"/>
<field name="view_mode">tree</field>
<field name="view_id" ref="hr_timesheet_line_tree"/>
<field name="act_window_id" ref="timesheet_action_from_employee"/>
</record>
<record id="timesheet_action_view_from_employee_form" model="ir.actions.act_window.view">
<field name="sequence" eval="10"/>
<field name="view_mode">form</field>
<field name="view_id" ref="hr_timesheet_line_form"/>
<field name="act_window_id" ref="timesheet_action_from_employee"/>
</record>
<record id="hr_employee_view_form_inherit_timesheet" model="ir.ui.view">
<field name="name">hr.employee.form.timesheet</field>
<field name="model">hr.employee</field>
+1 -1
View File
@@ -87,7 +87,7 @@
<field name="user_id" invisible="1"/>
<field name="employee_id" required="1"/>
<field name="name"/>
<field name="unit_amount" string="Duration" widget="float_time"/>
<field name="unit_amount" widget="timesheet_uom"/>
<field name="project_id" invisible="1"/>
</tree>
</field>
@@ -11,8 +11,35 @@
<field name="arch" type="xml">
<xpath expr="//div[hasclass('settings')]" position="inside">
<div class="app_settings_block" data-string="Timesheets" string="Timesheets" data-key="hr_timesheet" groups="hr_timesheet.group_timesheet_manager">
<h2>Timesheet Apps</h2>
<h2>Time Encoding</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box" attrs="{'invisible':[('project_time_mode_id', '!=', False)]}">
<div class="o_setting_right_pane">
<label for="project_time_mode_id"/>
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific." groups="base.group_multi_company"/>
<div class="content-group">
<div class="mt16">
<field name="project_time_mode_id" options="{'no_create': True, 'no_open': True}"/>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<label for="timesheet_encode_uom_id"/>
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific." groups="base.group_multi_company"/>
<div class="row">
<div class="text-muted col-md-12">
Set time unit used to record your timesheets
</div>
</div>
<div class="content-group">
<div class="mt16">
<field name="timesheet_encode_uom_id" options="{'no_create': True}"/>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="module_project_timesheet_synchro" widget="upgrade_boolean"/>
-1
View File
@@ -10,7 +10,6 @@
'summary': 'Organize and schedule your projects ',
'depends': [
'base_setup',
'product',
'mail',
'portal',
'rating',
-1
View File
@@ -3,7 +3,6 @@
from . import project
from . import res_config_settings
from . import res_company
from . import res_partner
from . import res_users
from . import digest
-42
View File
@@ -2,7 +2,6 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
from lxml import etree
from odoo import api, fields, models, tools, SUPERUSER_ID, _
from odoo.exceptions import UserError, AccessError, ValidationError
@@ -620,47 +619,6 @@ class Task(models.Model):
if not task._check_recursion():
raise ValidationError(_('Error! You cannot create recursive hierarchy of task(s).'))
# Override view according to the company definition
@api.model
def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
# read uom as admin to avoid access rights issues, e.g. for portal/share users,
# this should be safe (no context passed to avoid side-effects)
obj_tm = self.env.user.company_id.project_time_mode_id
tm = obj_tm and obj_tm.name or 'Hours'
res = super(Task, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
# read uom as admin to avoid access rights issues, e.g. for portal/share users,
# this should be safe (no context passed to avoid side-effects)
obj_tm = self.env.user.company_id.project_time_mode_id
# using get_object to get translation value
uom_hour = self.env.ref('uom.product_uom_hour', False)
if not obj_tm or not uom_hour or obj_tm.id == uom_hour.id:
return res
eview = etree.fromstring(res['arch'])
# if the project_time_mode_id is not in hours (so in days), display it as a float field
def _check_rec(eview):
if eview.attrib.get('widget', '') == 'float_time':
eview.set('widget', 'float')
for child in eview:
_check_rec(child)
return True
_check_rec(eview)
res['arch'] = etree.tostring(eview, encoding='unicode')
# replace reference of 'Hours' to 'Day(s)'
for f in res['fields']:
# TODO this NOT work in different language than english
# the field 'Initially Planned Hours' should be replaced by 'Initially Planned Days'
# but string 'Initially Planned Days' is not available in translation
if 'Hours' in res['fields'][f]['string']:
res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours', obj_tm.name)
return res
@api.model
def get_empty_list_help(self, help):
tname = _("task")
@@ -10,8 +10,3 @@ class ResConfigSettings(models.TransientModel):
module_project_forecast = fields.Boolean(string="Forecasts")
group_subtask_project = fields.Boolean("Sub-tasks", implied_group="project.group_subtask_project")
group_project_rating = fields.Boolean("Use Rating on Project", implied_group='project.group_project_rating')
project_time_mode_id = fields.Many2one(
'uom.uom', related='company_id.project_time_mode_id', string='Project Time Unit',
help="This will set the unit of measure used in projects and tasks.\n"
"If you use the timesheet linked to projects, don't "
"forget to setup the right unit of measure in your employees.")
@@ -50,7 +50,7 @@
</div>
</div>
<h2>Time Management</h2>
<div class="row mt16 o_settings_container">
<div class="row mt16 o_settings_container" name="project_time">
<div class="col-12 col-lg-6 o_setting_box" name="project_time_management">
<div class="o_setting_left_pane">
<field name="module_project_forecast" widget="upgrade_boolean"/>
+12
View File
@@ -22,6 +22,12 @@ class UoMCategory(models.Model):
('uom_category_unique_type', 'UNIQUE(measure_type)', 'You can have only one category per measurement type.'),
]
@api.multi
def unlink(self):
if self.filtered(lambda categ: categ.measure_type == 'time'):
raise UserError(_("You cannot delete this UoM Category as it is used by the system."))
return super(UoMCategory, self).unlink()
class UoM(models.Model):
_name = 'uom.uom'
@@ -102,6 +108,12 @@ class UoM(models.Model):
values['factor'] = factor_inv and (1.0 / factor_inv) or 0.0
return super(UoM, self).write(values)
@api.multi
def unlink(self):
if self.filtered(lambda uom: uom.measure_type == 'time'):
raise UserError(_("You cannot delete this UoM as it is used by the system. You should rather archive it."))
return super(UoM, self).unlink()
@api.model
def name_create(self, name):
""" The UoM category and factor are required, so we'll have to add temporary values
+20
View File
@@ -511,6 +511,26 @@ var utils = {
return Object.freeze(obj);
},
/**
* Find the closest value of the given one in the provided array
*
* @param {Number} num
* @param {Array} arr
* @returns {Number|undefined}
*/
closestNumber: function (num, arr) {
var curr = arr[0];
var diff = Math.abs (num - curr);
for (var val = 0; val < arr.length; val++) {
var newdiff = Math.abs (num - arr[val]);
if (newdiff < diff) {
diff = newdiff;
curr = arr[val];
}
}
return curr;
},
};
return utils;
+139 -1
View File
@@ -898,6 +898,142 @@ var FieldFloatTime = FieldFloat.extend({
formatType: 'float_time',
});
var FieldFloatFactor = FieldFloat.extend({
supportedFieldTypes: ['float'],
className: 'o_field_float_factor',
formatType: 'float_factor',
/**
* @constructor
*/
init: function () {
this._super.apply(this, arguments);
// default values
if (!this.nodeOptions.factor){
this.nodeOptions.factor = 1;
}
// use as format and parse options
this.parseOptions = this.nodeOptions;
}
});
/**
* The goal of this widget is to replace the input field by a button containing a
* range of possible values (given in the options). Each click allows the user to loop
* in the range. The purpose here is to restrict the field value to a predefined selection.
* Also, the widget support the factor conversion as the *float_factor* widget (Range values
* should be the result of the conversion).
**/
var FieldFloatToggle = AbstractField.extend({
supportedFieldTypes: ['float'],
formatType: 'float_factor',
className: 'o_field_float_toggle',
tagName: 'span',
events: {
click: '_onClick'
},
/**
* @constructor
*/
init: function () {
this._super.apply(this, arguments);
this.formatType = 'float_factor';
if (this.mode === 'edit') {
this.tagName = 'button';
}
// we don't inherit Float Field
if (this.attrs.digits) {
this.nodeOptions.digits = JSON.parse(this.attrs.digits);
}
// default values
if (!this.nodeOptions.factor){
this.nodeOptions.factor = 1;
}
if (!this.nodeOptions.range){
this.nodeOptions.range = [0.0, 0.5, 1.0];
}
// use as format and parse options
this.parseOptions = this.nodeOptions;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Get the display value but in real type to use it in calculations
*
* @private
* @returns {float} The current formatted value
*/
_getDisplayedValue: function () {
return parseFloat(this._formatValue(this.value));
},
/**
* Formats the HTML input tag for edit mode and stores selection status.
*
* @override
* @private
*/
_renderEdit: function () {
// Keep a reference to the input so $el can become something else
// without losing track of the actual input.
this.$el.text(this._formatValue(this.value));
},
/**
* Resets the content to the formated value in readonly mode.
*
* @override
* @private
*/
_renderReadonly: function () {
this.$el.text(this._formatValue(this.value));
},
/**
* Get the next value in the range, from the current one. If the current
* one is not in the range, the next value of the closest one will be chosen.
*
* @private
* @returns {number} The next formatted value in the range
*/
_nextValue: function () {
var range = this.nodeOptions.range;
var val = utils.closestNumber(this._getDisplayedValue(), range);
var index = _.indexOf(range, val);
if (index !== -1) {
if (index + 1 < range.length) {
return range[index + 1];
}
}
return range[0];
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Clicking on the button triggers the change of value; the next one of
* the range will be displayed.
*
* @private
* @param {OdooEvent} ev
*/
_onClick: function(ev) {
if (this.mode === 'edit') {
ev.stopPropagation(); // only stop propagation in edit mode
var next_val = this._nextValue();
this._setValue(next_val.toString()); // will be parsed in _setValue
}
},
});
var FieldPercentage = FieldFloat.extend({
formatType:'percentage',
});
@@ -1102,7 +1238,7 @@ var CopyClipboard = {
$clipboardBtn.tooltip({title: _t('Copied !'), trigger: 'manual', placement: 'right'});
this.clipboard = new ClipboardJS($clipboardBtn[0], {
text: function (_) {
return self.value.trim();
return self.value.trim();
},
// Container added because of Bootstrap modal that give the focus to another element.
// We need to give to correct focus to ClipboardJS (see in ClipboardJS doc)
@@ -2757,6 +2893,8 @@ return {
FieldDomain: FieldDomain,
FieldFloat: FieldFloat,
FieldFloatTime: FieldFloatTime,
FieldFloatFactor: FieldFloatFactor,
FieldFloatToggle: FieldFloatToggle,
FieldPercentage : FieldPercentage,
FieldInteger: FieldInteger,
FieldMonetary: FieldMonetary,
@@ -53,6 +53,8 @@ registry
.add('statinfo', basic_fields.StatInfo)
.add('percentpie', basic_fields.FieldPercentPie)
.add('float_time', basic_fields.FieldFloatTime)
.add('float_factor', basic_fields.FieldFloatFactor)
.add('float_toggle', basic_fields.FieldFloatToggle)
.add('progressbar', basic_fields.FieldProgressBar)
.add('toggle_button', basic_fields.FieldToggleBoolean)
.add('dashboard_graph', basic_fields.JournalDashboardGraph)
@@ -173,6 +173,21 @@ function formatFloat(value, field, options) {
return formatted.join(l10n.decimal_point);
}
/**
* Returns a string representing a float value, from a float converted with a
* factor.
*
* @param {number} value
* @param {number} [options.factor]
* Conversion factor, default value is 1.0
* @returns {string}
*/
function formatFloatFactor(value, field, options) {
var factor = options.factor || 1;
return formatFloat(value * factor, field, options);
}
/**
* Returns a string representing a time value, from a float. The idea is that
* we sometimes want to display something like 1:45 instead of 1.75, or 0:15
@@ -528,6 +543,18 @@ function parseMonetary(value, field, options) {
return parseFloat(values[0] === currency.symbol ? values[1] : values[0]);
}
/**
* Parse a String containing float and unconvert it with a conversion factor
*
* @param {number} [options.factor]
* Conversion factor, default value is 1.0
*/
function parseFloatFactor(value, field, options) {
var parsed = parseFloat(value);
var factor = options.factor || 1.0;
return parsed / factor;
}
function parseFloatTime(value) {
var factor = 1;
if (value[0] === '-') {
@@ -596,6 +623,7 @@ return {
date: formatDate,
datetime: formatDateTime,
float: formatFloat,
float_factor: formatFloatFactor,
float_time: formatFloatTime,
html: _.identity, // todo
integer: formatInteger,
@@ -615,6 +643,7 @@ return {
date: parseDate, // todo
datetime: parseDateTime, // todo
float: parseFloat,
float_factor: parseFloatFactor,
float_time: parseFloatTime,
html: _.identity, // todo
integer: parseInteger,
+5
View File
@@ -429,3 +429,8 @@
}
}
button.o_field_float_toggle {
width: 100%;
text-align: center;
}
@@ -3264,6 +3264,90 @@ QUnit.module('basic_fields', {
});
QUnit.module('FieldFloatFactor');
QUnit.test('float_factor field in form view', function (assert) {
assert.expect(4);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<field name="qux" widget="float_factor" options="{\'factor\': 0.5}" digits="[16,2]"/>' +
'</sheet>' +
'</form>',
mockRPC: function (route, args) {
if (route === '/web/dataset/call_kw/partner/write') {
// 16.4 / 2 = 8.2
assert.strictEqual(args.args[1].qux, 4.6, 'the correct float value should be saved');
}
return this._super.apply(this, arguments);
},
res_id: 5,
});
assert.strictEqual(form.$('.o_field_widget').first().text(), '4.55', // 9.1 / 0.5
'The formatted value should be displayed properly.');
form.$buttons.find('.o_form_button_edit').click();
assert.strictEqual(form.$('input').val(), '4.55',
'The value should be rendered correctly in the input.');
form.$('input').val('2.3').trigger('input');
form.$buttons.find('.o_form_button_save').click();
assert.strictEqual(form.$('.o_field_widget').first().text(), '2.30',
'The new value should be saved and displayed properly.');
form.destroy();
});
QUnit.module('FieldFloatToggle');
QUnit.test('float_toggle field in form view', function (assert) {
assert.expect(5);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<field name="qux" widget="float_toggle" options="{\'factor\': 0.125, \'range\': [0, 1, 0.75, 0.5, 0.25]}" digits="[5,3]"/>' +
'</sheet>' +
'</form>',
mockRPC: function (route, args) {
if (route === '/web/dataset/call_kw/partner/write') {
// 1.000 / 0.125 = 8
assert.strictEqual(args.args[1].qux, 8, 'the correct float value should be saved');
}
return this._super.apply(this, arguments);
},
res_id: 1,
});
assert.strictEqual(form.$('.o_field_widget').first().text(), '0.056',
'The formatted time value should be displayed properly.');
form.$buttons.find('.o_form_button_edit').click();
assert.strictEqual(form.$('button.o_field_float_toggle').text(), '0.056',
'The value should be rendered correctly on the button.');
form.$('button.o_field_float_toggle').click(); // clicking will make the next value 1, since 0 was the closest of 0.056
assert.strictEqual(form.$('button.o_field_float_toggle').text(), '1.000',
'The value should be rendered correctly on the button.');
form.$buttons.find('.o_form_button_save').click();
assert.strictEqual(form.$('.o_field_widget').first().text(), '1.000',
'The new value should be saved and displayed properly.');
form.destroy();
});
QUnit.module('PhoneWidget');
QUnit.test('phone field in form view on extra small screens', function (assert) {
+20
View File
@@ -1418,6 +1418,26 @@ order.
- Supported field types: *float*
- float_factor (FieldFloatFactor)
This widget aims to display properly a float value that converted using a factor
given in its options. So, for example, the value saved in database is 0.5 and the
factor is 3, the widget value should be formatted as 1.5.
- Supported field types: *float*
- float_toggle (FieldFloatToggle)
The goal of this widget is to replace the input field by a button containing a
range of possible values (given in the options). Each click allows the user to loop
in the range. The purpose here is to restrict the field value to a predefined selection.
Also, the widget support the factor conversion as the *float_factor* widget (Range values
should be the result of the conversion).
- Supported field types: *float*
.. code-block:: xml
<field name="days_to_close" widget="float_toggle" options='{"factor": 2, "range": [0, 4, 8]}'/>
- boolean (FieldBoolean)
This is the default field type for fields of type *boolean*.