From 92f24f2cfee293c80a1c831dd6b8fb542476f22c Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Fri, 10 Feb 2017 12:35:19 +0100 Subject: [PATCH] [IMP] web: adapt field domain to new API Add the implementation of the "FieldDomain" (widget='domain') in basic fields file. --- .../web/static/src/js/fields/basic_fields.js | 224 ++++++++++++++++++ .../static/src/js/fields/field_registry.js | 3 +- .../static/src/js/widgets/domain_selector.js | 6 + .../src/js/widgets/model_field_selector.js | 72 +++--- addons/web/static/src/xml/base.xml | 20 +- .../static/test/fields/basic_fields_tests.js | 176 ++++++++++++-- 6 files changed, 444 insertions(+), 57 deletions(-) diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 34c2b08e362..2dedec8978d 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -14,10 +14,14 @@ var core = require('web.core'); var crash_manager = require('web.crash_manager'); var datepicker = require('web.datepicker'); var dom = require('web.dom'); +var Domain = require('web.Domain'); +var DomainSelector = require('web.DomainSelector'); +var DomainSelectorDialog = require('web.DomainSelectorDialog'); var field_utils = require('web.field_utils'); var framework = require('web.framework'); var session = require('web.session'); var utils = require('web.utils'); +var view_dialogs = require('web.view_dialogs'); var qweb = core.qweb; var _t = core._t; @@ -1188,6 +1192,225 @@ var JournalDashboardGraph = AbstractField.extend({ }); +/** + * The "Domain" field allows the user to construct a technical-prefix domain + * thanks to a tree-like interface and see the selected records in real time. + * In debug mode, an input is also there to be able to enter the prefix char + * domain directly (or to build advanced domains the tree-like interface does + * not allow to). + */ +var FieldDomain = AbstractField.extend({ + /** + * The model this field widget works with could be dependant on another + * field value. This field widget must then be reset when a field is changed + * in the view. + */ + resetOnAnyFieldChange: true, + + events: _.extend({}, AbstractField.prototype.events, { + "click .o_domain_show_selection_button": "_onShowSelectionButtonClick", + "click .o_form_field_domain_dialog_button": "_onDialogEditButtonClick", + }), + custom_events: { + "domain_changed": "_onDomainSelectorValueChange", + "domain_selected": "_onDomainSelectorDialogValueChange", + }, + /** + * @constructor + * @override init from AbstractField + */ + init: function () { + this._super.apply(this, arguments); + + this.inDialog = !!this.nodeOptions.in_dialog; + this.modelOption = this.nodeOptions.model; + this.fsFilters = this.nodeOptions.fs_filters || {}; + + this.className = "o_form_field_domain"; + if (this.mode === "edit") { + this.className += " o_edit_mode"; + } + if (!this.inDialog) { + this.className += " o_inline_mode"; + } + + this._initModel(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override isValid from AbstractField.isValid + * Parsing the char value is not enough for this field. It is considered + * valid if the internal domain selector was built correctly and that the + * query to the model to test the domain did not fail. + * + * @returns {boolean} + */ + isValid: function () { + return ( + this._super.apply(this, arguments) + && this._isValidForModel + ); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @override _render from AbstractField + * @returns {Deferred} + */ + _render: function () { + // If there is no model, only change the non-domain-selector content + if (!this.domainModel) { + this._replaceContent(); + return $.when(); + } + + // Convert char value to array value + var value = this.value || "[]"; + var domain = Domain.prototype.stringToArray(value); + + // Create the domain selector or change the value of the current one... + var def; + if (!this.domainSelector) { + this.domainSelector = new DomainSelector(this, this.domainModel, domain, { + readonly: this.mode === "readonly" || this.inDialog, + filters: this.fsFilters, + debugMode: session.debug, + }); + def = this.domainSelector.prependTo(this.$el); + } else { + def = this.domainSelector.setDomain(domain); + } + // ... then query the model to get the number of matched record if valid + var self = this; + return def.then(function () { + var context = {}; // FIXME ? + return self.performModelRPC(self.domainModel, "search_count", [ + domain, + context + ]).then( + function (data) { + self._isValidForModel = true; + return data; + }, + function (error, e) { + e.preventDefault(); + self._isValidForModel = false; + } + ).always(self._replaceContent.bind(self)); + }); + }, + /** + * Render the field DOM except for the domain selector part. The full field + * DOM is composed of a DIV which contains the domain selector widget, + * followed by other content. This other content is handled by this method. + * + * @private + * @param {integer} [nbRecords=0] - The number of records the domain matches + */ + _replaceContent(nbRecords) { + if (this._$content) { + this._$content.remove(); + } + this._$content = $(qweb.render("FieldDomain.content", { + hasModel: !!this.domainModel, + isValid: !!this._isValidForModel, + nbRecords: nbRecords || 0, + inDialogEdit: this.inDialog && this.mode === "edit", + })); + this._$content.appendTo(this.$el); + }, + /** + * @override _reset from AbstractField + * Check if the model the field works with has (to be) changed. + */ + _reset: function () { + this._super.apply(this, arguments); + var oldDomainModel = this.domainModel; + this._initModel(); + if (this.domainSelector && this.domainModel !== oldDomainModel) { + // If the model has changed, destroy the current domain selector + this.domainSelector.destroy(); + this.domainSelector = null; + } + }, + /** + * Checks the model the field must work with. It is either directly given + * in the field option or it is the value of another field in the view. + * + * @private + */ + _initModel: function () { + this.domainModel = this.modelOption; + if (this.recordData.hasOwnProperty(this.domainModel)) { + this.domainModel = this.recordData[this.domainModel]; + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the "Show selection" button is clicked + * -> Open a modal to see the matched records + * + * @param {Event} e + */ + _onShowSelectionButtonClick: function (e) { + e.preventDefault(); + new view_dialogs.SelectCreateDialog(this, { + title: _t("Selected records"), + res_model: this.domainModel, + domain: this.value || "[]", + no_create: true, + readonly: true, + disable_multiple_selection: true, + }).open(); + }, + /** + * Called when the "Edit domain" button is clicked (when using the in_dialog + * option) -> Open a DomainSelectorDialog to edit the value + * + * @param {Event} e + */ + _onDialogEditButtonClick: function (e) { + e.preventDefault(); + new DomainSelectorDialog(this, this.domainModel, this.value || "[]", { + readonly: this.mode === "readonly", + filters: this.fsFilters, + debugMode: session.debug, + }).open(); + }, + /** + * Called when the domain selector value is changed (do nothing if it is the + * one which is in a dialog (@see _onDomainSelectorDialogValueChange)) + * -> Adapt the internal value state + * + * @param {OdooEvent} e + */ + _onDomainSelectorValueChange: function (e) { + if (this.inDialog) return; + this._setValue(Domain.prototype.arrayToString(this.domainSelector.getDomain())); + }, + /** + * Called when the in-dialog domain selector value is confirmed + * -> Adapt the internal value state + * + * @param {OdooEvent} e + */ + _onDomainSelectorDialogValueChange: function (e) { + this._setValue(Domain.prototype.arrayToString(e.data.domain)); + }, +}); + /** * This widget is intended to be used on Text fields. It will provide Ace Editor * for editing XML and Python. @@ -1275,6 +1498,7 @@ return { FieldChar: FieldChar, FieldDate: FieldDate, FieldDateTime: FieldDateTime, + FieldDomain: FieldDomain, FieldFloat: FieldFloat, FieldFloatTime: FieldFloatTime, FieldHtml: FieldHtml, diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js index 0bfddd03098..405af71438b 100644 --- a/addons/web/static/src/js/fields/field_registry.js +++ b/addons/web/static/src/js/fields/field_registry.js @@ -24,6 +24,7 @@ registry .add('boolean', basic_fields.FieldBoolean) .add('date', basic_fields.FieldDate) .add('datetime', basic_fields.FieldDateTime) + .add('domain', basic_fields.FieldDomain) .add('text', basic_fields.FieldText) .add('float', basic_fields.FieldFloat) .add('char', basic_fields.FieldChar) @@ -63,4 +64,4 @@ registry .add('statusbar', relational_fields.FieldStatus) .add('one2many_list', relational_fields.FieldOne2Many); -}); \ No newline at end of file +}); diff --git a/addons/web/static/src/js/widgets/domain_selector.js b/addons/web/static/src/js/widgets/domain_selector.js index f7c67aa37cb..ba1ac9e77c9 100644 --- a/addons/web/static/src/js/widgets/domain_selector.js +++ b/addons/web/static/src/js/widgets/domain_selector.js @@ -301,6 +301,12 @@ var DomainSelector = DomainTree.extend({ } return this._super(domain); }, + setDomain: function (domain) { + if (Domain.prototype.arrayToString(domain) === Domain.prototype.arrayToString(this.getDomain())) { + return $.when(); + } + return this._redraw(domain); + }, _postRender: function () { this._super.apply(this, arguments); diff --git a/addons/web/static/src/js/widgets/model_field_selector.js b/addons/web/static/src/js/widgets/model_field_selector.js index 907368920ac..9c843937c90 100644 --- a/addons/web/static/src/js/widgets/model_field_selector.js +++ b/addons/web/static/src/js/widgets/model_field_selector.js @@ -2,11 +2,20 @@ odoo.define("web.ModelFieldSelector", function (require) { "use strict"; var core = require("web.core"); -var Model = require("web.DataModel"); var Widget = require("web.Widget"); var _t = core._t; +/** + * Field Selector Cache - TODO Should be improved to use external cache ? + * - Stores fields per model used in field selector + * @see ModelFieldSelector._getModelFieldsFromCache + */ +var modelFieldsCache = { + cache: {}, + cacheDefs: {}, +}; + /// The ModelFieldSelector widget can be used to select a particular field chain from a given model. var ModelFieldSelector = Widget.extend({ template: "FieldSelector", @@ -32,7 +41,7 @@ var ModelFieldSelector = Widget.extend({ }, // Handle a direct change in the debug input - "change input": function() { + "change input": function () { var userChain = this.$input.val(); if (!this.options.followRelations) { var fields = userChain.split("."); @@ -208,7 +217,7 @@ var ModelFieldSelector = Widget.extend({ /// @return a deferred which is resolved once the last page is shown _prefill: function () { this.pages = []; - return this._pushPageData(this.model).then((function() { + return this._pushPageData(this.model).then((function () { return (this.chain ? processChain.call(this, this.chain.split(".").reverse()) : $.when()); }).bind(this)); @@ -234,7 +243,7 @@ var ModelFieldSelector = Widget.extend({ if (this.model === model && this.options.fields) { def = $.when(sortFields(this.options.fields)); } else { - def = fieldsCache.getFields(model, this.options.filters); + def = this._getModelFieldsFromCache(model, this.options.filters); } return def.then((function (fields) { this.pages.push(fields); @@ -294,36 +303,35 @@ var ModelFieldSelector = Widget.extend({ name: name, }); }, + /** + * The _getFieldsFromCache method searches the cache for the given model + * fields, according to the given filter. If the cache does not know about + * the model, the cache is updated. + * + * @param {string} model + * @param {Object} filters @see ModelFieldSelector.init.options.filters + * + * @returns {Object[]} a list of the model fields info, sorted by field + * non-technical name + */ + _getModelFieldsFromCache: function (model, filters) { + var def = modelFieldsCache.cacheDefs[model]; + if (!def) { + def = modelFieldsCache.cacheDefs[model] = this.performModelRPC(model, "fields_get", [ + false, + ["store", "searchable", "type", "string", "relation", "selection", "related"], + ]).then((function (fields) { + modelFieldsCache.cache[model] = sortFields(fields); + }).bind(this)); + } + return def.then((function () { + return _.filter(modelFieldsCache.cache[model], function (f) { + return !filters.searchable || f.searchable; + }); + }).bind(this)); + }, }); -/// Field Selector Cache -/// -/// * Stores fields per model used in field selector -/// * Apply filters on the fly -var fieldsCache = { - cache: {}, - cacheDefs: {}, - getFields: function (model, filters) { - return (this.cacheDefs[model] ? this.cacheDefs[model] : this.updateCache(model)).then((function () { - return this.filter(model, filters); - }).bind(this)); - }, - updateCache: function (model) { - this.cacheDefs[model] = new Model(model).call("fields_get", [ - false, - ["store", "searchable", "type", "string", "relation", "selection", "related"], - ]).then((function (fields) { - this.cache[model] = sortFields(fields); - }).bind(this)); - return this.cacheDefs[model]; - }, - filter: function (model, filters) { - return _.filter(this.cache[model], function (f) { - return !filters.searchable || f.searchable; - }); - }, -}; - function sortFields(fields) { return _.chain(fields) .pairs() diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index f5fb05044e6..6d51198fd8b 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -665,19 +665,19 @@ t-att-maxlength="widget.field.size"/> -
- -
+ +
- - - + Invalid domain + +
-
Select a model to add a filter.
-
+
Select a model to add a filter.
+
diff --git a/addons/web/static/test/fields/basic_fields_tests.js b/addons/web/static/test/fields/basic_fields_tests.js index e1dbb1f0b24..078008ced35 100644 --- a/addons/web/static/test/fields/basic_fields_tests.js +++ b/addons/web/static/test/fields/basic_fields_tests.js @@ -16,17 +16,17 @@ QUnit.module('basic_fields', { this.data = { partner: { fields: { - date: {string: "A date", type: "date"}, - display_name: { string: "Displayed name", type: "char" }, - foo: {string: "Foo", type: "char", default: "My little Foo Value"}, - bar: {string: "Bar", type: "boolean", default: true}, - int_field: {string: "int_field", type: "integer", sortable: true}, - qux: {string: "Qux", type: "float", digits: [16,1] }, - p: {string: "one2many field", type: "one2many", relation: 'partner'}, - trululu: {string: "Trululu", type: "many2one", relation: 'partner'}, - timmy: { string: "pokemon", type: "many2many", relation: 'partner_type'}, - product_id: {string: "Product", type: "many2one", relation: 'product'}, - sequence: {type: "integer", string: "Sequence"}, + date: {string: "A date", type: "date", searchable: true}, + display_name: {string: "Displayed name", type: "char", searchable: true}, + foo: {string: "Foo", type: "char", default: "My little Foo Value", searchable: true}, + bar: {string: "Bar", type: "boolean", default: true, searchable: true}, + int_field: {string: "int_field", type: "integer", sortable: true, searchable: true}, + qux: {string: "Qux", type: "float", digits: [16,1], searchable: true}, + p: {string: "one2many field", type: "one2many", relation: 'partner', searchable: true}, + trululu: {string: "Trululu", type: "many2one", relation: 'partner', searchable: true}, + timmy: {string: "pokemon", type: "many2many", relation: 'partner_type', searchable: true}, + product_id: {string: "Product", type: "many2one", relation: 'product', searchable: true}, + sequence: {type: "integer", string: "Sequence", searchable: true}, }, records: [{ id: 1, @@ -62,7 +62,7 @@ QUnit.module('basic_fields', { }, product: { fields: { - name: {string: "Product Name", type: "char"} + name: {string: "Product Name", type: "char", searchable: true} }, records: [{ id: 37, @@ -74,8 +74,8 @@ QUnit.module('basic_fields', { }, partner_type: { fields: { - name: {string: "Partner Type", type: "char"}, - color: {string: "Color index", type: "int"}, + name: {string: "Partner Type", type: "char", searchable: true}, + color: {string: "Color index", type: "integer", searchable: true}, }, records: [ {id: 12, display_name: "gold", color: 2}, @@ -539,6 +539,154 @@ QUnit.module('basic_fields', { "date input should have the focus (make sure the test window had the focus)"); }); + QUnit.module('FieldDomain'); + + QUnit.test('basic domain field usage is ok', function (assert) { + assert.expect(6); + + this.data.partner.records[0].foo = "[]"; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '
' + + '' + + '' + + '' + + '' + + '' + + '
', + res_id: 1, + }); + form.$buttons.find('.o_form_button_edit').click(); + + // As the domain is empty, there should be a button to add the first + // domain part + var $domain = form.$(".o_form_field_domain"); + var $domainAddFirstNodeButton = $domain.find(".o_domain_add_first_node_button"); + assert.equal($domainAddFirstNodeButton.length, 1, + "there should be a button to create first domain element"); + + // Clicking on the button should add the [["id", "=", "1"]] domain, so + // there should be a field selector in the DOM + $domainAddFirstNodeButton.click(); + var $fieldSelector = $domain.find(".o_field_selector"); + assert.equal($fieldSelector.length, 1, + "there should be a field selector"); + + // Focusing the field selector input should open the field selector + // popover + $fieldSelector.find("> input").focus(); + var $fieldSelectorPopover = $fieldSelector.find(".o_field_selector_popover"); + assert.ok($fieldSelectorPopover.is(":visible"), + "field selector popover should be visible"); + + // The popover should contain the list of partner_type fields and so + // there should be the "Color index" field + var $lis = $fieldSelectorPopover.find("li"); + var $colorIndex = $(); + $lis.each(function () { + var $li = $(this); + if ($li.html().indexOf("Color index") >= 0) { + $colorIndex = $li; + } + }); + assert.equal($colorIndex.length, 1, + "field selector popover should contain 'Color index' field"); + + // Clicking on this field should close the popover, then changing the + // associated value should reveal one matched record + $colorIndex.click(); + $domain.find(".o_domain_leaf_value_input").val(2).change(); + assert.equal($domain.find(".o_domain_show_selection_button").text().trim().substr(0, 2), "1 ", + "changing color value to 2 should reveal only one record"); + + // Saving the form view should show a readonly domain containing the + // "color" field + form.$buttons.find('.o_form_button_save').click(); + $domain = form.$(".o_form_field_domain"); + assert.ok($domain.html().indexOf("color") >= 0, + "field selector readonly value should now contain 'color'"); + }); + + QUnit.test('domain field is correctly reset on every view change', function (assert) { + assert.expect(7); + + this.data.partner.records[0].foo = '[["id","=",1]]'; + this.data.partner.fields.bar.type = "char"; + this.data.partner.records[0].bar = "product"; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + res_id: 1, + }); + form.$buttons.find('.o_form_button_edit').click(); + + // As the domain is equal to [["id", "=", 1]] there should be a field + // selector to change this + var $domain = form.$(".o_form_field_domain"); + var $fieldSelector = $domain.find(".o_field_selector"); + assert.equal($fieldSelector.length, 1, + "there should be a field selector"); + + // Focusing its input should open the field selector popover + $fieldSelector.find("> input").focus(); + var $fieldSelectorPopover = $fieldSelector.find(".o_field_selector_popover"); + assert.ok($fieldSelectorPopover.is(":visible"), + "field selector popover should be visible"); + + // As the value of the "bar" field is "product", the field selector + // popover should contain the list of "product" fields + var $lis = $fieldSelectorPopover.find("li"); + var $sampleLi = $(); + $lis.each(function () { + var $li = $(this); + if ($li.html().indexOf("Product Name") >= 0) { + $sampleLi = $li; + } + }); + assert.strictEqual($lis.length, 1, + "field selector popover should contain only one field"); + assert.strictEqual($sampleLi.length, 1, + "field selector popover should contain 'Product Name' field"); + + // Now change the value of the "bar" field to "partner_type" + form.$(".o_field_widget.o_form_input").click().val("partner_type").trigger("input"); + + // Refocusing the field selector input should open the popover again + $fieldSelector = form.$(".o_field_selector"); + $fieldSelector.find("> input").focus(); + $fieldSelectorPopover = $fieldSelector.find(".o_field_selector_popover"); + assert.ok($fieldSelectorPopover.is(":visible"), + "field selector popover should be visible"); + + // Now the list of fields should be the ones of the "partner_type" model + $lis = $fieldSelectorPopover.find("li"); + $sampleLi = $(); + $lis.each(function () { + var $li = $(this); + if ($li.html().indexOf("Color index") >= 0) { + $sampleLi = $li; + } + }); + assert.strictEqual($lis.length, 2, + "field selector popover should contain two fields"); + assert.strictEqual($sampleLi.length, 1, + "field selector popover should contain 'Color index' field"); + }); }); }); });