diff --git a/web_advanced_search/README.rst b/web_advanced_search/README.rst index 2b566350..3c01c0a6 100644 --- a/web_advanced_search/README.rst +++ b/web_advanced_search/README.rst @@ -1,6 +1,6 @@ -.. image:: https://img.shields.io/badge/license-LGPL--3-blue.svg - :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html - :alt: License: LGPL-3 +.. image:: https://img.shields.io/badge/license-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 ========================================= Search for x2x records in advanced search @@ -51,7 +51,6 @@ Contributors * Vicent Cubells * Jairo Llopis * Rami Alwafaie -* Jose Mª Bernet Maintainer ---------- diff --git a/web_advanced_search/__init__.py b/web_advanced_search/__init__.py index 723825bd..e69de29b 100644 --- a/web_advanced_search/__init__.py +++ b/web_advanced_search/__init__.py @@ -1,3 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2015 Therp BV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). diff --git a/web_advanced_search/__openerp__.py b/web_advanced_search/__manifest__.py similarity index 63% rename from web_advanced_search/__openerp__.py rename to web_advanced_search/__manifest__.py index e0bfb898..32cf5a28 100644 --- a/web_advanced_search/__openerp__.py +++ b/web_advanced_search/__manifest__.py @@ -1,24 +1,27 @@ # Copyright 2015 Therp BV # Copyright 2017 Tecnativa - Vicent Cubells +# Copyright 2018 Tecnativa - Jairo Llopis # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { - "name": "Search x2x fields", + "name": "Advanced search", "version": "11.0.1.0.0", "author": "Therp BV, " "Tecnativa, " "Odoo Community Association (OCA)", "license": "AGPL-3", "category": "Usability", - "summary": "Use a search widget in advanced search for x2x fields", - "depends": [], + "summary": "Easier and more powerful searching tools", + "website": "https://github.com/OCA/web", + "depends": [ + 'web', + ], "data": [ 'views/templates.xml', ], "qweb": [ - 'static/src/xml/web_advanced_search_x2x.xml', + 'static/src/xml/web_advanced_search.xml', ], - "auto_install": False, "installable": True, "application": False, } diff --git a/web_advanced_search/i18n/web_advanced_search_x2x.pot b/web_advanced_search/i18n/web_advanced_search.pot similarity index 100% rename from web_advanced_search/i18n/web_advanced_search_x2x.pot rename to web_advanced_search/i18n/web_advanced_search.pot diff --git a/web_advanced_search/readme/CONTRIBUTORS.rst b/web_advanced_search/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..4920464c --- /dev/null +++ b/web_advanced_search/readme/CONTRIBUTORS.rst @@ -0,0 +1,5 @@ +* Holger Brunn +* Vicent Cubells +* Jairo Llopis +* Rami Alwafaie +* Jose Mª Bernet diff --git a/web_advanced_search/readme/DESCRIPTION.rst b/web_advanced_search/readme/DESCRIPTION.rst new file mode 100644 index 00000000..bd4c29fd --- /dev/null +++ b/web_advanced_search/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +More powerful and easy to use search, especially for related fields. diff --git a/web_advanced_search/readme/ROADMAP.rst b/web_advanced_search/readme/ROADMAP.rst new file mode 100644 index 00000000..122e1efb --- /dev/null +++ b/web_advanced_search/readme/ROADMAP.rst @@ -0,0 +1,14 @@ +Improvements to the ``domain`` widget, not exclusively related to this addon: + +* Use relational widgets when filtering a relational field +* Allow to filter field names + +Improvements to the search view in this addon: + +* Use widgets ``one2many_tags`` when searching ``one2many`` fields +* Use widgets ``many2many_tags`` when searching ``many2many`` fields +* Allow to edit current full search using the advanced domain editor +* Allow to edit individually any facet from current search using the + advanced domain editor +* Beautiful, human-readable, domain representation when adding an + advanced filter diff --git a/web_advanced_search/readme/USAGE.rst b/web_advanced_search/readme/USAGE.rst new file mode 100644 index 00000000..b8e6f277 --- /dev/null +++ b/web_advanced_search/readme/USAGE.rst @@ -0,0 +1,21 @@ +To use this module, you need to: + +* Open *Filters* in a search view +* Select any relational field +* Select operator `is equal to` or `is not equal to` +* The text field changes to a relational selection field where you + can search for the record in question +* Click *Apply* + +To search for properties of linked records (ie invoices for customers +with a credit limit higher than X): + +* Open *Filters* in a search view +* Select *Add Advanced Filter* +* Edit the advanced filter +* Click *Save* + +Note that you can stack searching for properties: Simply add another +advanced search in the selection search window. You can do +this indefinetely, so it is possible to search for moves belonging +to a journal which has a user who is member of a certain group etc. diff --git a/web_advanced_search/static/src/css/web_advanced_search.less b/web_advanced_search/static/src/css/web_advanced_search.less new file mode 100644 index 00000000..796a756f --- /dev/null +++ b/web_advanced_search/static/src/css/web_advanced_search.less @@ -0,0 +1,13 @@ +.o_search_options { + .o_filters_menu { + .o_filter_condition { + max-width: inherit; + + .o_searchview_extended_prop_value { + .o_field_domain { + min-width: 30vw; + } + } + } + } +} diff --git a/web_advanced_search/static/src/css/web_advanced_search_x2x.less b/web_advanced_search/static/src/css/web_advanced_search_x2x.less deleted file mode 100644 index 57a0906c..00000000 --- a/web_advanced_search/static/src/css/web_advanced_search_x2x.less +++ /dev/null @@ -1,27 +0,0 @@ -.o_search_options { - - .o_filters_menu { - .o_filter_condition { - max-width: inherit; - - .o_searchview_extended_prop_value { - .ui-autocomplete-input { - .form-control(); - } - - .oe_m2o_drop_down_button { - top: 6px; - right: 2px; - } - - .o_form_field_domain { - min-width: 400px; - } - } - } - } -} - -.x2x_container { - min-width: 60ex; -} diff --git a/web_advanced_search/static/src/js/human_domain.js b/web_advanced_search/static/src/js/human_domain.js new file mode 100644 index 00000000..82aefc5c --- /dev/null +++ b/web_advanced_search/static/src/js/human_domain.js @@ -0,0 +1,86 @@ +/* Copyright 2018 Tecnativa - Jairo Llopis + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */ + + odoo.define("web_advanced_search.human_domain", function (require) { + "use strict"; + + var DomainSelector = require("web.DomainSelector"); + + var join_mapping = { + "&": _(" and "), + "|": _(" or "), + "!": _(" is not "), + }; + + // HACK I should extend classes, but they are not exposed + // TODO Remove file when merged https://github.com/odoo/odoo/pull/25922 + var human_domain_methods = { + DomainTree: function () { + var human_domains = []; + _.each(this.children, function (child) { + human_domains.push( + human_domain_methods[child.template].apply(child) + ); + }); + return _.str.sprintf( + "(%s)", + human_domains.join(join_mapping[this.operator]) + ); + }, + + DomainSelector: function () { + var result = human_domain_methods.DomainTree.apply(this, arguments); + // Remove surrounding parenthesis + return result.slice(1, -1); + }, + + DomainLeaf: function () { + var chain = [], + operator = this.operator_mapping[this.operator], + value = _.str.sprintf('"%s"', this.value); + // Humanize chain + this.chain.split(".").forEach(function (element, index) { + chain.push( + _.findWhere( + this.fieldSelector.pages[index], + {name: element} + ).string || element + ); + }, this); + // Special beautiness for some values + if (this.operator === "=" && _.isBoolean(this.value)) { + operator = this.operator_mapping[this.value ? "set" : "not set"]; + value = ""; + } else if (_.isArray(this.value)) { + value = _.str.sprintf('["%s"]', this.value.join('", "')); + } + return _.str.sprintf( + "%s %s %s", + chain.join("→"), + operator || this.operator, + value + ).trim(); + }, + }; + + function getHumanDomain (parent, model, domain, options) { + var domain_selector = new DomainSelector( + parent, + model, + domain, + options + ); + var dummy_parent = $("
"); + domain_selector.appendTo(dummy_parent); + var result = human_domain_methods.DomainSelector.apply( + domain_selector + ); + domain_selector.destroy(); + dummy_parent.destroy(); + return result; + } + + return { + getHumanDomain: getHumanDomain, + }; +}); diff --git a/web_advanced_search/static/src/js/web_advanced_search.js b/web_advanced_search/static/src/js/web_advanced_search.js new file mode 100644 index 00000000..ff4376cb --- /dev/null +++ b/web_advanced_search/static/src/js/web_advanced_search.js @@ -0,0 +1,293 @@ +/* Copyright 2015 Therp BV + * Copyright 2017-2018 Jairo Llopis + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */ + +odoo.define("web_advanced_search", function (require) { + "use strict"; + + var core = require("web.core"); + var Domain = require("web.Domain"); + var DomainSelectorDialog = require("web.DomainSelectorDialog"); + var field_registry = require("web.field_registry"); + var FieldManagerMixin = require("web.FieldManagerMixin"); + var FilterMenu = require("web.FilterMenu"); + var human_domain = require("web_advanced_search.human_domain"); + var SearchView = require("web.SearchView"); + var Widget = require("web.Widget"); + var Char = core.search_filters_registry.get("char"); + + SearchView.include({ + custom_events: _.extend({}, SearchView.prototype.custom_events, { + "get_dataset": "_on_get_dataset", + }), + + /** + * Add or update a `dataset` attribute in event target + * + * The search view dataset includes things such as the model, which + * is required to make some parts of search views smarter. + * + * @param {OdooEvent} event The target will get the dataset. + */ + _on_get_dataset: function (event) { + event.target.dataset = this.dataset; + event.stopPropagation(); + }, + }); + + /** + * An almost dummy search proposition, to use with domain widget + */ + var AdvancedSearchProposition = Widget.extend({ + init: function (parent, model, domain) { + this._super(parent); + this.model = model; + this.domain = new Domain(domain); + }, + + get_filter: function () { + var domain_array = this.domain.toArray(); + return { + attrs: { + domain: domain_array, + // TODO Remove when merged + // https://github.com/odoo/odoo/pull/25922 + string: human_domain.getHumanDomain( + this, + this.model, + domain_array + ), + }, + children: [], + tag: "filter", + }; + }, + }); + + // Add advanced search features + FilterMenu.include({ + custom_events: _.extend({}, FilterMenu.prototype.custom_events, { + "domain_selected": "advanced_search_commit", + }), + + events: _.extend({}, FilterMenu.prototype.events, { + "click .o_add_advanced_search": "advanced_search_open", + }), + + init: function () { + this._super.apply(this, arguments); + this.trigger_up("get_dataset"); + }, + + /** + * Open advanced search dialog + * + * @returns {$.Deferred} The opening dialog itself. + */ + advanced_search_open: function () { + var domain_selector_dialog = new DomainSelectorDialog( + this, + this.dataset.model, + "[]", + { + debugMode: core.debug, + readonly: false, + } + ); + // Add 1st domain node by default + domain_selector_dialog.domainSelector._onAddFirstButtonClick(); + return domain_selector_dialog.open(); + }, + + /** + * Apply advanced search on dialog save + * + * @param {OdooEvent} event A `domain_selected` event from the dialog. + */ + advanced_search_commit: function (event) { + _.invoke(this.propositions, "destroy"); + var proposition = new AdvancedSearchProposition( + this, + this.dataset.model, + event.data.domain + ); + this.propositions = [proposition]; + this.commit_search(); + }, + }); + + /** + * A search field for relational fields. + * + * It implements and extends the `FieldManagerMixin`, and acts as if it + * were a reduced dummy controller. Some actions "mock" the underlying + * model, since sometimes we use a char widget to fill related fields + * (which is not supported by that widget), and fields need an underlying + * model implementation, which can only hold fake data, given a search view + * has no data on it by definition. + */ + var Relational = Char.extend(FieldManagerMixin, { + tagName: "div", + className: "x2x_container", + attributes: {}, + + init: function () { + this._super.apply(this, arguments); + // To make widgets work, we need a model and an empty record + FieldManagerMixin.init.call(this); + this.trigger_up("get_dataset"); + // Make equal and not equal appear 1st and 2nd + this.operators = _.sortBy( + this.operators, + function(op) { + switch(op.value) { + case "=": + return -2; + case "!=": + return -1; + default: + return 0; + } + }); + // Create dummy record with only the field the user is searching + var params = { + fieldNames: [this.field.name], + modelName: this.dataset.model, + context: this.dataset.context, + // res_id: "virtual_0", + fields: {}, + type: "record", + viewType: "default", + fieldsInfo: { + default: {}, + }, + }; + // See https://stackoverflow.com/a/11508530/1468388 + params.fields[this.field.name] = _.omit(this.field, "onChange"); + params.fieldsInfo.default[this.field.name] = {}; + // Emulate `model.load()`, without RPC-calling `default_get()` + this.datapoint_id = this.model._makeDataPoint(params).id; + this.model.applyDefaultValues( + this.datapoint_id, + {}, + params.fieldNames + ); + // To generate a new fake ID + this._fake_id = -1; + }, + + start: function () { + var result = this._super.apply(this, arguments); + // Render the initial widget + result.done($.proxy(this, "show_inputs", $(""))); + return result; + }, + + destroy: function () { + if (this._field_widget) { + this._field_widget.destroy(); + } + this.model.destroy(); + delete this.record; + return this._super.apply(this, arguments); + }, + + _get_record: function () { + return this.model.get(this.datapoint_id); + }, + + show_inputs: function ($operator) { + // Get widget class to be used + switch ($operator.val()) { + case "=": + case "!=": + this._field_widget_name = "many2one"; + break; + default: + this._field_widget_name = "char"; + } + var _Widget = field_registry.get(this._field_widget_name); + // Destroy previous widget, if any + if (this._field_widget) { + this._field_widget.destroy(); + delete this._field_widget; + } + // Create new widget + var options = { + mode: "edit", + attrs: { + options: { + no_create_edit: true, + no_create: true, + no_open: true, + no_quick_create: true, + }, + }, + }; + this._field_widget = new _Widget( + this, + this.field.name, + this._get_record(), + options + ); + this._field_widget.appendTo(this.$el); + return this._super.apply(this, arguments); + }, + + _applyChanges: function (dataPointID, changes, event) { + // Make char updates look like valid x2one updates + if (_.isNaN(changes[this.field.name].id)) { + changes[this.field.name] = { + id: this._fake_id--, + display_name: event.target.lastSetValue, + }; + } + return FieldManagerMixin._applyChanges.apply(this, arguments); + }, + + _confirmChange: function (id, fields, event) { + this.datapoint_id = id; + return this._field_widget.reset(this._get_record(), event); + }, + + get_value: function () { + try { + switch (this._field_widget_name) { + case "many2one": + return this._field_widget.value.res_id; + default: + return this._field_widget.value.data.display_name; + } + } catch (error) { + if (error.name === "TypeError") { + return false; + } + } + }, + + toString: function () { + try { + switch (this._field_widget_name) { + case "many2one": + return this._field_widget.value.data.display_name; + } + return this._super.apply(this, arguments); + } catch (error) { + if (error.name === "TypeError") { + return ""; + } + } + }, + }); + + // Register search filter widgets + core.search_filters_registry + .add("many2many", Relational) + .add("many2one", Relational) + .add("one2many", Relational); + + return { + AdvancedSearchProposition: AdvancedSearchProposition, + Relational: Relational, + }; +}); diff --git a/web_advanced_search/static/src/js/web_advanced_search_x2x.js b/web_advanced_search/static/src/js/web_advanced_search_x2x.js deleted file mode 100644 index 7f78dc7b..00000000 --- a/web_advanced_search/static/src/js/web_advanced_search_x2x.js +++ /dev/null @@ -1,185 +0,0 @@ -/* Copyright 2015 Therp BV - * Copyright 2017 Jairo Llopis - * Copyright 2018 Jose Mª Bernet - * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */ - -odoo.define('web_advanced_search_x2x', function (require) { - "use strict"; - - var core = require('web.core'); - var DomainSelector = require('web.DomainSelector'); - var Domain = require("web.Domain"); - var FieldManagerMixin = require('web.FieldManagerMixin'); - var Char = core.search_filters_registry.get("char"); - - var X2XAdvancedSearchPropositionMixin = { - template: "web_advanced_search_x2x.proposition", - events: { - // If click on the node add or delete button, notify the parent and let - // it handle the addition/removal - "click .o_domain_tree_operator_caret": "_openCaret" - }, - - _openCaret: function (e) { - var selectorClass = $('.o_domain_tree_operator_selector'); - if (selectorClass.hasClass('open')) { - selectorClass.removeClass('open'); - } else { - selectorClass.addClass('open'); - } - }, - - init: function (parent, options) { - // Make equal and not equal appear 1st and 2nd - this.relation = options.relation; - this.type = options.type; - this.field_name = options.name; - this.name = parent.name; - - this.operators = _.sortBy( - this.operators, - function (op) { - switch (op.value) { - case '=': - return -2; - case '!=': - return -1; - default: - return 0; - } - }); - - // Append domain operator - this.operators.push({ - 'value': 'domain', 'text': core._lt('is in selection'), - }); - // Avoid hiding filter when using special widgets - this.events = $.extend({}, this.events, { - click: function (event) { - event.stopPropagation(); - } - }); - return this._super.apply(this, arguments); - }, - - get_field_desc: function () { - return this.field; - }, - - /** - * Add x2x widget after rendering. - */ - renderElement: function () { - var result = this._super.apply(this, arguments); - if (this.x2x_widget_name()) { - this.x2x_field().appendTo(this.$el); - } - return result; - }, - - /** - * Re-render widget when operator changes. - */ - show_inputs: function () { - this.renderElement(); - return this._super.apply(this, arguments); - }, - - /** - * Create a relational field for the user. - * - * @return {Field} - */ - x2x_field: function () { - if (this._x2x_field) { - this._x2x_field.destroy(); - delete this._x2x_field; - } - var widget = this.x2x_widget(); - if (!widget) return; - this._x2x_field = new DomainSelector(this, this.relation, [], {readonly: false}); - return this._x2x_field; - }, - x2x_value_changed: function () { - switch (this.x2x_widget_name()) { - case "char": - // Apply domain when selected - this.getParent().getParent().commit_search(); - break; - } - }, - - x2x_widget: function () { - var name = this.x2x_widget_name(); - return name && core.search_filters_registry.get(name); - }, - - /** - * Return the widget that should be used to render this proposition. - * - * If it returns `undefined`, it means you should use a simple - * ``. - */ - - x2x_widget_name: function () { - switch (this.get_operator()) { - case "=": - case "!=": - return undefined; - case "domain": - return "many2one"; - } - }, - - get_domain: function () { - // Special way to get domain if user chose "domain" filter - if (this.get_operator() == "domain") { - var domain = this._x2x_field.getDomain(); - var field_name = this.field_name; - - $.each(domain, function (index, value) { - if (domain[index].constructor == Array) { - domain[index][0] = field_name + '.' + domain[index][0] - } - }); - - return domain; - } else { - return this._super.apply(this, arguments); - } - }, - - get_operator: function () { - return !this.isDestroyed() && - this.getParent().$('.o_searchview_extended_prop_op').val(); - }, - - get_value: function () { - try { - if (!this.x2x_widget_name()) { - throw "No x2x widget, fallback to default"; - } - var domain = this._x2x_field.getDomain(); - return Domain.prototype.arrayToString(domain) - } catch (error) { - return this._super.apply(this, arguments); - } - } - }; - - var affected_types = ["one2many", "many2one", "many2many"], - X2XAdvancedSearchProposition = Char.extend( - FieldManagerMixin, - X2XAdvancedSearchPropositionMixin - ); - - // Register this search proposition for relational fields - $.each(affected_types, function (index, value) { - core.search_filters_registry.add(value, X2XAdvancedSearchProposition); - }); - - return { - X2XAdvancedSearchPropositionMixin: X2XAdvancedSearchPropositionMixin, - X2XAdvancedSearchProposition: X2XAdvancedSearchProposition, - }; -}); diff --git a/web_advanced_search/static/src/xml/web_advanced_search.xml b/web_advanced_search/static/src/xml/web_advanced_search.xml new file mode 100644 index 00000000..ed4e0bbc --- /dev/null +++ b/web_advanced_search/static/src/xml/web_advanced_search.xml @@ -0,0 +1,13 @@ + + + + + +
  • +
  • + Add Advanced Filter +
  • +
    +
    +
    diff --git a/web_advanced_search/static/src/xml/web_advanced_search_x2x.xml b/web_advanced_search/static/src/xml/web_advanced_search_x2x.xml deleted file mode 100644 index 78a79a1d..00000000 --- a/web_advanced_search/static/src/xml/web_advanced_search_x2x.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - -
    - - - - - - diff --git a/web_advanced_search/views/templates.xml b/web_advanced_search/views/templates.xml index 43eebace..9409c0e7 100644 --- a/web_advanced_search/views/templates.xml +++ b/web_advanced_search/views/templates.xml @@ -1,12 +1,13 @@ - -