diff --git a/web_widget_one2many_product_picker/README.rst b/web_widget_one2many_product_picker/README.rst new file mode 100644 index 000000000..f44960cec --- /dev/null +++ b/web_widget_one2many_product_picker/README.rst @@ -0,0 +1,171 @@ +================================== +Web Widget One2Many Product Picker +================================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/12.0/web_widget_one2many_product_picker + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-12-0/web-12-0-web_widget_one2many_product_picker + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/162/12.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Adds the 'one2many_product_picker' friendly mobile widget to create one2many lines linked with product.product records. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +It's advisable to install 'web_widget_numeric_step' to have a better usability on touch screens. + +Usage +===== + +You need to define the view fields. The view must be of ``form`` type. +This is an example that uses the 'sale.order.line' fields: + +.. code:: xml + + +
+ + + + + + + + + + + + + + +Other example for 'purchase.order.line' fields: + +.. code:: xml + + +
+ + + + + + + + + + + +** In this example we don't use 'field_map' option because the default match with the sale.order.line field names. + + +Default context: +~~~~~~~~~~~~~~~~ + +The widget sends a defaults context with the 'search_read' request: + + * active_search_group_name > Contains the name of the active search group + + * 'all' > Is the hard-coded name for the 'All' group + * 'main_lines' > Is the hard-coded name for the 'Lines' group + + +Preview: +~~~~~~~~ + + .. image:: https://raw.githubusercontent.com/OCA/web/12.0/web_widget_one2many_product_picker/static/img/product_picker.gif + +Known issues / Roadmap +====================== + +* Translations in the xml 'options' attribute of the field that use the widget can't be exported automatically to be translated +* The product card animations can be improved. Currently the card is recreated, so we lost some states to apply correct effects. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Tecnativa + +Contributors +~~~~~~~~~~~~ + +* `Tecnativa `_: + + * Alexandre D. Díaz + * Pedro M. Baeza + * David Vidal + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_widget_one2many_product_picker/__init__.py b/web_widget_one2many_product_picker/__init__.py new file mode 100644 index 000000000..f0e48908c --- /dev/null +++ b/web_widget_one2many_product_picker/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2020 Tecnativa - Alexandre Díaz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). diff --git a/web_widget_one2many_product_picker/__manifest__.py b/web_widget_one2many_product_picker/__manifest__.py new file mode 100644 index 000000000..1e3311c41 --- /dev/null +++ b/web_widget_one2many_product_picker/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2020 Tecnativa - Alexandre D. Díaz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + 'name': 'Web Widget One2Many Product Picker', + 'summary': 'Widget to select products on one2many fields', + 'version': '12.0.1.0.0', + 'category': 'Website', + 'author': "Tecnativa, " + "Odoo Community Association (OCA)", + 'website': 'https://www.tecnativa.com', + 'license': 'AGPL-3', + 'depends': [ + 'product', + ], + 'data': [ + 'templates/assets.xml', + ], + 'qweb': [ + 'static/src/xml/one2many_product_picker.xml', + ], + 'installable': True, + 'auto_install': False, +} diff --git a/web_widget_one2many_product_picker/readme/CONFIG.rst b/web_widget_one2many_product_picker/readme/CONFIG.rst new file mode 100644 index 000000000..cb36faf47 --- /dev/null +++ b/web_widget_one2many_product_picker/readme/CONFIG.rst @@ -0,0 +1,47 @@ +Create or edit a new view and use the new widget called 'one2many_product_picker'. + +Widget options: +~~~~~~~~~~~~~~~ + +* product_per_page > Integer -> Used to control the load more behaviour (16 by default) +* groups > Array of dictionaries -> Declare the groups + + * name -> The group name + * string -> The text displayed + * domain -> Forced domain to use + * order -> The order + + * name -> The field name to order + * asc -> Flag to use 'asc' order + +* currency_field > Model field used to format monetary values ('currency_id' by default) +* field_map > Dictionary: + + * product -> The field that represent the product (`product_id` by default) + * name -> The field that represent a name ('name' by default) + * product_uom -> The field that represent a product_uom ('product_uom' by default) + * product_uom_qty -> The field that represent a product_uom_qty ('product_uom_qty' by default) + * price_unit -> The field that represent a price_unit ('price_unit' by default) + * discount -> The field that represent a discount ('discount' by default) + +* search > Array of dictionaries or Array of 'triplets' ([[field_map.name, 'ilike', '$search']] by default) + + * name -> The name to display + * domain -> The domain to use + + * $search -> Replaces it with the current value of the searchbox + * $number_search -> Replaces all the leaf with the current value of the searchbox as a number + +* edit_discount > Enable/Disable discount edits (False by default) +* edit_price > Enable/Disable price edits (True by default) +* show_discount > Enable/Disable display discount (False by default) +* show_subtotal > Enable/Disable show subtotal (True by default) + +All widget options are optional. +Notice that you can call '_' method to use translations. This only can be used with this widget. + +Example: + +.. code:: + + options="{'search': [{'name': _('Starts With'), 'domain': [('name', '=like', '$search%')]}], 'groups': [{'name': 'cheap', 'string': _('Cheap'), 'domain': [('list_price', '<', 10.0)], 'field_map': { 'product': 'my_product_id' }}]}" diff --git a/web_widget_one2many_product_picker/readme/CONFIGURATION.rst b/web_widget_one2many_product_picker/readme/CONFIGURATION.rst new file mode 100644 index 000000000..e6592be0a --- /dev/null +++ b/web_widget_one2many_product_picker/readme/CONFIGURATION.rst @@ -0,0 +1 @@ +It's recommendable install 'web_widget_numeric_step' to have a better usability on touch screens. diff --git a/web_widget_one2many_product_picker/readme/CONTRIBUTORS.rst b/web_widget_one2many_product_picker/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..150026f71 --- /dev/null +++ b/web_widget_one2many_product_picker/readme/CONTRIBUTORS.rst @@ -0,0 +1,5 @@ +* `Tecnativa `_: + + * Alexandre D. Díaz + * Pedro M. Baeza + * David Vidal diff --git a/web_widget_one2many_product_picker/readme/DESCRIPTION.rst b/web_widget_one2many_product_picker/readme/DESCRIPTION.rst new file mode 100644 index 000000000..430ded1be --- /dev/null +++ b/web_widget_one2many_product_picker/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Adds the 'one2many_product_picker' friendly mobile widget to create one2many lines linked with product.product records. diff --git a/web_widget_one2many_product_picker/readme/INSTALL.rst b/web_widget_one2many_product_picker/readme/INSTALL.rst new file mode 100644 index 000000000..2f3d23e79 --- /dev/null +++ b/web_widget_one2many_product_picker/readme/INSTALL.rst @@ -0,0 +1 @@ +It's advisable to install 'web_widget_numeric_step' to have a better usability on touch screens. diff --git a/web_widget_one2many_product_picker/readme/ROADMAP.rst b/web_widget_one2many_product_picker/readme/ROADMAP.rst new file mode 100644 index 000000000..43b92528c --- /dev/null +++ b/web_widget_one2many_product_picker/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +* Translations in the xml 'options' attribute of the field that use the widget can't be exported automatically to be translated +* The product card animations can be improved. Currently the card is recreated, so we lost some states to apply correct effects. diff --git a/web_widget_one2many_product_picker/readme/USAGE.rst b/web_widget_one2many_product_picker/readme/USAGE.rst new file mode 100644 index 000000000..4f227a595 --- /dev/null +++ b/web_widget_one2many_product_picker/readme/USAGE.rst @@ -0,0 +1,79 @@ +You need to define the view fields. The view must be of ``form`` type. +This is an example that uses the 'sale.order.line' fields: + +.. code:: xml + + +
+ + + + + + + + + + + + + + +Other example for 'purchase.order.line' fields: + +.. code:: xml + + +
+ + + + + + + + + + + +** In this example we don't use 'field_map' option because the default match with the sale.order.line field names. + + +Default context: +~~~~~~~~~~~~~~~~ + +The widget sends a defaults context with the 'search_read' request: + + * active_search_group_name > Contains the name of the active search group + + * 'all' > Is the hard-coded name for the 'All' group + * 'main_lines' > Is the hard-coded name for the 'Lines' group + + +Preview: +~~~~~~~~ + + .. image:: ../static/img/product_picker.gif diff --git a/web_widget_one2many_product_picker/static/description/index.html b/web_widget_one2many_product_picker/static/description/index.html new file mode 100644 index 000000000..19e778bbe --- /dev/null +++ b/web_widget_one2many_product_picker/static/description/index.html @@ -0,0 +1,527 @@ + + + + + + +Web Widget One2Many Product Picker + + + +
+

Web Widget One2Many Product Picker

+ + +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runbot

+

Adds the ‘one2many_product_picker’ friendly mobile widget to create one2many lines linked with product.product records.

+

Table of contents

+ +
+

Installation

+

It’s advisable to install ‘web_widget_numeric_step’ to have a better usability on touch screens.

+
+
+

Usage

+

You need to define the view fields. The view must be of form type. +This is an example that uses the ‘sale.order.line’ fields:

+
+<field
+    name="order_line"
+    attrs="{'readonly': [('state', 'in', ('done','cancel'))]}"
+    nolabel="1"
+    mode="form"
+    widget="one2many_product_picker"
+    options="{'search': [{'name': 'Test', 'domain': [['name', 'ilike', '$search']]}] ,'edit_discount': True, 'show_discount': True, 'groups': [{'name': 'desk', 'string': _('Desks'), 'domain': [('name', 'ilike', '%desk%')], 'order': [{'name': 'id', 'asc': true}]}, {'name': 'chair', 'string': _('Chairs'), 'domain': [('name', 'ilike', '%chair%')]}]}"
+>
+    <form>
+        <field name="state" invisible="1" />
+        <field name="display_type" invisible="1" />
+        <field name="currency_id" invisible="1" />
+        <field name="discount" widget="numeric_step" options="{'max': 100}" invisible="1"/>
+        <field name="price_unit" widget="numeric_step" invisible="1"/>
+        <field name="name" invisible="1" />
+        <field name="product_id" invisible="1" />
+        <field name="order_id" invisible="1"/>
+        <field name="product_uom_qty" class="mb-1" widget="numeric_step" context="{
+            'partner_id': parent.partner_id,
+            'quantity': product_uom_qty,
+            'pricelist': parent.pricelist_id,
+            'uom': product_uom,
+            'company_id': parent.company_id
+        }" />
+        <field name="product_uom" force_save="1" attrs="{
+            'readonly': [('state', 'in', ('sale','done', 'cancel'))],
+            'required': [('display_type', '=', False)],
+        }" context="{'company_id': parent.company_id}" class="mb-2" options="{'no_open': True, 'no_create': True, 'no_edit': True}" />
+    </form>
+</field>
+
+

Other example for ‘purchase.order.line’ fields:

+
+<field
+    name="order_line"
+    attrs="{'readonly': [('state', 'in', ('done','cancel'))]}"
+    nolabel="1"
+    widget="one2many_product_picker"
+    mode="form"
+    options="{'search': [{'name': _('Name'), 'domain': [['name', 'ilike', '$search']]}, {'name': _('Price'), 'domain': [['list_price', '=', $number_search]]}], 'field_map': {'name': 'name', 'product': 'product_id', 'product_uom': 'product_uom', 'price': 'price_unit', 'parent_id': 'order_id', 'product_uom_qty': 'product_qty'}, 'groups': [{'name': _('Desk'), 'domain': [['name', 'ilike', 'desk']], 'order': {'name': 'id', 'asc': true}}, {'name': _('Chairs'), 'domain': [['name', 'ilike', 'chair']]}]}"
+>
+    <form>
+        <field name="name" invisible="1" />
+        <field name="product_id" invisible="1" />
+        <field name="price_unit" invisible="1"  />
+        <field name="currency_id" invisible="1" />
+        <field name="order_id" invisible="1" />
+        <field name="date_planned" class="mb-1" />
+        <field name="product_qty" class="mb-1" widget="numeric_step" required="1" />
+        <field name="product_uom" class="mb-2" options="{'no_open': True, 'no_create': True, 'no_edit': True}" />
+    </form>
+</field>
+
+

** In this example we don’t use ‘field_map’ option because the default match with the sale.order.line field names.

+
+

Default context:

+

The widget sends a defaults context with the ‘search_read’ request:

+
+
    +
  • active_search_group_name > Contains the name of the active search group

    +
    +
      +
    • ‘all’ > Is the hard-coded name for the ‘All’ group
    • +
    • ‘main_lines’ > Is the hard-coded name for the ‘Lines’ group
    • +
    +
    +
  • +
+
+
+
+

Preview:

+
+https://raw.githubusercontent.com/OCA/web/12.0/web_widget_one2many_product_picker/static/img/product_picker.gif +
+
+
+
+

Known issues / Roadmap

+
    +
  • Translations in the xml ‘options’ attribute of the field that use the widget can’t be exported automatically to be translated
  • +
  • The product card animations can be improved. Currently the card is recreated, so we lost some states to apply correct effects.
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Tecnativa
  • +
+
+
+

Contributors

+
    +
  • Tecnativa:

    +
    +
      +
    • Alexandre D. Díaz
    • +
    • Pedro M. Baeza
    • +
    • David Vidal
    • +
    +
    +
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/web_widget_one2many_product_picker/static/img/product_picker.gif b/web_widget_one2many_product_picker/static/img/product_picker.gif new file mode 100644 index 000000000..12c6d63a7 Binary files /dev/null and b/web_widget_one2many_product_picker/static/img/product_picker.gif differ diff --git a/web_widget_one2many_product_picker/static/src/js/tools.js b/web_widget_one2many_product_picker/static/src/js/tools.js new file mode 100644 index 000000000..3c9ccafc8 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/tools.js @@ -0,0 +1,42 @@ +// Copyright 2020 Tecnativa - Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +odoo.define("web_widget_one2many_product_picker.tools", function ( + require +) { + var field_utils = require("web.field_utils"); + + /** + * Calculate the price with discount + * + * @param {Number} price + * @param {Number} discount + */ + function priceReduce(price, discount) { + return price * (1.0 - discount / 100.0); + }; + + /** + * Print formatted price using the 'currency_field' + * info in 'data'. + * + * @param {Number} value + * @param {String} currency_field + * @param {Object} data + */ + function monetary(value, field_info, currency_field, data) { + return field_utils.format.monetary( + value, + field_info, + { + data: data, + currency_field: currency_field, + field_digits: true, + }); + }; + + return { + monetary: monetary, + priceReduce: priceReduce, + }; + +}); diff --git a/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_create_form.js b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_create_form.js new file mode 100644 index 000000000..a199d9762 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_create_form.js @@ -0,0 +1,155 @@ +// Copyright 2020 Tecnativa - Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +odoo.define("web_widget_one2many_product_picker.ProductPickerQuickCreateForm", function (require) { + "use strict"; + + var core = require("web.core"); + var Widget = require("web.Widget"); + var widgetRegistry = require("web.widget_registry"); + var ProductPickerQuickCreateFormView = require("web_widget_one2many_product_picker.ProductPickerQuickCreateFormView").ProductPickerQuickCreateFormView; + + var qweb = core.qweb; + + /** + * This widget render a Form. Used by FieldOne2ManyProductPicker + */ + var ProductPickerQuickCreateForm = Widget.extend({ + className: "oe_one2many_product_picker_quick_create", + xmlDependencies: [ + "/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml", + ], + + custom_events: { + reload_view: "_onReloadView", + }, + + /** + * @override + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.state = options.state; + this.main_state = options.main_state; + this.node = options.node; + this.fields = options.fields; + this.fieldMap = options.fieldMap; + this.searchRecord = options.searchRecord; + this.fieldsInfo = options.fieldsInfo; + this.readonly = options.readonly; + this.basicFieldParams = options.basicFieldParams; + this.compareKey = this.node.attr("compare-key") || false; + this.res_id = this.state && this.state.res_id; + this.id = this.state && this.state.id; + this.editContext = {}; + }, + /** + * @override + */ + start: function () { + var self = this; + var def1 = this._super.apply(this, arguments); + var form_arch = this._generateFormArch(); + var fieldsView = { + arch: form_arch, + fields: this.fields, + viewFields: this.fields, + base_model: this.basicFieldParams.field.relation, + type: "form", + model: this.basicFieldParams.field.relation, + }; + + var node_context = this.node.attr("context") || "{}"; + this.nodeContext = py.eval(node_context, { + active_id: this.res_id || false, + }); + var refinedContext = _.extend( + {}, + this.main_state.getContext(), + this.nodeContext, + ); + _.extend(refinedContext, this.editContext); + this.formView = new ProductPickerQuickCreateFormView(fieldsView, { + context: refinedContext, + compareKey: this.compareKey, + fieldMap: this.fieldMap, + modelName: this.basicFieldParams.field.relation, + userContext: this.getSession().user_context, + ids: this.res_id ? [this.res_id] : [], + currentId: this.res_id || undefined, + mode: this.res_id && this.readonly ? "readonly" : "edit", + recordID: this.id, + index: 0, + parentID: this.basicFieldParams.parentID, + default_buttons: false, + withControlPanel: false, + model: this.basicFieldParams.model, + mainRecordData: this.getParent().getParent().state, + }); + if (this.id) { + this.basicFieldParams.model.save(this.id, {savePoint: true}); + } + var def2 = this.formView.getController(this).then(function (controller) { + self.controller = controller; + self.$el.empty(); + self.controller.appendTo(self.$el); + }); + + return $.when(def1, def2); + }, + + on_attach_callback: function () { + if (this.controller) { + this.controller.autofocus(); + } + }, + + /** + * @private + * @returns {String} + */ + _generateFormArch: function () { + var template = ""; + template += this.basicFieldParams.field.views.form.arch; + template += ""; + qweb.add_template(template); + return qweb.render("One2ManyProductPicker.QuickCreateForm", { + field_map: this.fieldMap, + record_search: this.searchRecord, + }); + }, + + /** + * @private + * @param {CustomEvent} evt + */ + _onReloadView: function (evt) { + this.editContext = { + 'ignore_onchanges': [this.compareKey], + 'base_record_id': evt.data.baseRecordID || null, + 'base_record_res_id': evt.data.baseRecordResID || null, + 'base_record_compare_value': evt.data.baseRecordCompareValue || null, + }; + + if (evt.data.baseRecordCompareValue === evt.data.compareValue) { + this.res_id = evt.data.baseRecordResID; + this.id = evt.data.baseRecordID; + this.start(); + } else { + var self = this; + this.getParent()._generateVirtualState({}, this.editContext).then(function(state) { + var data = {}; + data[self.compareKey] = {operation: 'ADD', id: evt.data.compareValue}; + self.basicFieldParams.model._applyChange(state.id, data).then(function(){ + self.res_id = state.res_id; + self.id = state.id; + self.start(); + }); + }); + } + }, + }); + + widgetRegistry.add("product_picker_quick_create_form", ProductPickerQuickCreateForm); + + return ProductPickerQuickCreateForm; +}); diff --git a/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_create_form_view.js b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_create_form_view.js new file mode 100644 index 000000000..ccb9dc9cb --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_create_form_view.js @@ -0,0 +1,292 @@ +// Copyright 2020 Tecnativa - Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +odoo.define("web_widget_one2many_product_picker.ProductPickerQuickCreateFormView", function (require) { + "use strict"; + + /** + * This file defines the QuickCreateFormView, an extension of the FormView that + * is used by the RecordQuickCreate in One2ManyProductPicker views. + */ + + var QuickCreateFormView = require("web.QuickCreateFormView"); + var BasicModel = require("web.BasicModel"); + var core = require("web.core"); + + var qweb = core.qweb; + + BasicModel.include({ + _applyOnChange: function (values, record, viewType) { + if ('ignore_onchanges' in record.context) { + for (var field_name of record.context['ignore_onchanges']) { + delete values[field_name]; + } + delete record.context['ignore_onchanges']; + } + return this._super(values, record, viewType); + }, + }); + + var ProductPickerQuickCreateFormRenderer = QuickCreateFormView.prototype.config.Renderer.extend( + { + /** + * @override + */ + start: function () { + this.$el.addClass("oe_one2many_product_picker_form_view o_xxs_form_view"); + return this._super.apply(this, arguments); + }, + } + ); + + var ProductPickerQuickCreateFormController = QuickCreateFormView.prototype.config.Controller.extend( + { + events: _.extend({}, QuickCreateFormView.prototype.events, { + "click .oe_record_add": "_onClickAdd", + "click .oe_record_remove": "_onClickRemove", + "click .oe_record_change": "_onClickChange", + "click .oe_record_discard": "_onClickDiscard", + }), + + init: function (parent, model, renderer, params) { + this.compareKey = params.compareKey; + this.fieldMap = params.fieldMap; + this.context = params.context; + this.mainRecordData = params.mainRecordData; + this._super.apply(this, arguments); + }, + + /** + * Updates buttons depending on record status + * + * @private + */ + _updateButtons: function () { + var record = this.model.get(this.handle); + var state = "record"; + if (this.model.isNew(record.id)) { + state = "new"; + } else if (record.isDirty()) { + state = "dirty"; + } + if (state === "new") { + for (var index in this.mainRecordData.data) { + var recordData = this.mainRecordData.data[index]; + if (recordData.ref === record.ref) { + if (record.isDirty()) { + state = "dirty"; + } else { + state = "record"; + } + break; + } + } + } + this.$el.find(".oe_one2many_product_picker_form_buttons").remove(); + this.$el.find(".o_form_view").append( + qweb.render("One2ManyProductPicker.QuickCreate.FormButtons", { + state: state, + }) + ); + }, + + /** + * @private + */ + _disableQuickCreate: function () { + this._disabled = true; // ensures that the record won't be created twice + this.$el.addClass("o_disabled"); + this.$("input:not(:disabled)") + .addClass("o_temporarily_disabled") + .attr("disabled", "disabled"); + }, + + /** + * @private + */ + _enableQuickCreate: function () { + this._disabled = false; // allows to create again + this.$el.removeClass("o_disabled"); + this.$("input.o_temporarily_disabled") + .removeClass("o_temporarily_disabled") + .attr("disabled", false); + }, + + /** + * @private + * @param {Array[String]} fields_changed + */ + _needReloadCard: function (fields_changed) { + for (var index in fields_changed) { + var field = fields_changed[index]; + if (field === this.fieldMap[this.compareKey]) { + return true; + } + } + return false; + }, + + /** + * Handle "compare field" changes. This field is used + * as master to know if we are editing or creating a + * new record. + * + * @private + * @param {ChangeEvent} ev + */ + _onFieldChanged: function (ev) { + var fields_changed = Object.keys(ev.data.changes); + if (this._needReloadCard(fields_changed)) { + var field = ev.data.changes[fields_changed[0]]; + var new_value = false; + if (typeof field === "object") { + new_value = field.id; + } else { + new_value = field; + } + var reload_values = { + compareValue: new_value, + } + var record = this.model.get(this.handle); + if (!('base_record_id' in record.context)) { + var old_value = record.data[this.compareKey]; + if (typeof old_value === 'object') { + old_value = old_value.data.id; + } + reload_values['baseRecordID'] = record.id; + reload_values['baseRecordResID'] = record.ref; + reload_values['baseRecordCompareValue'] = old_value; + } else { + reload_values['baseRecordID'] = record.context.base_record_id; + reload_values['baseRecordResID'] = record.context.base_record_res_id; + reload_values['baseRecordCompareValue'] = record.context.base_record_compare_value; + } + this.trigger_up("reload_view", reload_values); + + // Discard current change + ev.data.changes = {}; + } else { + this._super.apply(this, arguments); + if (!_.isEmpty(ev.data.changes)) { + if (this.model.isPureVirtual(this.handle)) { + this.model.unsetDirty(this.handle); + } + this.model.updateRecordContext(this.handle, {has_changes_confirmed: false}); + this.trigger_up("quick_record_updated", { + changes: ev.data.changes, + }); + this._updateButtons(); + } + } + }, + + /** + * @returns {Deferred} + */ + _add: function () { + if (this._disabled) { + // don't do anything if we are already creating a record + return $.Deferred(); + } + var self = this; + this._disableQuickCreate(); + return this.saveRecord(this.handle, { + stayInEdit: true, + reload: true, + savePoint: true, + viewType: "form", + }).then(function() { + self._enableQuickCreate(); + var record = self.model.get(self.handle); + self.trigger_up("create_quick_record", { + id: record.id, + }); + self.model.unsetDirty(self.handle); + self._updateButtons(); + }); + }, + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAdd: function (ev) { + ev.stopPropagation(); + this.model.updateRecordContext(this.handle, {has_changes_confirmed: true}); + this._add(); + }, + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRemove: function (ev) { + ev.stopPropagation(); + this.trigger_up("list_record_remove", {id: this.renderer.state.id}); + }, + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickChange: function (ev) { + ev.stopPropagation(); + this.model.updateRecordContext(this.handle, {has_changes_confirmed: true}); + var record = this.model.get(this.handle); + this.trigger_up("update_quick_record", { + id: record.id, + }); + this.trigger_up("restore_flip_card"); + this.model.unsetDirty(this.handle); + this._updateButtons(); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickDiscard: function (ev) { + var self = this; + ev.stopPropagation(); + var record = this.model.get(this.handle); + this.model.discardChanges(this.handle, { + rollback: true, + }); + this.trigger_up("quick_record_updated", { + changes: record.data, + }); + if (this.model.isNew(record.id)) { + this.update({}, {reload: false}); + this.trigger_up("restore_flip_card"); + this._updateButtons(); + } else { + this.update({}, {reload: false}).then(function(){ + debugger; + self.model.unsetDirty(self.handle); + self.trigger_up("restore_flip_card"); + self._updateButtons(); + }); + } + }, + } + ); + + var ProductPickerQuickCreateFormView = QuickCreateFormView.extend({ + config: _.extend({}, QuickCreateFormView.prototype.config, { + Renderer: ProductPickerQuickCreateFormRenderer, + Controller: ProductPickerQuickCreateFormController, + }), + + init: function (viewInfo, params) { + this._super.apply(this, arguments); + this.controllerParams.compareKey = params.compareKey; + this.controllerParams.fieldMap = params.fieldMap; + this.controllerParams.context = params.context; + this.controllerParams.mainRecordData = params.mainRecordData; + }, + }); + + return { + ProductPickerQuickCreateFormRenderer: ProductPickerQuickCreateFormRenderer, + ProductPickerQuickCreateFormController: ProductPickerQuickCreateFormController, + ProductPickerQuickCreateFormView: ProductPickerQuickCreateFormView, + }; +}); diff --git a/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_modif_price_form.js b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_modif_price_form.js new file mode 100644 index 000000000..e8a0550df --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_modif_price_form.js @@ -0,0 +1,146 @@ +// Copyright 2020 Tecnativa - Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +odoo.define("web_widget_one2many_product_picker.ProductPickerQuickModifPriceForm", function (require) { + "use strict"; + + var core = require("web.core"); + var Widget = require("web.Widget"); + var ProductPickerQuickModifPriceFormView = require("web_widget_one2many_product_picker.ProductPickerQuickModifPriceFormView").ProductPickerQuickModifPriceFormView; + + var qweb = core.qweb; + + /** + * This widget render a Form. Used by FieldOne2ManyProductPicker + */ + var ProductPickerQuickModifPriceForm = Widget.extend({ + className: "oe_one2many_product_picker_quick_modif_price", + xmlDependencies: [ + "/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_modif_price.xml", + ], + + /** + * @override + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.state = options.state; + this.main_state = options.main_state; + this.node = options.node; + this.fields = options.fields; + this.fieldMap = options.fieldMap; + this.searchRecord = options.searchRecord; + this.fieldsInfo = options.fieldsInfo; + this.readonly = options.readonly; + this.basicFieldParams = options.basicFieldParams; + this.canEditPrice = options.canEditPrice; + this.canEditDiscount = options.canEditDiscount; + this.currencyField = options.currencyField; + this.res_id = this.state && this.state.res_id; + this.id = this.state && this.state.id; + this.editContext = {}; + }, + + /** + * @override + */ + start: function () { + var self = this; + var def1 = this._super.apply(this, arguments); + var fieldsView = { + arch: this._generateFormArch(), + fields: this.fields, + viewFields: this.fields, + base_model: this.basicFieldParams.field.relation, + type: "form", + model: this.basicFieldParams.field.relation, + }; + this.formView = new ProductPickerQuickModifPriceFormView(fieldsView, { + context: this.main_state.getContext(), + fieldMap: this.fieldMap, + modelName: this.basicFieldParams.field.relation, + userContext: this.getSession().user_context, + ids: this.res_id ? [this.res_id] : [], + currentId: this.res_id || undefined, + mode: this.res_id && this.readonly ? "readonly" : "edit", + recordID: this.id, + index: 0, + parentID: this.basicFieldParams.parentID, + default_buttons: true, + withControlPanel: false, + model: this.basicFieldParams.model, + parentRecordData: this.basicFieldParams.recordData, + currencyField: this.currencyField, + disable_autofocus: true, + }); + if (this.id) { + this.basicFieldParams.model.save(this.id, {savePoint: true}); + } + var def2 = this.formView.getController(this).then(function (controller) { + self.controller = controller; + self.$el.empty(); + self.controller.appendTo(self.$el); + }); + + return $.when(def1, def2); + }, + + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + }, + + on_attach_callback: function () { + // Do nothing + }, + + /** + * @private + * @returns {String} + */ + _generateFormArch: function () { + var wanted_field_states = this._getWantedFieldState(); + var template = ""; + template += this.basicFieldParams.field.views.form.arch; + template += ""; + qweb.add_template(template); + var $arch = $(qweb.render("One2ManyProductPicker.QuickModifPrice.Form", { + field_map: this.fieldMap, + record_search: this.searchRecord, + })); + + var field_names = Object.keys(wanted_field_states); + var gen_arch = "
"; + for (var index in field_names) { + var field_name = field_names[index]; + var $field = $arch.find("field[name='"+field_name+"']"); + var modifiers = $field.attr("modifiers") ? JSON.parse($field.attr("modifiers")) : {}; + modifiers.invisible = false; + modifiers.readonly = wanted_field_states[field_name]; + $field.attr("modifiers", JSON.stringify(modifiers)); + $field.attr("invisible", "0"); + $field.attr("readonly", wanted_field_states[field_name]?"1":"0"); + gen_arch += $field[0].outerHTML; + } + gen_arch += "
"; + return gen_arch; + }, + + /** + * This method returns the wanted fields to be displayed in the view. + * {field_name: readonly_state} + * + * @private + * @returns {Object} + */ + _getWantedFieldState: function() { + var wantedFieldState = {}; + wantedFieldState[this.fieldMap.discount] = !this.canEditDiscount; + wantedFieldState[this.fieldMap.price_unit] = !this.canEditPrice; + return wantedFieldState; + }, + }); + + return ProductPickerQuickModifPriceForm; +}); diff --git a/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_modif_price_form_view.js b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_modif_price_form_view.js new file mode 100644 index 000000000..be3bef345 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_modif_price_form_view.js @@ -0,0 +1,207 @@ +// Copyright 2020 Tecnativa - Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +odoo.define("web_widget_one2many_product_picker.ProductPickerQuickModifPriceFormView", function (require) { + "use strict"; + + /** + * This file defines the QuickCreateFormView, an extension of the FormView that + * is used by the RecordQuickCreate in One2ManyProductPicker views. + */ + + var QuickCreateFormView = require("web.QuickCreateFormView"); + var core = require("web.core"); + var tools = require("web_widget_one2many_product_picker.tools"); + + var qweb = core.qweb; + + var ProductPickerQuickModifPriceFormRenderer = QuickCreateFormView.prototype.config.Renderer.extend( + { + /** + * @override + */ + start: function () { + var self = this; + this.$el.addClass("oe_one2many_product_picker_form_view o_xxs_form_view"); + return this._super.apply(this, arguments).then(function(){ + self._appendPrice(); + self._appendButtons(); + }); + }, + + /** + * @private + */ + _appendButtons: function () { + this.$el.find(".oe_one2many_product_picker_form_buttons").remove(); + this.$el.append( + qweb.render("One2ManyProductPicker.QuickModifPrice.FormButtons", { + mode: this.mode, + }) + ); + }, + + /** + * @private + */ + _appendPrice: function () { + this.$el.find(".oe_price").remove(); + this.$el.append( + qweb.render("One2ManyProductPicker.QuickModifPrice.Price") + ); + } + + } + ); + + var ProductPickerQuickModifPriceFormController = QuickCreateFormView.prototype.config.Controller.extend( + { + events: _.extend({}, QuickCreateFormView.prototype.events, { + "click .oe_record_change": "_onClickChange", + "click .oe_record_discard": "_onClickDiscard", + }), + + /** + * @override + */ + init: function (parent, model, renderer, params) { + this.fieldMap = params.fieldMap; + this.context = params.context; + this._super.apply(this, arguments); + this.currencyField = params.currencyField; + this.parentRecordData = params.parentRecordData; + }, + + /** + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self._updatePrice(); + }); + }, + + /** + * @override + */ + _onFieldChanged: function (ev) { + this._super.apply(this, arguments); + this._updatePrice(); + }, + + /** + * @private + */ + _updatePrice: function () { + var record = this.model.get(this.handle); + var price_reduce = tools.priceReduce(record.data[this.fieldMap.price_unit], record.data[this.fieldMap.discount]); + this.renderer.$el.find(".oe_price").html( + tools.monetary( + price_reduce, + this.getParent().state.fields[this.fieldMap.price_unit], + this.currencyField, + record + ) + ); + }, + + /** + * @private + */ + _disableQuickCreate: function () { + this._disabled = true; // ensures that the record won't be created twice + this.$el.addClass("o_disabled"); + this.$("input:not(:disabled)") + .addClass("o_temporarily_disabled") + .attr("disabled", "disabled"); + }, + + /** + * @private + */ + _enableQuickCreate: function () { + this._disabled = false; // allows to create again + this.$el.removeClass("o_disabled"); + this.$("input.o_temporarily_disabled") + .removeClass("o_temporarily_disabled") + .attr("disabled", false); + }, + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickChange: function (ev) { + var self = this; + ev.stopPropagation(); + this.model.updateRecordContext(this.handle, {has_changes_confirmed: true}); + var is_virtual = this.model.isPureVirtual(this.handle); + // If is a 'pure virtual' record, save it in the selected list + if (is_virtual) { + if (this.model.isDirty(this.handle)) { + this._disableQuickCreate(); + this.saveRecord(this.handle, { + stayInEdit: true, + reload: true, + savePoint: true, + viewType: "form", + }).then(function() { + self._enableQuickCreate(); + var record = self.model.get(self.handle); + self.trigger_up("create_quick_record", { + id: record.id, + }); + self.getParent().destroy(); + }); + } else { + this.getParent().destroy(); + } + } else { + // If is a "normal" record, update it + var record = this.model.get(this.handle); + this.trigger_up("update_quick_record", { + id: record.id, + }); + this.getParent().destroy(); + } + }, + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickDiscard: function (ev) { + ev.stopPropagation(); + this.model.discardChanges(this.handle, { + rollback: true, + }); + var record = this.model.get(this.handle); + this.trigger_up("update_quick_record", { + id: record.id, + }); + this.getParent().destroy(); + }, + } + ); + + var ProductPickerQuickModifPriceFormView = QuickCreateFormView.extend({ + config: _.extend({}, QuickCreateFormView.prototype.config, { + Renderer: ProductPickerQuickModifPriceFormRenderer, + Controller: ProductPickerQuickModifPriceFormController, + }), + + init: function (viewInfo, params) { + this._super.apply(this, arguments); + this.controllerParams.fieldMap = params.fieldMap; + this.controllerParams.context = params.context; + this.controllerParams.parentRecordData = params.parentRecordData; + this.controllerParams.currencyField = params.currencyField; + }, + }); + + return { + ProductPickerQuickModifPriceFormRenderer: ProductPickerQuickModifPriceFormRenderer, + ProductPickerQuickModifPriceFormController: ProductPickerQuickModifPriceFormController, + ProductPickerQuickModifPriceFormView: ProductPickerQuickModifPriceFormView, + }; +}); diff --git a/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/record.js b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/record.js new file mode 100644 index 000000000..e36351a25 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/record.js @@ -0,0 +1,596 @@ +// Copyright 2020 Tecnativa - Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +odoo.define("web_widget_one2many_product_picker.One2ManyProductPickerRecord", function ( + require +) { + "use strict"; + + var core = require("web.core"); + var Widget = require("web.Widget"); + var Domain = require("web.Domain"); + var widgetRegistry = require("web.widget_registry"); + var core = require("web.core"); + var tools = require("web_widget_one2many_product_picker.tools"); + var ProductPickerQuickModifPriceForm = require( + "web_widget_one2many_product_picker.ProductPickerQuickModifPriceForm"); + var ProductPickerQuickCreateForm = require( + "web_widget_one2many_product_picker.ProductPickerQuickCreateForm"); + + var qweb = core.qweb; + + /* This represent a record (a card) */ + var One2ManyProductPickerRecord = Widget.extend({ + custom_events: { + quick_record_updated: "_onQuickRecordUpdated", + restore_flip_card: "_onRestoreFlipCard", + }, + events: { + "click .oe_flip_card": "_onClickFlipCard", + }, + + _click_card_delayed_time: 250, + + /** + * @override + */ + init: function (parent, state, options) { + this._super(parent); + this.options = options; + this.subWidgets = {}; + this._clickFlipCardCount = 0; + this._setState(state, options.searchRecord); + this.widgets = []; + }, + + /** + * Generates a new virtual state and recreates the product card + * + * @returns {Object} + */ + generateVirtualState: function () { + return this._generateVirtualState().then(this.recreate.bind(this)); + }, + + /** + * @override + */ + start: function () { + return $.when(this._super.apply(this, arguments), this._render()); + }, + + /** + * @override + */ + on_attach_callback: function () { + _.invoke(this.subWidgets, "on_attach_callback"); + }, + + /** + * @override + */ + on_detach_callback: function () { + _.invoke(this.subWidgets, "on_detach_callback"); + }, + + /** + * @override + */ + update: function (record) { + // detach the widgets because the record will empty its $el, which + // will remove all event handlers on its descendants, and we want + // to keep those handlers alive as we will re-use these widgets + _.invoke(_.pluck(this.subWidgets, "$el"), "detach"); + this._setState(record); + return this._render(); + }, + + /** + * Re-creates the product card and updates the current state if have a + * new one. + * + * @param {Object} state + */ + recreate: function (state) { + if (state) { + this._setState(state); + } + this.on_detach_callback(); + return this._render(); + }, + + /** + * Generates the URL for the given product using the selected field + * + * @private + * @param {string} field + * @returns {string} + */ + _getImageUrl: function (product_id, field_name) { + return _.str.sprintf( + "/web/image/product.product/%d/%s", + product_id, + field_name); + }, + + /** + * Prints the given field value using the selected format + * + * @private + * @param {String} price_field + */ + _getMonetaryFieldValue: function (price_field) { + var field_name = this.options.fieldMap[price_field]; + var price = this.state.data[field_name]; + return tools.monetary( + price, + this.state.fields[field_name], + this.options.currencyField, + this.state.data); + }, + + /** + * @private + * @param {string} d a stringified domain + * @returns {boolean} the domain evaluted with the current values + */ + _computeDomain: function (d) { + return new Domain(d).compute( + (this.state || this.getParent().state).evalContext + ); + }, + + /** + * Store model info used to represent the data + * + * @private + * @param {Object} viewState + * @param {Object} recordSearch + */ + _setState: function (viewState, recordSearch) { + this.fields = this.getParent().state.fields; + this.fieldsInfo = this.getParent().state.fieldsInfo.form; + this.state = viewState; + if (recordSearch) { + this.recordSearch = recordSearch; + } + var model = this.options.basicFieldParams.model; + this.is_virtual = this.state && model.isPureVirtual(this.state.id) || false; + }, + + /** + * @private + * @returns {Object} + */ + _getQWebContext: function () { + // Using directly the 'model record' instead of the state because + // the state it's a parsed version of this record that doesn't + // contains the '_virtual' attribute. + return { + record_search: this.recordSearch, + user_context: this.getSession() && this.getSession().user_context || {}, + image: this._getImageUrl.bind(this), + compute_domain: this._computeDomain.bind(this), + state: this.state, + field_map: this.options.fieldMap, + widget: this, + monetary: this._getMonetaryFieldValue.bind(this), + show_discount: this.options.showDiscount, + is_virtual: this.is_virtual, + active_model: '', + }; + }, + + /** + * Forced context used in virtual states + * + * @private + * @returns {Object} + */ + _getInternalVirtualRecordContext: function () { + var context = {}; + context["default_" + this.options.basicFieldParams.relation_field] = this.options.basicFieldParams.state.id || null; + return context; + }, + + /** + * Forced data used in virtual states. + * Be careful with the onchanges sequence. Think as user interaction, not as CRUD operation. + * + * @private + * @returns {Object} + */ + _getInternalVirtualRecordData: function () { + var data = {}; + data[this.options.fieldMap.product] = {operation: 'ADD', id: this.recordSearch.id}; + return data; + }, + + /** + * @private + * @param {Object} data + * @param {Object} context + * @returns {Object} + */ + _generateVirtualState: function (data, context) { + var model = this.options.basicFieldParams.model; + var scontext = _.extend({}, this._getInternalVirtualRecordContext(), context); + var sdata = _.extend({}, this._getInternalVirtualRecordData(), data); + return model.createVirtualRecord(this.options.basicFieldParams.value.id, { + data: sdata, + context: scontext, + }); + }, + + /** + * @override + */ + _render: function () { + this.defs = []; + this._replaceElement( + qweb.render("One2ManyProductPicker.FlipCard", this._getQWebContext()) + ); + this.$card = this.$(".oe_flip_card"); + this.$front = this.$(".oe_flip_card_front"); + this.$back = this.$(".oe_flip_card_back"); + this._processWidgetFields(this.$front); + this._processWidgets(this.$front); + this._processDynamicFields(); + return $.when.apply(this, this.defs); + }, + + /** + * Processes each 'field' tag and replaces it by the specified widget, if + * any, or directly by the formatted value + * + * @private + */ + _processWidgetFields: function ($container) { + var self = this; + $container.find("field").each(function () { + var $field = $(this); + if ($field.parents("widget").length) { + return; + } + var field_name = $field.attr("name"); + var field_widget = $field.attr("widget"); + + // a widget is specified for that field or a field is a many2many ; + // in this latest case, we want to display the widget many2manytags + // even if it is not specified in the view. + if (field_widget || self.fields[field_name].type === "many2many") { + var widget = self.subWidgets[field_name]; + if (!widget) { + // the widget doesn't exist yet, so instanciate it + var Widget = self.fieldsInfo[field_name].Widget; + if (Widget) { + widget = self._processWidget($field, field_name, Widget); + self.subWidgets[field_name] = widget; + } else if (config.debug) { + // the widget is not implemented + $field.replaceWith( + $("", { + text: _.str.sprintf( + _t("[No widget %s]"), + field_widget + ), + }) + ); + } + } else { + // a widget already exists for that field, so reset it + // with the new state + widget.reset(self.state); + $field.replaceWith(widget.$el); + } + } + }); + }, + + /** + * Replace a field by its corresponding widget. + * + * @private + * @param {JQuery} $field + * @param {String} field_name + * @param {Class} Widget + * @returns {Widget} the widget instance + */ + _processWidget: function ($field, field_name, Widget) { + // some field's attrs might be record dependent (they start with + // 't-att-') and should thus be evaluated, which is done by qweb + // we here replace those attrs in the dict of attrs of the state + // by their evaluted value, to make it transparent from the + // field's widgets point of view + // that dict being shared between records, we don't modify it + // in place + var self = this; + var attrs = Object.create(null); + _.each(this.fieldsInfo[field_name], function (value, key) { + if (_.str.startsWith(key, "t-att-")) { + key = key.slice(6); + value = $field.attr(key); + } + attrs[key] = value; + }); + var options = _.extend({}, this.options, {attrs: attrs, data: this.state.data}); + var widget = new Widget(this, field_name, this.getParent().state, options); + var def = widget.replace($field); + if (def.state() === "pending") { + this.defs.push(def); + } + return widget; + }, + + /** + * Initialize widgets using "widget" tag + * + * @private + * @param {jQueryElement} $container + */ + _processWidgets: function ($container) { + var self = this; + $container.find("widget").each(function () { + var $field = $(this); + var Widget = widgetRegistry.get($field.attr("name")); + var widget = new Widget(self, { + fieldsInfo: self.fieldsInfo, + fields: self.fields, + main_state: self.getParent().state, + state: self.state, + fieldMap: self.options.fieldMap, + searchRecord: self.recordSearch, + node: $field, + readonly: self.options.readOnlyMode, + basicFieldParams: self.options.basicFieldParams, + data: self.state && self.state.data, + }); + + self.widgets.push(widget); + + var def = widget + ._widgetRenderAndInsert(function () {}) + .then(function () { + widget.$el.addClass("o_widget"); + $field.replaceWith(widget.$el); + }); + if (def.state() === "pending") { + self.defs.push(def); + } + }); + }, + + /** + * This is a special handle for display the non-fields. + * Similar 't-esc' behaviour. + * A non-field element has defined the "data-field" paramenter with + * the field that trigger the update. Also this non-field element has + * the attribute "format" to use with "py.eval". + * Note that the context used in py.eval has all record fields data. + * + * Exmaple: + * + * ** This will change the elements when 'qty' changes and prints the + * text: 20 Items + * + * @private + * @param {Array[String]} fields + */ + _processDynamicFields: function (fields) { + if (!this.state) { + return; + } + var self = this; + var model = this.options.basicFieldParams.model; + var record = model.get(this.state.id); + var state_data = record.data; + + var to_find = []; + if (!_.isEmpty(fields)) { + to_find = _.map(fields, function(field){ return _.str.sprintf("[data-field=%s]", [field]); }); + } else { + to_find = ["[data-field]"]; + } + + this.$el.find(to_find.join()).each(function () { + var $elm = $(this); + var format_out = $elm.data("esc") || $elm.data("field"); + $elm.html( + py.eval(format_out, _.extend({}, state_data, self.recordSearch)) + ); + }); + + if (this.options.showDiscount) { + var field_map = this.options.fieldMap; + if (state_data) { + var has_discount = state_data[field_map.discount] > 0.0; + this.$el.find(".original_price,.discount_price").toggleClass("d-none", !has_discount); + if (has_discount) { + this.$el.find(".price_unit").html(this._calcPriceReduced()); + } else { + this.$el.find(".price_unit").html(this._getMonetaryFieldValue("price_unit")); + } + } + } + }, + + /** + * @private + * @returns {String} + */ + _calcPriceReduced: function () { + var price_reduce = 0; + var field_map = this.options.fieldMap; + var state_data = this.state.data; + if (state_data && state_data[field_map.discount]) { + price_reduce = tools.priceReduce(state_data[field_map.price_unit], state_data[field_map.discount]); + } + return price_reduce && tools.monetary( + price_reduce, + this.state.fields[field_map.price_unit], + this.options.currencyField, + this.state.data + ); + }, + + /** + * @private + */ + _openPriceModifier: function () { + var state_data = this.state && this.state.data; + if (this.options.readOnlyMode || !state_data) { + return; + } + var modif_price_form = new ProductPickerQuickModifPriceForm(this, { + fieldsInfo: this.fieldsInfo, + fields: this.fields, + main_state: this.getParent().state, + state: this.state, + fieldMap: this.options.fieldMap, + searchRecord: this.recordSearch, + readonly: this.options.readOnlyMode, + basicFieldParams: this.options.basicFieldParams, + canEditPrice: this.options.editPrice, + canEditDiscount: this.options.editDiscount, + currencyField: this.options.currencyField, + }); + this.$modifPricePopup = $(qweb.render("One2ManyProductPicker.QuickModifPricePopup")); + this.$modifPricePopup.appendTo($(".o_main_content")); + modif_price_form.attachTo(this.$modifPricePopup); + }, + + // HANDLE EVENTS + + /** + * @private + * @param {ClickEvent} evt + */ + _onClickFlipCard: function (evt) { + // Avoid clicks on form elements + if (['INPUT','BUTTON', 'A'].indexOf(evt.target.tagName) !== -1) { + return; + } + if (!this._clickFlipCardDelayed) { + this._clickFlipCardDelayed = setTimeout(this._onClickDelayedFlipCard.bind(this, evt), this._click_card_delayed_time); + } + ++this._clickFlipCardCount; + if (this._clickFlipCardCount >= 2) { + clearTimeout(this._clickFlipCardDelayed); + this._clickFlipCardDelayed = false; + this._clickFlipCardCount = 0; + this._onDblClickDelayedFlipCard(evt); + } + }, + + /** + * @private + * @param {MouseEvent} evt + */ + _onClickDelayedFlipCard: function (evt) { + this._clickFlipCardDelayed = false; + this._clickFlipCardCount = 0; + + if (this.options.readOnlyMode || !this.state) { + return; + } + if (this.$card.hasClass("active")) { + this.$card.removeClass("active"); + this.$card.find('.oe_flip_card_front').removeClass("d-none"); + } else { + var self = this; + this.defs = []; + this._processWidgetFields(this.$back); + this._processWidgets(this.$back); + this._processDynamicFields(); + $.when(this.defs).then(function(){ + var $actived_card = self.$el.parent().find(".active"); + $actived_card.removeClass("active"); + $actived_card.find('.oe_flip_card_front').removeClass("d-none"); + self.$card.addClass("active"); + setTimeout(() => { + self.$('.oe_flip_card_front').addClass("d-none"); + }, 200); + }); + } + }, + + /** + * @private + * @param {MouseEvent} evt + */ + _onDblClickDelayedFlipCard: function (evt) { + var $target = $(evt.target); + if ($target.hasClass('badge_price') || $target.parents('.badge_price').length) { + this._openPriceModifier(); + } else { + var $currentTarget = $(evt.currentTarget); + var $img = $currentTarget.find(".oe_flip_card_front img"); + var cur_img_src = $img.attr("src"); + if ($currentTarget.hasClass('oe_flip_card_maximized')) { + $currentTarget.removeClass('oe_flip_card_maximized'); + $currentTarget.on('transitionend', function() { + $currentTarget.css({ + position: "", + top: "", + left: "", + width: "", + height: "", + zIndex: "", + }); + $currentTarget.off('transitionend'); + }); + } else { + var $actived_card = this.$el.parent().find(".active"); + if ($actived_card[0] !== $currentTarget[0]) { + $actived_card.removeClass("active"); + $actived_card.find('.oe_flip_card_front').removeClass("d-none"); + } + var offset = $currentTarget.offset(); + $currentTarget.css({ + position: "fixed", + top: offset.top, + left: offset.left, + width: $currentTarget.width(), + height: $currentTarget.height(), + zIndex: 50, + }); + _.defer(function(){ + $currentTarget.addClass('oe_flip_card_maximized'); + }); + } + $img.attr("src", $img.data("srcAlt")); + $img.data("srcAlt", cur_img_src); + } + }, + + /** + * @private + * @param {CustomEvent} evt + */ + _onRestoreFlipCard: function (evt) { + this.$(".oe_flip_card").removeClass("active"); + this.$('.oe_flip_card_front').removeClass("d-none"); + }, + + /** + * Update the selected element using the given format. + * A non-field element has defined the "data-field" paramenter with + * the field that trigger the update. Also this non-field element has + * the attribute "format" to use with "py.eval". + * Note that the context used in py.eval has all record fields data. + * + * Exmaple: + * + * ** This will change the elements when 'qty' changes and prints the + * text: 20 Items + * + * @private + * @param {CustomEvent} evt + */ + _onQuickRecordUpdated: function (ev) { + this._processDynamicFields(Object.keys(ev.data.changes)); + this.trigger_up("update_subtotal"); + }, + }); + + return One2ManyProductPickerRecord; +}); diff --git a/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/renderer.js b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/renderer.js new file mode 100644 index 000000000..54c1fc4f2 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/renderer.js @@ -0,0 +1,427 @@ +// Copyright 2020 Tecnativa - Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +odoo.define("web_widget_one2many_product_picker.One2ManyProductPickerRenderer", function (require) { + "use strict"; + + var core = require("web.core"); + var BasicRenderer = require("web.BasicRenderer"); + var One2ManyProductPickerRecord = require("web_widget_one2many_product_picker.One2ManyProductPickerRecord"); + + var qweb = core.qweb; + + /* This is the renderer of the main widget */ + var One2ManyProductPickerRenderer = BasicRenderer.extend({ + className: 'oe_one2many_product_picker_view', + + events: { + //'scroll': '_lazyOnScrollView', + 'click #productPickerLoadMore': '_onClickLoadMore', + }, + + DELAY_GET_RECORDS: 150, + MIN_PERC_GET_RECORDS: 0.9, + + /** + * @override + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.widgets = []; + this.recordOptions = _.extend({}, params.record_options, { + viewType: 'One2ManyProductPicker', + }); + // Workaraound: Odoo initilize this class so we need do this to + // 'receive' more arguments. + this.options = parent.options; + this.mode = parent.mode; + this.search_data = parent._searchRecords; + this.last_search_data_count = parent._lastSearchRecordsCount; + this._lazyOnScrollView = _.debounce(this._onScrollView.bind(this), this.DELAY_GET_RECORDS); + }, + + /** + * Propagate the event to the view widgets + */ + on_attach_callback: function () { + this._isInDom = true; + _.invoke(this.widgets, 'on_attach_callback'); + }, + + /** + * Propagate the event to the view widgets + */ + on_detach_callback: function () { + this._isInDom = false; + _.invoke(this.widgets, 'on_detach_callback'); + }, + + /** + * @param {Object} widget + */ + removeWidget: function (widget) { + this.widgets.splice(this.widgets.indexOf(widget), 1); + widget.destroy(); + }, + + /** + * @override + */ + start: function () { + //this.$el.addClass("row"); + return this._super.apply(this, arguments); + }, + + /** + * @param {Object} searchState + */ + updateSearchData: function (search_data, count) { + this.search_data = search_data; + this.last_search_data_count = count; + this._loadMoreWorking = false; + this.$btnLoadMore.attr("disabled", false); + }, + + /** + * @param {Boolean} block + */ + blockLoadMore: function (block) { + this.$btnLoadMore.attr("disabled", block); + }, + + /** + * Avoid re-render 'pure virtual' states + * + * @override + */ + updateState: function (state, params) { + var self = this; + if (_.isEqual(this.state.data, state.data)) { + return this._super.apply(this, arguments); + } + var old_state = _.clone(this.state.data); + return this._super(state, _.extend({}, params, {noRender: true})).then(function() { + self._updateStateRecords(old_state); + }); + }, + + /** + * @private + * @param {Array[Object]} states + * @returns {Deferred} + */ + _removeRecords: function (states) { + var defs = []; + var to_destroy = []; + for (var index in states) { + var state = states[index]; + for (var e = this.widgets.length-1; e>=0; --e) { + var widget = this.widgets[e]; + if (widget && widget.state.id === state.id) { + to_destroy.push(widget); + delete this.widgets[e]; + } + } + } + this.widgets = _.compact(this.widgets); + + // If doesn't exists other records with the same product, we need + // create a 'pure virtual' record again. + for (var index in to_destroy) { + var widget_destroyed = to_destroy[index]; + var widget_product_id = widget_destroyed.state.data[this.options.field_map.product].data.id; + var found = false; + for (var e = this.widgets.length-1; e>=0; --e) { + var widget = this.widgets[e]; + if (widget.state.data[this.options.field_map.product].data.id === widget_product_id) { + found = true; + break; + } + } + if (!found) { + var search_record = _.find(this.search_data, {id: widget_product_id}) + var new_search_record = _.extend({}, search_record, {__id: state.id}); + var search_record_index = widget_destroyed.$el.index(); + defs.push(this.appendSearchRecords([new_search_record], false, true, search_record_index)); + } + } + + _.invoke(to_destroy, "destroy"); + return $.when(defs); + }, + + /** + * When the state change this method tries to update current records, delete + * or update them. + * Thanks to this we don't need re-render 'pure virtual' records. + * + * @private + * @returns {Deferred} + */ + _updateStateRecords: function (old_states) { + // States to remove + var states_to_destroy = []; + for (var index in old_states) { + var old_state = old_states[index]; + var found = false; + for (var e in this.state.data) { + var current_state = this.state.data[e]; + if (current_state.id === old_state.id) { + found = true; + break; + } + } + if (!found) { + states_to_destroy.push(old_state); + } + } + this._removeRecords(states_to_destroy); + + // Records to Update or Create + var defs = []; + var to_destroy = []; + for (var index in this.state.data) { + var state = this.state.data[index]; + var exists = false; + var search_record_index = -1; + var search_record = false; + for (var e = this.widgets.length-1; e>=0; --e) { + var widget = this.widgets[e]; + if (!widget) { + // Already processed widget (deleted) + continue; + } + if (widget.state.id === state.id) { + widget.recreate(state); + exists = true; + break; + } else if (widget.recordSearch.id === state.data[this.options.field_map.product].data.id) { + // Is a new record + search_record_index = widget.$el.index(); + search_record = widget.recordSearch; + } + // Remove "pure virtual" records that have the same product that the new record + if (widget.is_virtual && widget.state.data[this.options.field_map.product].data.id === state.data[this.options.field_map.product].data.id) { + to_destroy.push(widget); + delete this.widgets[e]; + } + } + // Need add a new one? + if (!exists && search_record_index !== -1) { + var new_search_record = _.extend({}, search_record, {__id: state.id}); + defs.push(this.appendSearchRecords([new_search_record], false, true, search_record_index)); + } + } + this.widgets = _.compact(this.widgets); + _.invoke(to_destroy, "destroy"); + return $.when(defs); + }, + + /** + * @override + */ + _renderView: function () { + var self = this; + var oldWidgets = this.widgets; + this.widgets = []; + this.$recordsContainer = $("
", { + class: "w-100 row", + }); + this.$extraButtonsContainer = $(qweb.render("One2ManyProductPicker.ExtraButtons")); + this.$btnLoadMore = this.$extraButtonsContainer.find("#productPickerLoadMore"); + return $.Deferred(function(d){ + self.appendSearchRecords(self.search_data, true).then(function(){ + _.invoke(oldWidgets, "destroy"); + self.$el.empty(); + self.$el.append(self.$recordsContainer); + self.$el.append(self.$extraButtonsContainer); + self.showLoadMore(self.last_search_data_count >= self.options.records_per_page); + if (self._isInDom) { + _.invoke(self.widgets, "on_attach_callback"); + } + d.resolve(); + }); + }); + }, + + /** + * Compare search results with current lines + * + * @private + * @param {Array[Object]} results + */ + _processSearchRecords: function (results) { + var field_name = this.options.field_map.product; + var records = []; + for (var index in results) { + var record_search = results[index]; + var state_data_found = false; + + for (var state_record of this.state.data) { + var field = state_record.data[field_name]; + if ( + (typeof field === "object" && field.data.id === record_search.id) || + field === record_search.id + ) { + records.push( + _.extend({}, record_search, { + __id: state_record.id, + }) + ); + state_data_found = true; + } + } + if (!state_data_found) { + records.push(record_search); + } + } + + return records; + }, + + /** + * @private + * @param {Int} id + * @returns {Object} + */ + _getRecordDataById: function (id) { + for (var index in this.state.data) { + var record = this.state.data[index]; + if (record.id === id) { + return record; + } + } + return false; + }, + + /** + * @private + * @param {Object} search_record + * @returns {Object} + */ + _getRecordOptions: function (search_record) { + return _.extend({}, this.recordOptions, { + fieldMap: this.options.field_map, + searchRecord: search_record, + basicFieldParams: this.getParent().getBasicFieldParams(), + currencyField: this.options.currency_field, + readOnlyMode: this.mode === "readonly", + showDiscount: this.options.show_discount, + editDiscount: this.options.edit_discount, + editPrice: this.options.edit_price, + }); + }, + + /** + * Generates the 'Product Card' per record. + * + * @private + * @param {Array[Object]} search_records + * @param {Boolean} no_process_records + * @param {Number} position + */ + _appendSearchRecords: function (search_records, no_process_records, position) { + var self = this; + var processed_records = no_process_records?search_records:this._processSearchRecords(search_records); + _.each(processed_records, function (search_record) { + var state_data = self._getRecordDataById(search_record.__id); + var ProductPickerRecord = new One2ManyProductPickerRecord( + self, + state_data, + self._getRecordOptions(search_record) + ); + self.widgets.push(ProductPickerRecord); + // Simulate new lines to dispatch get_default & onchange's to get the + // relevant data to print. This case increase the TTI time. + if (!state_data) { + var defVirtualState = ProductPickerRecord.generateVirtualState(); + if (defVirtualState.state() === "pending") { + self.defsVirtualState.push(defVirtualState); + } + } + // At this point the widget will use the existing state (line) or + // the search data. Using search data instead of waiting for + // simulated state gives a low FCP time. + var def = ProductPickerRecord.appendTo(self.$recordsContainer).then(function(){ + if (typeof position !== "undefined") { + var $elm = self.$el.find("> div > div:nth("+position+")"); + ProductPickerRecord.$el.insertAfter($elm); + } + }); + if (def.state() === "pending") { + self.defs.push(def); + } + }); + }, + + /** + * @param {Boolean} status + */ + showLoadMore: function (status) { + this.$btnLoadMore.toggleClass("d-none", !status); + }, + + /** + * Append search records to the view + * + * @param {Array[Object]} search_records + * @param {Boolean} no_attach_widgets + * @param {Boolean} no_process_records + * @param {Number} position + * @returns {Deferred} + */ + appendSearchRecords: function (search_records, no_attach_widgets, no_process_records, position) { + var self = this; + this.trigger_up("loading_records"); + this.defs = []; + this.defsVirtualState = []; + var cur_widget_index = this.widgets.length; + this._appendSearchRecords(search_records, no_process_records, position); + var defs = this.defs; + delete this.defs; + var defsVirtualState = this.defsVirtualState; + delete this.defsVirtualState; + $.when.apply($, defsVirtualState).then(function(){ + self.trigger_up("loading_records", {finished:true}); + }); + return $.when.apply($, defs).then(function () { + if (!no_attach_widgets && self._isInDom) { + var new_widgets = self.widgets.slice(cur_widget_index); + _.invoke(new_widgets, "on_attach_callback"); + } + }); + }, + + /** + * Auto-load more records (scroll pagination). + * + * @private + * @param {ScrollEvent} evt + */ + _onScrollView: function (evt) { + var cur_pos = evt.target.scrollTop; + var max_pos = evt.target.scrollHeight - evt.target.clientHeight; + var perc_pos = cur_pos / max_pos; + if (perc_pos > this.MIN_PERC_GET_RECORDS) { + if (!this._loadMoreWorking) { + this.trigger_up("load_more"); + this._loadMoreWorking = true; + this.$btnLoadMore.attr("disabled", true); + } + } else { + this._loadMoreWorking = false; + } + }, + + /** + * @private + */ + _onClickLoadMore: function (evt) { + this.$btnLoadMore.attr("disabled", true); + this.trigger_up("load_more"); + this._loadMoreWorking = true; + } + + }); + + return One2ManyProductPickerRenderer; +}); diff --git a/web_widget_one2many_product_picker/static/src/js/views/basic_model.js b/web_widget_one2many_product_picker/static/src/js/views/basic_model.js new file mode 100644 index 000000000..d2da98ef8 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/basic_model.js @@ -0,0 +1,228 @@ +// Copyright 2020 Tecnativa - Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +odoo.define("web_widget_one2many_product_picker.BasicModel", function (require) { + "use strict"; + + var BasicModel = require("web.BasicModel"); + + BasicModel.include({ + /** + * @param {Number/String} handle + * @param {Object} context + */ + updateRecordContext: function (handle, context) { + this.localData[handle].context = _.extend( + {}, + this.localData[handle].context, + context); + }, + + /** + * @param {Number/String} id + * @returns {Boolean} + */ + isPureVirtual: function (id) { + var data = this.localData[id]; + return data._virtual || false; + }, + + /** + * @param {Number/String} id + * @param {Boolean} status + */ + setPureVirtual: function (id, status) { + var data = this.localData[id]; + if (status) { + data._virtual = true; + } else { + delete data._virtual; + } + }, + + /** + * @param {Number/String} id + */ + unsetDirty: function (id) { + var data = this.localData[id]; + data._isDirty = false; + this._visitChildren(data, function (r) { + r._isDirty = false; + }); + }, + + /** + * Generates a virtual records without link it + * + * @param {Integer/String} listID + * @param {Object} options + * @returns {Deferred} + */ + createVirtualRecord: function (listID, options) { + var self = this; + var list = this.localData[listID]; + var context = _.extend({}, this._getContext(list), options.context); + + var position = (options && options.position) || 'top'; + var params = { + context: context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + parentID: list.id, + position: position, + viewType: list.viewType, + allowWarning: true, + doNotSetDirty: true, + }; + + return $.Deferred(function(d){ + self._makeDefaultRecord(list.model, params).then(function (recordID) { + self.setPureVirtual(recordID, true); + if (options.data) { + self._applyChangeNoWarnings(recordID, options.data, params).then(function(){ + d.resolve(self.get(recordID)); + }); + } else { + d.resolve(self.get(recordID)); + } + }); + }); + }, + + /** + * Cloned '_applyChange' but without warning messages + * + * @private + * @param {Object} record + * @param {Object} fields + * @param {String} viewType + * @returns {Deferred} + */ + _applyChangeNoWarnings: function (recordID, changes, options) { + var self = this; + var record = this.localData[recordID]; + var field; + var defs = []; + options = options || {}; + record._changes = record._changes || {}; + if (!options.doNotSetDirty) { + record._isDirty = true; + } + var initialData = {}; + this._visitChildren(record, function (elem) { + initialData[elem.id] = $.extend(true, {}, _.pick(elem, 'data', '_changes')); + }); + + // apply changes to local data + for (var fieldName in changes) { + field = record.fields[fieldName]; + if (field && (field.type === 'one2many' || field.type === 'many2many')) { + defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options.viewType, options.allowWarning)); + } else if (field && (field.type === 'many2one' || field.type === 'reference')) { + defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName])); + } else { + record._changes[fieldName] = changes[fieldName]; + } + } + + if (options.notifyChange === false) { + return $.Deferred().resolve(_.keys(changes)); + } + + return $.when.apply($, defs).then(function () { + var onChangeFields = []; // the fields that have changed and that have an on_change + for (var fieldName in changes) { + field = record.fields[fieldName]; + if (field && field.onChange) { + var isX2Many = field.type === 'one2many' || field.type === 'many2many'; + if (!isX2Many || (self._isX2ManyValid(record._changes[fieldName] || record.data[fieldName]))) { + onChangeFields.push(fieldName); + } + } + } + var onchangeDef = $.Deferred(); + if (onChangeFields.length) { + self._performOnChangeNoWarnings(record, onChangeFields, options.viewType) + .then(function (result) { + delete record._warning; + onchangeDef.resolve(_.keys(changes).concat(Object.keys(result && result.value || {}))); + }).fail(function () { + self._visitChildren(record, function (elem) { + _.extend(elem, initialData[elem.id]); + }); + // safe fix for stable version, for opw-2267444 + if (!options.force_fail) { + onchangeDef.resolve({}); + } else { + onchangeDef.reject({}); + } + }); + } else { + onchangeDef = $.Deferred().resolve(_.keys(changes)); + } + return onchangeDef.then(function (fieldNames) { + _.each(fieldNames, function (name) { + if (record._changes && record._changes[name] === record.data[name]) { + delete record._changes[name]; + record._isDirty = !_.isEmpty(record._changes); + } + }); + return self._fetchSpecialData(record).then(function (fieldNames2) { + // Return the names of the fields that changed (onchange or + // associated special data change) + return _.union(fieldNames, fieldNames2); + }); + }); + }); + }, + + /** + * Cloned '_performOnChange' but without warning messages + * + * @private + * @param {Object} record + * @param {Object} fields + * @param {String} viewType + * @returns {Deferred} + */ + _performOnChangeNoWarnings: function (record, fields, viewType) { + var self = this; + var onchangeSpec = this._buildOnchangeSpecs(record, viewType); + if (!onchangeSpec) { + return $.when(); + } + var idList = record.data.id ? [record.data.id] : []; + var options = { + full: true, + }; + if (fields.length === 1) { + fields = fields[0]; + // if only one field changed, add its context to the RPC context + options.fieldName = fields; + } + var context = this._getContext(record, options); + var currentData = this._generateOnChangeData(record, {changesOnly: false}); + + return self._rpc({ + model: record.model, + method: 'onchange', + args: [idList, currentData, fields, onchangeSpec, context], + }) + .then(function (result) { + if (!record._changes) { + // if the _changes key does not exist anymore, it means that + // it was removed by discarding the changes after the rpc + // to onchange. So, in that case, the proper response is to + // ignore the onchange. + return; + } + if (result.domain) { + record._domains = _.extend(record._domains, result.domain); + } + return self._applyOnChange(result.value, record).then(function () { + return result; + }); + }); + }, + }); + +}); diff --git a/web_widget_one2many_product_picker/static/src/js/views/basic_view.js b/web_widget_one2many_product_picker/static/src/js/views/basic_view.js new file mode 100644 index 000000000..eb69bc417 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/basic_view.js @@ -0,0 +1,34 @@ +// Copyright 2020 Tecnativa - Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +odoo.define("web_widget_one2many_product_picker.BasicView", function (require) { + "use strict"; + + var core = require("web.core"); + var pyUtils = require("web.py_utils"); + var BasicView = require("web.BasicView"); + + var _t = core._t; + + // py.js _ -> _t() call + var PY_t = new py.PY_def.fromJSON(function() { + var args = py.PY_parseArgs(arguments, ['str']); + return py.str.fromJSON(_t(args.str.toJSON())); + }); + + BasicView.include({ + /** + * @override + */ + _processField: function (viewType, field, attrs) { + /* We need process 'options' attribute to handle translations and special replacements */ + if (attrs.widget === "one2many_product_picker" && !_.isObject(attrs.options)) { + attrs.options = attrs.options ? pyUtils.py_eval(attrs.options, { + _: PY_t, + number_search: '$number_search', + }) : {}; + } + return this._super(viewType, field, attrs); + }, + }); + +}); diff --git a/web_widget_one2many_product_picker/static/src/js/widgets/field_one2many_product_picker.js b/web_widget_one2many_product_picker/static/src/js/widgets/field_one2many_product_picker.js new file mode 100644 index 000000000..ce4db1600 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/widgets/field_one2many_product_picker.js @@ -0,0 +1,610 @@ +// Copyright 2020 Tecnativa - Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +odoo.define("web_widget_one2many_product_picker.FieldOne2ManyProductPicker", function ( + require +) { + "use strict"; + + var core = require("web.core"); + var field_registry = require("web.field_registry"); + var FieldOne2Many = require("web.relational_fields").FieldOne2Many; + var One2ManyProductPickerRenderer = require("web_widget_one2many_product_picker.One2ManyProductPickerRenderer"); + var tools = require("web_widget_one2many_product_picker.tools"); + + var _t = core._t; + var qweb = core.qweb; + + /* This is the main widget */ + var FieldOne2ManyProductPicker = FieldOne2Many.extend({ + className: "oe_field_one2many_product_picker", + + events: _.extend({}, FieldOne2Many.prototype.events, { + "click .dropdown-item": "_onClickSearchMode", + "click .oe_search_erase": "_onClickSearchEraser", + "click .oe_btn_lines": "_onClickLines", + "click .oe_btn_search_group": "_onClickSearchGroup", + "keypress .oe_search_input": "_onKeyPressSearch", + "show.bs.dropdown .o_cp_buttons": "_onShowSearchDropdown", + }), + custom_events: _.extend({}, FieldOne2Many.prototype.custom_events, { + create_quick_record: "_onCreateQuickRecord", + update_quick_record: "_onUpdateQuickRecord", + update_subtotal: "_onUpdateSubtotal", + load_more: "_onLoadMore", + loading_records: "_onLoadingRecords", + }), + + _auto_search_delay: 450, + + // product.product fields + search_read_fields: [ + "id", + "display_name", + ], + + /** + * @override + */ + init: function (parent, name, record, options) { + this._super.apply(this, arguments); + this.state = record; // This is the parent state + // Use jquery 'extend' to have a 'deep' merge. + this.options = $.extend(true, this._getDefaultOptions(), this.attrs.options); + if (!this.options.search) { + this.options.search = [[this.options.field_map.name, 'ilike', '$search']]; + } + this._searchMode = 0; + this._searchCategoryNames = []; + if (!(this.options.search[0] instanceof Array)) { + this._searchCategoryNames = _.map(this.options.search, "name"); + } + // FIXME: Choose a better way to get the active controller or model objects + this.parent_controller = parent.getParent(); + if (this.view) { + this._processGroups(); + } + }, + + /** + * @override + */ + willStart: function () { + if (!this.view) { + return $.when(); + } + // Uses to work with searchs, so we can mix properties with the user values. + this._searchContext = { + domain: this.mode === "readonly" ? this._getLinesDomain() : false, + text: false, + order: false, + }; + return $.when(this._super.apply(this, arguments), this._getSearchRecords()); + }, + + /** + * Updates the lines counter badge + */ + updateBadgeLines: function () { + var records = this.parent_controller.model.get(this.state.id).data[this.name].data; + this.$badgeLines.text(records.length); + }, + + updateSubtotalPrice: function () { + if (!this.options.show_subtotal) { + return; + } + var prices = []; + var field_map = this.options.field_map; + var records = this.parent_controller.model.get(this.state.id).data[this.name].data; + if (this.options.show_discounts) { + prices = _.map(records, function (line) { + return line.data[field_map.product_uom_qty] * tools.priceReduce(line.data[field_map.price_unit], line.data[field_map.discount]); + }); + } else { + prices = _.map(records, function (line) { + return ( + line.data[field_map.product_uom_qty] * + line.data[field_map.price_unit] + ); + }); + } + var total = + _.reduce(prices, function (a, b) { + return a + b; + }) || 0; + total = tools.monetary( + total, + this.state.data[this.name].fields[this.options.field_map.price_unit], + this.options.currency_field, + this.state.data + ); + this.$totalZone.find(".total_price").html(total || 0.0); + }, + + /** + * Helper to constucts a dictionary with essential values + * used by the involved views. + * + * @returns {Object} + */ + getBasicFieldParams: function () { + return { + domain: this.record.getDomain(this.recordParams), + field: this.field, + parentID: this.value.id, + state: this.state, + model: this.parent_controller.model, + fieldName: this.name, + recordData: this.recordData, + value: this.value, + relation_field: this.state.fields[this.name].relation_field, + }; + }, + + /** + * @override + */ + _getRenderer: function () { + return One2ManyProductPickerRenderer; + }, + + /** + * Create the group buttons defined in options + * + * @private + */ + _processGroups: function () { + this.searchGroups = []; + var hasUserActive = false; + var groups = this.options.groups || []; + for (var groupIndex in groups) { + var group_def = groups[groupIndex]; + if (group_def.active) { + group_def.active = !hasUserActive; + hasUserActive = true; + } + this.searchGroups.push(group_def); + } + + this.searchGroups.splice(0, 0, { + name: 'all', + string: _t("All"), + domain: [], + order: false, + active: !hasUserActive, + }); + this._activeSearchGroup = this.searchGroups[0]; + }, + + /** + * Inject widget buttons and ignore default pagination to use + * we own implementation. + * + * @override + */ + _renderControlPanel: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.control_panel.update({ + cp_content: { + $buttons: self.$buttons, + $pager: false, + }, + }); + }); + }, + + /** + * @override + */ + _renderButtons: function () { + if (!this.isReadonly) { + this.$buttons = $( + qweb.render("One2ManyProductPicker.ControlPanelButtons", { + search_category_names: this._searchCategoryNames, + search_mode: this._searchMode, + } + )); + this.$searchInput = this.$buttons.find(".oe_search_input"); + this.$groups = $( + qweb.render("One2ManyProductPicker.ControlPanelGroupButtons", { + groups: this.searchGroups, + }) + ); + this.$btnLines = this.$groups.find(".oe_btn_lines"); + this.$badgeLines = this.$btnLines.find(".badge"); + this.updateBadgeLines(); + this.$groups.appendTo(this.$buttons); + } else { + return this._super.apply(this, arguments); + } + }, + + /** + * @override + */ + _render: function () { + var self = this; + var def = this._super.apply(this, arguments); + // Parent implementation can return 'undefined' :( + return ( + def && + def.then(function () { + if (!self.$el.hasClass("oe_field_one2many_product_picker_maximized")) { + self.$el.addClass("position-relative d-flex flex-column"); + } + self._addMaximizeButton(); + if (self.options.show_subtotal) { + self._addTotalsZone(); + } + }) + ); + }, + + /** + * @returns {Deferred} + */ + doRenderSearchRecords: function () { + var self = this; + return $.Deferred(function(d){ + self._getSearchRecords().then(function () { + self.renderer.$el.scrollTop(0); + self.renderer._renderView().then(d.resolve); + }); + }); + }, + + /** + * Inject the 'maximize' button + * + * @private + */ + _addMaximizeButton: function () { + this.$("#product_picker_maximize").remove(); + this.$btnMaximize = $(qweb.render("One2ManyProductPicker.ButtonMaximize")); + this.$btnMaximize + .appendTo(this.$el) + .on("click", this._onClickMaximize.bind(this)); + }, + + /** + * Inject the 'maximize' button + * + * @private + */ + _addTotalsZone: function () { + this.$("#product_picker_total").remove(); + this.$totalZone = $(qweb.render("One2ManyProductPicker.Total")); + this.$totalZone.appendTo(this.$el); + this.updateSubtotalPrice(); + }, + + /** + * Obtain the linked records defined in the options. + * If merge is true the current records aren't removed. + * + * @private + * @param {Array} domain + * @param {Dictionary} options + * @param {Boolean} merge + * @returns {Deferred} + */ + _getSearchRecords: function (domain, options, merge) { + var self = this; + var arch = this.view.arch; + var field_name = this.options.field_map.product; + var field_info = this.view.fieldsInfo[arch.tag][field_name]; + var model = this.view.viewFields[field_info.name].relation; + + // Launch the rpc request and ensures that we wait for the reply + // to continue + var sdomain = this._getFullSearchDomain(domain); + var soptions = options || {}; + var context = _.extend({ + 'active_search_group_name': this._activeSearchGroup.name, + },this.state.data[this.name].getContext()); + + return $.Deferred(function (d) { + var limit = soptions.limit || self.options.records_per_page; + var offset = soptions.offset || 0; + self._rpc({ + model: model, + method: "search_read", + fields: self.search_read_fields, + domain: sdomain, + limit: limit, + offset: offset, + orderBy: self._searchContext.order, + kwargs: {context: context}, + }).then(function (results) { + if (merge) { + self._searchRecords = _.union( + self._searchRecords || [], + results + ); + } else { + self._searchRecords = results; + } + self._lastSearchRecordsCount = results.length; + self._searchOffset = offset + limit; + if (self.renderer) { + self.renderer.updateSearchData(self._searchRecords, self._lastSearchRecordsCount); + } + d.resolve(results); + }); + }); + }, + + /** + * @private + * @param {MouseEvent} evt + */ + _onClickSearchGroup: function (evt) { + var self = this; + var $btn = $(evt.target); + var groupIndex = Number($btn.data("group")) || 0; + this._activeSearchGroup = this.searchGroups[groupIndex]; + this._searchContext.domain = this._activeSearchGroup.domain; + this._searchContext.order = this._activeSearchGroup.order; + this.doRenderSearchRecords(); + this.$btnLines.removeClass("active"); + $btn.parent().find(".active").removeClass("active"); + $btn.addClass("active"); + }, + + /** + * @private + */ + _onClickMaximize: function () { + this.$el.toggleClass( + "position-relative h-100 bg-white oe_field_one2many_product_picker_maximized" + ); + if (this.$buttons) { + this.$buttons.find('.dropdown-toggle').popover('update'); + } + }, + + /** + * @private + */ + _onClickLines: function () { + this.showLines(); + }, + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickSearchMode: function (ev) { + var self = this; + ev.preventDefault(); + var $target = $(ev.target); + this._searchMode = $target.index(); + $target.parent().children().removeClass('active'); + $target.addClass('active'); + this.doRenderSearchRecords().then(function(){ + self.$searchInput.focus(); + }); + }, + + /** + * @private + * @returns {Object} + */ + _getDefaultOptions: function () { + return { + currency_field: "currency_id", + records_per_page: 16, + show_subtotal: true, + show_discount: false, + edit_discount: false, + edit_price: true, + field_map: { + name: "name", + product: "product_id", + product_uom: "product_uom", + product_uom_qty: "product_uom_qty", + price_unit: "price_unit", + discount: "discount", + }, + }; + }, + + /** + * Mix context search domain and user input search. + * This domain is used to get the records to display. + * + * @private + * @param {Array} domain + * @returns {Array} + */ + _getFullSearchDomain: function (domain) { + var sdomain = _.clone(domain); + if (!sdomain) { + sdomain = _.clone(this._searchContext.domain) || []; + if (this._searchContext.text) { + var search_domain = this.options.search; + if (!(search_domain[0] instanceof Array)) { + search_domain = search_domain[this._searchMode].domain; + } + // Iterate domain triplets and logic operators + for (var index in search_domain) { + var domain = _.clone(search_domain[index]); + // Is a triplet + if (domain instanceof Array) { + // Replace right leaf with the current value of the search input + if (domain[2] === "$number_search") { + domain[2] = Number(this._searchContext.text); + } else if (typeof(domain[2]) === "string" && domain[2].includes("$search")) { + domain[2] = domain[2].replace(/\$search/, this._searchContext.text) + } + } + sdomain.push(domain); + } + } + } + return sdomain || []; + }, + + /** + * Domain to get the related records of the lines. + * + * @private + * @returns {Array} + */ + _getLinesDomain: function () { + if (!this.view) { + return []; + } + var field_name = this.options.field_map.product; + var lines = this.parent_controller.model.get(this.state.id).data[this.name].data; + var ids = _.map(lines, function (line) { + return line.data[field_name].data.id; + }); + return [["id", "in", ids]]; + }, + + /** + * The lines are special data, so we need display it in a other way + * that the search results. Use directy in-memory values. + */ + showLines: function () { + var self = this; + this._clearSearchInput(); + this.$btnLines.parent().find(".active").removeClass("active"); + this.$btnLines.addClass("active"); + this._activeSearchGroup = { + 'name': 'main_lines', + } + this._searchContext.domain = this._getLinesDomain(); + this._searchContext.order = false; + this.doRenderSearchRecords(); + }, + + /** + * @private + */ + _clearSearchInput: function () { + this.$searchInput.val(""); + this._searchContext.text = ""; + }, + + _onKeyPressSearch: function (evt) { + if (evt.keyCode === $.ui.keyCode.ENTER) { + var self = this; + this._searchContext.text = evt.target.value; + this.doRenderSearchRecords().then(function(){ + self.$searchInput.focus(); + }); + } + }, + + /** + * @private + */ + _onClickSearchEraser: function () { + this._clearSearchInput(); + this.doRenderSearchRecords(); + }, + + /** + * @private + * @param {DropdownEvent} evt + */ + _onShowSearchDropdown: function (evt) { + // Workaround: This "ensures" a correct dropdown position + var offset = $(evt.currentTarget).find(".dropdown-toggle").parent().height(); + _.defer(function() { $(evt.currentTarget).find(".dropdown-menu").css("transform", "translate3d(0px, " + offset + "px, 0px)"); }); + }, + + /** + * Runs the x2many (4,id,0) command. + * + * @private + * @param {CustomEvent} evt + */ + _onCreateQuickRecord: function (evt) { + this.parent_controller.model.setPureVirtual(evt.data.id, false); + this._setValue({operation: "ADD", id: evt.data.id}); + }, + + /** + * Runs the x2many (1,id,values) command. + * + * @private + * @param {CustomEevent} evt + */ + _onUpdateQuickRecord: function (evt) { + this._setValue({operation: "UPDATE", id: evt.data.id, data: evt.data.data}); + }, + + /** + * @private + */ + _onUpdateSubtotal: function () { + this.updateSubtotalPrice(); + }, + + /** + * Event dispatched by the 'scroll spy' to load + * records. + * + * @private + */ + _onLoadMore: function () { + if (this._isLoading) { + return; + } + var self = this; + this._getSearchRecords( + false, + { + offset: this._searchOffset, + }, + true + ).then(function (records) { + self.renderer.appendSearchRecords(records); + }); + }, + + /** + * @private + * @param {CustomEvent} evt + */ + _onLoadingRecords: function (evt) { + this._isLoading = !evt.data.finished; + this._blockControlPanel(this._isLoading); + if (this.renderer) { + this.renderer.blockLoadMore(this._isLoading); + } + }, + + /** + * @private + * @param {Boolean} block + */ + _blockControlPanel: function (block) { + if (this.$buttons) { + this.$buttons.find("input,button").attr("disabled", block); + } + }, + + + /** + * Refresh lines count on every change. + * + * @override + */ + _setValue: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.updateBadgeLines(); + self.updateSubtotalPrice(); + }); + }, + }); + + field_registry.add("one2many_product_picker", FieldOne2ManyProductPicker); + + return FieldOne2ManyProductPicker; +}); diff --git a/web_widget_one2many_product_picker/static/src/scss/_variables.scss b/web_widget_one2many_product_picker/static/src/scss/_variables.scss new file mode 100644 index 000000000..4a65150a3 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/scss/_variables.scss @@ -0,0 +1,7 @@ +$one2many-product-picker-max-height: 500px; +$one2many-product-picker-card-min-height: 150px; +$one2many-product-picker-card-max-height: 150px; +$one2many-product-picker-transition-3d-time: 0.3s; +$one2many-product-picker-card-form-padding: 0em; +$one2many-product-picker-quick-modif-price-max-width: 400px; +$one2many-product-picker-zoom-scale: 1.4; diff --git a/web_widget_one2many_product_picker/static/src/scss/one2many_product_picker.scss b/web_widget_one2many_product_picker/static/src/scss/one2many_product_picker.scss new file mode 100644 index 000000000..e0c3af1c5 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/scss/one2many_product_picker.scss @@ -0,0 +1,227 @@ +.oe_field_one2many_product_picker { + &.oe_field_one2many_product_picker_maximized { + position: fixed; + top: 0; + right: 0; + left: 0; + z-index: 50; + .oe_one2many_product_picker_view { + max-height: 100%; + } + } + &:hover { + button.o_fullscreen { + display: inline-block; + } + } + + > div { + width: unset !important; + } + + #product_picker_total > h2 { + margin: 0; + padding: 0.5rem; + } + + .o_cp_buttons { + width: 100%; + + > div { + width: 95%; + } + } + + button.o_fullscreen { + top: 0; + right: 0; + z-index: 10; + } + + .oe_one2many_product_picker_view { + max-height: $one2many-product-picker-max-height; + overflow: auto; + + > .row { + margin-left: 0; + margin-right: 0; + } + + .oe_flip_card { + user-select: none; + background-color: transparent; + perspective: 1000px; + transition: top $one2many-product-picker-transition-3d-time, left $one2many-product-picker-transition-3d-time, width $one2many-product-picker-transition-3d-time, height $one2many-product-picker-transition-3d-time; + height: $one2many-product-picker-card-min-height; + + &.disabled { + filter: grayscale(100%); + opacity: 0.5; + } + + &.oe_flip_card_maximized { + position: fixed; + top: 1% !important; + left: 1% !important; + height: 98% !important; + width: 98% !important; + z-index: 50; + + .oe_flip_card_inner { + height: 100% !important; + box-shadow: 0px 0px 15px; + .img-fluid { + transform: translateY(-50%) !important; + top: 50%; + position: relative; + } + .oe_one2many_product_picker_title { + font-size: 1.95rem !important; + } + + .oe_flip_card_back { + .oe_one2many_product_picker_quick_create { + width: 100%; + padding: 1em; + } + } + + .o_field_widget, .oe_one2many_product_picker_form_buttons .btn { + transform: scale($one2many-product-picker-zoom-scale); + margin-bottom: 1.3em !important; + } + .o_field_widget, .w-100 { + width: 100% / $one2many-product-picker-zoom-scale !important; + } + } + + .badge { + font-size: 2rem !important; + } + } + + &:not(.oe_flip_card_maximized) { + .oe_one2many_product_picker_title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + bottom: -1px; /* Workarraound to fix overflow issue on firefox */ + } + } + + &.active { + .oe_flip_card_inner { + transform: rotateY(180deg); + height: $one2many-product-picker-card-max-height; + } + height: $one2many-product-picker-card-max-height; + } + + .oe_flip_card_inner { + position: relative; + width: 100%; + height: $one2many-product-picker-card-min-height; + transition: transform $one2many-product-picker-transition-3d-time, height $one2many-product-picker-transition-3d-time/2 ease-in-out $one2many-product-picker-transition-3d-time/2; + transform-style: preserve-3d; + + .position-absolute { + z-index: 1; + } + + .badge { + font-size: 1rem; + } + + .badge_price { + top: 50%; + right: -2px; + transform: translateY(-50%); + display: grid; + flex-direction: column; + + .discount_price { + grid-row: 1; + grid-column: 2; + } + + .original_price { + text-decoration: line-through; + grid-row: 2; + grid-column: 1; + margin: auto; + margin-right: 0.3rem; + } + + .price_unit { + grid-row: 2; + grid-column: 2; + } + } + + .oe_flip_card_front, + .oe_flip_card_back { + position: absolute; + width: 100%; + height: 100%; + -webkit-backface-visibility: hidden; /* Safari */ + backface-visibility: hidden; + border: 1px solid $border-color; + overflow: hidden; + transform: rotateX(0deg); + + .o_form_view.o_form_nosheet { + padding: $one2many-product-picker-card-form-padding; + + .o_field_widget .o_input_dropdown > input { + height: unset; + } + } + } + + .oe_flip_card_front { + background-color: white; + color: black; + } + + .oe_flip_card_back { + background-color: $secondary; + transform: rotateY(180deg); + + .oe_one2many_product_picker_quick_create { + margin: 0; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + } + } + + .oe_one2many_product_picker_form_view { + background-color: transparent; + } + } + .oe_one2many_product_picker_title { + background-color: hsla(0, 0%, 100%, 0.667); + font-size: 0.95rem; + z-index: 0; + } + } + } +} + +.oe_product_picker_quick_modif_price { + width: 80%; + max-width: $one2many-product-picker-quick-modif-price-max-width; + margin: auto; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border: 1px solid $border-color; + z-index: 55; + + .oe_one2many_product_picker_form_buttons { + text-align: center; + } +} diff --git a/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml b/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml new file mode 100644 index 000000000..78078dba4 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml @@ -0,0 +1,112 @@ + diff --git a/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml b/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml new file mode 100644 index 000000000..8ce6c28bd --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml @@ -0,0 +1,18 @@ + diff --git a/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_modif_price.xml b/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_modif_price.xml new file mode 100644 index 000000000..48f1ff09a --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_modif_price.xml @@ -0,0 +1,14 @@ + diff --git a/web_widget_one2many_product_picker/static/tests/widget_tests.js b/web_widget_one2many_product_picker/static/tests/widget_tests.js new file mode 100644 index 000000000..e3cfe8d19 --- /dev/null +++ b/web_widget_one2many_product_picker/static/tests/widget_tests.js @@ -0,0 +1,137 @@ +// Copyright 2020 Tecnativa - Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +odoo.define('web_widget_one2many_product_picker.widget_tests', function (require) { + "use strict"; + + var FormView = require('web.FormView'); + var testUtils = require('web.test_utils'); + + var createView = testUtils.createView; + + var getArch = function () { + return '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }; + + console.log(getArch()); + + QUnit.module('Web Widget One2Many Product Picker', { + beforeEach: function() { + this.data = { + foo: { + fields: { + currency_id: {string: "Currency", type: "many2one", relation: "currency"}, + line_ids: {string: "Lines Test", type: "one2many", relation: "line", relation_field: "foo_id"}, + display_name: {string: "Display Name", type: "char"}, + }, + records: [ + {id: 1, line_ids: [1,2], currency_id: 1, display_name: "FT01"}, + ] + }, + line: { + fields: { + name: {string: "Product Name", type: "string"}, + product_id: {string: "Product", type: "many2one", relation: "product"}, + product_uom: {string: "UoM", type: "many2one", relation: "uom"}, + product_uom_qty: {string: "Qty", type: "integer"}, + price_unit: {string: "Product Price", type: "float"}, + price_reduce: {string: "Product Price Reduce", type: "float"}, + foo_id: {string: "Parent", type: "many2one", relation: "foo"}, + }, + records: [ + {id: 1, name: "Large Cabinet", product_id: 1, product_uom: 1, product_uom_qty: 3, price_unit: 9.99, price_reduce: 9.00, foo_id: 1}, + {id: 2, name: "Cabinet with Doors", product_id: 2, product_uom: 1, product_uom_qty: 8, price_unit: 42.99, price_reduce: 40.00, foo_id: 1}, + ] + }, + product: { + fields: { + name: {string : "Product name", type: "char"}, + display_name: {string : "Display Name", type: "char"}, + list_price: {string: "Price", type: "float"}, + image_medium: {string: "Image Medium", type: "binary"}, + uom_category_id: {string: "Category", type: "many2one", relation: "uom_category"}, + }, + records: [ + {id: 1, name: "Large Cabinet", display_name: "Large Cabinet", list_price: 9.99, image_medium: "", uom_category_id: 1}, + {id: 2, name: "Cabinet with Doors", display_name: "Cabinet with Doors", list_price: 42.0, image_medium: "", uom_category_id: 1}, + ], + }, + uom_category: { + fields: { + display_name: {string : "Display Name", type: "char"}, + }, + records: [ + {id: 1, display_name: "Unit(s)"}, + ], + }, + uom: { + fields: { + name: {string: "Name", type: "char"}, + }, + records: [ + {id: 1, name: "Unit(s)"}, + ], + }, + currency: { + fields: { + name: {string: "Name", type: "char"}, + symbol: {string: "Symbol", type: "char"}, + }, + records: [ + {id: 1, name: "Eur", symbol: "€"}, + ], + }, + }; + } + }, function () { + QUnit.test('Load widget', function(assert) { + assert.expect(4); + + var form = createView({ + View: FormView, + model: 'foo', + data: this.data, + arch: getArch(), + res_id: 1, + viewOptions: { + ids: [1], + index: 0, + }, + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/foo/read') { + assert.deepEqual(args.args[1], ['currency_id', 'line_ids', 'display_name'], + 'should only read "currency_id", "line_ids" and "display_name"'); + return $.when(this.data.foo.records); + } else if (route === '/web/dataset/call_kw/line/read') { + assert.deepEqual(args.args[1], ['name', 'product_id', 'price_reduce', 'price_unit', 'foo_id', 'product_uom_qty', 'product_uom'], + 'should only read "name", "product_id", "price_reduce", "price_unit", "foo_id", "product_uom_qty" and "product_uom"'); + return $.when(this.data.line.records); + } else if (route === '/web/dataset/call_kw/product/search_read') { + assert.deepEqual(args.kwargs.fields, ['id', 'uom_id', 'display_name', 'uom_category_id', 'image_medium', 'list_price'], + 'should only read "id", "uom_id", "display_name", "uom_category_id", "image_medium" and "list_price"'); + return $.when(this.data.product.records); + } + return this._super.apply(this, arguments); + }, + }); + + assert.ok(form.$('.oe_field_one2many_product_picker').is(':visible'), + "should have a visible one2many product picker"); + + form.destroy(); + }); + }); + +}); diff --git a/web_widget_one2many_product_picker/templates/assets.xml b/web_widget_one2many_product_picker/templates/assets.xml new file mode 100644 index 000000000..0ebee880a --- /dev/null +++ b/web_widget_one2many_product_picker/templates/assets.xml @@ -0,0 +1,32 @@ + + + + + + + +