diff --git a/addons/hr_timesheet/__manifest__.py b/addons/hr_timesheet/__manifest__.py index cac0188cc03..520b5470cdd 100644 --- a/addons/hr_timesheet/__manifest__.py +++ b/addons/hr_timesheet/__manifest__.py @@ -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', diff --git a/addons/hr_timesheet/data/hr_timesheet_data.xml b/addons/hr_timesheet/data/hr_timesheet_data.xml index 303736ccca8..594c0ca9745 100644 --- a/addons/hr_timesheet/data/hr_timesheet_data.xml +++ b/addons/hr_timesheet/data/hr_timesheet_data.xml @@ -8,9 +8,19 @@ + + + float_toggle + + + + float_time + + + - \ No newline at end of file + diff --git a/addons/hr_timesheet/models/__init__.py b/addons/hr_timesheet/models/__init__.py index b8bfb2333b9..f6758089f5c 100644 --- a/addons/hr_timesheet/models/__init__.py +++ b/addons/hr_timesheet/models/__init__.py @@ -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 diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index aac3e474862..f50886d9e6d 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -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. diff --git a/addons/hr_timesheet/models/ir_http.py b/addons/hr_timesheet/models/ir_http.py new file mode 100644 index 00000000000..a0694d71d1b --- /dev/null +++ b/addons/hr_timesheet/models/ir_http.py @@ -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 diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index 7976df1defb..8ab95b05cab 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -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 diff --git a/addons/project/models/res_company.py b/addons/hr_timesheet/models/res_company.py similarity index 54% rename from addons/project/models/res_company.py rename to addons/hr_timesheet/models/res_company.py index afad6d1b54e..2c77e7e83cf 100644 --- a/addons/project/models/res_company.py +++ b/addons/hr_timesheet/models/res_company.py @@ -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).""") diff --git a/addons/hr_timesheet/models/res_config_settings.py b/addons/hr_timesheet/models/res_config_settings.py index 7200bc5c21f..73d57d048fa 100644 --- a/addons/hr_timesheet/models/res_config_settings.py +++ b/addons/hr_timesheet/models/res_config_settings.py @@ -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).""") diff --git a/addons/hr_timesheet/models/uom.py b/addons/hr_timesheet/models/uom.py new file mode 100644 index 00000000000..885fc9de387 --- /dev/null +++ b/addons/hr_timesheet/models/uom.py @@ -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.") diff --git a/addons/hr_timesheet/security/ir.model.access.csv b/addons/hr_timesheet/security/ir.model.access.csv index 63bc0cace28..c6776df9364 100644 --- a/addons/hr_timesheet/security/ir.model.access.csv +++ b/addons/hr_timesheet/security/ir.model.access.csv @@ -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 diff --git a/addons/hr_timesheet/static/src/js/timesheet_uom.js b/addons/hr_timesheet/static/src/js/timesheet_uom.js new file mode 100644 index 00000000000..74fd96b5412 --- /dev/null +++ b/addons/hr_timesheet/static/src/js/timesheet_uom.js @@ -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; +}); + diff --git a/addons/hr_timesheet/views/assets.xml b/addons/hr_timesheet/views/assets.xml new file mode 100644 index 00000000000..bc14570e60e --- /dev/null +++ b/addons/hr_timesheet/views/assets.xml @@ -0,0 +1,8 @@ + + + + diff --git a/addons/hr_timesheet/views/hr_timesheet_views.xml b/addons/hr_timesheet/views/hr_timesheet_views.xml index b6522f559e7..298c8537542 100644 --- a/addons/hr_timesheet/views/hr_timesheet_views.xml +++ b/addons/hr_timesheet/views/hr_timesheet_views.xml @@ -43,8 +43,8 @@ - - + + @@ -70,7 +70,7 @@ - + @@ -93,7 +93,7 @@ - + @@ -184,7 +184,7 @@ - Duration: + Duration: @@ -201,7 +201,9 @@ account.analytic.line tree,form [('project_id', '!=', False), ('user_id', '=', uid)] - {"search_default_week":1} + { + "search_default_week":1, + }

@@ -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, } diff --git a/addons/hr_timesheet/views/hr_views.xml b/addons/hr_timesheet/views/hr_views.xml index b8c94984d69..630e45bcd5f 100644 --- a/addons/hr_timesheet/views/hr_views.xml +++ b/addons/hr_timesheet/views/hr_views.xml @@ -13,6 +13,20 @@ } + + + tree + + + + + + + form + + + + hr.employee.form.timesheet hr.employee diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml index a7b8bf0a74f..caa69689a42 100644 --- a/addons/hr_timesheet/views/project_views.xml +++ b/addons/hr_timesheet/views/project_views.xml @@ -87,7 +87,7 @@ - + diff --git a/addons/hr_timesheet/views/res_config_settings_views.xml b/addons/hr_timesheet/views/res_config_settings_views.xml index 19d21fac97c..4be15949746 100644 --- a/addons/hr_timesheet/views/res_config_settings_views.xml +++ b/addons/hr_timesheet/views/res_config_settings_views.xml @@ -11,8 +11,35 @@

-

Timesheet Apps

+

Time Encoding

+
+
+
+
+
+
+
+
diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 43c81ed1cae..a2d56f1624f 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -10,7 +10,6 @@ 'summary': 'Organize and schedule your projects ', 'depends': [ 'base_setup', - 'product', 'mail', 'portal', 'rating', diff --git a/addons/project/models/__init__.py b/addons/project/models/__init__.py index 081d29b8d4e..5a94820d26e 100644 --- a/addons/project/models/__init__.py +++ b/addons/project/models/__init__.py @@ -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 diff --git a/addons/project/models/project.py b/addons/project/models/project.py index c5c5370d78a..bddeff7d218 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -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") diff --git a/addons/project/models/res_config_settings.py b/addons/project/models/res_config_settings.py index bf8f890d968..c6815aaae90 100644 --- a/addons/project/models/res_config_settings.py +++ b/addons/project/models/res_config_settings.py @@ -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.") diff --git a/addons/project/views/res_config_settings_views.xml b/addons/project/views/res_config_settings_views.xml index da472468e96..d052efeac6f 100644 --- a/addons/project/views/res_config_settings_views.xml +++ b/addons/project/views/res_config_settings_views.xml @@ -50,7 +50,7 @@

Time Management

-
+
diff --git a/addons/uom/models/uom_uom.py b/addons/uom/models/uom_uom.py index 151ae10aa47..41c2271da0d 100644 --- a/addons/uom/models/uom_uom.py +++ b/addons/uom/models/uom_uom.py @@ -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 diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js index 3068a6528c9..f4031fccec9 100644 --- a/addons/web/static/src/js/core/utils.js +++ b/addons/web/static/src/js/core/utils.js @@ -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; diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index f209925f37c..399647d1dbe 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -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, diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js index 20af2a01064..df8ffc9dd5b 100644 --- a/addons/web/static/src/js/fields/field_registry.js +++ b/addons/web/static/src/js/fields/field_registry.js @@ -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) diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js index 989f99c5fef..0e9dc063834 100644 --- a/addons/web/static/src/js/fields/field_utils.js +++ b/addons/web/static/src/js/fields/field_utils.js @@ -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, diff --git a/addons/web/static/src/scss/fields.scss b/addons/web/static/src/scss/fields.scss index 5f14493c613..9a1e904fdc2 100644 --- a/addons/web/static/src/scss/fields.scss +++ b/addons/web/static/src/scss/fields.scss @@ -429,3 +429,8 @@ } } + +button.o_field_float_toggle { + width: 100%; + text-align: center; +} diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 030d173dbeb..1855b032bf3 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -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:'
' + + '' + + '' + + '' + + '
', + 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:'
' + + '' + + '' + + '' + + '
', + 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) { diff --git a/doc/reference/javascript_reference.rst b/doc/reference/javascript_reference.rst index 6cbfd3563dd..14647955afa 100644 --- a/doc/reference/javascript_reference.rst +++ b/doc/reference/javascript_reference.rst @@ -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 + + + - boolean (FieldBoolean) This is the default field type for fields of type *boolean*.