Merge PR #1858 into 13.0

Signed-off-by pedrobaeza
pull/1868/head
OCA-git-bot 2021-07-29 11:54:17 +00:00
commit 680e86a5f1
38 changed files with 6766 additions and 0 deletions

View File

@ -0,0 +1 @@
../../../../web_widget_one2many_product_picker

View File

@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

View File

@ -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
<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:
.. code:: xml
<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.
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 <https://github.com/OCA/web/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 <https://github.com/OCA/web/issues/new?body=module:%20web_widget_one2many_product_picker%0Aversion:%2013.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Tecnativa
Contributors
~~~~~~~~~~~~
* `Tecnativa <https://www.tecnativa.com>`_:
* 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 <https://github.com/OCA/web/tree/13.0/web_widget_one2many_product_picker>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@ -0,0 +1,2 @@
# Copyright 2020 Tecnativa - Alexandre Díaz
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

View File

@ -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,
}

View File

@ -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 <claudia.gargallo@qubiq.es>\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"

View File

@ -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 <claudia.gargallo@qubiq.es>\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"

View File

@ -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 ""

View File

@ -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 ""

View File

@ -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
<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:
.. code:: xml
<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.

View File

@ -0,0 +1,5 @@
* `Tecnativa <https://www.tecnativa.com>`_:
* Alexandre D. Díaz
* Pedro M. Baeza
* David Vidal

View File

@ -0,0 +1 @@
Adds the 'one2many_product_picker' friendly mobile widget to create one2many lines linked with product.product records.

View File

@ -0,0 +1 @@
It's advisable to install 'web_widget_numeric_step' to have a better usability on touch screens.

View File

@ -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.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -0,0 +1,654 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
<title>Web Widget One2Many Product Picker</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="web-widget-one2many-product-picker">
<h1 class="title">Web Widget One2Many Product Picker</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/web/tree/13.0/web_widget_one2many_product_picker"><img alt="OCA/web" src="https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/web-13-0/web-13-0-web_widget_one2many_product_picker"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/162/13.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
<p>Adds the one2many_product_picker friendly mobile widget to create one2many lines linked with product.product records.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#installation" id="id1">Installation</a></li>
<li><a class="reference internal" href="#configuration" id="id2">Configuration</a><ul>
<li><a class="reference internal" href="#widget-options" id="id3">Widget options:</a></li>
<li><a class="reference internal" href="#default-context" id="id4">Default context:</a></li>
<li><a class="reference internal" href="#examples" id="id5">Examples:</a></li>
<li><a class="reference internal" href="#boostrap-modifications" id="id6">Boostrap Modifications:</a></li>
</ul>
</li>
<li><a class="reference internal" href="#usage" id="id7">Usage</a><ul>
<li><a class="reference internal" href="#parts-of-the-widget" id="id8">Parts of the widget:</a></li>
<li><a class="reference internal" href="#preview" id="id9">Preview:</a></li>
</ul>
</li>
<li><a class="reference internal" href="#known-issues-roadmap" id="id10">Known issues / Roadmap</a></li>
<li><a class="reference internal" href="#bug-tracker" id="id11">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="id12">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="id13">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="id14">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="id15">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="installation">
<h1><a class="toc-backref" href="#id1">Installation</a></h1>
<p>Its advisable to install web_widget_numeric_step to have a better usability on touch screens.</p>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#id2">Configuration</a></h1>
<p>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 <tt class="docutils literal">form</tt> type.</p>
<div class="section" id="widget-options">
<h2><a class="toc-backref" href="#id3">Widget options:</a></h2>
<ul>
<li><p class="first">groups &gt; Array of dictionaries -&gt; Declare the groups</p>
<blockquote>
<ul>
<li><p class="first">name -&gt; The group name</p>
</li>
<li><p class="first">string -&gt; The text displayed</p>
</li>
<li><p class="first">domain -&gt; Forced domain to use</p>
</li>
<li><p class="first">order -&gt; The order</p>
<blockquote>
<ul class="simple">
<li>name -&gt; The field name to order</li>
<li>asc -&gt; Flag to use asc order</li>
</ul>
</blockquote>
</li>
<li><p class="first">records_per_page &gt; Integer -&gt; Used to control the load more behaviour (16 by default)</p>
</li>
<li><p class="first">active -&gt; Boolean -&gt; Select the default group to use (false by default = All group)</p>
</li>
</ul>
</blockquote>
</li>
<li><p class="first">currency_field &gt; Model field used to format monetary values (currency_id by default)</p>
</li>
<li><p class="first">field_map &gt; Dictionary:</p>
<blockquote>
<ul class="simple">
<li>product -&gt; The field that represent the product (<cite>product_id</cite> by default)</li>
<li>name -&gt; The field that represent a name (name by default)</li>
<li>product_uom -&gt; The field that represent a product_uom (product_uom by default)</li>
<li>product_uom_qty -&gt; The field that represent a product_uom_qty (product_uom_qty by default)</li>
<li>price_unit -&gt; The field that represent a price_unit (price_unit by default)</li>
<li>discount -&gt; The field that represent a discount (discount by default)</li>
</ul>
</blockquote>
</li>
<li><p class="first">search &gt; Array of dictionaries (defines to use name_search by default)</p>
<blockquote>
<ul>
<li><p class="first">name -&gt; The name to display</p>
</li>
<li><p class="first">domain -&gt; The domain to use</p>
<blockquote>
<ul class="simple">
<li>$search -&gt; Replaces it with the current value of the searchbox</li>
<li>$number_search -&gt; Replaces all the leaf with the current value of the searchbox as a number</li>
</ul>
</blockquote>
</li>
<li><p class="first">name_search_value -&gt; Enables the use of name_search instead of search_read and defines the value to search ($search by default)</p>
</li>
<li><p class="first">operator -&gt; Operator used in name_search (ilike by default)</p>
</li>
</ul>
</blockquote>
</li>
<li><p class="first">edit_discount &gt; Enable/Disable discount edits (False by default)</p>
</li>
<li><p class="first">edit_price &gt; Enable/Disable price edits (True by default)</p>
</li>
<li><p class="first">show_discount &gt; Enable/Disable display discount (False by default)</p>
</li>
<li><p class="first">show_subtotal &gt; Enable/Disable show subtotal (True by default)</p>
</li>
<li><p class="first">auto_save &gt; Enable/Disable auto save (False by default)</p>
</li>
<li><p class="first">all_domain &gt; The domain used in All section ([] by default)</p>
<p>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.</p>
</li>
<li><p class="first">ignore_warning &gt; Enable/Disable display onchange warnings (False by default)</p>
</li>
<li><p class="first">instant_search &gt; Enable/Disable instant search mode (False by default)</p>
</li>
<li><p class="first">trigger_refresh_fields &gt; Fields in the main record that dispatch a widget refresh ([“partner_id”, “currency_id”] by default)</p>
</li>
</ul>
<p>All widget options are optional.
Notice that you can call _ method to use translations. This only can be used with this widget.</p>
<p>Example:</p>
<pre class="code literal-block">
options=&quot;{'search': [{'name': _('Starts With'), 'domain': [('name', '=like', '$search%')]}], 'groups': [{'name': 'cheap', 'string': _('Cheap'), 'domain': [('list_price', '&lt;', 10.0)], 'field_map': { 'product': 'my_product_id' }}]}&quot;
</pre>
</div>
<div class="section" id="default-context">
<h2><a class="toc-backref" href="#id4">Default context:</a></h2>
<p>The widget sends a defaults context with the search_read request:</p>
<blockquote>
<ul>
<li><p class="first">active_search_group_name &gt; Contains the name of the active search group</p>
<blockquote>
<ul class="simple">
<li>all &gt; Is the hard-coded name for the All group</li>
<li>main_lines &gt; Is the hard-coded name for the Lines group</li>
</ul>
</blockquote>
</li>
<li><p class="first">active_search_involved_fields &gt; Contains an array of dictionaries with the fields used with the searchbox content</p>
<blockquote>
<ul class="simple">
<li>type &gt; Can be text or number</li>
<li>field &gt; The field name</li>
<li>oper &gt; The operator used</li>
</ul>
</blockquote>
</li>
</ul>
</blockquote>
</div>
<div class="section" id="examples">
<h2><a class="toc-backref" href="#id5">Examples:</a></h2>
<p>This is an example that uses the sale.order.line fields:</p>
<pre class="code xml literal-block">
<span class="nt">&lt;field</span>
<span class="na">name=</span><span class="s">&quot;order_line&quot;</span>
<span class="na">attrs=</span><span class="s">&quot;{'readonly': [('state', 'in', ('done','cancel'))]}&quot;</span>
<span class="na">nolabel=</span><span class="s">&quot;1&quot;</span>
<span class="na">mode=</span><span class="s">&quot;form&quot;</span>
<span class="na">widget=</span><span class="s">&quot;one2many_product_picker&quot;</span>
<span class="na">options=</span><span class="s">&quot;{'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%')]}]}&quot;</span>
<span class="nt">&gt;</span>
<span class="nt">&lt;form&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;state&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;display_type&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;currency_id&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;discount&quot;</span> <span class="na">widget=</span><span class="s">&quot;numeric_step&quot;</span> <span class="na">options=</span><span class="s">&quot;{'max': 100}&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span><span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;price_unit&quot;</span> <span class="na">widget=</span><span class="s">&quot;numeric_step&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span><span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;name&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;product_id&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;order_id&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span><span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;product_uom_qty&quot;</span> <span class="na">class=</span><span class="s">&quot;mb-1&quot;</span> <span class="na">widget=</span><span class="s">&quot;numeric_step&quot;</span> <span class="na">context=</span><span class="s">&quot;{
'partner_id': parent.partner_id,
'quantity': product_uom_qty,
'pricelist': parent.pricelist_id,
'uom': product_uom,
'company_id': parent.company_id
}&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;product_uom&quot;</span> <span class="na">force_save=</span><span class="s">&quot;1&quot;</span> <span class="na">attrs=</span><span class="s">&quot;{
'readonly': [('state', 'in', ('sale','done', 'cancel'))],
'required': [('display_type', '=', False)],
}&quot;</span> <span class="na">context=</span><span class="s">&quot;{'company_id': parent.company_id}&quot;</span> <span class="na">class=</span><span class="s">&quot;mb-2&quot;</span> <span class="na">options=</span><span class="s">&quot;{'no_open': True, 'no_create': True, 'no_edit': True}&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;/form&gt;</span>
<span class="nt">&lt;/field&gt;</span>
</pre>
<p>** In this example we dont use field_map option because the default match with the sale.order.line field names.</p>
<p>Other example for purchase.order.line fields:</p>
<pre class="code xml literal-block">
<span class="nt">&lt;field</span>
<span class="na">name=</span><span class="s">&quot;order_line&quot;</span>
<span class="na">attrs=</span><span class="s">&quot;{'readonly': [('state', 'in', ('done','cancel'))]}&quot;</span>
<span class="na">nolabel=</span><span class="s">&quot;1&quot;</span>
<span class="na">widget=</span><span class="s">&quot;one2many_product_picker&quot;</span>
<span class="na">mode=</span><span class="s">&quot;form&quot;</span>
<span class="na">options=</span><span class="s">&quot;{'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']]}]}&quot;</span>
<span class="nt">&gt;</span>
<span class="nt">&lt;form&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;name&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;product_id&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;price_unit&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;currency_id&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;order_id&quot;</span> <span class="na">invisible=</span><span class="s">&quot;1&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;date_planned&quot;</span> <span class="na">class=</span><span class="s">&quot;mb-1&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;product_qty&quot;</span> <span class="na">class=</span><span class="s">&quot;mb-1&quot;</span> <span class="na">widget=</span><span class="s">&quot;numeric_step&quot;</span> <span class="na">required=</span><span class="s">&quot;1&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;product_uom&quot;</span> <span class="na">class=</span><span class="s">&quot;mb-2&quot;</span> <span class="na">options=</span><span class="s">&quot;{'no_open': True, 'no_create': True, 'no_edit': True}&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;/form&gt;</span>
<span class="nt">&lt;/field&gt;</span>
</pre>
</div>
<div class="section" id="boostrap-modifications">
<h2><a class="toc-backref" href="#id6">Boostrap Modifications:</a></h2>
<p>The product picker view container have a custom media queries space adding a new screen size called xxl (&gt;= 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.</p>
</div>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#id7">Usage</a></h1>
<p>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.</p>
<div class="section" id="parts-of-the-widget">
<h2><a class="toc-backref" href="#id8">Parts of the widget:</a></h2>
<blockquote>
<img alt="https://raw.githubusercontent.com/OCA/web/13.0/web_widget_one2many_product_picker/static/img/product_picker_anat.png" src="https://raw.githubusercontent.com/OCA/web/13.0/web_widget_one2many_product_picker/static/img/product_picker_anat.png" />
</blockquote>
</div>
<div class="section" id="preview">
<h2><a class="toc-backref" href="#id9">Preview:</a></h2>
<blockquote>
<img alt="https://raw.githubusercontent.com/OCA/web/13.0/web_widget_one2many_product_picker/static/img/product_picker.gif" src="https://raw.githubusercontent.com/OCA/web/13.0/web_widget_one2many_product_picker/static/img/product_picker.gif" />
</blockquote>
</div>
</div>
<div class="section" id="known-issues-roadmap">
<h1><a class="toc-backref" href="#id10">Known issues / Roadmap</a></h1>
<ul class="simple">
<li>Translations in the xml options attribute of the field that use the widget cant be exported automatically to be translated</li>
<li>The product card animations can be improved. Currently the card is recreated, so we lost some elements to apply correct effects.</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#id11">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/web/issues">GitHub Issues</a>.
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
<a class="reference external" href="https://github.com/OCA/web/issues/new?body=module:%20web_widget_one2many_product_picker%0Aversion:%2013.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#id12">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#id13">Authors</a></h2>
<ul class="simple">
<li>Tecnativa</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#id14">Contributors</a></h2>
<ul>
<li><p class="first"><a class="reference external" href="https://www.tecnativa.com">Tecnativa</a>:</p>
<blockquote>
<ul class="simple">
<li>Alexandre D. Díaz</li>
<li>Pedro M. Baeza</li>
<li>David Vidal</li>
</ul>
</blockquote>
</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#id15">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>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.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/web/tree/13.0/web_widget_one2many_product_picker">OCA/web</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View File

@ -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,
};
});

View File

@ -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 =
"<templates><t t-name='One2ManyProductPicker.QuickCreateForm'>";
template += this.basicFieldParams.field.views.form.arch;
template += "</t></templates>";
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;
});

View File

@ -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,
};
}
);

View File

@ -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 =
"<templates><t t-name='One2ManyProductPicker.QuickModifPrice.Form'>";
template += this.basicFieldParams.field.views.form.arch;
template += "</t></templates>";
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 = "<form><group>";
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 += "</group></form>";
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;
}
);

View File

@ -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,
};
}
);

View File

@ -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 = $("<DIV/>", {
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;
}
);

View File

@ -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
);
}
});
},
});
});

View File

@ -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,
});
},
});
});

View File

@ -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);
},
});
});

View File

@ -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;
});

View File

@ -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;

View File

@ -0,0 +1,6 @@
$one2many-product-picker-grid-breakpoints: map-merge(
$grid-breakpoints,
(
xxl: 1440px,
)
);

View File

@ -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;
}
}
}
}

View File

@ -0,0 +1,229 @@
<template>
<t t-name="One2ManyProductPicker.ControlPanelButtons">
<div class="text-center mx-auto">
<div class="input-group">
<div class="input-group-prepend">
<t t-if="search_category_names">
<button
class="btn btn-secondary dropdown-toggle"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
<i class="fa fa-search" />
</button>
<div class="dropdown-menu">
<t t-foreach="search_category_names" t-as="name">
<a
t-attf-class="dropdown-item search_mode_option {{name_index == search_mode ? 'active' : ''}}"
href="#"
t-esc="name"
/>
</t>
</div>
</t>
<t t-else="">
<button
type="button"
t-attf-class="btn btn-secondary btn-lg input-group-button"
>
<i class="fa fa-search" />
</button>
</t>
</div>
<input
type="search"
class="form-control form-control-lg oe_search_input"
placeholder="Search..."
aria-label="Search..."
aria-describedby="btnGroupAddon2"
/>
<div class="input-group-append">
<button
id="product_picker_maximize"
class='o_fullscreen btn btn-primary'
>
<i class='fa fa-expand' />
</button>
</div>
</div>
</div>
</t>
<t t-name="One2ManyProductPicker.ControlPanelGroupButtons">
<div class="oe_one2many_product_picker_groups">
<div class="btn-group" role="group" aria-label="Groups">
<t t-foreach="groups" t-as="group">
<button
type="button"
t-att-name="group.name"
t-attf-class="btn btn-lg btn-secondary rounded-0 border-top-0 {{group.active &amp;&amp; 'active' || ''}} oe_btn_search_group"
t-att-data-group="group_index"
>
<t t-esc="group.string" />
</button>
</t>
</div>
<button type="button" class="btn btn-light btn-lg oe_btn_lines">
Lines
<span class="ml-1 badge badge-light">0</span>
</button>
</div>
</t>
<t t-name="One2ManyProductPicker.Total">
<div id="product_picker_total" class="text-right">
<h2>
Subtotal:
<span class="total_price" />
</h2>
</div>
</t>
<t t-name="One2ManyProductPicker.ExtraButtons">
<div class="w-100 row">
<button
id="productPickerLoadMore"
class="btn btn-lg btn-secondary m-auto d-none"
>Load More</button>
</div>
</t>
<t t-name="One2ManyProductPicker.ActionButton">
<div class="safezone d-inline-block float-left m-0 pb-2 pr-2 text-left">
<t t-if="is_saving &amp;&amp; lazy_qty > 0">
<span
class="badge record_saving badge-warning font-weight-bold rounded-0 mt-0 px-2 py-3 product_qty"
><span class="lazy_product_qty" t-esc="lazy_qty || '1'" /> x <t
t-esc="state.data[field_map[field_uom]].data.display_name"
/></span>
</t>
<t t-elif="!is_virtual">
<span
t-att-data-field="field_map[field_uom_qty]"
t-attf-data-esc="'{{floatFixed(field_map[field_uom_qty])}}' + ' x ' + {{field_map[field_uom]}}.data.display_name"
t-attf-class="badge {{modified &amp;&amp; 'badge-warning' || 'badge-success'}} font-weight-bold rounded-0 mt-0 px-2 py-3 product_qty"
/>
</t>
<t t-else="">
<span
class="badge badge-primary font-weight-bold rounded-0 mt-0 px-2 py-3 add_product"
><i class="fa fa-plus" /> 1 <t
t-esc="state.data[field_map[field_uom]].data.display_name"
/></span>
</t>
</div>
</t>
<t t-name="One2ManyProductPicker.PriceZone">
<div class="position-absolute m-0 text-left badge_price">
<t t-if="show_discount">
<span
t-att-data-field="field_map.discount"
t-attf-data-esc="str({{field_map.discount}} * -1.0) +'%'"
class="badge badge-dark discount_price font-weight-bold rounded-0 mt-1 p-2"
/>
<span
t-att-data-field="field_map.price_unit"
t-attf-data-esc="'{{monetary('price_unit',true)}}'"
class="badge font-weight-bold rounded-0 original_price"
/>
<span
t-if="has_onchange"
class="badge badge-info price_unit font-weight-bold rounded-0 mt-1 p-2"
/>
</t>
<t t-else="has_onchange">
<span
t-att-data-field="field_map.price_unit"
t-attf-data-esc="'{{monetary('price_unit',true)}}'"
class="badge badge-info price_unit font-weight-bold rounded-0 mt-1 p-2"
/>
</t>
</div>
</t>
<t t-name="One2ManyProductPicker.FlipCard.Front">
<div
t-attf-class="oe_flip_card_front p-0 {{((modified || is_saving) &amp;&amp; 'border-warning') || (state &amp;&amp; !is_virtual &amp;&amp; 'border-success') || ''}}"
>
<t t-if="state">
<div class="indicator_zones float-left">
<t t-call="One2ManyProductPicker.ActionButton" />
<t t-call="One2ManyProductPicker.PriceZone" />
</div>
<span
data-field="display_name"
class="oe_one2many_product_picker_title position-absolute fixed-bottom p-1"
data-esc="display_name"
/>
<img
alt=""
class="img img-fluid"
t-att-src="image(state.data[field_map.product].data.id,'image_128')"
t-att-data-src-alt="image(state.data[field_map.product].data.id,'image_1024')"
/>
</t>
<t t-else="">
<span
class="oe_one2many_product_picker_title position-absolute fixed-bottom p-1"
t-esc="record_search.display_name"
/>
<img
alt=""
class="img img-fluid"
t-att-src="image(record_search.id,'image_128')"
t-att-data-src-alt="image(record_search.id,'image_1024')"
/>
</t>
</div>
</t>
<t t-name="One2ManyProductPicker.FlipCard.Back">
<div class="oe_flip_card_back">
<widget
name="product_picker_quick_create_form"
t-att-compare-key="field_map.product_uom"
/>
</div>
</t>
<t t-name="One2ManyProductPicker.FlipCard">
<div
class="oe_flip_container p-1 col-12 col-sm-8 col-md-6 col-lg-4 col-xl-3 col-xxl-2"
t-att-data-card-id="state &amp;&amp; state.id || record_search.id"
>
<div
t-attf-class="oe_flip_card {{!state &amp;&amp; 'disabled' || (auto_save &amp;&amp; (!is_virtual || is_saving) &amp;&amp; !state.data.id &amp;&amp; 'blocked') || ''}}"
>
<div class="oe_flip_card_inner text-center">
<t t-call="One2ManyProductPicker.FlipCard.Front" />
<t t-call="One2ManyProductPicker.FlipCard.Back" />
</div>
</div>
</div>
</t>
<t t-name="One2ManyProductPicker.QuickModifPrice.Modal">
<div
class="oe_product_picker_quick_modif_price modal fade"
id="One2ManyProductPickerQuickModifPrice"
tabindex="-1"
role="dialog"
aria-labelledby="One2ManyProductPickerQuickModifPriceLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body p-0" />
<div class="modal-footer">
<button
class="btn btn-success oe_record_change mr-auto"
data-dismiss="modal"
>
<i class="fa fa-check" />
</button>
<button
class="btn btn-warning oe_record_discard"
data-dismiss="modal"
>
<i class="fa fa-times" />
</button>
</div>
</div>
</div>
</div>
</t>
</template>

View File

@ -0,0 +1,25 @@
<template>
<t t-name="One2ManyProductPicker.QuickCreate.FormButtons">
<div class="oe_one2many_product_picker_form_buttons">
<t t-if="state == 'new'">
<button t-attf-class="btn btn-primary oe_record_add">Add</button>
</t>
<t t-elif="state == 'dirty'">
<button class="btn btn-success oe_record_change w-100">
<i class="fa fa-check" />
</button>
<button class="btn btn-warning oe_record_discard ml-1">
<i class="fa fa-times" />
</button>
</t>
<t t-else="">
<button class="btn btn-danger oe_record_remove w-100"><i
class="fa fa-trash"
/> Remove</button>
<button class="btn btn-warning oe_record_discard ml-1">
<i class="fa fa-times" />
</button>
</t>
</div>
</t>
</template>

View File

@ -0,0 +1,8 @@
<template>
<t t-name="One2ManyProductPicker.QuickModifPrice.Price">
<div class="text-left">
<strong>Price</strong>
<div class="oe_price" />
</div>
</t>
</template>

View File

@ -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 (
"<form>" +
'<field name="currency_id" invisible="1" />' +
"<field name=\"line_ids\" widget=\"one2many_product_picker\" options=\"{'groups': [{'name': 'Desk', 'domain': [('name', 'ilike', '%desk%')], 'order': {'name': 'id', 'asc': true}}, {'name': 'Chairs', 'domain': [('name', 'ilike', '%chair%')]}]}\">" +
"<kanban>" +
'<field name="name" />' +
'<field name="product_id" />' +
'<field name="price_reduce" />' +
'<field name="price_unit" />' +
'<field name="foo_id" />' +
'<field name="product_uom_qty" />' +
'<field name="product_uom" />' +
"</kanban>" +
"</field>" +
"</form>"
);
};
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();
});
}
);
});

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<template id="_assets_backend_helpers" inherit_id="web._assets_backend_helpers">
<xpath expr=".">
<link
type="text/css"
rel="stylesheet"
href="/web_widget_one2many_product_picker/static/src/scss/_variables.scss"
/>
</xpath>
</template>
<template id="_assets_bootstrap" inherit_id="web._assets_bootstrap">
<xpath expr="link[2]">
<link
type="text/css"
rel="stylesheet"
href="/web_widget_one2many_product_picker/static/src/scss/main_variables.scss"
/>
</xpath>
</template>
<template id="assets_backend" name="account assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link
type="text/css"
rel="stylesheet"
href="/web_widget_one2many_product_picker/static/src/scss/one2many_product_picker.scss"
/>
<script
type="text/javascript"
src="/web_widget_one2many_product_picker/static/src/js/tools.js"
/>
<script
type="text/javascript"
src="/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/record.js"
/>
<script
type="text/javascript"
src="/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/renderer.js"
/>
<script
type="text/javascript"
src="/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_create_form_view.js"
/>
<script
type="text/javascript"
src="/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_create_form.js"
/>
<script
type="text/javascript"
src="/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_modif_price_form_view.js"
/>
<script
type="text/javascript"
src="/web_widget_one2many_product_picker/static/src/js/views/One2ManyProductPicker/quick_modif_price_form.js"
/>
<script
type="text/javascript"
src="/web_widget_one2many_product_picker/static/src/js/views/basic_view.js"
/>
<script
type="text/javascript"
src="/web_widget_one2many_product_picker/static/src/js/views/basic_model.js"
/>
<script
type="text/javascript"
src="/web_widget_one2many_product_picker/static/src/js/views/basic_controller.js"
/>
<script
type="text/javascript"
src="/web_widget_one2many_product_picker/static/src/js/widgets/field_one2many_product_picker.js"
/>
</xpath>
</template>
<template id="qunit_suite" name="base_import_tests" inherit_id="web.qunit_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script
type="text/javascript"
src="/web_widget_one2many_product_picker/static/tests/widget_tests.js"
/>
</xpath>
</template>
</odoo>