diff options
Diffstat (limited to 'addons/web/static')
21 files changed, 747 insertions, 89 deletions
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index ae3b06d3a02..3b47b8c64b0 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -619,12 +619,15 @@ var FieldX2Many = AbstractField.extend({ * @returns {Deferred} */ commitChanges: function () { + var self = this; var inEditionRecordID = this.renderer && this.renderer.viewType === "list" && this.renderer.getEditableRecordID(); if (inEditionRecordID) { - return this._saveLine(inEditionRecordID); + return this.renderer.commitChanges(inEditionRecordID).then(function () { + return self._saveLine(inEditionRecordID); + }); } return this._super.apply(this, arguments); }, @@ -767,21 +770,17 @@ var FieldX2Many = AbstractField.extend({ * did not agree to discard. */ _saveLine: function (recordID) { - var self = this; var def = $.Deferred(); - this.renderer.commitChanges(recordID).then(function () { // TODO wrong as no mutex protection - var fieldNames = self.renderer.canBeSaved(recordID); - if (fieldNames.length) { - self.trigger_up('discard_changes', { - recordID: recordID, - onSuccess: def.resolve.bind(def), - onFailure: def.reject.bind(def), - }); - } else { - self.renderer.setRowMode(recordID, 'readonly') - .done(def.resolve.bind(def)); - } - }); + var fieldNames = this.renderer.canBeSaved(recordID); + if (fieldNames.length) { + this.trigger_up('discard_changes', { + recordID: recordID, + onSuccess: def.resolve.bind(def), + onFailure: def.reject.bind(def), + }); + } else { + this.renderer.setRowMode(recordID, 'readonly').done(def.resolve.bind(def)); + } return def; }, @@ -900,9 +899,15 @@ var FieldX2Many = AbstractField.extend({ ev.stopPropagation(); this.trigger_up('mutexify', { action: function () { - return self._saveLine(ev.data.recordID) - .done(ev.data.onSuccess) - .fail(ev.data.onFailure); + return self.renderer.commitChanges(ev.data.recordID).then(function () { + self.trigger_up('mutexify', { + action: function () { + return self._saveLine(ev.data.recordID) + .done(ev.data.onSuccess) + .fail(ev.data.onFailure); + }, + }); + }); }, }); }, @@ -922,7 +927,7 @@ var FieldX2Many = AbstractField.extend({ id: rowID, data: data, }); - }) + }); }, /** * Adds field name information to the event, so that the view upstream is diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 16a066db495..af4ca7d7a08 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -419,6 +419,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { var saveDef = this.model.save(recordID, { // Save then leave edit mode reload: options.reload, savePoint: options.savePoint, + viewType: options.viewType, }); if (!options.stayInEdit) { saveDef = saveDef.then(function (fieldNames) { diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 61c285d6581..d897a74c696 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -208,6 +208,7 @@ var BasicModel = AbstractModel.extend({ delete self.localData[record.id]; } else { record.res_ids.splice(record.offset, 1); + record.offset = Math.min(record.offset, record.res_ids.length - 1); record.res_id = record.res_ids[record.offset]; record.count--; } @@ -704,6 +705,8 @@ var BasicModel = AbstractModel.extend({ * @param {boolean} [options.savePoint=false] if true, the record will only * be 'locally' saved: its changes written in a _savePoint key that can * be restored later by call discardChanges with option rollback to true + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record * @returns {Deferred} * Resolved with the list of field names (whose value has been modified) */ @@ -718,6 +721,11 @@ var BasicModel = AbstractModel.extend({ if (newValue instanceof Array) { rec._savePoint = newValue.slice(0); } else { + // save the viewType of edition, so that the correct readonly modifiers + // can be evaluated when the record will be saved + for (var fieldName in (rec._changes || {})) { + rec._editionViewType[fieldName] = options.viewType; + } rec._savePoint = _.extend({}, newValue); } }); @@ -728,7 +736,7 @@ var BasicModel = AbstractModel.extend({ // id never changes, and should not be written delete record._changes.id; } - var changes = self._generateChanges(record); + var changes = self._generateChanges(record, {viewType: options.viewType}); if (method === 'create') { var fieldNames = record.getFieldNames(); @@ -1869,12 +1877,36 @@ var BasicModel = AbstractModel.extend({ * * @private * @param {Object} record + * @param {Object} [options] + * @param {boolean} [options.changesOnly=true] if true, only generates + * commands for fields that have changed (concerns x2many fields only) + * @param {boolean} [options.withReadonly=false] if false, doesn't generate + * changes for readonly fields + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record. Note that if an editionViewType is + * specified for a field, it will take the priority over the viewType arg. * @returns {Object} a map from changed fields to their new value */ - _generateChanges: function (record) { + _generateChanges: function (record, options) { + options = options || {}; + var viewType = options.viewType || record.viewType; var changes = _.extend({}, record._changes); - var commands = this._generateX2ManyCommands(record, true); + var withReadonly = options.withReadonly || false; + var commands = this._generateX2ManyCommands(record, { + changesOnly: 'changesOnly' in options ? options.changesOnly : true, + withReadonly: withReadonly, + }); for (var fieldName in record.fields) { + // remove readonly fields from the list of changes + if (!withReadonly && fieldName in changes || fieldName in commands) { + var editionViewType = record._editionViewType[fieldName] || viewType; + if (this._isFieldReadonly(record, fieldName, editionViewType)) { + delete changes[fieldName]; + continue; + } + } + + // process relational fields and handle the null case var type = record.fields[fieldName].type; if (type === 'one2many' || type === 'many2many') { if (commands[fieldName].length) { // replace localId by commands @@ -1901,7 +1933,7 @@ var BasicModel = AbstractModel.extend({ * @returns {Object} the data */ _generateOnChangeData: function (record) { - var commands = this._generateX2ManyCommands(record, false); + var commands = this._generateX2ManyCommands(record, {withReadonly: true}); var data = _.extend(this.get(record.id, {raw: true}).data, commands); // one2many records have a parentID @@ -1924,12 +1956,16 @@ var BasicModel = AbstractModel.extend({ * or write them... * * @param {Object} record + * @param {Object} [options] * @param {boolean} [changesOnly=false] if true, only generates commands for * fields that have changed + * @param {boolean} [options.withReadonly=false] if false, doesn't generate + * changes for readonly fields in commands * @returns {Object} a map from some field names to commands */ - _generateX2ManyCommands: function (record, changesOnly) { + _generateX2ManyCommands: function (record, options) { var self = this; + options = options || {}; var commands = {}; var data = _.extend({}, record.data, record._changes); var type; @@ -1943,7 +1979,7 @@ var BasicModel = AbstractModel.extend({ continue; } var list = this.localData[data[fieldName]]; - if (changesOnly && !list._changes) { + if (options.changesOnly && !list._changes) { // if only changes are requested, skip if there is no change continue; } @@ -1961,7 +1997,7 @@ var BasicModel = AbstractModel.extend({ // generate update commands for records that have been // updated (it may happen with editable lists) _.each(relData, function (relRecord) { - var changes = self._generateChanges(relRecord); + var changes = self._generateChanges(relRecord, options); if (!_.isEmpty(changes)) { var command = x2ManyCommands.update(relRecord.res_id, changes); commands[fieldName].push(command); @@ -1980,7 +2016,7 @@ var BasicModel = AbstractModel.extend({ if (_.contains(keptIds, relIds[i])) { // this is an id that already existed relRecord = _.findWhere(relData, {res_id: relIds[i]}); - changes = this._generateChanges(relRecord); + changes = this._generateChanges(relRecord, options); if (!_.isEmpty(changes)) { command = x2ManyCommands.update(relRecord.res_id, changes); didChange = true; @@ -1991,11 +2027,11 @@ var BasicModel = AbstractModel.extend({ } else if (_.contains(addedIds, relIds[i])) { // this is a new id relRecord = _.findWhere(relData, {res_id: relIds[i]}); - changes = this._generateChanges(relRecord); + changes = this._generateChanges(relRecord, options); commands[fieldName].push(x2ManyCommands.create(changes)); } } - if (changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) { + if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) { // in this situation, we have no changed ids, no added // ids and no removed ids, so we can safely ignore the // last changes @@ -2155,7 +2191,7 @@ var BasicModel = AbstractModel.extend({ if (context[fieldName] === null) { context[fieldName] = false; } - if (!field) { + if (!field || field.name === 'id') { continue; } if (field.type === 'float' || @@ -2200,6 +2236,31 @@ var BasicModel = AbstractModel.extend({ return context; }, /** + * Returns true if the field is readonly (checking first in the modifiers, + * and if there is no readonly modifier, checking the readonly attribute of + * the field). + * + * @private + * @param {Object} record an element from the localData + * @param {string} fieldName + * @param {string} [viewType] current viewType. If not set, we will assume + * main viewType from the record + * @returns {boolean} + */ + _isFieldReadonly: function (record, fieldName, viewType) { + var fieldInfo = record.fieldsInfo[viewType || record.viewType][fieldName]; + var modifiers; + if (fieldInfo) { + var rawModifiers = JSON.parse(fieldInfo.modifiers || "{}"); + modifiers = this._evalModifiers(record, rawModifiers); + } + if (modifiers && 'readonly' in modifiers) { + return modifiers.readonly; + } else { + return record.fields[fieldName].readonly; + } + }, + /** * Returns true iff value is considered to be set for the given field's type. * * @private @@ -2330,6 +2391,12 @@ var BasicModel = AbstractModel.extend({ viewType: params.viewType, }; + // _editionViewType is a dict whose keys are field names and which is populated when a field + // is edited with the viewType as value. This is useful for one2manys to determine whether + // or not a field is readonly (using the readonly modifiers of the view in which the field + // has been edited) + dataPoint._editionViewType = {}; + dataPoint.evalModifiers = this._evalModifiers.bind(this, dataPoint); dataPoint.getContext = this._getContext.bind(this, dataPoint); dataPoint.getDomain = this._getDomain.bind(this, dataPoint); @@ -2507,7 +2574,7 @@ var BasicModel = AbstractModel.extend({ .then(function () { // save initial changes, so they can be restored later, // if we need to discard. - self.save(record.id, {savePoint: true}) + self.save(record.id, {savePoint: true}); return record.id; }); @@ -2582,8 +2649,7 @@ var BasicModel = AbstractModel.extend({ * @param {string[]} fields changed fields * @param {string} [viewType] current viewType. If not set, we will assume * main viewType from the record - * @returns {Deferred} The returned deferred can fail, in which case the - * fail value will be the warning message received from the server + * @returns {Deferred} */ _performOnChange: function (record, fields, viewType) { var self = this; diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index 1f0247b732b..0b85ee8cf42 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -151,7 +151,7 @@ var FormController = BasicController.extend({ if (this.is_action_enabled('delete')) { otherItems.push({ label: _t('Delete'), - callback: this._deleteRecords.bind(this, [this.handle]), + callback: this._onDeleteRecord.bind(this), }); } if (this.is_action_enabled('create') && this.is_action_enabled('duplicate')) { @@ -395,6 +395,14 @@ var FormController = BasicController.extend({ this.createRecord(); }, /** + * Deletes the current record + * + * @private + */ + _onDeleteRecord: function () { + this._deleteRecords([this.handle]); + }, + /** * Called when the user wants to discard the changes made to the current * record -> @see discardChanges * diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js index 80d48a78774..99c9500d210 100644 --- a/addons/web/static/src/js/views/kanban/kanban_column.js +++ b/addons/web/static/src/js/views/kanban/kanban_column.js @@ -45,7 +45,6 @@ var KanbanColumn = Widget.extend({ this.folded = !data.isOpen; this.has_active_field = 'active' in data.fields; this.size = data.count; - this.values = data.values; this.fields = data.fields; this.records = []; this.modelName = data.model; @@ -63,13 +62,12 @@ var KanbanColumn = Widget.extend({ this.record_options = _.clone(recordOptions); - if (data.options && data.options.group_by_tooltip) { - var self = this; - this.tooltip_info = _.map(data.options.group_by_tooltip, function (key, value) { - return (self.values && self.values[value] && "<div>" +key + "<br>" + self.values[value] + "</div>") || ''; + if (options.group_by_tooltip) { + this.tooltipInfo = _.map(options.group_by_tooltip, function (help, field) { + return (data.tooltipData && data.tooltipData[field] && "<div>" + help + "<br>" + data.tooltipData[field] + "</div>") || ''; }).join(''); } else { - this.tooltip_info = ""; + this.tooltipInfo = ""; } }, /** @@ -217,7 +215,7 @@ var KanbanColumn = Widget.extend({ this.$el.toggleClass('o_column_folded', this.folded); var tooltip = this.size + _t(' records'); - tooltip = '<p>' + tooltip + '</p>' + this.tooltip_info; + tooltip = '<p>' + tooltip + '</p>' + this.tooltipInfo; this.$header.tooltip({html: true}).attr('data-original-title', tooltip); if (!this.remaining) { this.$('.o_kanban_load_more').remove(); diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js index c606776ccc0..cd69889bda9 100644 --- a/addons/web/static/src/js/views/kanban/kanban_model.js +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -90,6 +90,21 @@ var KanbanModel = BasicModel.extend({ }); }, /** + * Add the key `tooltipData` (kanban specific) when performing a `geĊ§`. + * + * @override + * @see _readTooltipFields + * @returns {Object} + */ + get: function () { + var result = this._super.apply(this, arguments); + var dp = result && this.localData[result.id]; + if (dp && dp.tooltipData) { + result.tooltipData = $.extend(true, {}, dp.tooltipData); + } + return result; + }, + /** * @override */ load: function (params) { @@ -203,10 +218,55 @@ var KanbanModel = BasicModel.extend({ * @param {Object} list valid resource object */ _readGroup: function (list) { + var self = this; if (list.groupedBy.length > 1) { list.groupedBy = [list.groupedBy[0]]; } - return this._super.apply(this, arguments); + return this._super.apply(this, arguments).then(function (result) { + return self._readTooltipFields(list).then(_.constant(result)); + }); + }, + /** + * Fetches tooltip specific fields on the group by relation and stores it in + * the column datapoint in a special key `tooltipData`. + * Data for the tooltips (group_by_tooltip) are fetched in batch for all + * groups, to avoid doing multiple calls. + * Data are stored in a special key `tooltipData` on the datapoint. + * Note that the option `group_by_tooltip` is only for m2o fields. + * + * @private + * @param {Object} list a list of groups + * @returns {Deferred} + */ + _readTooltipFields: function (list) { + var self = this; + var groupedByField = list.fields[list.groupedBy[0]]; + if (groupedByField.type !== 'many2one') { + return $.when(); + } + var groupIds = _.map(list.data, function (id) { + return self.get(id, {raw: true}).res_id; + }); + var tooltipFields = []; + var groupedByFieldInfo = list.fieldsInfo.kanban[list.groupedBy[0]]; + if (groupedByFieldInfo && groupedByFieldInfo.options) { + tooltipFields = Object.keys(groupedByFieldInfo.options.group_by_tooltip || {}); + } + if (groupIds.length && tooltipFields.length) { + var fieldNames = _.union(['display_name'], tooltipFields); + return this._rpc({ + model: groupedByField.relation, + method: 'read', + args: [groupIds, fieldNames], + context: list.context, + }).then(function (result) { + _.each(list.data, function (id) { + var dp = self.localData[id]; + dp.tooltipData = _.findWhere(result, {id: dp.res_id}); + }); + }); + } + return $.when(); }, }); diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js index 7bfe21e66a9..87275260b01 100644 --- a/addons/web/static/src/js/views/kanban/kanban_renderer.js +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -188,6 +188,7 @@ var KanbanRenderer = BasicRenderer.extend({ _renderGrouped: function (fragment) { var self = this; var groupByFieldAttrs = this.state.fields[this.state.groupedBy[0]]; + var groupByFieldInfo = this.state.fieldsInfo.kanban[this.state.groupedBy[0]]; // Deactivate the drag'n'drop if the groupedBy field: // - is a date or datetime since we group by month or // - is readonly @@ -201,8 +202,10 @@ var KanbanRenderer = BasicRenderer.extend({ } var groupedByM2O = groupByFieldAttrs && (groupByFieldAttrs.type === 'many2one'); var grouped_by_field = groupedByM2O && groupByFieldAttrs.relation; + var groupByTooltip = groupByFieldInfo && groupByFieldInfo.options.group_by_tooltip; this.columnOptions = _.extend(this.columnOptions, { draggable: draggable, + group_by_tooltip: groupByTooltip, grouped_by_m2o: groupedByM2O, relation: grouped_by_field, }); diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js index fb175aa1934..78640a97124 100644 --- a/addons/web/static/src/js/views/pivot/pivot_controller.js +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -45,13 +45,15 @@ var PivotController = AbstractController.extend({ this.groupableFields = params.groupableFields; this.title = params.title; this.enableLinking = params.enableLinking; + // views to use in the action triggered when a data cell is clicked + this.views = params.views; this.lastHeaderSelected = null; }, /** * @override */ start: function () { - this.$el.toggleClass('o_enableLinking', this.enableLinking); + this.$el.toggleClass('o_enable_linking', this.enableLinking); this.$fieldSelection = this.$('.o_field_selection'); core.bus.on('click', this, function () { this.$fieldSelection.empty(); @@ -253,33 +255,23 @@ var PivotController = AbstractController.extend({ !this.enableLinking) { return; } - var row_id = $target.data('id'); - var col_id = $target.data('col_id'); var state = this.model.get(this.handle); - var row_domain = state.data.headers[row_id].domain; - var col_domain = state.data.headers[col_id].domain; - var context = _.omit(_.clone(state.context), 'group_by'); - - function _find_view_info (views, view_type) { - return _.find(views, function (view) { - return view[1] === view_type; - }) || [false, view_type]; - } - var views = [ - _find_view_info(this.options.action.views, "list"), - _find_view_info(this.options.action.views, "form"), - ]; + var colDomain = this.model.getHeader($target.data('col_id')).domain; + var rowDomain = this.model.getHeader($target.data('id')).domain; + var context = _.omit(state.context, function (val, key) { + return key === 'group_by' || _.str.startsWith(key, 'search_default_'); + }); this.do_action({ type: 'ir.actions.act_window', name: this.title, - res_model: this.model, - views: views, - view_type : "list", - view_mode : "list", + res_model: this.modelName, + views: this.views, + view_type: 'list', + view_mode: 'list', target: 'current', context: context, - domain: state.domain.concat(row_domain, col_domain), + domain: state.domain.concat(rowDomain, colDomain), }); }, /** diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index 6bbadd56ed8..49c00663d47 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -16,9 +16,10 @@ odoo.define('web.PivotModel', function (require) { * @todo add a full description/specification of the data layout */ +var AbstractModel = require('web.AbstractModel'); var core = require('web.core'); +var session = require('web.session'); var utils = require('web.utils'); -var AbstractModel = require('web.AbstractModel'); var _t = core._t; @@ -189,6 +190,8 @@ var PivotModel = AbstractModel.extend({ } return { colGroupBys: this.data.main_col.groupbys, + context: this.data.context, + domain: this.data.domain, fields: this.fields, headers: !isRaw && this._computeHeaders(), has_data: true, @@ -225,7 +228,7 @@ var PivotModel = AbstractModel.extend({ this.modelName = params.modelName; this.data = { domain: params.domain, - context: params.context, + context: _.extend({}, session.user_context, params.context), groupedBy: params.rowGroupBys, colGroupBys: params.colGroupBys, measures: params.measures, diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js index d90c0d21780..356672081d9 100644 --- a/addons/web/static/src/js/views/pivot/pivot_view.js +++ b/addons/web/static/src/js/views/pivot/pivot_view.js @@ -96,6 +96,17 @@ var PivotView = AbstractView.extend({ this.controllerParams.enableLinking = !arch.attrs.disable_linking; this.controllerParams.measures = measures; this.controllerParams.groupableFields = groupableFields; + // retrieve form and list view ids from the action to open those views + // when a data cell of the pivot view is clicked + this.controllerParams.views = [ + _findView(params.action && params.action.views, 'list'), + _findView(params.action && params.action.views, 'form'), + ]; + function _findView(views, viewType) { + return _.find(views, function (view) { + return view[1] === viewType; + }) || [false, viewType]; + } }, }); diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index a19e440913e..d38400c2e84 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -222,6 +222,7 @@ var FormViewDialog = ViewDialog.extend({ stayInEdit: true, reload: false, savePoint: this.shouldSaveLocally, + viewType: 'form', }); } return $.when(def).then(function () { @@ -326,7 +327,6 @@ var SelectCreateDialog = ViewDialog.extend({ var searchview = new SearchView(this, this.dataset, fields_views.search, options); searchview.prependTo($header).done(function () { var d = searchview.build_search_data(); - d.domains = d.domains.concat([self.domain]); if (self.initial_ids) { d.domains.push([["id", "in", self.initial_ids]]); self.initial_ids = undefined; @@ -387,8 +387,9 @@ var SelectCreateDialog = ViewDialog.extend({ contexts: [this.context].concat(contexts), group_by_seq: groupbys || [] }); + var context = _.omit(results.context, function (value, key) { return key.indexOf('search_default_') === 0; }); return { - context: results.context, + context: context, domain: results.domain, groupBy: results.group_by, }; diff --git a/addons/web/static/src/less/fields.less b/addons/web/static/src/less/fields.less index b070a8f4cc8..02885ef3514 100644 --- a/addons/web/static/src/less/fields.less +++ b/addons/web/static/src/less/fields.less @@ -33,7 +33,7 @@ } // Block fields - &.note-editor, &.oe_form_field_html, &.oe_form_field_html_text, &.oe_form_field_text, &.o_view_manager_content, &.o_field_domain { + &.note-editor, &.oe_form_field_html, &.oe_form_field_html_text, &.oe_form_field_text, &.o_view_manager_content, &.o_field_domain, &.o_graph_linechart, &.o_graph_barchart { display: block; } @@ -180,24 +180,6 @@ } } - // Status - &.o_selection .o_status { - display: inline-block; - height: 12px; - width: 12px; - border-radius: 6px; - vertical-align: middle; - - background-color: @odoo-brand-secondary; - - &.o_status_green { - background-color: @brand-success; - } - &.o_status_red { - background-color: @brand-danger; - } - } - // Specific success color for toggle_button widget .fa.o_toggle_button_success { color: @brand-success; diff --git a/addons/web/static/src/less/kanban_dashboard.less b/addons/web/static/src/less/kanban_dashboard.less index fd544445f6d..e86350f6b49 100644 --- a/addons/web/static/src/less/kanban_dashboard.less +++ b/addons/web/static/src/less/kanban_dashboard.less @@ -39,7 +39,7 @@ // If invisible, elem will no take more than its original height a, span { - .o-text-overflow(block); + .o-text-overflow(inline-block); position: relative; } diff --git a/addons/web/static/src/less/views.less b/addons/web/static/src/less/views.less index db5fe8f814c..102d50e7590 100644 --- a/addons/web/static/src/less/views.less +++ b/addons/web/static/src/less/views.less @@ -21,3 +21,23 @@ .o_invisible_modifier { display: none!important; } + +// Status +// This should normally be put in fields.less but these classes are used outside +// of `.o_field_widget` so it needs to be placed at an upper level. +.o_status { + display: inline-block; + height: 12px; + width: 12px; + border-radius: 6px; + vertical-align: middle; + + background-color: @odoo-brand-secondary; + + &.o_status_green { + background-color: @brand-success; + } + &.o_status_red { + background-color: @brand-danger; + } +} diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 559a9387baa..a1f851eb91f 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -659,6 +659,87 @@ QUnit.module('relational_fields', { }); }); + QUnit.test('list in form: quick create then add a new line directly', function (assert) { + // required many2one inside a one2many list: directly after quick creating + // a new many2one value (before the name_create returns), click on add an item: + // at this moment, the many2one has still no value, and as it is required, + // the row is discarded if a saveLine is requested. However, it should + // wait for the name_create to return before trying to save the line. + var done = assert.async(); + assert.expect(8); + + this.data.partner.onchanges = { + trululu: function () {}, + }; + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + var def = $.Deferred(); + var newRecordID; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<sheet>' + + '<field name="p">' + + '<tree editable="bottom">' + + '<field name="trululu" required="1"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'name_create') { + return def.then(_.constant(result)).then(function (nameGet) { + newRecordID = nameGet[0]; + return nameGet; + }); + } + if (args.method === 'create') { + assert.deepEqual(args.args[0].p[0][2].trululu, newRecordID); + } + return result; + }, + }); + + form.$('.o_field_x2many_list_row_add a').click(); + + var $dropdown = form.$('.o_field_many2one input').autocomplete('widget'); + form.$('.o_field_many2one input').val('b').trigger('keydown'); + concurrency.delay(0).then(function () { + $dropdown.find('li:first()').click(); // quick create 'b' + form.$('.o_field_x2many_list_row_add a').click(); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "there should still be only one row"); + assert.ok(form.$('.o_data_row').hasClass('o_selected_row'), + "the row should still be in edition"); + + def.resolve(); + + assert.strictEqual(form.$('.o_data_row:first .o_data_cell').text(), 'b', + "first row should have the correct m2o value"); + assert.strictEqual(form.$('.o_data_row').length, 2, + "there should now be 2 rows"); + assert.ok(form.$('.o_data_row:nth(1)').hasClass('o_selected_row'), + "the second row should be in edition"); + + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "there should be 1 row saved (the second one was empty and invalid)"); + assert.strictEqual(form.$('.o_data_row .o_data_cell').text(), 'b', + "should have the correct m2o value"); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + done(); + }); + }); + QUnit.test('autocompletion in a many2one, in form view with a domain', function (assert) { assert.expect(1); diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index fefc7231e5b..ca2f6f37f3e 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -572,6 +572,20 @@ QUnit.module('Views', { this.params.fieldNames = ['product_id', 'category', 'product_ids']; this.params.res_id = undefined; this.params.type = 'record'; + this.params.fieldsInfo = { + form: { + category: {}, + product_id: {}, + product_ids: { + fieldsInfo: { + default: { name: {} }, + }, + relatedFields: this.data.product.fields, + viewType: 'default', + }, + }, + }; + this.params.viewType = 'form'; var model = createModel({ Model: BasicModel, @@ -1015,6 +1029,19 @@ QUnit.module('Views', { this.params.fieldNames = ['total', 'product_ids']; this.params.res_id = undefined; this.params.type = 'record'; + this.params.fieldsInfo = { + form: { + product_ids: { + fieldsInfo: { + default: { name: {} }, + }, + relatedFields: this.data.product.fields, + viewType: 'default', + }, + total: {}, + }, + }; + this.params.viewType = 'form'; var o2mRecordParams = { fields: this.data.product.fields, @@ -1277,6 +1304,63 @@ QUnit.module('Views', { model.destroy(); }); + QUnit.test('dont write on readonly fields (write and create)', function (assert) { + assert.expect(6); + + this.params.fieldNames = ['foo', 'bar']; + this.data.partner.onchanges.foo = function (obj) { + obj.bar = obj.foo.length; + }; + this.data.partner.fields.bar.readonly = true; + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1], {foo: "verylongstring"}, + "should only save foo field"); + } + if (args.method === 'create') { + assert.deepEqual(args.args[0], {foo: "anotherverylongstring"}, + "should only save foo field"); + } + return this._super(route, args); + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.bar, 2, + "should be initialized with correct value"); + + model.notifyChanges(resultID, {foo: "verylongstring"}); + + record = model.get(resultID); + assert.strictEqual(record.data.bar, 14, + "should be changed with correct value"); + + model.save(resultID); + }); + + // start again, but with a new record + delete this.params.res_id; + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.bar, false, + "should be initialized with correct value"); + + model.notifyChanges(resultID, {foo: "anotherverylongstring"}); + + record = model.get(resultID); + assert.strictEqual(record.data.bar, 21, + "should be changed with correct value"); + + model.save(resultID); + }); + model.destroy(); + }); + QUnit.test('default_get with one2many values', function (assert) { assert.expect(1); @@ -1297,6 +1381,18 @@ QUnit.module('Views', { fields: this.data.partner.fields, modelName: 'partner', type: 'record', + fieldsInfo: { + form: { + product_ids: { + fieldsInfo: { + default: { name: {} }, + }, + relatedFields: this.data.product.fields, + viewType: 'default', + }, + }, + }, + viewType: 'form', }; model.load(params).then(function (resultID) { assert.strictEqual(typeof resultID, 'string', "result should be a valid id"); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index 7ed63d3bece..ab304e62ebc 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -5013,5 +5013,147 @@ QUnit.module('Views', { form.destroy(); }); + + QUnit.test('readonly fields are not sent when saving', function (assert) { + assert.expect(6); + + // define an onchange on display_name to check that the value of readonly + // fields is correctly sent for onchanges + this.data.partner.onchanges = { + display_name: function () {}, + p: function () {}, + }; + var checkOnchange = false; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="p">' + + '<tree>' + + '<field name="display_name"/>' + + '</tree>' + + '<form string="Partners">' + + '<field name="display_name"/>' + + '<field name="foo" attrs="{\'readonly\': [[\'display_name\', \'=\', \'readonly\']]}"/>' + + '</form>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + if (checkOnchange && args.method === 'onchange') { + if (args.args[2] === 'display_name') { // onchange on field display_name + assert.strictEqual(args.args[1].foo, 'foo value', + "readonly fields value should be sent for onchanges"); + } else { // onchange on field p + assert.deepEqual(args.args[1].p, [ + [0, false, {display_name: 'readonly', foo: 'foo value'}] + ], "readonly fields value should be sent for onchanges"); + } + } + if (args.method === 'create') { + assert.deepEqual(args.args[0], { + p: [[0, false, {display_name: 'readonly'}]] + }, "should not have sent the value of the readonly field"); + } + return this._super.apply(this, arguments); + }, + }); + + form.$('.o_field_x2many_list_row_add a').click(); + assert.strictEqual($('.modal input.o_field_widget[name=foo]').length, 1, + 'foo should be editable'); + checkOnchange = true; + $('.modal .o_field_widget[name=foo]').val('foo value').trigger('input'); + $('.modal .o_field_widget[name=display_name]').val('readonly').trigger('input'); + assert.strictEqual($('.modal span.o_field_widget[name=foo]').length, 1, + 'foo should be readonly'); + $('.modal .modal-footer .btn-primary').click(); // close the modal + checkOnchange = false; + + form.$('.o_data_row').click(); // re-open previous record + assert.strictEqual($('.modal .o_field_widget[name=foo]').text(), 'foo value', + "the edited value should have been kept"); + $('.modal .modal-footer .btn-primary').click(); // close the modal + + form.$buttons.find('.o_form_button_save').click(); // save the record + + form.destroy(); + }); + + QUnit.test('id is False in evalContext for new records', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="id"/>' + + '<field name="foo" attrs="{\'readonly\': [[\'id\', \'=\', False]]}"/>' + + '</form>', + }); + + assert.ok(form.$('.o_field_widget[name=foo]').hasClass('o_readonly_modifier'), + "foo should be readonly in 'Create' mode"); + + form.$buttons.find('.o_form_button_save').click(); + form.$buttons.find('.o_form_button_edit').click(); + + assert.notOk(form.$('.o_field_widget[name=foo]').hasClass('o_readonly_modifier'), + "foo should not be readonly anymore"); + + form.destroy(); + }); + + QUnit.test('delete a duplicated record', function (assert) { + assert.expect(5); + + var newRecordID; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<field name="display_name"/>' + + '</form>', + res_id: 1, + viewOptions: {sidebar: true}, + mockRPC: function (route, args) { + if (args.method === 'search_read' && args.model === 'ir.attachment') { + // rpcs done by the sidebar + return $.when([]); + } + var result = this._super.apply(this, arguments); + if (args.method === 'copy') { + return result.then(function (id) { + newRecordID = id; + return id; + }); + } + if (args.method === 'unlink') { + assert.deepEqual(args.args[0], [newRecordID], + "should delete the newly created record"); + } + return result; + }, + }); + + form.sidebar.$('a:contains(Duplicate)').click(); // duplicate record 1 + assert.strictEqual(form.$('.o_form_editable').length, 1, + "form should be in edit mode"); + assert.strictEqual(form.$('.o_field_widget').val(), 'first record (copy)', + "duplicated record should have correct name"); + form.$buttons.find('.o_form_button_save').click(); // save duplicated record + + form.sidebar.$('a:contains(Delete)').click(); // delete duplicated record + assert.strictEqual($('.modal').length, 1, "should have opened a confirm dialog"); + $('.modal .modal-footer .btn-primary').click(); // confirm + + assert.strictEqual(form.$('.o_field_widget').text(), 'first record', + "should have come back to previous record"); + + form.destroy(); + }); }); }); diff --git a/addons/web/static/tests/views/kanban_model_tests.js b/addons/web/static/tests/views/kanban_model_tests.js index eeb07c17358..1a3391c720d 100644 --- a/addons/web/static/tests/views/kanban_model_tests.js +++ b/addons/web/static/tests/views/kanban_model_tests.js @@ -55,6 +55,7 @@ QUnit.module('Views', { limit: 40, modelName: 'partner', openGroupByDefault: true, + viewType: 'kanban', }; } }, function () { diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index a6c0e049a17..3d375e7be0a 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -1503,6 +1503,43 @@ QUnit.module('Views', { "Create button should now be highlighted"); kanban.destroy(); }); + + QUnit.test('group_by_tooltip option when grouping on a many2one', function (assert) { + assert.expect(5); + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban default_group_by="bar">' + + '<field name="bar"/>' + + '<field name="product_id" '+ + 'options=\'{"group_by_tooltip": {"name": "Kikou"}}\'/>' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates></kanban>', + mockRPC: function (route, args) { + if (route === '/web/dataset/call_kw/product/read') { + assert.strictEqual(args.args[0].length, 2, + "read on two groups"); + assert.deepEqual(args.args[1], ['display_name', 'name'], + "should read on specified fields on the group by relation"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_group').length, 2, "should have " + 2 + " columns"); + + // simulate an update coming from the searchview, with another groupby given + kanban.update({groupBy: ['product_id']}); + assert.ok(kanban.$('.o_kanban_group:first span.o_column_title:contains(hello)').length, + "first column should have a title with a value from the many2one"); + assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_header').data('original-title'), + "<p>2 records</p><div>Kikou<br>hello</div>", + "first column should have a tooltip with the number of records, the group_by_tooltip title and many2one field value"); + + kanban.destroy(); + }); }); }); diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js index d74e29204d7..c3acdb52399 100644 --- a/addons/web/static/tests/views/pivot_tests.js +++ b/addons/web/static/tests/views/pivot_tests.js @@ -1,6 +1,7 @@ odoo.define('web.pivot_tests', function (require) { "use strict"; +var Context = require('web.Context'); var core = require('web.core'); var PivotView = require('web.PivotView'); var testUtils = require('web.test_utils'); @@ -81,7 +82,7 @@ QUnit.module('Views', { QUnit.module('PivotView'); QUnit.test('simple pivot rendering', function (assert) { - assert.expect(2); + assert.expect(3); var pivot = createView({ View: PivotView, @@ -97,6 +98,8 @@ QUnit.module('Views', { }, }); + assert.ok(pivot.$el.hasClass('o_enable_linking'), + "root node should have classname 'o_enable_linking'"); assert.strictEqual(pivot.$('td.o_pivot_cell_value:contains(32)').length, 1, "should contain a pivot cell with the sum of all records"); pivot.destroy(); @@ -119,6 +122,77 @@ QUnit.module('Views', { pivot.destroy(); }); + QUnit.test('clicking on a cell triggers a do_action', function (assert) { + assert.expect(2); + + var pivot = createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot>' + + '<field name="product_id" type="row"/>' + + '<field name="foo" type="measure"/>' + + '</pivot>', + intercepts: { + do_action: function (ev) { + assert.deepEqual(ev.data.action, { + context: {someKey: true, userContextKey: true}, + domain: [['product_id', '=', 37]], + name: 'Partners', + res_model: 'partner', + target: 'current', + type: 'ir.actions.act_window', + view_mode: 'list', + view_type: 'list', + views: [[false, 'list'], [2, 'form']], + }, "should trigger do_action with the correct args"); + }, + }, + session: { + user_context: {userContextKey: true}, + }, + viewOptions: { + action: { + views: [[2, 'form'], [5, 'kanban'], [false, 'list'], [false, 'pivot']], + }, + context: {someKey: true, search_default_test: 3}, + title: 'Partners', + } + }); + + assert.ok(pivot.$el.hasClass('o_enable_linking'), + "root node should have classname 'o_enable_linking'"); + pivot.$('.o_pivot_cell_value:contains(12)').click(); // should trigger a do_action + + pivot.destroy(); + }); + + QUnit.test('pivot view with disable_linking="True"', function (assert) { + assert.expect(2); + + var pivot = createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot disable_linking="True">' + + '<field name="foo" type="measure"/>' + + '</pivot>', + intercepts: { + do_action: function () { + assert.ok(false, "should not trigger do_action"); + }, + }, + }); + + assert.notOk(pivot.$el.hasClass('o_enable_linking'), + "root node should not have classname 'o_enable_linking'"); + assert.strictEqual(pivot.$('.o_pivot_cell_value').length, 1, + "should have one cell"); + pivot.$('.o_pivot_cell_value').click(); // should not trigger a do_action + + pivot.destroy(); + }); + QUnit.test('pivot view grouped by date field', function (assert) { assert.expect(2); diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js index 963b3c1552e..8c46d8fff46 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -10,10 +10,14 @@ QUnit.module('Views', { this.data = { partner: { fields: { + display_name: { string: "Displayed name", type: "char" }, foo: {string: "Foo", type: 'char'}, + bar: {string: "Bar", type: "boolean"}, }, records: [ - {id: 1, foo: 'blip',}, + {id: 1, foo: 'blip', display_name: 'blipblip', bar: true}, + {id: 2, foo: 'ta tata ta ta', display_name: 'macgyver', bar: false}, + {id: 3, foo: 'piou piou', display_name: "Jack O'Neill", bar: true}, ], }, }; @@ -63,6 +67,79 @@ QUnit.module('Views', { dialog.destroy(); }); + QUnit.test('SelectCreateDialog use domain, group_by and search default', function (assert) { + assert.expect(3); + + var search = 0; + var parent = createParent({ + data: this.data, + archs: { + 'partner,false,list': + '<tree string="Partner">' + + '<field name="display_name"/>' + + '<field name="foo"/>' + + '</tree>', + 'partner,false,search': + '<search>' + + '<field name="foo" filter_domain="[(\'display_name\',\'ilike\',self), (\'foo\',\'ilike\',self)]"/>' + + '<group expand="0" string="Group By">' + + '<filter name="groupby_bar" context="{\'group_by\' : \'bar\'}"/>' + + '</group>' + + '</search>', + }, + mockRPC: function (route, args) { + search++; + if (search === 1 && args.method === 'read_group') { + assert.deepEqual(args.kwargs, { + context: {group_by: "bar"}, + domain: [["display_name","like","a"], ["display_name","ilike","piou"], ["foo","ilike","piou"]], + fields:["display_name","foo","bar"], + groupby:["bar"], + lazy: true + }, "should search with the complete domain (domain + search), and group by 'bar'"); + } + if (search === 2 && route === '/web/dataset/search_read') { + assert.deepEqual(args, { + context: {}, + domain: [["display_name","like","a"], ["display_name","ilike","piou"], ["foo","ilike","piou"]], + fields:["display_name","foo"], + model: "partner", + limit: 80, + sort: "" + }, "should search with the complete domain (domain + search)"); + } + if (search === 3 && route === '/web/dataset/search_read') { + assert.deepEqual(args, { + context: {}, + domain: [["display_name","like","a"]], + fields:["display_name","foo"], + model: "partner", + limit: 80, + sort: "" + }, "should search with the domain"); + } + + return this._super.apply(this, arguments); + }, + }); + + var dialog = new dialogs.SelectCreateDialog(parent, { + no_create: true, + readonly: true, + res_model: 'partner', + domain: [['display_name', 'like', 'a']], + context: { + search_default_groupby_bar: true, + search_default_foo: 'piou', + }, + }).open(); + + dialog.$('.o_searchview_facet:contains(groupby_bar) .o_facet_remove').click(); + dialog.$('.o_searchview_facet .o_facet_remove').click(); + + dialog.destroy(); + }); + }); });
\ No newline at end of file |
