diff --git a/setup/web_widget_one2many_product_picker/odoo/addons/web_widget_one2many_product_picker b/setup/web_widget_one2many_product_picker/odoo/addons/web_widget_one2many_product_picker new file mode 120000 index 000000000..fb1be7c81 --- /dev/null +++ b/setup/web_widget_one2many_product_picker/odoo/addons/web_widget_one2many_product_picker @@ -0,0 +1 @@ +../../../../web_widget_one2many_product_picker \ No newline at end of file diff --git a/setup/web_widget_one2many_product_picker/setup.py b/setup/web_widget_one2many_product_picker/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/web_widget_one2many_product_picker/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/web_widget_one2many_product_picker/README.rst b/web_widget_one2many_product_picker/README.rst new file mode 100644 index 000000000..10fb845f2 --- /dev/null +++ b/web_widget_one2many_product_picker/README.rst @@ -0,0 +1,262 @@ +================================== +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/13.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-13-0/web-13-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/13.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. + +Configuration +============= + +Create or edit a new view and use the new widget called 'one2many_product_picker'. +You need to define the view fields. The view must be of ``form`` type. + + +Widget options: +~~~~~~~~~~~~~~~ + +* 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 + + * records_per_page > Integer -> Used to control the load more behaviour (16 by default) + * active -> Boolean -> Select the default group to use ('false' by default = 'All' group) + +* 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 (defines to use name_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 + + * name_search_value -> Enables the use of 'name_search' instead of 'search_read' and defines the value to search ('$search' by default) + * operator -> Operator used in 'name_search' ('ilike' by default) + +* 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) +* auto_save > Enable/Disable auto save (False by default) +* all_domain > The domain used in 'All' section ([] by default) + + If using auto save feature, you should keep in mind that the "Save" and "Discard" buttons + will lose part of its functionality as the document will be saved every time you + modify/create a record with the widget. + +* ignore_warning > Enable/Disable display onchange warnings (False by default) +* instant_search > Enable/Disable instant search mode (False by default) +* trigger_refresh_fields > Fields in the main record that dispatch a widget refresh (["partner_id", "currency_id"] 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' }}]}" + + +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 + + * active_search_involved_fields > Contains an array of dictionaries with the fields used with the searchbox content + + * 'type' > Can be 'text' or 'number' + * 'field' > The field name + * 'oper' > The operator used + + +Examples: +~~~~~~~~~ + +This is an example that uses the 'sale.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. + +Other example for 'purchase.order.line' fields: + +.. code:: xml + + +
+ + + + + + + + + + + + +Boostrap Modifications: +~~~~~~~~~~~~~~~~~~~~~~~ + +The product picker view container have a custom media queries space adding a new screen size called 'xxl' (>= 1440px) and modifies the columns to have 24 instead of 12. +This means that you can use "col-xxl-" inside the product picker view container. + +Usage +===== + +When you change the value of a field and switch to edit another record, the +changes will be applied to the previous record without having to click on +accept changes. + +Parts of the widget: +~~~~~~~~~~~~~~~~~~~~ + + .. image:: https://raw.githubusercontent.com/OCA/web/13.0/web_widget_one2many_product_picker/static/img/product_picker_anat.png + +Preview: +~~~~~~~~ + + .. image:: https://raw.githubusercontent.com/OCA/web/13.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 elements 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..f9b68f363 --- /dev/null +++ b/web_widget_one2many_product_picker/__manifest__.py @@ -0,0 +1,17 @@ +# 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": "13.0.1.0.0", + "category": "Website", + "author": "Tecnativa, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "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/i18n/ca.po b/web_widget_one2many_product_picker/i18n/ca.po new file mode 100644 index 000000000..fc6ba21ff --- /dev/null +++ b/web_widget_one2many_product_picker/i18n/ca.po @@ -0,0 +1,123 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_widget_one2many_product_picker +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2021-02-17 13:45+0000\n" +"Last-Translator: claudiagn \n" +"Language-Team: none\n" +"Language: ca\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.3.2\n" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml:6 +#, python-format +msgid "Add" +msgstr "Afegir" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/js/widgets/field_one2many_product_picker.js:193 +#, python-format +msgid "All" +msgstr "Tot" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:35 +#, python-format +msgid "Groups" +msgstr "Grups" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:43 +#, python-format +msgid "Lines" +msgstr "Línias" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:67 +#, python-format +msgid "Load More" +msgstr "Carrega més" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_modif_price.xml:11 +#, python-format +msgid "Price:" +msgstr "Preu:" + +#. module: web_widget_one2many_product_picker +#: model:ir.model,name:web_widget_one2many_product_picker.model_product_product +msgid "Product" +msgstr "Producte" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml:13 +#, python-format +msgid "Remove" +msgstr "Eliminar" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:23 +#, python-format +msgid "Search..." +msgstr "Cerca..." + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:58 +#, python-format +msgid "Subtotal:" +msgstr "Subtotal:" + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,help:web_widget_one2many_product_picker.field_product_product__image_variant_medium +msgid "" +"This field holds the image used as image for the product variantor product " +"image medium, limited to 512x512px." +msgstr "" +"Aquest camp conté la imatge que s'utilitza com a imatge per al mitjà " +"d'imatge del producte que varia el producte, limitada a 512x512px." + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,help:web_widget_one2many_product_picker.field_product_product__image_variant_big +msgid "" +"This field holds the image used as image for the product variantor product " +"image, limited to 1024x1024px." +msgstr "" +"Aquest camp conté la imatge que s'utilitza com a imatge per al mitjà " +"d'imatge del producte que varia el producte, limitada a 1024x1024px." + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,field_description:web_widget_one2many_product_picker.field_product_product__image_variant_big +msgid "Variant Image Big (Computed)" +msgstr "Imatge variant gran (calculada)" + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,field_description:web_widget_one2many_product_picker.field_product_product__image_variant_medium +msgid "Variant Image Medium (Computed)" +msgstr "Imatge variant mitjana ( calculada)" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/record.js:341 +#, python-format +msgid "[No widget %s]" +msgstr "[Sense widget %s]" + +#~ msgid "Add 1" +#~ msgstr "Afegir 1" diff --git a/web_widget_one2many_product_picker/i18n/es.po b/web_widget_one2many_product_picker/i18n/es.po new file mode 100644 index 000000000..8caba4d46 --- /dev/null +++ b/web_widget_one2many_product_picker/i18n/es.po @@ -0,0 +1,123 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_widget_one2many_product_picker +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2021-02-17 13:45+0000\n" +"Last-Translator: claudiagn \n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.3.2\n" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml:6 +#, python-format +msgid "Add" +msgstr "Añadir" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/js/widgets/field_one2many_product_picker.js:193 +#, python-format +msgid "All" +msgstr "Todo" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:35 +#, python-format +msgid "Groups" +msgstr "Grupos" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:43 +#, python-format +msgid "Lines" +msgstr "Línias" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:67 +#, python-format +msgid "Load More" +msgstr "Carga más" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_modif_price.xml:11 +#, python-format +msgid "Price:" +msgstr "Precio:" + +#. module: web_widget_one2many_product_picker +#: model:ir.model,name:web_widget_one2many_product_picker.model_product_product +msgid "Product" +msgstr "Producto" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml:13 +#, python-format +msgid "Remove" +msgstr "Eliminar" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:23 +#, python-format +msgid "Search..." +msgstr "Buscar..." + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:58 +#, python-format +msgid "Subtotal:" +msgstr "Subtotal:" + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,help:web_widget_one2many_product_picker.field_product_product__image_variant_medium +msgid "" +"This field holds the image used as image for the product variantor product " +"image medium, limited to 512x512px." +msgstr "" +"Aquest camp conté la imatge que s'utilitza com a imatge per al mitjà " +"d'imatge del producte que varia el producte, limitada a 512x512px." + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,help:web_widget_one2many_product_picker.field_product_product__image_variant_big +msgid "" +"This field holds the image used as image for the product variantor product " +"image, limited to 1024x1024px." +msgstr "" +"Aquest camp conté la imatge que s'utilitza com a imatge per al mitjà " +"d'imatge del producte que varia el producte, limitada a 1024x1024px." + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,field_description:web_widget_one2many_product_picker.field_product_product__image_variant_big +msgid "Variant Image Big (Computed)" +msgstr "Imagen variante grande (calculada)" + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,field_description:web_widget_one2many_product_picker.field_product_product__image_variant_medium +msgid "Variant Image Medium (Computed)" +msgstr "Imagen variante media (calculada)" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/record.js:341 +#, python-format +msgid "[No widget %s]" +msgstr "[Sin widget %s]" + +#~ msgid "Add 1" +#~ msgstr "Añadir 1" diff --git a/web_widget_one2many_product_picker/i18n/pt_BR.po b/web_widget_one2many_product_picker/i18n/pt_BR.po new file mode 100644 index 000000000..d977eaa14 --- /dev/null +++ b/web_widget_one2many_product_picker/i18n/pt_BR.po @@ -0,0 +1,114 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_widget_one2many_product_picker +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: pt_BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml:6 +#, python-format +msgid "Add" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/js/widgets/field_one2many_product_picker.js:193 +#, python-format +msgid "All" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:35 +#, python-format +msgid "Groups" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:43 +#, python-format +msgid "Lines" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:67 +#, python-format +msgid "Load More" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_modif_price.xml:11 +#, python-format +msgid "Price:" +msgstr "" + +#. module: web_widget_one2many_product_picker +#: model:ir.model,name:web_widget_one2many_product_picker.model_product_product +msgid "Product" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml:13 +#, python-format +msgid "Remove" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:23 +#, python-format +msgid "Search..." +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:58 +#, python-format +msgid "Subtotal:" +msgstr "" + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,help:web_widget_one2many_product_picker.field_product_product__image_variant_medium +msgid "" +"This field holds the image used as image for the product variantor product " +"image medium, limited to 512x512px." +msgstr "" + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,help:web_widget_one2many_product_picker.field_product_product__image_variant_big +msgid "" +"This field holds the image used as image for the product variantor product " +"image, limited to 1024x1024px." +msgstr "" + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,field_description:web_widget_one2many_product_picker.field_product_product__image_variant_big +msgid "Variant Image Big (Computed)" +msgstr "" + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,field_description:web_widget_one2many_product_picker.field_product_product__image_variant_medium +msgid "Variant Image Medium (Computed)" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/record.js:341 +#, python-format +msgid "[No widget %s]" +msgstr "" diff --git a/web_widget_one2many_product_picker/i18n/web_widget_one2many_product_picker.pot b/web_widget_one2many_product_picker/i18n/web_widget_one2many_product_picker.pot new file mode 100644 index 000000000..ad2e0a011 --- /dev/null +++ b/web_widget_one2many_product_picker/i18n/web_widget_one2many_product_picker.pot @@ -0,0 +1,109 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * web_widget_one2many_product_picker +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml:6 +#, python-format +msgid "Add" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/js/widgets/field_one2many_product_picker.js:193 +#, python-format +msgid "All" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:35 +#, python-format +msgid "Groups" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:43 +#, python-format +msgid "Lines" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:67 +#, python-format +msgid "Load More" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_modif_price.xml:11 +#, python-format +msgid "Price:" +msgstr "" + +#. module: web_widget_one2many_product_picker +#: model:ir.model,name:web_widget_one2many_product_picker.model_product_product +msgid "Product" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml:13 +#, python-format +msgid "Remove" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:23 +#, python-format +msgid "Search..." +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml:58 +#, python-format +msgid "Subtotal:" +msgstr "" + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,help:web_widget_one2many_product_picker.field_product_product__image_variant_medium +msgid "This field holds the image used as image for the product variantor product image medium, limited to 512x512px." +msgstr "" + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,help:web_widget_one2many_product_picker.field_product_product__image_variant_big +msgid "This field holds the image used as image for the product variantor product image, limited to 1024x1024px." +msgstr "" + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,field_description:web_widget_one2many_product_picker.field_product_product__image_variant_big +msgid "Variant Image Big (Computed)" +msgstr "" + +#. module: web_widget_one2many_product_picker +#: model:ir.model.fields,field_description:web_widget_one2many_product_picker.field_product_product__image_variant_medium +msgid "Variant Image Medium (Computed)" +msgstr "" + +#. module: web_widget_one2many_product_picker +#. openerp-web +#: code:addons/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/record.js:341 +#, python-format +msgid "[No widget %s]" +msgstr "" diff --git a/web_widget_one2many_product_picker/readme/CONFIGURE.rst b/web_widget_one2many_product_picker/readme/CONFIGURE.rst new file mode 100644 index 000000000..9d6350245 --- /dev/null +++ b/web_widget_one2many_product_picker/readme/CONFIGURE.rst @@ -0,0 +1,153 @@ +Create or edit a new view and use the new widget called 'one2many_product_picker'. +You need to define the view fields. The view must be of ``form`` type. + + +Widget options: +~~~~~~~~~~~~~~~ + +* 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 + + * records_per_page > Integer -> Used to control the load more behaviour (16 by default) + * active -> Boolean -> Select the default group to use ('false' by default = 'All' group) + +* 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 (defines to use name_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 + + * name_search_value -> Enables the use of 'name_search' instead of 'search_read' and defines the value to search ('$search' by default) + * operator -> Operator used in 'name_search' ('ilike' by default) + +* 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) +* auto_save > Enable/Disable auto save (False by default) +* all_domain > The domain used in 'All' section ([] by default) + + If using auto save feature, you should keep in mind that the "Save" and "Discard" buttons + will lose part of its functionality as the document will be saved every time you + modify/create a record with the widget. + +* ignore_warning > Enable/Disable display onchange warnings (False by default) +* instant_search > Enable/Disable instant search mode (False by default) +* trigger_refresh_fields > Fields in the main record that dispatch a widget refresh (["partner_id", "currency_id"] 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' }}]}" + + +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 + + * active_search_involved_fields > Contains an array of dictionaries with the fields used with the searchbox content + + * 'type' > Can be 'text' or 'number' + * 'field' > The field name + * 'oper' > The operator used + + +Examples: +~~~~~~~~~ + +This is an example that uses the 'sale.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. + +Other example for 'purchase.order.line' fields: + +.. code:: xml + + +
+ + + + + + + + + + + + +Boostrap Modifications: +~~~~~~~~~~~~~~~~~~~~~~~ + +The product picker view container have a custom media queries space adding a new screen size called 'xxl' (>= 1440px) and modifies the columns to have 24 instead of 12. +This means that you can use "col-xxl-" inside the product picker view container. 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..0e3fe6e62 --- /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 elements 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..dced5bc77 --- /dev/null +++ b/web_widget_one2many_product_picker/readme/USAGE.rst @@ -0,0 +1,13 @@ +When you change the value of a field and switch to edit another record, the +changes will be applied to the previous record without having to click on +accept changes. + +Parts of the widget: +~~~~~~~~~~~~~~~~~~~~ + + .. image:: ../static/img/product_picker_anat.png + +Preview: +~~~~~~~~ + + .. image:: ../static/img/product_picker.gif diff --git a/web_widget_one2many_product_picker/static/description/icon.png b/web_widget_one2many_product_picker/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/web_widget_one2many_product_picker/static/description/icon.png differ 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..823f5446b --- /dev/null +++ b/web_widget_one2many_product_picker/static/description/index.html @@ -0,0 +1,654 @@ + + + + + + +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.

+
+
+

Configuration

+

Create or edit a new view and use the new widget called ‘one2many_product_picker’. +You need to define the view fields. The view must be of form type.

+
+

Widget options:

+
    +
  • 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
      • +
      +
      +
    • +
    • records_per_page > Integer -> Used to control the load more behaviour (16 by default)

      +
    • +
    • active -> Boolean -> Select the default group to use (‘false’ by default = ‘All’ group)

      +
    • +
    +
    +
  • +
  • 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 (defines to use name_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
      • +
      +
      +
    • +
    • name_search_value -> Enables the use of ‘name_search’ instead of ‘search_read’ and defines the value to search (‘$search’ by default)

      +
    • +
    • operator -> Operator used in ‘name_search’ (‘ilike’ by default)

      +
    • +
    +
    +
  • +
  • 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)

    +
  • +
  • auto_save > Enable/Disable auto save (False by default)

    +
  • +
  • all_domain > The domain used in ‘All’ section ([] by default)

    +

    If using auto save feature, you should keep in mind that the “Save” and “Discard” buttons +will lose part of its functionality as the document will be saved every time you +modify/create a record with the widget.

    +
  • +
  • ignore_warning > Enable/Disable display onchange warnings (False by default)

    +
  • +
  • instant_search > Enable/Disable instant search mode (False by default)

    +
  • +
  • trigger_refresh_fields > Fields in the main record that dispatch a widget refresh ([“partner_id”, “currency_id”] 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:

+
+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' }}]}"
+
+
+
+

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
    • +
    +
    +
  • +
  • active_search_involved_fields > Contains an array of dictionaries with the fields used with the searchbox content

    +
    +
      +
    • ‘type’ > Can be ‘text’ or ‘number’
    • +
    • ‘field’ > The field name
    • +
    • ‘oper’ > The operator used
    • +
    +
    +
  • +
+
+
+
+

Examples:

+

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>
+
+

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

+

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': {'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>
+
+
+
+

Boostrap Modifications:

+

The product picker view container have a custom media queries space adding a new screen size called ‘xxl’ (>= 1440px) and modifies the columns to have 24 instead of 12. +This means that you can use “col-xxl-” inside the product picker view container.

+
+
+
+

Usage

+

When you change the value of a field and switch to edit another record, the +changes will be applied to the previous record without having to click on +accept changes.

+ +
+

Preview:

+
+https://raw.githubusercontent.com/OCA/web/13.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 elements 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..dcc9f288b 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/img/product_picker_anat.png b/web_widget_one2many_product_picker/static/img/product_picker_anat.png new file mode 100644 index 000000000..e47ca65fa Binary files /dev/null and b/web_widget_one2many_product_picker/static/img/product_picker_anat.png 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..a839b884d --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/tools.js @@ -0,0 +1,48 @@ +// 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) { + "use strict"; + + const field_utils = require("web.field_utils"); + + /** + * Calculate the price with discount + * + * @param {Number} price + * @param {Number} discount + * @returns {Number} + */ + 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 {Object} field_info, + * @param {String} currency_field + * @param {Object} data + * @returns {String} + */ + 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, + }); + } + + function float(value, field_info, digits) { + return field_utils.format.float(value, field_info, { + digits: digits, + }); + } + + return { + monetary: monetary, + float: float, + 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..63a54b389 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_create_form.js @@ -0,0 +1,169 @@ +/* global py */ +// 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"; + + const core = require("web.core"); + const Widget = require("web.Widget"); + const widgetRegistry = require("web.widget_registry"); + const ProductPickerQuickCreateFormView = require("web_widget_one2many_product_picker.ProductPickerQuickCreateFormView") + .ProductPickerQuickCreateFormView; + + const qweb = core.qweb; + + /** + * This widget render a Form. Used by FieldOne2ManyProductPicker + */ + const 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() { + const def1 = this._super.apply(this, arguments); + const form_arch = this._generateFormArch(); + const fieldsView = { + arch: form_arch, + fields: this.fields, + viewFields: this.fields, + base_model: this.basicFieldParams.field.relation, + type: "form", + model: this.basicFieldParams.field.relation, + }; + + const node_context = this.node.attr("context") || "{}"; + this.nodeContext = py.eval(node_context, { + active_id: this.res_id || false, + }); + const 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}); + // } + const def2 = this.formView.getController(this).then(controller => { + this.controller = controller; + this.$el.empty(); + this.controller.appendTo(this.$el); + }); + + return Promise.all([def1, def2]); + }, + + on_attach_callback: function() { + if (this.controller) { + this.controller.autofocus(); + } + }, + + /** + * @private + * @returns {String} + */ + _generateFormArch: function() { + let 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 { + this.getParent() + ._generateVirtualState({}, this.editContext) + .then(state => { + const data = {}; + data[this.compareKey] = { + operation: "ADD", + id: evt.data.compareValue, + }; + this.basicFieldParams.model + ._applyChange(state.id, data) + .then(() => { + this.res_id = state.res_id; + this.id = state.id; + this.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..0c5b178bb --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_create_form_view.js @@ -0,0 +1,424 @@ +// 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. + */ + + const QuickCreateFormView = require("web.QuickCreateFormView"); + const BasicModel = require("web.BasicModel"); + const core = require("web.core"); + + const qweb = core.qweb; + + BasicModel.include({ + _applyOnChange: function(values, record, viewType) { + // Ignore changes by record context 'ignore_onchanges' fields + if ("ignore_onchanges" in record.context) { + const ignore_changes = record.context.ignore_onchanges; + for (const index in ignore_changes) { + const field_name = ignore_changes[index]; + delete values[field_name]; + } + delete record.context.ignore_onchanges; + } + return this._super(values, record, viewType); + }, + }); + + const 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); + }, + } + ); + + const 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); + }, + + /** + * @override + */ + _applyChanges: function() { + return this._super.apply(this, arguments).then(() => { + this._updateButtons(); + }); + }, + + /** + * Create or accept changes + */ + auto: function() { + const record = this.model.get(this.handle); + if ( + record.context.has_changes_confirmed || + typeof record.context.has_changes_confirmed === "undefined" + ) { + return; + } + const state = this._getRecordState(); + if (state === "new") { + this._add(); + } else if (state === "dirty") { + this._change(); + } + }, + + /** + * Know the real state of the record + * - record: Normal + * - new: Is a new record + * - dirty: Has changes + * + * @returns {Object} + */ + _getRecordState: function() { + const record = this.model.get(this.handle); + let state = "record"; + if (this.model.isNew(record.id)) { + state = "new"; + } else if (record.isDirty()) { + state = "dirty"; + } + if (state === "new") { + for (const index in this.mainRecordData.data) { + const recordData = this.mainRecordData.data[index]; + if (recordData.ref === record.ref) { + if (record.isDirty()) { + state = "dirty"; + } else { + state = "record"; + } + break; + } + } + } + + return state; + }, + + /** + * Updates buttons depending on record status + * + * @private + */ + _updateButtons: function() { + this.$el.find(".oe_one2many_product_picker_form_buttons").remove(); + this.$el.find(".o_form_view").append( + qweb.render("One2ManyProductPicker.QuickCreate.FormButtons", { + state: this._getRecordState(), + }) + ); + + if (this._disabled) { + this._disableQuickCreate(); + } + }, + + /** + * @private + */ + _disableQuickCreate: function() { + if (!this.$el) { + return; + } + // Ensures that the record won't be created twice + this._disabled = true; + this.$el.addClass("o_disabled"); + this.$("input:not(:disabled),button:not(:disabled)") + .addClass("o_temporarily_disabled") + .attr("disabled", "disabled"); + }, + + /** + * @private + */ + _enableQuickCreate: function() { + // Allows to create again + this._disabled = false; + this.$el.removeClass("o_disabled"); + this.$("input.o_temporarily_disabled,button.o_temporarily_disabled") + .removeClass("o_temporarily_disabled") + .attr("disabled", false); + }, + + /** + * @private + * @param {Array} fields_changed + * @returns {Boolean} + */ + _needReloadCard: function(fields_changed) { + for (const index in fields_changed) { + const 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) { + const fields_changed = Object.keys(ev.data.changes); + if (this._needReloadCard(fields_changed)) { + const field = ev.data.changes[fields_changed[0]]; + let new_value = false; + if (typeof field === "object") { + new_value = field.id; + } else { + new_value = field; + } + const reload_values = { + compareValue: new_value, + }; + const record = this.model.get(this.handle); + if ("base_record_id" in record.context) { + 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; + } else { + let 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; + } + 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, + }); + } + } + }, + + /** + * @returns {Deferred} + */ + _add: function() { + if (this._disabled) { + // Don't do anything if we are already creating a record + return Promise.resolve(); + } + this.model.updateRecordContext(this.handle, { + has_changes_confirmed: true, + }); + this._disableQuickCreate(); + return this.saveRecord(this.handle, { + stayInEdit: true, + reload: true, + savePoint: true, + viewType: "form", + }).then(() => { + const record = this.model.get(this.handle); + this.model.updateRecordContext(this.handle, {saving: true}); + this.trigger_up("restore_flip_card", { + success_callback: () => { + this.trigger_up("create_quick_record", { + id: record.id, + callback: () => { + this.model.updateRecordContext(this.handle, { + saving: false, + }); + this.model.unsetDirty(this.handle); + this._enableQuickCreate(); + }, + }); + }, + block: true, + }); + }); + }, + + _remove: function() { + if (this._disabled) { + return Promise.resolve(); + } + + this._disableQuickCreate(); + this.trigger_up("restore_flip_card", {block: true}); + const record = this.model.get(this.handle); + this.trigger_up("list_record_remove", { + id: record.id, + }); + }, + + _change: function() { + const self = this; + if (this._disabled) { + // Don't do anything if we are already creating a record + return Promise.resolve(); + } + this._disableQuickCreate(); + this.model.updateRecordContext(this.handle, { + has_changes_confirmed: true, + }); + const record = this.model.get(this.handle); + + this.trigger_up("restore_flip_card", { + success_callback: function() { + // Qty are handled in a special way because can be modified without + // wait for server response + self.model.localData[record.id].data[ + self.fieldMap.product_uom_qty + ] = record.data[self.fieldMap.product_uom_qty]; + // SaveRecord used to make a save point. + self.saveRecord(self.handle, { + stayInEdit: true, + reload: true, + savePoint: true, + viewType: "form", + }).then(() => { + self.trigger_up("update_quick_record", { + id: record.id, + callback: function() { + self.model.unsetDirty(self.handle); + self._enableQuickCreate(); + }, + }); + }); + }, + block: true, + }); + }, + + _discard: function() { + if (this._disabled) { + // Don't do anything if we are already creating a record + return; + } + + this._disableQuickCreate(); + this.model.updateRecordContext(this.handle, { + has_changes_confirmed: true, + }); + // Rollback to restore the save point + this.model.discardChanges(this.handle, { + rollback: true, + }); + const record = this.model.get(this.handle); + this.trigger_up("quick_record_updated", { + changes: record.data, + }); + + this.update({}, {reload: false}).then(() => { + if (!this.model.isNew(record.id)) { + this.model.unsetDirty(this.handle); + } + this.trigger_up("restore_flip_card"); + this._updateButtons(); + this._enableQuickCreate(); + }); + }, + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAdd: function(ev) { + ev.stopPropagation(); + this._add(); + }, + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRemove: function(ev) { + ev.stopPropagation(); + this._remove(); + }, + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickChange: function(ev) { + ev.stopPropagation(); + this._change(); + }, + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickDiscard: function(ev) { + ev.stopPropagation(); + this._discard(); + }, + } + ); + + const ProductPickerQuickCreateFormView = QuickCreateFormView.extend({ + config: _.extend({}, QuickCreateFormView.prototype.config, { + Renderer: ProductPickerQuickCreateFormRenderer, + Controller: ProductPickerQuickCreateFormController, + }), + + /** + * @override + */ + 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..2b3f3b552 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_modif_price_form.js @@ -0,0 +1,256 @@ +// 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"; + + const core = require("web.core"); + const Widget = require("web.Widget"); + const ProductPickerQuickModifPriceFormView = require("web_widget_one2many_product_picker.ProductPickerQuickModifPriceFormView") + .ProductPickerQuickModifPriceFormView; + + const qweb = core.qweb; + + /** + * This widget render a Form. Used by FieldOne2ManyProductPicker + */ + const 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", + ], + events: { + "click .oe_record_change": "_onClickChange", + "click .oe_record_discard": "_onClickDiscard", + }, + + /** + * @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() { + const def1 = this._super.apply(this, arguments); + const 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}); + } + const def2 = this.formView.getController(this).then(controller => { + this.controller = controller; + this.$(".modal-body").empty(); + this.controller.appendTo(this.$(".modal-body")); + this.$el.on("hidden.bs.modal", this._onModalHidden.bind(this)); + }); + + return Promise.all([def1, def2]); + }, + + /** + * @override + */ + destroy: function() { + this.$el.off("hidden.bs.modal"); + this._super.apply(this, arguments); + }, + + on_attach_callback: function() { + // Do nothing + }, + + /** + * @private + * @returns {String} + */ + _generateFormArch: function() { + const wanted_field_states = this._getWantedFieldState(); + let template = + ""; + template += this.basicFieldParams.field.views.form.arch; + template += ""; + qweb.add_template(template); + const $arch = $( + qweb.render("One2ManyProductPicker.QuickModifPrice.Form", { + field_map: this.fieldMap, + record_search: this.searchRecord, + }) + ); + + const field_names = Object.keys( + this.basicFieldParams.field.views.form.fields + ); + let gen_arch = "
"; + for (const index in field_names) { + const field_name = field_names[index]; + const $field = $arch.find("field[name='" + field_name + "']"); + const modifiers = $field.attr("modifiers") + ? JSON.parse($field.attr("modifiers")) + : {}; + modifiers.invisible = !(field_name in wanted_field_states); + modifiers.readonly = wanted_field_states[field_name]; + $field.attr("modifiers", JSON.stringify(modifiers)); + $field.attr("invisible", modifiers.invisible ? "1" : "0"); + $field.attr( + "readonly", + wanted_field_states[field_name] ? "1" : "0" + ); + if ( + [this.fieldMap.price_unit, this.fieldMap.product].indexOf( + field_name + ) !== -1 + ) { + $field.attr("force_save", "1"); + } + 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() { + const wantedFieldState = {}; + wantedFieldState[this.fieldMap.discount] = !this.canEditDiscount; + wantedFieldState[this.fieldMap.price_unit] = !this.canEditPrice; + return wantedFieldState; + }, + + /** + * @private + */ + _onModalHidden: function() { + this.destroy(); + }, + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickChange: function(ev) { + ev.stopPropagation(); + const model = this.basicFieldParams.model; + model.updateRecordContext(this.id, { + has_changes_confirmed: true, + }); + const is_virtual = model.isPureVirtual(this.id); + + // If is a 'pure virtual' record, save it in the selected list + if (is_virtual) { + if (model.isDirty(this.id)) { + this._disableQuickCreate(); + this.controller + .saveRecord(this.id, { + stayInEdit: true, + reload: true, + savePoint: true, + viewType: "form", + }) + .then(() => { + this._enableQuickCreate(); + model.unsetDirty(this.id); + this.trigger_up("create_quick_record", { + id: this.id, + }); + }); + } + } else { + // If is a "normal" record, update it + this.trigger_up("update_quick_record", { + id: this.id, + }); + model.unsetDirty(this.id); + } + }, + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickDiscard: function(ev) { + ev.stopPropagation(); + const model = this.basicFieldParams.model; + model.discardChanges(this.id, { + rollback: true, + }); + this.trigger_up("update_quick_record", { + id: this.id, + }); + }, + + /** + * @private + */ + _disableQuickCreate: function() { + // Ensures that the record won't be created twice + this.$el.addClass("o_disabled"); + this.$("input:not(:disabled),button:not(:disabled)") + .addClass("o_temporarily_disabled") + .attr("disabled", "disabled"); + }, + + /** + * @private + */ + _enableQuickCreate: function() { + // Allows to create again + this.$el.removeClass("o_disabled"); + this.$("input.o_temporarily_disabled,button.o_temporarily_disabled") + .removeClass("o_temporarily_disabled") + .attr("disabled", false); + }, + }); + + 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..d0040b4e7 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_modif_price_form_view.js @@ -0,0 +1,121 @@ +// 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. + */ + + const QuickCreateFormView = require("web.QuickCreateFormView"); + const core = require("web.core"); + const tools = require("web_widget_one2many_product_picker.tools"); + + const qweb = core.qweb; + + const ProductPickerQuickModifPriceFormRenderer = 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).then(() => { + this._appendPrice(); + }); + }, + + /** + * @private + */ + _appendPrice: function() { + this.$el.find(".oe_price").remove(); + this.$el.append( + qweb.render("One2ManyProductPicker.QuickModifPrice.Price") + ); + }, + } + ); + + const ProductPickerQuickModifPriceFormController = QuickCreateFormView.prototype.config.Controller.extend( + { + /** + * @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() { + return this._super.apply(this, arguments).then(() => { + const record = this.model.get(this.handle); + this._updatePrice(record.data); + }); + }, + + /** + * @override + */ + _onFieldChanged: function(ev) { + this._super.apply(this, arguments); + const record = this.model.get(this.handle); + this._updatePrice(_.extend({}, record.data, ev.data.changes)); + }, + + /** + * @private + * @param {Object} values + */ + _updatePrice: function(values) { + const price_reduce = tools.priceReduce( + values[this.fieldMap.price_unit], + values[this.fieldMap.discount] + ); + this.renderer.$el + .find(".oe_price") + .html( + tools.monetary( + price_reduce, + this.getParent().state.fields[this.fieldMap.price_unit], + this.currencyField, + values + ) + ); + }, + } + ); + + const 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..170efe4ed --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/record.js @@ -0,0 +1,1151 @@ +/* global py */ +// 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"; + + const core = require("web.core"); + const Widget = require("web.Widget"); + const Domain = require("web.Domain"); + const widgetRegistry = require("web.widget_registry"); + const tools = require("web_widget_one2many_product_picker.tools"); + const ProductPickerQuickModifPriceForm = require("web_widget_one2many_product_picker.ProductPickerQuickModifPriceForm"); + const config = require("web.config"); + + const qweb = core.qweb; + const _t = core._t; + + /* This represent a record (a card) */ + const One2ManyProductPickerRecord = Widget.extend({ + custom_events: { + quick_record_updated: "_onQuickRecordUpdated", + restore_flip_card: "_onRestoreFlipCard", + }, + events: { + "click .oe_flip_card": "_onClickFlipCard", + }, + + _click_card_delayed_time: 250, + _onchange_delay: 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 = { + front: [], + back: [], + }; + this._lazyUpdateRecord = _.debounce(this._updateRecord.bind(this), 450); + }, + + /** + * Generates a new virtual state and recreates the product card + * + * @param {Boolean} simple_mode + * @returns {Object} + */ + generateVirtualState: function(simple_mode) { + return this._generateVirtualState(undefined, undefined, simple_mode).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 + */ + destroy: function() { + this.abortTimeouts(); + if (this.state) { + this.options.basicFieldParams.model.removeVirtualRecord(this.state.id); + } + this.$el.remove(); + this.$card.off(""); + this._super.apply(this, arguments); + }, + + abortTimeouts: function() { + if (this._timerOnChange) { + clearTimeout(this._timerOnChange); + this._timerOnChange = false; + } + if (this.state) { + const model = this.options.basicFieldParams.model; + model.updateRecordContext(this.state.id, {aborted: true}); + } + }, + + /** + * @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 + * @returns {Promise} + */ + recreate: function(state) { + if (!this.getParent()) { + // It's a zombie record! ensure kill it! + this.destroy(); + return; + } + + if (state) { + this._setState(state); + } + if (this.$card) { + this.$card.removeClass("blocked"); + // Avoid recreate active record + if (this.$card.hasClass("active")) { + this._processDynamicFields(); + return $.when(); + } + } + + this.on_detach_callback(); + return this._render(); + }, + + markToDestroy: function() { + this.toDestroy = true; + this.$el.hide(); + }, + + isMarkedToDestroy: function() { + return this.toDestroy === true; + }, + + /** + * Generates the URL for the given product using the selected field + * + * @private + * @param {Number} product_id + * @param {String} field_name + * @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 + * @returns {String} + */ + _getMonetaryFieldValue: function(price_field) { + const field_name = this.options.fieldMap[price_field]; + const price = this.state.data[field_name]; + return tools.monetary( + price, + this.state.fields[field_name], + this.options.currencyField, + this.options.basicFieldParams.record.data + ); + }, + + /** + * Prints the given field value using the selected format + * + * @private + * @param {String} price_field + * @returns {String} + */ + _getFloatFieldValue: function(field) { + const field_name = this.options.fieldMap[field]; + const value = this.state.data[field_name]; + return tools.float(value, this.state.fields[field_name]); + }, + + /** + * @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) { + // No parent = product_pricker widget destroyed + // So this is a 'zombie' record. Destroy it! + if (!this.getParent()) { + this.on_detach_callback(); + this.destroy(); + return; + } + + this.fields = this.getParent().state.fields; + this.fieldsInfo = this.getParent().state.fieldsInfo.form; + const model = this.options.basicFieldParams.model; + if (this.state && (!viewState || this.state.id !== viewState.id)) { + model.removeVirtualRecord(this.state.id); + } + this.state = viewState; + + if (recordSearch) { + this.recordSearch = recordSearch; + } + this.is_virtual = + (this.state && model.isPureVirtual(this.state.id)) || false; + + // Check if has cached qty + if (this.state && this.state.id) { + const record = model.get(this.state.id); + const lazy_qty = (record && record.context.lazy_qty) || 0; + if (lazy_qty) { + model.updateRecordContext(this.state.id, {lazy_qty: 0}); + // Record already has 1 + this._incProductQty(lazy_qty - 1); + } + } + + this._setMasterUomMap(); + }, + + /** + * Used to know what is the "main" uom + * @private + */ + _setMasterUomMap: function() { + this.master_uom_map = { + field_uom: "product_uom", + field_uom_qty: "product_uom_qty", + search_field_uom: "uom_id", + }; + }, + + /** + * @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. + const model = this.options.basicFieldParams.model; + const record = model.get(this.state.id); + 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), + floatFixed: this._getFloatFieldValue.bind(this), + show_discount: this.options.showDiscount, + is_virtual: this.is_virtual, + modified: + record && + model.hasChanges(record.id) && + !model.isPureVirtual(record.id), + active_model: "", + auto_save: this.options.autoSave, + is_saving: record && record.context.saving, + lazy_qty: record && record.context.lazy_qty, + has_onchange: record && !record.context.not_onchange, + field_uom: this.master_uom_map.field_uom, + field_uom_qty: this.master_uom_map.field_uom_qty, + }; + }, + + /** + * Forced context used in virtual states + * + * @private + * @returns {Object} + */ + _getInternalVirtualRecordContext: function() { + const context = {}; + context[`default_${this.options.basicFieldParams.relation_field}`] = + this.options.basicFieldParams.record.id || null; + context[`default_${this.options.fieldMap.product}`] = + this.recordSearch.id || null; + context[`default_${this.master_uom_map.field_uom_qty}`] = 1.0; + return context; + }, + + /** + * Forced data used in virtual states. + * Be careful with the onchanges sequence. Think as user interaction ("ADD", "DELETE", ... commands), not as CRUD operation. + * + * @private + * @returns {Object} + */ + _getInternalVirtualRecordData: function() { + // To be overwritten + return {}; + }, + + /** + * This generates a virtual record with delayed call to "get_default" & "onchange" + * used in "instant search" mode + * + * @private + * @param {Object} context + * @param {Object} def_values + * @returns {Promise} + */ + _generateVirtualStateSimple: function(context, def_values) { + const model = this.options.basicFieldParams.model; + return new Promise(resolve => { + const record_def = model.createVirtualDatapoint( + this.options.basicFieldParams.value.id, + { + context: context, + } + ); + model.applyDefaultValues(record_def.record.id, def_values).then(() => { + const new_state = model.get(record_def.record.id); + const product_uom_id = + new_state.data[ + this.options.fieldMap[this.master_uom_map.field_uom] + ].id; + // Apply default values + model + .applyDefaultValues( + product_uom_id, + { + display_name: this.recordSearch[ + this.master_uom_map.search_field_uom + ][1], + }, + { + fieldNames: ["display_name"], + } + ) + .then(() => { + return model._fetchRelationalData(record_def.record); + }) + .then(() => { + return model._postprocess(record_def.record); + }) + .then(() => { + this._timerOnChange = setTimeout( + (current_batch_id, record_def) => { + this._timerOnChange = false; + if ( + current_batch_id !== + this.options.basicFieldParams + .current_batch_id || + record_def.record.context.aborted + ) { + return; + } + model + ._makeDefaultRecordNoDatapoint( + record_def.record, + record_def.params + ) + .then(() => { + if (record_def.record.context.aborted) { + return; + } + model.updateRecordContext( + record_def.record.id, + { + not_onchange: false, + } + ); + this.recreate( + model.get(record_def.record.id) + ); + }); + }, + this._onchange_delay, + this.options.basicFieldParams.current_batch_id, + record_def + ); + + resolve(model.get(record_def.record.id)); + }); + }); + }); + }, + + /** + * Generates a complete virtual record + * + * @private + * @param {Object} data + * @param {Object} context + * @param {Object} def_values + * @returns {Promise} + */ + _generateVirtualStateFull: function(data, context, def_values) { + const model = this.options.basicFieldParams.model; + return new Promise(resolve => { + model + .createVirtualRecord(this.options.basicFieldParams.value.id, { + context: context, + }) + .then(result => { + // Apply default values + model + .applyDefaultValues(result.record.id, def_values) + .then(() => { + const new_state = model.get(result.record.id); + const product_uom_id = + new_state.data[ + this.options.fieldMap[ + this.master_uom_map.field_uom + ] + ].id; + model + .applyDefaultValues( + product_uom_id, + { + display_name: this.recordSearch[ + this.master_uom_map.search_field_uom + ][1], + }, + { + fieldNames: ["display_name"], + } + ) + .then(() => { + const sdata = _.extend( + {}, + this._getInternalVirtualRecordData(), + data + ); + this._applyChanges( + result.record.id, + sdata, + result.params + ).then(() => + resolve(model.get(result.record.id)) + ); + }); + }); + }); + }); + }, + + /** + * @private + * @param {Object} data + * @param {Object} context + * @param {Boolean} simple_mode + * @returns {Object} + */ + _generateVirtualState: function(data, context, simple_mode) { + const scontext = _.extend( + {}, + this._getInternalVirtualRecordContext(), + context + ); + // Apply default values + const def_values = { + [this.options.fieldMap.product]: this.recordSearch.id, + [this.options.fieldMap[this.master_uom_map.field_uom_qty]]: 1.0, + [this.options.fieldMap[this.master_uom_map.field_uom]]: this + .recordSearch[this.master_uom_map.search_field_uom][0], + }; + if (simple_mode) { + return this._generateVirtualStateSimple(scontext, def_values); + } + return this._generateVirtualStateFull(data, scontext, def_values); + }, + + /** + * Apply changes (with onchange) + * + * @param {Integer/String} record_id + * @param {Object} changes + * @param {Object} options + * @returns {Promise} + */ + _applyChanges: function(record_id, changes, options) { + const model = this.options.basicFieldParams.model; + return model._applyChange(record_id, changes, options).then(() => { + model.updateRecordContext(record_id, { + not_onchange: false, + }); + this.recreate(model.get(record_id)); + }); + }, + + /** + * @private + */ + _detachAllWidgets: function() { + _.invoke(this.widgets.front, "on_detach_callback"); + _.invoke(this.widgets.back, "on_detach_callback"); + this.widgets = { + front: [], + back: [], + }; + }, + + /** + * @override + */ + _render: function() { + this._detachAllWidgets(); + this.defs = []; + this._replaceElement( + qweb.render("One2ManyProductPicker.FlipCard", this._getQWebContext()) + ); + this.$el.data("renderer_widget_index", this.options.renderer_widget_index); + 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, "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 + * @param {jQueryElement} $container + */ + _processWidgetFields: function($container) { + $container.find("field").each((key, value) => { + const $field = $(value); + if ($field.parents("widget").length) { + return; + } + const field_name = $field.attr("name"); + const 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 || this.fields[field_name].type === "many2many") { + let widget = this.subWidgets[field_name]; + if (widget) { + // A widget already exists for that field, so reset it + // with the new state + widget.reset(this.state); + $field.replaceWith(widget.$el); + } else { + // The widget doesn't exist yet, so instanciate it + const Widget = this.fieldsInfo[field_name].Widget; + if (Widget) { + widget = this._processWidget($field, field_name, Widget); + this.subWidgets[field_name] = widget; + } else if (config.isDebug()) { + // The widget is not implemented + $field.replaceWith( + $("", { + text: _.str.sprintf( + _t("[No widget %s]"), + field_widget + ), + }) + ); + } + } + } + }); + }, + + /** + * 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 + const attrs = Object.create(null); + _.each(this.fieldsInfo[field_name], (value, key) => { + if (_.str.startsWith(key, "t-att-")) { + key = key.slice(6); + value = $field.attr(key); + } + attrs[key] = value; + }); + const options = _.extend({}, this.options, { + attrs: attrs, + data: this.state.data, + }); + const widget = new Widget( + this, + field_name, + this.getParent().state, + options + ); + const def = widget.replace($field); + this.defs.push(def); + return widget; + }, + + /** + * Initialize widgets using "widget" tag + * + * @private + * @param {jQueryElement} $container + * @param {String} widget_zone + */ + _processWidgets: function($container, widget_zone) { + $container.find("widget").each((key, value) => { + const $field = $(value); + const FieldWidget = widgetRegistry.get($field.attr("name")); + const widget = new FieldWidget(this, { + fieldsInfo: this.fieldsInfo, + fields: this.fields, + main_state: this.getParent().state, + state: this.state, + fieldMap: this.options.fieldMap, + searchRecord: this.recordSearch, + node: $field, + readonly: this.options.readOnlyMode, + basicFieldParams: this.options.basicFieldParams, + data: this.state && this.state.data, + }); + + this.widgets[widget_zone].push(widget); + + const def = widget + ._widgetRenderAndInsert(() => { + // Do nothing + }) + .then(() => { + widget.$el.addClass("o_widget"); + $field.replaceWith(widget.$el); + }); + this.defs.push(def); + }); + }, + + /** + * @private + */ + _updateLazyQty: function() { + var model = this.options.basicFieldParams.model; + var record = model.get(this.state.id); + this.$el.find(".lazy_product_qty").text(record.context.lazy_qty); + }, + + /** + * 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} fields + */ + _processDynamicFields: function(fields) { + if (!this.state) { + return; + } + const model = this.options.basicFieldParams.model; + const record = model.get(this.state.id); + const state_data = record.data; + + let to_find = []; + if (_.isEmpty(fields)) { + to_find = ["[data-field]"]; + } else { + to_find = _.map(fields, field => { + return _.str.sprintf("[data-field=%s]", [field]); + }); + } + + this.$el.find(to_find.join()).each((key, value) => { + const $elm = $(value); + const format_out = $elm.data("esc") || $elm.data("field"); + const text_out = py.eval( + format_out, + _.extend({}, state_data, this.recordSearch) + ); + $elm.html(text_out); + $elm.attr("title", text_out); + }); + + if (this.options.showDiscount) { + const field_map = this.options.fieldMap; + if (state_data) { + const 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() { + let price_reduce = 0; + const field_map = this.options.fieldMap; + const model = this.options.basicFieldParams.model; + const record = model.get(this.state.id); + if (record && record.data[field_map.discount]) { + price_reduce = tools.priceReduce( + record.data[field_map.price_unit], + record.data[field_map.discount] + ); + } + return ( + price_reduce && + tools.monetary( + price_reduce, + this.state.fields[field_map.price_unit], + this.options.currencyField, + this.options.basicFieldParams.record.data + ) + ); + }, + + /** + * @private + * @returns {Promise} + */ + _saveRecord: function() { + const model = this.options.basicFieldParams.model; + const record = model.get(this.state.id); + model.updateRecordContext(this.state.id, {saving: true}); + this.recreate(); + return model + .save(record.id, { + stayInEdit: true, + reload: true, + savePoint: true, + viewType: "form", + }) + .then(() => { + const record = model.get(this.state.id); + this.trigger_up("create_quick_record", { + id: record.id, + callback: () => { + model.updateRecordContext(this.state.id, {saving: false}); + this.$card + .find(".o_catch_attention") + .removeClass("o_catch_attention"); + }, + }); + model.unsetDirty(this.state.id); + }); + }, + + /** + * @private + */ + _updateRecord: function() { + const model = this.options.basicFieldParams.model; + const record = model.get(this.state.id); + this.trigger_up("update_quick_record", { + id: record.id, + callback: () => { + this.$card + .find(".o_catch_attention") + .removeClass("o_catch_attention"); + }, + }); + model.unsetDirty(this.state.id); + }, + + /** + * @private + * @returns {Promise} + */ + _addProduct: function() { + const model = this.options.basicFieldParams.model; + model.updateRecordContext(this.state.id, { + ignore_warning: this.options.ignoreWarning, + }); + const record = model.get(this.state.id); + // Because we don't hide the 'add' button when the product is added form back form + // we check if the record is in "saving" mode to prevent duplicate it. + if (record.context.saving) { + return Promise.resolve(); + } + const changes = _.pick( + record.data, + this.options.fieldMap[this.master_uom_map.field_uom_qty] + ); + if ( + changes[this.options.fieldMap[this.master_uom_map.field_uom_qty]] === 0 + ) { + changes[this.options.fieldMap[this.master_uom_map.field_uom_qty]] = 1; + } + this.$card.addClass("blocked"); + return model.notifyChanges(record.id, changes).then(() => { + this._saveRecord(); + }); + }, + + /** + * @private + * @param {Number} amount + * @returns {Promise} + */ + _incProductQty: function(amount) { + const model = this.options.basicFieldParams.model; + model.updateRecordContext(this.state.id, { + ignore_warning: this.options.ignoreWarning, + }); + const record = model.get(this.state.id); + if (this.options.autoSave && !this.state.data.id) { + let lazy_qty = record.context.lazy_qty || 1; + lazy_qty += amount; + model.updateRecordContext(this.state.id, {lazy_qty: lazy_qty}); + this._updateLazyQty(); + } else { + // HACK: Modify the raw state value to show correct 'qty' when + // receive the response from Odoo. This happens because the widget + // sends a creation with qty 1 but can still add more qty mean while + // wait for the Odoo response. + const model_record_data = model.localData[this.state.id].data; + if ( + _.isNull( + model_record_data[ + this.options.fieldMap[this.master_uom_map.field_uom_qty] + ] + ) + ) { + model_record_data[ + this.options.fieldMap[this.master_uom_map.field_uom_qty] + ] = 1; + } + model_record_data[ + this.options.fieldMap[this.master_uom_map.field_uom_qty] + ] += amount; + return model + .notifyChanges(record.id, { + [this.options.fieldMap[this.master_uom_map.field_uom_qty]]: + model_record_data[ + this.options.fieldMap[this.master_uom_map.field_uom_qty] + ], + }) + .then(() => { + this._processDynamicFields(); + this._lazyUpdateRecord(); + }); + } + }, + + /** + * @private + * @param {Selector/HTMLElement} target + */ + _doInteractAnim: function(target) { + const $target = $(target); + $target.addClass("o_catch_attention"); + }, + + /** + * @private + */ + _openPriceModifier: function() { + const state_data = this.state && this.state.data; + if (this.options.readOnlyMode || !state_data) { + return; + } + + const 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.$modifPriceModal = $( + qweb.render("One2ManyProductPicker.QuickModifPrice.Modal") + ); + this.$modifPriceModal.appendTo($(".oe_one2many_product_picker_view")); + modif_price_form.attachTo(this.$modifPriceModal); + this.$modifPriceModal.modal(); + }, + + // HANDLE EVENTS + + /** + * @private + * @param {ClickEvent} evt + */ + _onClickFlipCard: function(evt) { + // Avoid clicks on form elements + if (["INPUT", "BUTTON", "A"].indexOf(evt.target.tagName) !== -1) { + return; + } + const $target = $(evt.target); + if (!this.options.readOnlyMode) { + if ( + $target.hasClass("add_product") || + $target.parents(".add_product").length + ) { + if (!this.is_adding_product) { + this.is_adding_product = true; + this._addProduct(); + this._doInteractAnim(evt.target); + } + return; + } else if ( + $target.hasClass("product_qty") || + $target.parents(".product_qty").length + ) { + this._incProductQty(1); + this._doInteractAnim(evt.target); + return; + } else if ($target.hasClass("safezone")) { + // Do nothing on safe zones + return; + } + } + + if (this.$card.hasClass("blocked")) { + 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 + */ + _onClickDelayedFlipCard: function() { + this._clickFlipCardDelayed = false; + this._clickFlipCardCount = 0; + + if (this.options.readOnlyMode || !this.state) { + return; + } + if (this.$card.hasClass("active")) { + this.$card.removeClass("active"); + this.$front.removeClass("d-none"); + } else { + this.defs = []; + if (!this.widgets.back.length) { + this._processWidgetFields(this.$back); + this._processWidgets(this.$back, "back"); + } + this._processDynamicFields(); + $.when(this.defs).then(() => { + const $actived_card = this.$el.parent().find(".active"); + $actived_card.removeClass("active"); + $actived_card.find(".oe_flip_card_front").removeClass("d-none"); + this.$card.addClass("active"); + this.$card.on("transitionend", () => { + this.$front.addClass("d-none"); + this.$card.off("transitionend"); + }); + this.trigger_up("record_flip", { + widget_index: this.$el.data("renderer_widget_index"), + prev_widget_index: $actived_card + .parent() + .data("renderer_widget_index"), + }); + }); + } + }, + + /** + * @private + * @param {MouseEvent} evt + */ + _onDblClickDelayedFlipCard: function(evt) { + const $target = $(evt.target); + if ( + $target.hasClass("badge_price") || + $target.parents(".badge_price").length + ) { + this._openPriceModifier(); + } else { + const $currentTarget = $(evt.currentTarget); + const $img = $currentTarget.find(".oe_flip_card_front img"); + const 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 { + const $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"); + } + const 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.$card.removeClass("active"); + this.$front.removeClass("d-none"); + const $img = this.$front.find("img"); + const cur_img_src = $img.attr("src"); + if (this.$card.hasClass("oe_flip_card_maximized")) { + this.$card.removeClass("oe_flip_card_maximized"); + this.$card.on("transitionend", () => { + this.$card.css({ + position: "", + top: "", + left: "", + width: "", + height: "", + zIndex: "", + }); + this.$card.off("transitionend"); + if (evt.data.success_callback) { + evt.data.success_callback(); + } + }); + } else if (evt.data.success_callback) { + evt.data.success_callback(); + } + + if (evt.data.block) { + this.$card.addClass("blocked"); + } + $img.attr("src", $img.data("srcAlt")); + $img.data("srcAlt", cur_img_src); + }, + + /** + * 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(evt) { + this._processDynamicFields(Object.keys(evt.data.changes)); + // This.recreate(); + 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..c50076b54 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/renderer.js @@ -0,0 +1,731 @@ +// 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"; + + const core = require("web.core"); + const BasicRenderer = require("web.BasicRenderer"); + const One2ManyProductPickerRecord = require("web_widget_one2many_product_picker.One2ManyProductPickerRecord"); + const ProductPickerQuickCreateForm = require("web_widget_one2many_product_picker.ProductPickerQuickCreateForm"); + + const qweb = core.qweb; + + /* This is the renderer of the main widget */ + const One2ManyProductPickerRenderer = BasicRenderer.extend({ + className: "oe_one2many_product_picker_view", + + events: { + "click #productPickerLoadMore": "_onClickLoadMore", + }, + custom_events: { + record_flip: "_onRecordFlip", + }, + + 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", + }); + + // Workaround: Odoo initilize this class so we need do this to + // 'receive' more arguments. + this.options = parent.options; + this.mode = parent.mode; + this.search_group = parent._activeSearchGroup; + }, + + /** + * Propagate the event to the view widgets + */ + on_attach_callback: function() { + this._isInDom = true; + _.invoke(_.compact(this.widgets), "on_attach_callback"); + }, + + /** + * Propagate the event to the view widgets + */ + on_detach_callback: function() { + this._isInDom = false; + _.invoke(_.compact(this.widgets), "on_detach_callback"); + }, + + /** + * @param {Object} widget + */ + removeWidget: function(widget) { + const index = this.widgets.indexOf(widget); + widget.destroy(); + delete this.widgets[index]; + }, + + /** + * @override + */ + start: function() { + return this._super.apply(this, arguments); + }, + + /** + * @param {Object} search_group + */ + updateSearchGroup: function(search_group) { + this.search_group = search_group; + }, + + /** + * @param {Boolean} block + */ + blockLoadMore: function(block) { + this.$btnLoadMore.attr("disabled", block); + }, + + /** + * Avoid re-render 'pure virtual' states + * + * @override + */ + updateState: function(state, params) { + const force_update = params.force; + delete params.force; + const sparams = _.extend({}, params, {noRender: true}); + if (!force_update && _.isEqual(this.state.data, state.data)) { + return this._super(state, sparams); + } + const old_state = _.clone(this.state.data); + return this._super(state, sparams).then(() => { + this._updateStateRecords(old_state); + }); + }, + + /** + * Because this widget doesn't support comments/sections line types + * we need check if the line is valid to be shown. + * + * @private + * @param {Object} state + * @returns {Boolean} + */ + _isValidLineState: function(state) { + return ( + state.data[this.options.field_map.product] && + state.data[this.options.field_map.product].data.id + ); + }, + + /** + * @private + * @param {Object} state_a + * @param {Object} state_b + * @returns {Boolean} + */ + _isEqualState: function(state_a, state_b) { + if (state_a.id === state_b.id) { + return true; + } + const product_id_a = + state_a.data[this.options.field_map.product].data.id; + const product_uom_id_a = + state_a.data[this.options.field_map.product_uom].data.id; + const product_id_b = + state_b.data[this.options.field_map.product].data.id; + const product_uom_id_b = + state_b.data[this.options.field_map.product_uom].data.id; + + return ( + product_id_a === product_id_b && + product_uom_id_a === product_uom_id_b + ); + }, + + /** + * @private + * @param {Object} state + * @returns {Boolean} + */ + _existsWidgetWithState: function(state) { + for (let eb = this.widgets.length - 1; eb >= 0; --eb) { + const widget = this.widgets[eb]; + if ( + widget && + widget.state && + this._isEqualState(widget.state, state) + ) { + return true; + } + } + return false; + }, + + /** + * When destroy states we need check if pure virtual records + * are affected to recreate a new one because this widget can't + * remove pure virtual records. + * + * @private + * @param {Array} states + * @returns {Array} + */ + _processStatesToDestroy: function(states) { + // Get widgets to destroy + // Update states only affect to "non pure virtual" records + const to_destroy = []; + const to_add = []; + for (const state of states) { + for (let e = this.widgets.length - 1; e >= 0; --e) { + const widget = this.widgets[e]; + if (widget && this._isEqualState(widget.state, state)) { + // If already exists a widget for the product don't try create a new one + let recreated = false; + if (!this._existsWidgetWithState(widget.state)) { + // Get the new state ID if exists to link it with the new record + // This happens when remove a record that have a new state info + for ( + let eb = this.state.data.length - 1; + eb >= 0; + --eb + ) { + const state = this.state.data[eb]; + if (!this._isValidLineState(state)) { + continue; + } + if (this._isEqualState(state, widget.state)) { + widget.recreate(state); + recreated = true; + break; + } + } + } + if (!recreated) { + widget.markToDestroy(); + to_destroy.push(widget); + const search_record = _.omit( + widget.recordSearch, + "__id" + ); + + to_add.push([ + [search_record], + { + no_attach_widgets: false, + no_process_records: false, + position: widget.state.id, + }, + ]); + } + } + } + } + + return [to_destroy, to_add]; + }, + + /** + * We need check current states to ensure that doesn't exists duplications, + * update the existing and add the new ones. + * + * @private + * @returns {Array} + */ + _processCurrentStates: function() { + // Records to Update or Create + const model = this.getParent().getBasicFieldParams().model; + const to_destroy = []; + const to_add = []; + for (const index in this.state.data) { + const state = this.state.data[index]; + if (!this._isValidLineState(state)) { + continue; + } + let exists = false; + let search_record_index = false; + let search_record = false; + for (let e = this.widgets.length - 1; e >= 0; --e) { + const widget = this.widgets[e]; + if (!widget || !widget.state) { + // Already processed widget (deleted) + continue; + } + + const is_equal_state = this._isEqualState(widget.state, state); + if (widget.isMarkedToDestroy()) { + exists = true; + } else if (is_equal_state) { + const record = model.get(widget.state.id); + model.updateRecordContext(state.id, { + lazy_qty: record.context.lazy_qty || 0, + }); + widget.recreate(state); + exists = true; + break; + } + if ( + !is_equal_state && + widget.recordSearch.id === + state.data[this.options.field_map.product].data.id + ) { + // Is a new record (can be other record for the same 'search record' or a replacement for a pure virtual) + search_record_index = widget.state.id; + search_record = widget.recordSearch; + const record = model.get(widget.state.id); + model.updateRecordContext(state.id, { + lazy_qty: record.context.lazy_qty || 0, + }); + } + + // Remove "pure virtual" records that have the same product that the new record + if ( + widget.is_virtual && + this._isEqualState(widget.state, state) + ) { + to_destroy.push(widget); + delete this.widgets[e]; + } + } + + this.state.data = _.compact(this.state.data); + + // Add to create the new record + if (!exists && search_record_index) { + const new_search_record = _.extend({}, search_record, { + __id: state.id, + }); + to_add.push([ + [new_search_record], + { + no_attach_widgets: true, + no_process_records: true, + position: search_record_index, + }, + ]); + } + } + + return [to_destroy, to_add]; + }, + + /** + * 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 + * @param {Object} old_states + * @returns {Deferred} + */ + _updateStateRecords: function(old_states) { + // States to remove + const states_to_destroy = []; + for (const index in old_states) { + const old_state = old_states[index]; + if (!this._isValidLineState(old_state)) { + continue; + } + let found = false; + for (const e in this.state.data) { + const current_state = this.state.data[e]; + if (!this._isValidLineState(current_state)) { + continue; + } + if (this._isEqualState(current_state, old_state)) { + found = true; + break; + } + } + if (!found) { + states_to_destroy.push(old_state); + } + } + + const def = $.Deferred(); + this.state.data = _.compact(this.state.data); + const [to_destroy_old, to_add_virtual] = this._processStatesToDestroy( + states_to_destroy + ); + + const [ + destroyed_current, + to_add_current, + ] = this._processCurrentStates(); + + const currentTasks = []; + const to_add = [].concat(to_add_current, to_add_virtual); + for (const params of to_add) { + currentTasks.push(this.appendSearchRecords.apply(this, params)[0]); + } + + Promise.all(currentTasks).then(() => { + _.invoke(to_destroy_old, "destroy"); + _.invoke(destroyed_current, "destroy"); + this.widgets = _.difference(this.widgets, to_destroy_old); + def.resolve(); + }); + + return def; + }, + + clearRecords: function() { + _.invoke(_.compact(this.widgets), "destroy"); + this.widgets = []; + if (this.$recordsContainer) { + this.$recordsContainer.empty(); + } + }, + + /** + * @override + */ + _renderView: function() { + _.invoke(_.compact(this.widgets), "destroy"); + this.widgets = []; + this.$recordsContainer = $("
", { + class: "w-100 row", + }); + this.$extraButtonsContainer = $( + qweb.render("One2ManyProductPicker.ExtraButtons") + ); + this.$btnLoadMore = this.$extraButtonsContainer.find( + "#productPickerLoadMore" + ); + // This.search_data = this._sort_search_data(this.search_data); + this.$el.empty(); + this.$el.append(this.$recordsContainer); + this.$el.append(this.$extraButtonsContainer); + // This.showLoadMore( + // this.last_search_data_count >= this.options.records_per_page + // ); + return this._super.apply(this, arguments); + }, + + /** + * @private + * @param {Array} datas + * @returns {Array} + */ + _sort_search_data: function(datas) { + if (this.search_group.name === "main_lines") { + const field_name = this.options.field_map.product; + for (const index_datas in datas) { + const data = datas[index_datas]; + + for (const index_state in this.state.data) { + const state_data = this.state.data[index_state]; + if ( + this._isValidLineState(state_data) && + state_data.data[field_name].res_id === data.id + ) { + data._order_value = state_data.res_id; + } + } + } + const sorted_datas = _.chain(datas) + .sortBy("_order_value") + .map(item => _.omit(item, "_order_value")) + .value() + .reverse(); + return sorted_datas; + } + return datas; + }, + + /** + * Compare search results with current lines. + * Link a current state with the 'search record'. + * + * @private + * @param {Array} results + * @returns {Array} + */ + _processSearchRecords: function(results) { + const field_name = this.options.field_map.product; + const records = []; + const states = []; + + var test_values = function(field_value, record_search) { + return ( + (typeof field_value === "object" && + field_value.data.id === record_search.id) || + field_value === record_search.id + ); + }; + + for (const index in results) { + const record_search = results[index]; + let state_data_found = false; + + // Analyze 'pure virtual' records + // Pure virtual records aren't linked with field list + // so we need search them linked in the widgets. + for (const index_widget in this.widgets) { + const widget = this.widgets[index_widget]; + if (widget.isMarkedToDestroy()) { + continue; + } + if ( + record_search.__id === widget.state.id || + (!record_search.__id && + widget.recordSearch.id === record_search.id) + ) { + state_data_found = true; + if (widget.state) { + states.push(widget.state); + } + break; + } + } + + // If already exists a widget with the search result + // avoid create a new one + if (state_data_found) { + continue; + } + + // Analyze field records + // If not found any widget we need create a new one + // linked with the state record + for (const index_data in this.state.data) { + const state_record = this.state.data[index_data]; + if (!this._isValidLineState(state_record)) { + continue; + } + const field_value = state_record.data[field_name]; + if (test_values(field_value, record_search)) { + records.push( + _.extend({}, record_search, { + __id: state_record.id, + }) + ); + states.push(state_record); + state_data_found = true; + } + } + + if (!state_data_found) { + records.push(record_search); + } + } + + return { + records: records, + states: states, + }; + }, + + /** + * @private + * @param {Int} id + * @returns {Object} + */ + _getRecordDataById: function(id) { + for (const index in this.state.data) { + const 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, + autoSave: this.options.auto_save, + ignoreWarning: this.options.ignore_warning, + }); + }, + + /** + * Generates the 'Product Card' per record. + * + * @private + * @param {Array} search_records + * @param {Object} options + */ + _appendSearchRecords: function(search_records, options) { + const processed_info = options.no_process_records + ? search_records + : this._processSearchRecords(search_records); + const records_to_add = processed_info.records || search_records; + _.each(records_to_add, search_record => { + const state_data = this._getRecordDataById(search_record.__id); + const widget_options = this._getRecordOptions(search_record); + widget_options.renderer_widget_index = this.widgets.length; + const ProductPickerRecord = new One2ManyProductPickerRecord( + this, + state_data, + widget_options + ); + this.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) { + const defVirtualState = ProductPickerRecord.generateVirtualState( + this.options.instant_search + ); + this.defsVirtualState.push(defVirtualState); + } + + // At this point the widget will use the existing state (line) or + // a simple state data. Using simple state data instead of waiting for + // complete state (default + onchange) gives a low FCP time. + const def = $.Deferred(); + ProductPickerRecord.appendTo(this.$recordsContainer).then( + function(widget, widget_position) { + if (typeof widget_position !== "undefined") { + const $elm = this.$el.find( + `[data-card-id="${widget_position}"]:first` + ); + widget.$el.insertBefore($elm); + } + def.resolve(); + }.bind(this, ProductPickerRecord, options.position) + ); + this.defs.push(def); + }); + // Destroy unused + if (options.cleanup) { + const num_widgets = this.widgets.length; + for ( + let index_widget = num_widgets - 1; + index_widget >= 0; + --index_widget + ) { + const widget = this.widgets[index_widget]; + let found_state = false; + for (const state of processed_info.states) { + if (widget.state && widget.state.id === state.id) { + found_state = true; + break; + } + } + if (!found_state && widget.state) { + widget.destroy(); + delete this.widgets[index_widget]; + } + } + // Clean widget array + this.widgets = _.compact(this.widgets); + } + }, + + /** + * @param {Boolean} status + */ + showLoadMore: function(status) { + this.$btnLoadMore.toggleClass("d-none", !status); + }, + + /** + * Append search records to the view + * + * @param {Array} search_records + * @param {Object} options + * @returns {Array} + */ + appendSearchRecords: function(search_records, options = {}) { + this.trigger_up("loading_records"); + this.defs = []; + this.defsVirtualState = []; + const cur_widget_index = this.widgets.length; + this._appendSearchRecords(search_records, options); + + const defs = this.defs; + delete this.defs; + const defsVirtualState = this.defsVirtualState; + delete this.defsVirtualState; + return [ + Promise.all(defs).then(() => { + if (!options.no_attach_widgets && this._isInDom) { + const new_widgets = this.widgets.slice(cur_widget_index); + _.invoke(new_widgets, "on_attach_callback"); + } + }), + Promise.all(defsVirtualState).then(() => { + this.trigger_up("loading_records", {finished: true}); + }), + ]; + }, + + /** + * @private + */ + _onClickLoadMore: function() { + this.$btnLoadMore.attr("disabled", true); + this.trigger_up("load_more"); + }, + + /** + * Do card flip + * + * @param {Integer} index + */ + doWidgetFlip: function(index) { + const widget = this.widgets[index]; + const $actived_card = this.$el.find(".active"); + if (widget.$card.hasClass("active")) { + widget.$card.removeClass("active"); + widget.$card.find(".oe_flip_card_front").removeClass("d-none"); + } else { + widget.defs = []; + widget._processWidgetFields(widget.$back); + widget._processWidgets(widget.$back); + widget._processDynamicFields(); + $.when(widget.defs).then(() => { + $actived_card.removeClass("active"); + $actived_card.find(".oe_flip_card_front").removeClass("d-none"); + widget.$card.addClass("active"); + setTimeout(() => { + widget.$(".oe_flip_card_front").addClass("d-none"); + }, 200); + }); + } + }, + + /** + * Handle card flip. + * Used to create/update the record + * + * @private + * @param {CustomEvent} evt + */ + _onRecordFlip: function(evt) { + const prev_widget_index = evt.data.prev_widget_index; + if (typeof prev_widget_index !== "undefined") { + // Only check 'back' widgets so there is where the form was created + for (const index in this.widgets[prev_widget_index].widgets.back) { + const widget = this.widgets[prev_widget_index].widgets.back[ + index + ]; + if (widget instanceof ProductPickerQuickCreateForm) { + widget.controller.auto(); + } + } + } + }, + }); + + return One2ManyProductPickerRenderer; + } +); diff --git a/web_widget_one2many_product_picker/static/src/js/views/basic_controller.js b/web_widget_one2many_product_picker/static/src/js/views/basic_controller.js new file mode 100644 index 000000000..7c13bede5 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/basic_controller.js @@ -0,0 +1,32 @@ +// Copyright 2021 Tecnativa - Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +odoo.define("web_widget_one2many_product_picker.BasicController", function(require) { + "use strict"; + + const BasicController = require("web.BasicController"); + + BasicController.include({ + /** + * This is necessary to refresh 'one2many_product_picker' when some 'trigger_refresh_fields' fields changes. + * + * @override + */ + _confirmChange: function(id, fields, e) { + id = id || this.handle; + return this._super.apply(this, arguments).then(() => { + if (this.renderer && !_.isEmpty(this.renderer.allFieldWidgets)) { + const product_picker_widgets = _.filter( + this.renderer.allFieldWidgets[id], + item => item.attrs.widget === "one2many_product_picker" + ); + _.invoke( + product_picker_widgets, + "onDocumentConfirmChanges", + fields, + e + ); + } + }); + }, + }); +}); 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..e027c3b70 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/basic_model.js @@ -0,0 +1,477 @@ +// 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"; + + const BasicModel = require("web.BasicModel"); + + BasicModel.include({ + /** + * @override + */ + init: function() { + this._super.apply(this, arguments); + }, + + /** + * This is necessary because 'pure virtual' records + * can be destroyed at any time. + * + * @param {String} id + * @returns {Boolean} + */ + exists: function(id) { + return !_.isEmpty(this.localData[id]); + }, + + /** + * @param {String} id + * @param {Object} context + */ + updateRecordContext: function(id, context) { + this.localData[id].context = _.extend( + {}, + this.localData[id].context, + context + ); + }, + + /** + * @param {String} id + * @returns {Boolean} + */ + isPureVirtual: function(id) { + const data = this.localData[id]; + return data._virtual || false; + }, + + /** + * @param {String} id + * @param {Boolean} status + */ + setPureVirtual: function(id, status) { + const data = this.localData[id]; + if (status) { + data._virtual = true; + } else { + delete data._virtual; + } + }, + + /** + * @param {String} id + */ + unsetDirty: function(id) { + const data = this.localData[id]; + data._isDirty = false; + this._visitChildren(data, r => { + r._isDirty = false; + }); + }, + + /** + * 'Pure virtual' records are not used by other + * elements so can be removed safesly + * + * @param {String} id + * @returns {Boolean} + */ + removeVirtualRecord: function(id) { + if (!this.isPureVirtual(id)) { + return false; + } + + const data = this.localData[id]; + const to_remove = []; + this._visitChildren(data, item => { + to_remove.push(item.id); + }); + + to_remove.reverse(); + for (const remove_id of to_remove) { + this.removeLine(remove_id); + delete this.localData[remove_id]; + } + return true; + }, + + /** + * This is a cloned method from Odoo framework. + * Virtual records are processed in two parts, + * this is the second part and here we trigger onchange + * process. + * + * @param {Object} record + * @param {Object} params + * @returns {Promise} + */ + _makeDefaultRecordNoDatapoint: function(record, params) { + var self = this; + + var targetView = params.viewType; + var fieldsInfo = params.fieldsInfo; + var fieldNames = Object.keys(fieldsInfo[targetView]); + var fields_key = _.without(fieldNames, "__last_update"); + + // Fields that are present in the originating view, that need to be initialized + // Hence preventing their value to crash when getting back to the originating view + var parentRecord = + params.parentID && this.localData[params.parentID].type === "list" + ? this.localData[params.parentID] + : null; + + if (parentRecord && parentRecord.viewType in parentRecord.fieldsInfo) { + var originView = parentRecord.viewType; + fieldNames = _.union( + fieldNames, + Object.keys(parentRecord.fieldsInfo[originView]) + ); + } + + return this._rpc({ + model: record.model, + method: "default_get", + args: [fields_key], + context: params.context, + }).then(function(result) { + // Interrupt point (used in instant search) + if (!self.exists(record.id)) { + return Promise.reject(); + } + + // We want to overwrite the default value of the handle field (if any), + // in order for new lines to be added at the correct position. + // -> This is a rare case where the defaul_get from the server + // will be ignored by the view for a certain field (usually "sequence"). + + var overrideDefaultFields = self._computeOverrideDefaultFields( + params.parentID, + params.position + ); + + if (overrideDefaultFields) { + result[overrideDefaultFields.field] = overrideDefaultFields.value; + } + + return self + .applyDefaultValues(record.id, result, {fieldNames: fieldNames}) + .then(function() { + if (!self.exists(record.id)) { + return Promise.reject(); + } + var def = new Promise(function(resolve, reject) { + // Interrupt point (used in instant search) + if (!self.exists(record.id)) { + return Promise.reject(); + } + var always = function() { + if (record._warning) { + if (params.allowWarning) { + delete record._warning; + } else { + reject(); + } + } + resolve(); + }; + self._performOnChange(record, fields_key) + .then(always) + .guardedCatch(always); + }); + return def; + }) + .then(function() { + if (!self.exists(record.id)) { + return Promise.reject(); + } + return self._fetchRelationalData(record); + }) + .then(function() { + if (!self.exists(record.id)) { + return Promise.reject(); + } + return self._postprocess(record); + }) + .then(function() { + if (!self.exists(record.id)) { + return Promise.reject(); + } + // Save initial changes, so they can be restored later, + // if we need to discard. + self.save(record.id, {savePoint: true}); + + return record.id; + }); + }); + }, + + /** + * Virtual records are processed in two parts, + * this is the first part and here we create + * the state (without aditional process) + * + * @param {String} listID + * @param {Object} options + * @returns {Object} + */ + createVirtualDatapoint: function(listID, options) { + const list = this.localData[listID]; + const context = _.extend({}, this._getContext(list), options.context); + + const position = options ? options.position : "top"; + const params = { + context: context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + parentID: list.id, + position: position, + viewType: list.viewType, + allowWarning: true, + doNotSetDirty: true, + }; + + var targetView = params.viewType; + var fields = params.fields; + var fieldsInfo = params.fieldsInfo; + + // Fields that are present in the originating view, that need to be initialized + // Hence preventing their value to crash when getting back to the originating view + var parentRecord = + params.parentID && this.localData[params.parentID].type === "list" + ? this.localData[params.parentID] + : null; + + if (parentRecord && parentRecord.viewType in parentRecord.fieldsInfo) { + var originView = parentRecord.viewType; + fieldsInfo[targetView] = _.defaults( + {}, + fieldsInfo[targetView], + parentRecord.fieldsInfo[originView] + ); + fields = _.defaults({}, fields, parentRecord.fields); + } + + const record = this._makeDataPoint({ + modelName: list.model, + fields: fields, + fieldsInfo: fieldsInfo, + context: params.context, + parentID: params.parentID, + res_ids: params.res_ids, + viewType: targetView, + }); + this.setPureVirtual(record.id, true); + this.updateRecordContext(record.id, { + ignore_warning: true, + not_onchange: true, + }); + + return { + record: record, + params: params, + }; + }, + + /** + * Generates a virtual records without hard-link to any model. + * + * @param {Integer/String} listID + * @param {Object} options + * @returns {Deferred} + */ + createVirtualRecord: function(listID, options) { + const list = this.localData[listID]; + const context = _.extend({}, this._getContext(list), options.context); + + const position = options ? options.position : "top"; + const params = { + context: context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + parentID: list.id, + position: position, + viewType: list.viewType, + allowWarning: true, + doNotSetDirty: true, + }; + + return new Promise(resolve => { + this._makeDefaultRecord(list.model, params).then(recordID => { + this.setPureVirtual(recordID, true); + this.updateRecordContext(recordID, { + ignore_warning: true, + not_onchange: true, + }); + resolve({ + record: this.get(recordID), + params: params, + }); + }); + }); + }, + + /** + * Adds support to avoid show onchange warnings. + * The implementation is a pure hack that clone + * the context and do a monkey patch to the + * 'trigger_up' method. + * + * @override + */ + _performOnChange: function(record) { + if (record && record.context && record.context.ignore_warning) { + const this_mp = _.clone(this); + const super_call = this.trigger_up; + this_mp.trigger_up = function(event_name, data) { + if (event_name === "warning" && data.type === "dialog") { + // Do nothing + return; + } + return super_call.apply(this, arguments); + }.bind(this); + return this._super.apply(this_mp, arguments); + } + return this._super.apply(this, arguments); + }, + + /** + * Because records can be removed at any time we + * need check if the record still existing. + * Necessary for 'instant search' feature. + * + * @override + */ + _applyOnChange: function(values, record) { + if (!this.exists(record.id)) { + return Promise.reject(); + } + return this._super.apply(this, arguments); + }, + + /** + * @param {String} recordID + * @returns {Boolean} + */ + hasChanges: function(recordID) { + const record = this.localData[recordID]; + return record && !_.isEmpty(record._changes); + }, + + /** + * @param {Object} model_fields + * @param {String} model + * @param {String} search_val + * @param {Array} domain + * @param {Array} fields + * @param {Object} orderby + * @param {String} operator + * @param {Number} limit + * @param {Number} offset + * @param {Object} context + * @returns {Promise} + */ + fetchNameSearchFull: function( + model_fields, + model, + search_val, + domain, + fields, + orderby, + operator, + limit, + offset, + context + ) { + return this._rpc({ + model: model, + method: "name_search", + kwargs: { + name: search_val, + args: domain || [], + operator: operator || "ilike", + limit: this.limit, + context: context || {}, + }, + }).then(results => { + const record_ids = results.map(item => item[0]); + return this.fetchGenericRecords( + model_fields, + model, + [["id", "in", record_ids]], + fields, + orderby, + limit, + offset, + context + ); + }); + }, + + /** + * @param {Object} model_fields + * @param {String} model + * @param {Array} domain + * @param {Array} fields + * @param {Array} orderby + * @param {Number} limit + * @param {Number} offset + * @param {Object} context + * @returns {Promise} + */ + fetchGenericRecords: function( + model_fields, + model, + domain, + fields, + orderby, + limit, + offset, + context + ) { + return this._rpc({ + model: model, + method: "search_read", + fields: fields, + domain: domain, + limit: limit, + offset: offset, + orderBy: orderby, + kwargs: {context: context}, + }).then(result => { + for (const index in result) { + const record = result[index]; + for (const fieldName in record) { + const field = model_fields[fieldName]; + if (field.type !== "many2one") { + record[fieldName] = this._parseServerValue( + model_fields[fieldName], + record[fieldName] + ); + } + } + } + return result; + }); + }, + + fetchModelFieldsInfo: function(model) { + return this._rpc({ + model: model, + method: "fields_get", + args: [ + false, + [ + "store", + "searchable", + "type", + "string", + "relation", + "selection", + "related", + ], + ], + context: this.getSession().user_context, + }); + }, + }); +}); 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..eed9641ee --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/views/basic_view.js @@ -0,0 +1,44 @@ +/* global py */ +// 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"; + + const core = require("web.core"); + const pyUtils = require("web.py_utils"); + const BasicView = require("web.BasicView"); + + const _t = core._t; + + // Add ref to _() -> _t() call + const PY_t = new py.PY_def.fromJSON(function() { + const 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, + + // Hack: This allow use $number_search out of an string + 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..01613eeea --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/js/widgets/field_one2many_product_picker.js @@ -0,0 +1,877 @@ +// 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"; + + const core = require("web.core"); + const field_registry = require("web.field_registry"); + const FieldOne2Many = require("web.relational_fields").FieldOne2Many; + const One2ManyProductPickerRenderer = require("web_widget_one2many_product_picker.One2ManyProductPickerRenderer"); + const tools = require("web_widget_one2many_product_picker.tools"); + + const _t = core._t; + const qweb = core.qweb; + + /* This is the main widget */ + const FieldOne2ManyProductPicker = FieldOne2Many.extend({ + className: "oe_field_one2many_product_picker", + + // Workaround: We need know all records, + // the widget pagination works with product.product. + limit: 9999999, + + events: _.extend({}, FieldOne2Many.prototype.events, { + "click .dropdown-item": "_onClickSearchMode", + "click .oe_btn_lines": "_onClickLines", + "click .oe_btn_search_group": "_onClickSearchGroup", + "search .oe_search_input": "_onSearch", + "input .oe_search_input": "_onInputSearch", + "focusin .oe_search_input": "_onFocusInSearch", + "show.bs.dropdown .o_cp_buttons": "_onShowSearchDropdown", + "click #product_picker_maximize": "_onClickMaximize", + }), + custom_events: _.extend({}, FieldOne2Many.prototype.custom_events, { + create_quick_record: "_onCreateQuickRecord", + update_quick_record: "_onUpdateQuickRecord", + update_subtotal: "_onUpdateSubtotal", + load_more: "_onLoadMore", + loading_records: "_onLoadingRecords", + list_record_remove: "_onListRecordRemove", + }), + + _auto_search_delay: 450, + _input_instant_search_time: 100, + + // Model product.product fields + search_read_fields: ["id", "display_name", "uom_id"], + + /** + * @override + */ + init: function(parent) { + this._super.apply(this, arguments); + + // Use jquery 'extend' to have a 'deep' merge. + this.options = $.extend( + true, + this._getDefaultOptions(), + this.attrs.options + ); + if (!this.options.search) { + // Default search domain + this.options.search = [ + { + name: _t("By Name"), + domain: [], + name_search_value: "$search", + }, + ]; + } + this._searchMode = 0; + this._searchCategoryNames = _.map(this.options.search, "name"); + this._searchContext = {}; + + // FIXME: Choose a better way to get the active controller or model objects + this.parent_controller = parent.getParent(); + if (this.view) { + this._processGroups(); + } + + this._currentSearchBatchID = 0; + + this._lazyRenderSearchRecords = _.debounce(() => { + this.doRenderSearchRecords(); + ++this._currentSearchBatchID; + }, this._input_instant_search_time); + }, + + willStart: function() { + return this._super + .apply(this, arguments) + .then(() => { + const arch = this.view.arch; + const field_name = this.options.field_map.product; + const field_info = this.view.fieldsInfo[arch.tag][field_name]; + const model = this.view.viewFields[field_info.name].relation; + this._modelName = model; + return this.parent_controller.model.fetchModelFieldsInfo(model); + }) + .then(fields_info => { + this._fieldsInfo = fields_info; + if (this.isReadonly) { + // Show Lines + this._updateSearchContext(-1); + } else { + this._updateSearchContext(0); + } + }); + }, + + /** + * Updates the lines counter badge + */ + updateBadgeLines: function() { + const records = this.parent_controller.model.get(this.record.id).data[ + this.name + ].data; + this.$badgeLines.text(records.length); + }, + + updateSubtotalPrice: function() { + if (!this.options.show_subtotal) { + return; + } + let prices = []; + const field_map = this.options.field_map; + const records = this.parent_controller.model.get(this.record.id).data[ + this.name + ].data; + if (this.options.show_discount) { + prices = _.map(records, 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, line => { + return ( + line.data[field_map.product_uom_qty] * + line.data[field_map.price_unit] + ); + }); + } + let total = + _.reduce(prices, (a, b) => { + return a + b; + }) || 0; + total = tools.monetary( + total, + this.value.fields[this.options.field_map.price_unit], + this.options.currency_field, + this.record.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, + record: this.record, + model: this.parent_controller.model, + fieldName: this.name, + recordData: this.recordData, + value: this.value, + relation_field: this.record.fields[this.name].relation_field, + current_batch_id: this._currentSearchBatchID, + }; + }, + + /** + * Because the widget shows "pure virtual" information, we don't have any 'onchange' linked. + * This method forces 'refresh' the widget if the selected fields was changed. + * + * @param {Array} fields + * @param {Event} e + */ + onDocumentConfirmChanges: function(fields, e) { + const trigger_fields = this.options.trigger_refresh_fields || []; + if (_.difference(trigger_fields, fields).length !== trigger_fields.length) { + this._reset( + this.parent_controller.model.get(this.parent_controller.handle), + e + ); + // Force re-launch onchanges on 'pure virtual' records + this.renderer.clearRecords(); + this._render(); + } + }, + + /** + * @override + */ + _getRenderer: function() { + return One2ManyProductPickerRenderer; + }, + + /** + * Create the group buttons defined in options + * + * @private + */ + _processGroups: function() { + this.searchGroups = []; + let hasUserActive = false; + const groups = this.options.groups || []; + for (const groupIndex in groups) { + const group_def = groups[groupIndex]; + if (group_def.active) { + group_def.active = !hasUserActive; + hasUserActive = true; + } + if (!group_def.records_per_page) { + group_def.records_per_page = 16; + } + this.searchGroups.push(group_def); + } + + this.searchGroups.splice(0, 0, { + name: "all", + string: _t("All"), + domain: this.options.all_domain, + order: false, + active: !hasUserActive, + records_per_page: 16, + }); + this._activeSearchGroup = this.searchGroups[0]; + }, + + /** + * Inject widget buttons and ignore default pagination to use + * we own implementation. + * + * @override + */ + _renderControlPanel: function() { + return this._super.apply(this, arguments).then(() => { + this._controlPanel.updateContents({ + cp_content: { + $buttons: this.$buttons, + $pager: false, + }, + }); + }); + }, + + /** + * @override + */ + _renderButtons: function() { + if (this.isReadonly) { + return this._super.apply(this, arguments); + } + 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); + }, + + /** + * @override + */ + _render: function() { + const def = this._super.apply(this, arguments); + if (def) { + this.renderer.updateSearchGroup(this._activeSearchGroup); + + // Check maximize state + if (!this.$el.hasClass("oe_field_one2many_product_picker_maximized")) { + this.$el.addClass("position-relative d-flex flex-column"); + } + + return new Promise(resolve => { + this._getSearchRecords() + .then(records => { + return this.renderer.appendSearchRecords(records, { + cleanup: false, + }); + }) + .then(() => resolve()); + }).then(() => { + if (this.options.show_subtotal) { + this._addTotalsZone(); + } + }); + } + + return def; + }, + + /** + * @returns {Deferred} + */ + doRenderSearchRecords: function() { + return new Promise(resolve => { + this._getSearchRecords() + .then(records => { + this.renderer.$el.scrollTop(0); + return this.renderer.appendSearchRecords(records, { + cleanup: true, + }); + }) + .then(() => resolve()); + }); + }, + + /** + * 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(); + }, + + /** + * Replace placeholders for search + * - $number_search -> Is a number + * - $search -> Is a string + * + * @private + * @param {Number/String} value + * @param {String} format + * @returns {Number/String} + */ + _getSearchValue: function(value, format) { + if (format === "$number_search") { + return Number(value); + } else if (typeof value === "string") { + return format.replace(/\$search/, value); + } + return value; + }, + + /** + * Obtain the linked records defined in the options. + * If merge is true the current records aren't removed. + * + * @private + * @param {Dictionary} options + * @returns {Deferred} + */ + _getSearchRecords: function(options) { + const search_mode = this.options.search[this._searchMode]; + const orderby = this._searchContext.order; + const fields = this.search_read_fields; + + // Launch the rpc request and ensures that we wait for the reply + // to continue + const domain = this._getFullSearchDomain(search_mode); + const soptions = options || {}; + const context = _.extend( + { + active_search_group_name: this._activeSearchGroup.name, + active_search_involved_fields: this._searchContext.involvedFields, + active_test: this._searchContext.activeTest, + }, + this.value.getContext() + ); + const limit = soptions.limit || this._activeSearchGroup.records_per_page; + const offset = soptions.offset || 0; + + return new Promise(resolve => { + let task = false; + if (search_mode.name_search_value) { + const search_val = this._getSearchValue( + this._searchContext.text, + search_mode.name_search_value + ); + const operator = search_mode.operator; + task = this.parent_controller.model.fetchNameSearchFull( + this._fieldsInfo, + this._modelName, + search_val, + domain, + fields, + orderby, + operator, + limit, + offset, + context + ); + } else { + task = this.parent_controller.model.fetchGenericRecords( + this._fieldsInfo, + this._modelName, + domain, + fields, + orderby, + limit, + offset, + context + ); + } + + task.then(results => { + this._searchOffset = offset + limit; + this.renderer.showLoadMore(limit && results.length === limit); + resolve(results); + }); + }); + }, + + /** + * @private + * @param {MouseEvent} evt + */ + _onClickSearchGroup: function(evt) { + const $btn = $(evt.target); + const groupIndex = Number($btn.data("group")) || 0; + this.showGroup(groupIndex); + $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) { + ev.preventDefault(); + const $target = $(ev.target); + this._searchMode = $target.index(); + $target + .parent() + .children() + .removeClass("active"); + $target.addClass("active"); + this.doRenderSearchRecords().then(() => { + this.$searchInput.focus(); + }); + }, + + /** + * @private + * @returns {Object} + */ + _getDefaultOptions: function() { + return { + currency_field: "currency_id", + 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", + }, + trigger_refresh_fields: ["partner_id", "currency_id"], + auto_save: false, + ignore_warning: false, + all_domain: [], + instant_search: false, + }; + }, + + /** + * Mix context search domain and user input search. + * This domain is used to get the records to display. + * + * @private + * @param {Object} search_mode + * @returns {Array} + */ + _getFullSearchDomain: function(search_mode) { + this._searchContext.involvedFields = []; + const domain = _.clone(this._searchContext.domain) || []; + if (this._searchContext.text) { + const search_domain = search_mode.domain; + const involved_fields = []; + + // Iterate domain triplets and logic operators + for (const index in search_domain) { + const domain_cloned = _.clone(search_domain[index]); + + // Is a triplet + if (domain_cloned instanceof Array) { + // Replace right leaf with the current value of the search input + domain_cloned[2] = this._getSearchValue( + domain_cloned[2], + this._searchContext.text + ); + involved_fields.push({ + type: "number", + field: domain_cloned[0], + oper: domain_cloned[1], + }); + } + domain.push(domain_cloned); + } + this._searchContext.involvedFields = involved_fields; + } + + return domain || []; + }, + + /** + * Domain to get the related records of the lines. + * + * @private + * @returns {Array} + */ + _getLinesDomain: function() { + if (!this.view) { + return []; + } + const field_name = this.options.field_map.product; + const lines = this.parent_controller.model.get(this.record.id).data[ + this.name + ].data; + // Here only get lines with product_id assigned + // This happens beacuse sale.order has lines for sections/comments + const ids = _.chain(lines) + .filter(line => line.data[field_name] !== false) + .map(line => { + return line.data[field_name].data.id; + }) + .value(); + return [["id", "in", ids]]; + }, + + /** + * @param {Number} group_id + */ + _updateSearchContext: function(group_id) { + if (group_id >= 0) { + this._activeSearchGroup = this.searchGroups[group_id]; + this._searchContext.domain = this._activeSearchGroup.domain; + this._searchContext.order = this._activeSearchGroup.order; + this._searchContext.activeTest = this._activeSearchGroup.active_test; + } else { + this._activeSearchGroup = { + name: "main_lines", + }; + this._searchContext.domain = this._getLinesDomain(); + this._searchContext.order = [{name: "sequence"}, {name: "id"}]; + this._searchContext.activeTest = false; + } + if (this.renderer) { + this.renderer.updateSearchGroup(this._activeSearchGroup); + } + }, + + /** + * 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() { + this.renderer.clearRecords(); + this._updateSearchContext(-1); + this._clearSearchInput(); + this.$btnLines + .parent() + .find(".active") + .removeClass("active"); + this.$btnLines.addClass("active"); + this.doRenderSearchRecords(); + }, + + /** + * @param {Number} group_id + */ + showGroup: function(group_id) { + this.renderer.clearRecords(); + this._updateSearchContext(group_id); + this.doRenderSearchRecords(); + this.$btnLines.removeClass("active"); + }, + + /** + * @private + */ + _clearSearchInput: function() { + if (this.$searchInput) { + this.$searchInput.val(""); + this._searchContext.text = ""; + } + }, + + /** + * Odoo stop bubble of the event, but we need listen it. + * + * @override + */ + _onKeydown: function(evt) { + if (evt.keyCode === $.ui.keyCode.ENTER) { + // Do nothing + return; + } + return this._super.apply(this, arguments); + }, + + /** + * @private + * @param {SearchEvent} evt + */ + _onSearch: function(evt) { + this._searchContext.text = evt.target.value; + this.doRenderSearchRecords(); + }, + + /** + * @private + * @param {InputEvent} evt + */ + _onInputSearch: function(evt) { + if (this.options.instant_search) { + this._searchContext.text = evt.target.value; + this._lazyRenderSearchRecords(); + } + }, + + /** + * Auto select all content when user enters into fields with this + * widget. + * + * @private + */ + _onFocusInSearch: function() { + // Workaround: In some cases the focus it's not properly + // assigned due an "event collision". + // Use deferred call to ensure dispatch our event in + // a new frame. + _.defer(() => this.$searchInput.select()); + }, + + /** + * @private + * @param {DropdownEvent} evt + */ + _onShowSearchDropdown: function(evt) { + // Workaround: This "ensures" a correct dropdown position + const offset = $(evt.currentTarget) + .find(".dropdown-toggle") + .parent() + .height(); + _.defer(() => { + $(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) { + evt.stopPropagation(); + var model = this.parent_controller.model; + model.setPureVirtual(evt.data.id, false); + + if (this.options.auto_save) { + // Dont trigger state update + this._setValue( + {operation: "ADD", id: evt.data.id}, + {notifyChange: false} + ).then(() => { + this.parent_controller + .saveRecord(undefined, {stayInEdit: true}) + .then(() => { + self.renderer.updateState( + model.get(self.parent_controller.handle).data[ + self.name + ], + {force: true} + ); + if (evt.data.callback) { + evt.data.callback(); + } + }); + if (evt.data.callback) { + evt.data.callback(); + } + }); + } else { + // This will trigger an "state" update + this._setValue({operation: "ADD", id: evt.data.id}).then(() => { + if (evt.data.callback) { + evt.data.callback(); + } + }); + } + }, + + /** + * @param {Number} id + * @param {Object} data + * @param {Function} callback + */ + _doUpdateQuickRecord: function(id, data, callback) { + if (this.options.auto_save) { + var self = this; + // Dont trigger state update + this._setValue( + {operation: "UPDATE", id: id, data: data}, + {notifyChange: false} + ).then(function() { + self.parent_controller + .saveRecord(undefined, {stayInEdit: true}) + .then(function() { + self.renderer.updateState( + self.parent_controller.model.get( + self.parent_controller.handle + ).data[self.name], + {force: true} + ); + if (callback) { + callback(); + } + }); + if (callback) { + callback(); + } + }); + } else { + // This will trigger an "state" update + this._setValue({operation: "UPDATE", id: id, data: data}).then( + function() { + if (callback) { + callback(); + } + } + ); + } + }, + + /** + * Runs the x2many (1,id,values) command. + * + * @private + * @param {CustomEevent} evt + */ + _onUpdateQuickRecord: function(evt) { + evt.stopPropagation(); + this._doUpdateQuickRecord(evt.data.id, evt.data.data, evt.data.callback); + }, + + /** + * Handle auto_save when remove a record + * + * @param {CustomEvent} evt + */ + _onListRecordRemove: function(evt) { + evt.stopPropagation(); + this._setValue({operation: "DELETE", ids: [evt.data.id]}).then(() => { + if (this.options.auto_save) { + this.parent_controller + .saveRecord(undefined, {stayInEdit: true}) + .then(() => { + if (evt.data.callback) { + evt.data.callback(); + } + }); + } else if (evt.data.callback) { + evt.data.callback(); + } + }); + }, + + /** + * @private + */ + _onUpdateSubtotal: function() { + this.updateSubtotalPrice(); + }, + + /** + * Event dispatched by the 'scroll spy' to load + * records. + * + * @private + */ + _onLoadMore: function() { + if (this._isLoading) { + return; + } + this._getSearchRecords({ + offset: this._searchOffset, + }).then(records => { + this.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("button").attr("disabled", block); + } + }, + + /** + * Refresh lines count on every change. + * + * @override + */ + _setValue: function() { + return this._super.apply(this, arguments).then(() => { + this.updateBadgeLines(); + this.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..6d2d3e00a --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/scss/_variables.scss @@ -0,0 +1,6 @@ +$one2many-product-picker-card-min-height: 150px; +$one2many-product-picker-card-max-height: 150px; +$one2many-product-picker-transition-3d-time: 0.15s; +$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/main_variables.scss b/web_widget_one2many_product_picker/static/src/scss/main_variables.scss new file mode 100644 index 000000000..96ab3f083 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/scss/main_variables.scss @@ -0,0 +1,6 @@ +$one2many-product-picker-grid-breakpoints: map-merge( + $grid-breakpoints, + ( + xxl: 1440px, + ) +); 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..48ec908be --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/scss/one2many_product_picker.scss @@ -0,0 +1,266 @@ +.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 { + position: sticky; + bottom: 0; + background-color: $secondary; + + > 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 { + @include make-grid-columns( + $columns: 24, + $breakpoints: $one2many-product-picker-grid-breakpoints + ); + + 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; + .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; + + .img-fluid { + transform: translate(-50%, -50%); + top: 50%; + left: 50%; + z-index: -1; + position: absolute; + } + + .position-absolute { + z-index: 1; + } + + .badge { + font-size: 1rem; + } + + .indicator_zones { + display: inline-flex; + flex-direction: column; + max-width: 50%; + align-items: flex-start; + + > span.badge { + font-size: 0.8rem; + } + } + + .badge_price { + top: 55%; + 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 { + &:not(.widget_numeric_step) { + max-width: 95%; + } + + .o_input_dropdown > input { + height: unset; + } + } + .btn.w-100 { + max-width: 95%; + } + } + } + + .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_buttons { + display: flex; + padding: 0 3px; + justify-content: center; + + .oe_record_remove { + flex-grow: 1; + } + } + } + } + + .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; + } + .add_product, + .product_qty, + .price_unit { + cursor: pointer; + } + } + } +} 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..01b23ddfa --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker.xml @@ -0,0 +1,229 @@ + 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..4fe591890 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_create.xml @@ -0,0 +1,25 @@ + 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..927b2fab3 --- /dev/null +++ b/web_widget_one2many_product_picker/static/src/xml/one2many_product_picker_quick_modif_price.xml @@ -0,0 +1,8 @@ + 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..64049fdfb --- /dev/null +++ b/web_widget_one2many_product_picker/static/tests/widget_tests.js @@ -0,0 +1,228 @@ +/* global QUnit */ +// 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 ( + "
" + + '' + + "" + + "" + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + "" + + "" + + "" + ); + }; + + 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.0, + 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.0, + 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..46664a0bc --- /dev/null +++ b/web_widget_one2many_product_picker/templates/assets.xml @@ -0,0 +1,82 @@ + + + + +