diff options
Diffstat (limited to 'addons/web/static')
32 files changed, 737 insertions, 113 deletions
diff --git a/addons/web/static/lib/qweb/qweb2.js b/addons/web/static/lib/qweb/qweb2.js index c4bb7128e9b..94a0afaa32a 100644 --- a/addons/web/static/lib/qweb/qweb2.js +++ b/addons/web/static/lib/qweb/qweb2.js @@ -358,7 +358,12 @@ QWeb2.Engine = (function() { req.open('GET', s, async); if (async) { req.addEventListener("load", function() { - if (req.status == 200) { + // 0, not being a valid HTTP status code, is used by browsers + // to indicate success for a non-http xhr response + // (for example, using the file:// protocol) + // https://developer.mozilla.org/fr/docs/Web/API/XMLHttpRequest + // https://bugzilla.mozilla.org/show_bug.cgi?id=331610 + if (req.status == 200 || req.status == 0) { callback(null, self._parse_from_request(req)); } else { callback(new Error("Can't load template " + s + ", http status " + req.status)); diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index b4b192c1ea9..9fe38adbde6 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -68,6 +68,7 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, { .load_filters(event.data.dataset, event.data.action_id) .then(event.data.on_success); }, + create_filter: '_onCreateFilter', push_state: '_onPushState', show_effect: '_onShowEffect', // session @@ -338,6 +339,18 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, { //-------------------------------------------------------------------------- /** + * @private + * @param {OdooEvent} e + * @param {Object} e.data.filter the filter description + * @param {function} e.data.on_success called when the RPC succeeds with its + * returned value as argument + */ + _onCreateFilter: function (e) { + data_manager + .create_filter(e.data.filter) + .then(e.data.on_success); + }, + /** * Displays a warning in a dialog of with the NotificationManager * * @private diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js index e213b55189d..1e9c52238bc 100644 --- a/addons/web/static/src/js/chrome/action_manager.js +++ b/addons/web/static/src/js/chrome/action_manager.js @@ -136,9 +136,10 @@ var ActionManager = Widget.extend({ * is useful when we come from a loadState()) * @param {boolean} [options.replace_last_action=false] set to true to * replace last part of the breadcrumbs with the action - * @return {Deferred} resolved when the action is loaded and appended to the - * DOM ; rejected if the action can't be executed (e.g. if doAction has - * been called to execute another action before this one was complete). + * @return {$.Deferred<Object>} resolved with the action when the action is + * loaded and appended to the DOM ; rejected if the action can't be + * executed (e.g. if doAction has been called to execute another action + * before this one was complete). */ doAction: function (action, options) { var self = this; @@ -175,7 +176,9 @@ var ActionManager = Widget.extend({ self._preprocessAction(action, options); - return self._handleAction(action, options); + return self._handleAction(action, options).then(function () { + return action; + }); }); }, /** @@ -349,6 +352,11 @@ var ActionManager = Widget.extend({ // toggle the fullscreen mode for actions in target='fullscreen' self._toggleFullscreen(); + // now that the action has been executed, force its 'pushState' + // flag to 'true', as we don't want to prevent its controller + // from pushing its state if it changes in the future + action.pushState = true; + return action; }) .fail(function () { diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js index 9cfa99a9e7d..dfb512900e8 100644 --- a/addons/web/static/src/js/chrome/action_manager_act_window.js +++ b/addons/web/static/src/js/chrome/action_manager_act_window.js @@ -21,6 +21,7 @@ ActionManager.include({ custom_events: _.extend({}, ActionManager.prototype.custom_events, { env_updated: '_onEnvUpdated', execute_action: '_onExecuteAction', + get_controller_context: '_onGetControllerContext', search: '_onSearch', switch_view: '_onSwitchView', }), @@ -254,18 +255,26 @@ ActionManager.include({ action.env = self._generateActionEnv(action, options); action.controllers = {}; - // select the first view to display - var lazyLoadFirstView = false; + // select the first view to display, and optionally the main view + // which will be lazyloaded var firstView = options.viewType && _.findWhere(views, {type: options.viewType}); + var mainView; if (firstView) { if (!firstView.multiRecord && views[0].multiRecord) { - lazyLoadFirstView = true; + mainView = views[0]; } } else { firstView = views[0]; } - if (config.device.isMobile && !firstView.isMobileFriendly) { - firstView = _.findWhere(action.views, {isMobileFriendly: true}) || firstView; + + // use mobile-friendly view by default in mobile, if possible + if (config.device.isMobile) { + if (!firstView.isMobileFriendly) { + firstView = self._findMobileView(views, firstView.multiRecord) || firstView; + } + if (mainView && !mainView.isMobileFriendly) { + mainView = self._findMobileView(views, mainView.multiRecord) || mainView; + } } var def; @@ -279,8 +288,8 @@ ActionManager.include({ return $.when(def).then(function () { var defs = []; defs.push(self._createViewController(action, firstView.type)); - if (lazyLoadFirstView) { - defs.push(self._createViewController(action, views[0].type, {}, {lazy: true})); + if (mainView) { + defs.push(self._createViewController(action, mainView.type, {}, {lazy: true})); } return $.when.apply($, defs); }).then(function (controller, lazyLoadedController) { @@ -297,6 +306,22 @@ ActionManager.include({ }); }, /** + * Helper function to find the first mobile-friendly view, if any. + * + * @private + * @param {Array} views an array of views + * @param {boolean} multiRecord set to true iff we search for a multiRecord + * view + * @returns {Object|undefined} a mobile-friendly view of the requested + * multiRecord type, undefined if there is no such view + */ + _findMobileView: function (views, multiRecord) { + return _.findWhere(views, { + isMobileFriendly: true, + multiRecord: multiRecord, + }); + }, + /** * Generates the initial environment of a given action, which is a dict * containing information like the model name, the domain, the context... * That information is shared between the controllers of the corresponding @@ -716,6 +741,20 @@ ActionManager.include({ }); }, /** + * Handles a context request: provides to the caller the context of the + * current controller. + * + * @private + * @param {OdooEvent} ev + * @param {function} ev.data.callback used to send the requested context + */ + _onGetControllerContext: function (ev) { + ev.stopPropagation(); + var currentController = this.getCurrentController(); + var context = currentController && currentController.widget.getContext(); + ev.data.callback(context || {}); + }, + /** * Called when there is a change in the search view, so the current action's * environment needs to be updated with the new domain, context and groupby. * diff --git a/addons/web/static/src/js/chrome/control_panel.js b/addons/web/static/src/js/chrome/control_panel.js index 16b22761581..03b9e756bb7 100644 --- a/addons/web/static/src/js/chrome/control_panel.js +++ b/addons/web/static/src/js/chrome/control_panel.js @@ -154,7 +154,9 @@ var ControlPanel = Widget.extend({ this._attach_content(new_cp_content); // Update the searchview and switch buttons - this._update_search_view(status.searchview, status.search_view_hidden, status.groupable); + if (status.searchview || options.clear) { + this._update_search_view(status.searchview, status.search_view_hidden, status.groupable); + } if (status.active_view_selector) { this._update_switch_buttons(status.active_view_selector); } diff --git a/addons/web/static/src/js/chrome/search_menus.js b/addons/web/static/src/js/chrome/search_menus.js index 38f2bb7374c..d33cdce6500 100644 --- a/addons/web/static/src/js/chrome/search_menus.js +++ b/addons/web/static/src/js/chrome/search_menus.js @@ -97,10 +97,15 @@ return Widget.extend({ } var user_context = this.getSession().user_context; var search = this.searchview.build_search_data(); - var view_context = this.action.env.context; + var controllerContext; + this.trigger_up('get_controller_context', { + callback: function (ctx) { + controllerContext = ctx; + }, + }); var results = pyeval.eval_domains_and_contexts({ domains: search.domains, - contexts: [user_context].concat(search.contexts.concat(view_context || [])), + contexts: [user_context].concat(search.contexts.concat(controllerContext || [])), group_by_seq: search.groupbys || [], }); if (!_.isEmpty(results.group_by)) { @@ -123,7 +128,7 @@ return Widget.extend({ is_default: default_filter, action_id: this.action_id, }; - return data_manager.create_filter(filter).done(function (id) { + return this._createFilter(filter).then(function (id) { filter.id = id; self.toggle_save_menu(false); self.$save_name.find('input').val('').prop('checked', false); @@ -273,6 +278,26 @@ return Widget.extend({ } }); }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Creates a new search view filter. + * + * @private + * @param {Object} filter the filter description + * @returns {$.Deferred} resolved with the RPC's result when it succeeds + */ + _createFilter: function (filter) { + var def = $.Deferred(); + this.trigger_up('create_filter', { + filter: filter, + on_success: def.resolve.bind(def), + }); + return def; + }, }); }); diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js index 251cd2b508c..ffeb4a1de87 100644 --- a/addons/web/static/src/js/core/utils.js +++ b/addons/web/static/src/js/core/utils.js @@ -228,7 +228,7 @@ var utils = { * @returns {boolean} */ is_bin_size: function (v) { - return (/^\d+(\.\d*)? \w+$/).test(v); + return (/^\d+(\.\d*)? [^0-9]+$/).test(v); }, /** * @param {any} node diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 64783e09cdd..2a9a2906501 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -1008,7 +1008,10 @@ var FieldX2Many = AbstractField.extend({ * @param {OdooEvent} ev */ _onDiscardChanges: function (ev) { - ev.data.fieldName = this.name; + if (ev.target !== this) { + ev.stopPropagation(); + this.trigger_up('discard_changes', _.extend({}, ev.data, {fieldName: this.name})); + } }, /** * Called when the renderer asks to edit a line, in that case simply tells diff --git a/addons/web/static/src/js/services/crash_manager.js b/addons/web/static/src/js/services/crash_manager.js index 9c7914eeebf..ccc7e089e65 100644 --- a/addons/web/static/src/js/services/crash_manager.js +++ b/addons/web/static/src/js/services/crash_manager.js @@ -103,7 +103,7 @@ var CrashManager = core.Class.extend({ if (!this.active) { return; } - new Dialog(this, { + return new Dialog(this, { size: 'medium', title: _.str.capitalize(error.type || error.message) || _t("Odoo Warning"), subtitle: error.data.title, @@ -149,10 +149,10 @@ var CrashManager = core.Class.extend({ clipboard.destroy(); }); - dialog.open(); + return dialog.open(); }, show_message: function(exception) { - this.show_error({ + return this.show_error({ type: _t("Odoo Client Error"), message: exception, data: {debug: ""} 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 7998a6ca8f1..05031ca2463 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -272,6 +272,13 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { * @returns {Deferred} */ _confirmChange: function (id, fields, e) { + if (e.name === 'discard_changes' && e.target.reset) { + // the target of the discard event is a field widget. In that + // case, we simply want to reset the specific field widget, + // not the full view + return e.target.reset(this.model.get(e.target.dataPointID), e, true); + } + var state = this.model.get(this.handle); return this.renderer.confirmChange(state, id, fields, e); }, 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 2ef65cbd1b6..3013cb32f85 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -198,6 +198,90 @@ var BasicModel = AbstractModel.extend({ }); }, /** + * Add and process default values for a given record. Those values are + * parsed and stored in the '_changes' key of the record. For relational + * fields, sub-dataPoints are created, and missing relational data is + * fetched. Also generate default values for fields with no given value. + * Typically, this function is called with the result of a 'default_get' + * RPC, to populate a newly created dataPoint. It may also be called when a + * one2many subrecord is open in a form view (dialog), to generate the + * default values for the fields displayed in the o2m form view, but not in + * the list or kanban (mainly to correctly create sub-dataPoints for + * relational fields). + * + * @param {string} recordID local id for a record + * @param {Object} values dict of default values for the given record + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @param {Array} [options.fieldNames] list of field names for which a + * default value must be generated (used to complete the values dict) + * @returns {Deferred} + */ + applyDefaultValues: function (recordID, values, options) { + options = options || {}; + var record = this.localData[recordID]; + var viewType = options.viewType || record.viewType; + var fieldNames = options.fieldNames || Object.keys(record.fieldsInfo[viewType]); + var field; + var fieldName; + record._changes = record._changes || {}; + + // ignore values for non requested fields (for instance, fields that are + // not in the view) + values = _.pick(values, fieldNames); + + // fill default values for missing fields + for (var i = 0; i < fieldNames.length; i++) { + fieldName = fieldNames[i]; + if (!(fieldName in values) && !(fieldName in record._changes)) { + field = record.fields[fieldName]; + if (field.type === 'float' || + field.type === 'integer' || + field.type === 'monetary') { + values[fieldName] = 0; + } else if (field.type === 'one2many' || field.type === 'many2many') { + values[fieldName] = []; + } else { + values[fieldName] = null; + } + } + } + + // parse each value and create dataPoints for relational fields + var defs = []; + for (fieldName in values) { + field = record.fields[fieldName]; + record.data[fieldName] = null; + var dp; + if (field.type === 'many2one' && values[fieldName]) { + dp = this._makeDataPoint({ + context: record.context, + data: {id: values[fieldName]}, + modelName: field.relation, + parentID: record.id, + }); + record._changes[fieldName] = dp.id; + } else if (field.type === 'reference' && values[fieldName]) { + var ref = values[fieldName].split(','); + dp = this._makeDataPoint({ + context: record.context, + data: {id: parseInt(ref[1])}, + modelName: ref[0], + parentID: record.id, + }); + defs.push(this._fetchNameGet(dp)); + record._changes[fieldName] = dp.id; + } else if (field.type === 'one2many' || field.type === 'many2many') { + defs.push(this._processX2ManyCommands(record, fieldName, values[fieldName], options)); + } else { + record._changes[fieldName] = this._parseServerValue(field, values[fieldName]); + } + } + + return $.when.apply($, defs); + }, + /** * Onchange RPCs may return values for fields that are not in the current * view. Those fields might even be unknown when the onchange returns (e.g. * in x2manys, we only know the fields that are used in the inner view, but @@ -1414,6 +1498,7 @@ var BasicModel = AbstractModel.extend({ list = self._makeDataPoint({ fields: view ? view.fields : fieldInfo.relatedFields, fieldsInfo: view ? view.fieldsInfo : fieldInfo.fieldsInfo, + limit: fieldInfo.limit, modelName: field.relation, parentID: record.id, static: true, @@ -2980,7 +3065,7 @@ var BasicModel = AbstractModel.extend({ if (field.type === 'one2many' || field.type === 'many2many') { var ids; if (!context[fieldName] || _.isArray(context[fieldName])) { // no dataPoint created yet - ids = context[fieldName] || []; + ids = context[fieldName] ? context[fieldName].slice(0) : []; } else { relDataPoint = this._applyX2ManyOperations(this.localData[context[fieldName]]); ids = relDataPoint.res_ids.slice(0); @@ -3260,27 +3345,8 @@ var BasicModel = AbstractModel.extend({ context: params.context, }) .then(function (result) { - // fill default values for missing fields - for (var i = 0; i < fieldNames.length; i++) { - var fieldName = fieldNames[i]; - if (!(fieldName in result)) { - var field = params.fields[fieldName]; - if (field.type === 'float' || - field.type === 'integer' || - field.type === 'monetary') { - result[fieldName] = 0; - } else if (field.type === 'one2many' || field.type === 'many2many') { - result[fieldName] = []; - } else { - result[fieldName] = null; - } - } - } - - var data = {}; var record = self._makeDataPoint({ modelName: modelName, - data: data, fields: params.fields, fieldsInfo: params.fieldsInfo, context: params.context, @@ -3289,37 +3355,7 @@ var BasicModel = AbstractModel.extend({ viewType: params.viewType, }); - var defs = []; - _.each(fieldNames, function (name) { - var field = params.fields[name]; - data[name] = null; - record._changes = record._changes || {}; - var dp; - if (field.type === 'many2one' && result[name]) { - dp = self._makeDataPoint({ - context: record.context, - data: {id: result[name]}, - modelName: field.relation, - parentID: record.id, - }); - record._changes[name] = dp.id; - } else if (field.type === 'reference' && result[name]) { - var ref = result[name].split(','); - dp = self._makeDataPoint({ - context: record.context, - data: {id: parseInt(ref[1])}, - modelName: ref[0], - parentID: record.id, - }); - defs.push(self._fetchNameGet(dp)); - record._changes[name] = dp.id; - } else if (field.type === 'one2many' || field.type === 'many2many') { - defs.push(self._processX2ManyCommands(record, name, result[name])); - } else { - record._changes[name] = self._parseServerValue(field, result[name]); - } - }); - return $.when.apply($, defs) + return self.applyDefaultValues(record.id, result) .then(function () { var def = $.Deferred(); self._performOnChange(record, fields_key).always(function () { @@ -3502,13 +3538,17 @@ var BasicModel = AbstractModel.extend({ * @param {Object} record * @param {string} fieldName * @param {Array[Array]} commands + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record * @returns {Deferred} */ - _processX2ManyCommands: function (record, fieldName, commands) { + _processX2ManyCommands: function (record, fieldName, commands, options) { var self = this; + options = options || {}; var defs = []; var field = record.fields[fieldName]; - var fieldInfo = record.fieldsInfo[record.viewType][fieldName]; + var fieldInfo = record.fieldsInfo[options.viewType || record.viewType][fieldName]; var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; var fields = view ? view.fields : fieldInfo.relatedFields; @@ -3802,14 +3842,14 @@ var BasicModel = AbstractModel.extend({ // for multi-pages list datapoints, we might need to read the // order field first to apply the order on all pages - if (list.res_ids.length > list.limit) { - if (!list.orderedResIDs && list.orderedBy.length) { + if (list.res_ids.length > list.limit && list.orderedBy.length) { + if (!list.orderedResIDs) { var fieldNames = _.pluck(list.orderedBy, 'name'); def = this._readMissingFields(list, _.filter(list.res_ids, _.isNumber), fieldNames); - def.then(function () { - self._sortList(list); - }); } + def.then(function () { + self._sortList(list); + }); } return def.then(function () { var resIDs = []; diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js index df9f9807bfd..b4df9c8e09e 100644 --- a/addons/web/static/src/js/views/basic/basic_view.js +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -45,7 +45,7 @@ var BasicView = AbstractView.extend({ this.rendererParams.viewType = this.viewType; this.controllerParams.confirmOnDelete = true; - this.controllerParams.archiveEnabled = 'active' in viewInfo.fields; + this.controllerParams.archiveEnabled = 'active' in this.fields; this.controllerParams.hasButtons = 'action_buttons' in params ? params.action_buttons : true; @@ -161,7 +161,12 @@ var BasicView = AbstractView.extend({ // (because those fields were unknow at that time). So we ask // the model to process them. def = this.model.applyRawChanges(record.id, viewType).then(function () { - if (!self.model.isNew(record.id)) { + if (self.model.isNew(record.id)) { + return self.model.applyDefaultValues(record.id, {}, { + fieldNames: fieldNames, + viewType: viewType, + }); + } else { return self.model.reload(record.id, { fieldNames: fieldNames, keepChanges: true, diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js index 2755c4c39fd..d49c7c33f5c 100644 --- a/addons/web/static/src/js/views/calendar/calendar_controller.js +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -183,7 +183,7 @@ var CalendarController = AbstractController.extend({ // When clicking on a random day of a random other week, switch to week view this.model.setScale('week'); } - this.model.setDate(event.data.date, true); + this.model.setDate(event.data.date); this.reload(); }, /** diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js index ca28e90713f..5542ab06476 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -218,9 +218,9 @@ return AbstractModel.extend({ filters: params.filters, }; + this.setDate(params.initialDate); // Use mode attribute in xml file to specify zoom timeline (day,week,month) // by default month. - this.setDate(params.initialDate, true); this.setScale(params.mode); _.each(this.data.filters, function (filter) { @@ -260,9 +260,8 @@ return AbstractModel.extend({ }, /** * @param {Moment} start - * @param {boolean} highlight */ - setDate: function (start, highlight) { + setDate: function (start) { this.data.start_date = this.data.end_date = this.data.target_date = this.data.highlight_date = start; this.data.start_date.utc().add(this.getSession().getTZOffset(this.data.start_date), 'minutes'); @@ -279,9 +278,6 @@ return AbstractModel.extend({ this.data.start_date = this.data.start_date.clone().startOf('day'); this.data.end_date = this.data.end_date.clone().endOf('day'); } - if (highlight) { - this.data.highlight_date = this.data.target_date; - } }, /** * @param {string} scale the scale to set diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js index 469130c723a..a8d5d07ed0d 100644 --- a/addons/web/static/src/js/views/calendar/calendar_renderer.js +++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js @@ -467,11 +467,6 @@ return AbstractRenderer.extend({ this.target_date = this.state.target_date.toString(); } - var highlightDate = moment(this.state.highlight_date).format('YYYY-MM-DD'); - $calendar.find('.o_target_date').removeClass('o_target_date'); - $calendar.find('.fc-bg .fc-day[data-date="'+highlightDate+'"]') - .addClass('o_target_date'); - this.$small_calendar.datepicker("setDate", this.state.highlight_date.toDate()) .find('.o_selected_range') .removeClass('o_color o_selected_range'); 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 731183e7efe..5f31c7103c7 100644 --- a/addons/web/static/src/js/views/pivot/pivot_controller.js +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -126,6 +126,7 @@ var PivotController = AbstractController.extend({ var table = this.model.exportData(); if(table.measure_row.length + 1 > 256) { crash_manager.show_message(_t("For Excel compatibility, data cannot be exported if there are more than 256 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures.")); + framework.unblockUI(); return; } framework.blockUI(); 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 99a5e4ee334..1c76c41ab71 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -135,7 +135,7 @@ var PivotModel = AbstractModel.extend({ exportData: function () { var measureNbr = this.data.measures.length; var headers = this._computeHeaders(); - var measureRow = measureNbr > 1 ? _.last(headers) : []; + var measureRow = measureNbr >= 1 ? _.last(headers) : []; var rows = this._computeRows(); var i, j, value; headers[0].splice(0,1); diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index 4be84c23b71..1b74068d44d 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -260,6 +260,7 @@ var SelectCreateDialog = ViewDialog.extend({ var searchData = this._process_search_data(d.domains, d.contexts, d.groupbys); this.list_controller.reload(searchData); }, + get_controller_context: '_onGetControllerContext', }), /** @@ -416,6 +417,24 @@ var SelectCreateDialog = ViewDialog.extend({ dialog.on('closed', this, this.close); return dialog; }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Handles a context request: provides to the caller the context of the + * list controller. + * + * @private + * @param {OdooEvent} ev + * @param {function} ev.data.callback used to send the requested context + */ + _onGetControllerContext: function (ev) { + ev.stopPropagation(); + var context = this.list_controller && this.list_controller.getContext(); + ev.data.callback(context || {}); + } }); return { diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js index 4b9f6a29a47..dd0096f269b 100644 --- a/addons/web/static/src/js/widgets/data_export.js +++ b/addons/web/static/src/js/widgets/data_export.js @@ -455,7 +455,7 @@ var DataExport = Dialog.extend({ Dialog.alert(this, _t("Please select fields to export...")); return; } - exported_fields.unshift({name: 'id', label: 'External ID'}); + exported_fields.unshift({name: 'id', label: _t('External ID')}); var export_format = this.$export_format_inputs.filter(':checked').val(); diff --git a/addons/web/static/src/js/widgets/domain_selector_dialog.js b/addons/web/static/src/js/widgets/domain_selector_dialog.js index 5f41973951d..46b54089c5f 100644 --- a/addons/web/static/src/js/widgets/domain_selector_dialog.js +++ b/addons/web/static/src/js/widgets/domain_selector_dialog.js @@ -40,7 +40,11 @@ return Dialog.extend({ this.domainSelector = new DomainSelector(this, model, domain, options); }, start: function () { - this.$el.css("overflow", "visible").closest(".modal-dialog").css("height", "auto"); // This restores default modal height (bootstrap) and allows field selector to overflow + var self = this; + this.opened().then(function () { + // this restores default modal height (bootstrap) and allows field selector to overflow + self.$el.css('overflow', 'visible').closest('.modal-dialog').css('height', 'auto'); + }); return $.when( this._super.apply(this, arguments), this.domainSelector.appendTo(this.$el) diff --git a/addons/web/static/src/js/widgets/notification.js b/addons/web/static/src/js/widgets/notification.js index 21c24c99e1b..d05a0be380c 100644 --- a/addons/web/static/src/js/widgets/notification.js +++ b/addons/web/static/src/js/widgets/notification.js @@ -34,7 +34,8 @@ var Notification = Widget.extend({ this.title = params.title; this.text = params.text; this.sticky = !!params.sticky; - if (params.type === 'warning') { + this.type = params.type; + if (this.type === 'warning') { this.template = 'Warning'; } }, @@ -43,6 +44,9 @@ var Notification = Widget.extend({ */ start: function () { var self = this; + if (this.type === 'warning') { + this.$el.addClass('o_error'); + } return this._super.apply(this, arguments).then(function () { self.$el.animate({opacity: 1.0}, 400, "swing", function () { if(!self.sticky) { diff --git a/addons/web/static/src/less/form_view.less b/addons/web/static/src/less/form_view.less index 78246df9e6c..96e8bd2ac97 100644 --- a/addons/web/static/src/less/form_view.less +++ b/addons/web/static/src/less/form_view.less @@ -165,7 +165,9 @@ .o-flex-flow(row, wrap); > .btn { - margin-right: 3px; + @o-statusbar-buttons-vmargin: 4px; + min-height: @odoo-statusbar-height - 2 * @o-statusbar-buttons-vmargin; + margin: @o-statusbar-buttons-vmargin 3px @o-statusbar-buttons-vmargin 0; padding-top: 2px; padding-bottom: 2px; } diff --git a/addons/web/static/src/less/views.less b/addons/web/static/src/less/views.less index 104d4e1b2a5..0c7936fc570 100644 --- a/addons/web/static/src/less/views.less +++ b/addons/web/static/src/less/views.less @@ -61,6 +61,10 @@ font-size: 125%; } + a { + cursor: pointer; + } + .o-init-image() { content: ""; display: block; diff --git a/addons/web/static/src/less/web_calendar.less b/addons/web/static/src/less/web_calendar.less index a3ead61f011..5179552b3b6 100644 --- a/addons/web/static/src/less/web_calendar.less +++ b/addons/web/static/src/less/web_calendar.less @@ -68,11 +68,6 @@ overflow-x: auto; } } - - .o_target_date:not(.fc-today) { - background-color: @odoo-brand-primary; - opacity: 0.1; - } } .o_calendar_sidebar_container { diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 65b75fa00e0..748addc4b87 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -125,7 +125,6 @@ </div> </t> <t t-name="Warning" t-extend="Notification"> - <t t-jquery=".o_notification">$(this).addClass('o_error');</t> <t t-jquery=".o_icon" t-operation="replace"> <span class="o_icon fa fa-3x fa-exclamation"/> </t> diff --git a/addons/web/static/tests/chrome/action_manager_mobile_tests.js b/addons/web/static/tests/chrome/action_manager_mobile_tests.js index c23d7be19f8..491830578e6 100644 --- a/addons/web/static/tests/chrome/action_manager_mobile_tests.js +++ b/addons/web/static/tests/chrome/action_manager_mobile_tests.js @@ -80,6 +80,49 @@ QUnit.module('ActionManager', { actionManager.destroy(); }); + + QUnit.test('lazy load mobile-friendly view', function (assert) { + assert.expect(11); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + return this._super.apply(this, arguments); + }, + }); + actionManager.loadState({ + action: 1, + view_type: 'form', + }); + + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "should not have rendered a list view"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 0, + "should not have rendered a kanban view either"); + assert.strictEqual(actionManager.$('.o_form_view').length, 1, + "should have rendered a form view"); + + // go back to lazy loaded view + $('.o_control_panel .breadcrumb a').click(); + assert.strictEqual(actionManager.$('.o_form_view').length, 0, + "should not display the form view anymore"); + assert.strictEqual(actionManager.$('.o_list_view').length, 0, + "should not display the list view either"); + assert.strictEqual(actionManager.$('.o_kanban_view').length, 1, + "should have lazy loaded the kanban view"); + + assert.verifySteps([ + '/web/action/load', + 'load_views', + 'default_get', // default_get to open form view + '/web/dataset/search_read', // search read when coming back to Kanban + ]); + + actionManager.destroy(); + }); }); }); diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js index 4c4f6f9e338..9f7bacc1689 100644 --- a/addons/web/static/tests/chrome/action_manager_tests.js +++ b/addons/web/static/tests/chrome/action_manager_tests.js @@ -6,6 +6,7 @@ var ReportClientAction = require('report.client_action'); var AbstractAction = require('web.AbstractAction'); var ControlPanelMixin = require('web.ControlPanelMixin'); var core = require('web.core'); +var ListController = require('web.ListController'); var ReportService = require('web.ReportService'); var testUtils = require('web.test_utils'); var Widget = require('web.Widget'); @@ -760,7 +761,7 @@ QUnit.module('ActionManager', { }); QUnit.test('should not push a loaded state', function (assert) { - assert.expect(1); + assert.expect(3); var actionManager = createActionManager({ actions: this.actions, @@ -772,9 +773,14 @@ QUnit.module('ActionManager', { }, }, }); - actionManager.loadState({action: 1}); + actionManager.loadState({action: 3}); - assert.verifySteps([]); + assert.verifySteps([], "should not push the loaded state"); + + actionManager.$('tr.o_data_row:first').click(); + + assert.verifySteps(['push_state'], + "should push the state of it changes afterwards"); actionManager.destroy(); }); @@ -2350,6 +2356,70 @@ QUnit.module('ActionManager', { actionManager.destroy(); }); + QUnit.test('save current search', function (assert) { + assert.expect(4); + + testUtils.patch(ListController, { + getContext: function () { + return { + shouldBeInFilterContext: true, + }; + }, + }); + + _.extend(this.archs, { + 'partner,7,search': + '<search>' + + '<filter name="bar" help="Bar" domain="[(\'bar\', \'=\', 1)]"/>' + + '</search>', + }); + + this.actions.push({ + id: 33, + context: { + shouldNotBeInFilterContext: false, + }, + name: 'Partners', + res_model: 'partner', + search_view_id: [7, 'a custom search view'], + type: 'ir.actions.act_window', + views: [[false, 'list']], + }); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + intercepts: { + create_filter: function (event) { + var filter = event.data.filter; + assert.deepEqual(filter.domain, [['bar', '=', 1]], + "should save the correct domain"); + assert.deepEqual(filter.context, {shouldBeInFilterContext: true}, + "should save the correct context"); + }, + }, + }); + actionManager.doAction(33); + + assert.strictEqual(actionManager.$('.o_data_row').length, 5, + "should contain 5 records"); + + // filter on bar + $('.o_control_panel .o_filters_menu a:contains(Bar)').click(); + + assert.strictEqual(actionManager.$('.o_data_row').length, 2, + "should contain 2 records"); + + // save filter + $('.o_control_panel .o_save_search a').click(); // toggle 'Save current search' + $('.o_control_panel .o_save_name input[type=text]').val('some name'); // name the filter + $('.o_control_panel .o_save_name button').click(); // click on 'Save' + + testUtils.unpatch(ListController); + actionManager.destroy(); + }); + QUnit.module('Actions in target="new"'); QUnit.test('can execute act_window actions in target="new"', function (assert) { @@ -2528,6 +2598,35 @@ QUnit.module('ActionManager', { actionManager.destroy(); }); + + QUnit.test('doAction resolved with an action', function (assert) { + assert.expect(4); + var done = assert.async(); + + this.actions.push({ + id: 21, + name: 'A Close Action', + type: 'ir.actions.act_window_close', + }); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + + actionManager.doAction(21).then(function (action) { + assert.ok(action, "doAction should be resolved with an action"); + assert.strictEqual(action.id, 21, + "should be resolved with correct action id"); + assert.strictEqual(action.name, 'A Close Action', + "should be resolved with correct action name"); + assert.strictEqual(action.type, 'ir.actions.act_window_close', + "should be resolved with correct action type"); + actionManager.destroy(); + done(); + }); + }); }); }); diff --git a/addons/web/static/tests/core/util_tests.js b/addons/web/static/tests/core/util_tests.js index 204503117e6..c7158083dcb 100644 --- a/addons/web/static/tests/core/util_tests.js +++ b/addons/web/static/tests/core/util_tests.js @@ -44,6 +44,17 @@ QUnit.module('core', {}, function () { assert.strictEqual(intersperse("12345678", [3,0], '.'), '12.345.678'); }); + QUnit.test('is_bin_size', function (assert) { + assert.expect(3); + + var is_bin_size = utils.is_bin_size; + + assert.strictEqual(is_bin_size('Cg=='), false); + assert.strictEqual(is_bin_size('2.5 Mb'), true); + // should also work for non-latin languages (e.g. russian) + assert.strictEqual(is_bin_size('64.2 Кб'), true); + }); + }); });
\ No newline at end of file diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 2e40d7cc5ec..9b8df65ab29 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -368,10 +368,6 @@ QUnit.module('relational_fields', { "should have an open record button"); form.$('input.o_input').click(); - form.$('input.o_input').trigger($.Event('keyup', { - which: $.ui.keyCode.ESC, - keyCode: $.ui.keyCode.ESC, - })); assert.strictEqual(form.$('button.o_external_button:visible').length, 1, "should still have an open record button"); @@ -3064,6 +3060,108 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('onchange on one2many containing x2many in form view', function (assert) { + assert.expect(16); + + this.data.partner.onchanges = { + foo: function (obj) { + obj.turtles = [[0, false, {turtle_foo: 'new record'}]]; + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form>' + + '<field name="foo"/>' + + '<field name="turtles">' + + '<tree>' + + '<field name="turtle_foo"/>' + + '</tree>' + + '<form>' + + '<field name="partner_ids">' + + '<tree editable="top">' + + '<field name="foo"/>' + + '</tree>' + + '</field>' + + '</form>' + + '</field>' + + '</form>', + archs: { + 'partner,false,list': '<tree><field name="foo"/></tree>', + 'partner,false,search': '<search></search>', + }, + }); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "the onchange should have created one record in the relation"); + + // open the created o2m record in a form view, and add a m2m subrecord + // in its relation + form.$('.o_data_row').click(); + + assert.strictEqual($('.modal').length, 1, "should have opened a dialog"); + assert.strictEqual($('.modal .o_data_row').length, 0, + "there should be no record in the one2many in the dialog"); + + // add a many2many subrecord + $('.modal .o_field_x2many_list_row_add a').click(); + + assert.strictEqual($('.modal').length, 2, + "should have opened a second dialog"); + + // select a many2many subrecord + $('.modal:nth(1) .o_list_view .o_data_cell:first').click(); + + assert.strictEqual($('.modal').length, 1, + "second dialog should be closed"); + assert.strictEqual($('.modal .o_data_row').length, 1, + "there should be one record in the one2many in the dialog"); + assert.notOk($('.modal .o_x2m_control_panel .o_cp_pager div').is(':visible'), + 'm2m pager should be hidden'); + + // click on 'Save & Close' + $('.modal .modal-footer .btn-primary:first').click(); + + assert.strictEqual($('.modal').length, 0, "dialog should be closed"); + + // reopen o2m record, and another m2m subrecord in its relation, but + // discard the changes + form.$('.o_data_row').click(); + + assert.strictEqual($('.modal').length, 1, "should have opened a dialog"); + assert.strictEqual($('.modal .o_data_row').length, 1, + "there should be one record in the one2many in the dialog"); + + // add another m2m subrecord + $('.modal .o_field_x2many_list_row_add a').click(); + + assert.strictEqual($('.modal').length, 2, + "should have opened a second dialog"); + + $('.modal:nth(1) .o_list_view .o_data_cell:first').click(); + + assert.strictEqual($('.modal').length, 1, + "second dialog should be closed"); + assert.strictEqual($('.modal .o_data_row').length, 2, + "there should be two records in the one2many in the dialog"); + + // click on 'Discard' + $('.modal .modal-footer .btn-default').click(); + + assert.strictEqual($('.modal').length, 0, "dialog should be closed"); + + // reopen o2m record to check that second changes have properly been discarded + form.$('.o_data_row').click(); + + assert.strictEqual($('.modal').length, 1, "should have opened a dialog"); + assert.strictEqual($('.modal .o_data_row').length, 1, + "there should be one record in the one2many in the dialog"); + + form.destroy(); + }); + QUnit.test('embedded one2many with handle widget with minimum setValue calls', function (assert) { var done = assert.async(); assert.expect(20); @@ -8017,6 +8115,80 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('one2many with several pages, onchange and default order', function (assert) { + // This test reproduces a specific scenario where a one2many is displayed + // over several pages, and has a default order such that a record that + // would normally be on page 1 is actually on another page. Moreover, + // there is an onchange on that one2many which converts all commands 4 + // (LINK_TO) into commands 1 (UPDATE), which is standard in the ORM. + // This test ensures that the record displayed on page 2 is never fully + // read. + assert.expect(8); + + var data = this.data; + data.partner.records[0].turtles = [1, 2, 3]; + data.turtle.records[0].partner_ids = [1]; + data.partner.onchanges = { + turtles: function (obj) { + var res = _.map(obj.turtles, function (command) { + if (command[0] === 1) { // already an UPDATE command: do nothing + return command; + } + // convert LINK_TO commands to UPDATE commands + var id = command[1]; + var record = _.findWhere(data.turtle.records, {id: id}); + return [1, id, _.pick(record, ['turtle_int', 'turtle_foo', 'partner_ids'])]; + }); + obj.turtles = [[5]].concat(res); + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: data, + arch: '<form string="Partners">' + + '<field name="turtles">' + + '<tree editable="top" limit="2" default_order="turtle_foo">' + + '<field name="turtle_int"/>' + + '<field name="turtle_foo" class="foo"/>' + + '<field name="partner_ids" widget="many2many_tags"/>' + + '</tree>' + + '</field>' + + '</form>', + mockRPC: function (route, args) { + var ids = args.method === 'read' ? ' [' + args.args[0] + ']' : ''; + assert.step(args.method + ids); + return this._super.apply(this, arguments); + }, + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('.o_data_cell.foo').text(), 'blipkawa', + "should display two records out of three, in the correct order"); + + // edit turtle_int field of first row + form.$('.o_data_cell:first').click(); + form.$('.o_field_widget[name=turtle_int]').val(3).trigger('input'); + form.$el.click(); // leave edition + + assert.strictEqual(form.$('.o_data_cell.foo').text(), 'blipkawa', + "should still display the same two records"); + + assert.verifySteps([ + 'read [1]', // main record + 'read [1,2,3]', // one2many (turtle_foo, all records) + 'read [2,3]', // one2many (all fields in view, records of first page) + 'read [2,4]', // many2many inside one2many (partner_ids), first page only + 'onchange', + ]); + + form.destroy(); + }); + QUnit.test('new record, with one2many with more default values than limit', function (assert) { assert.expect(2); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index fb5af01fdcd..99b85dcfa56 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -565,9 +565,13 @@ var MockServer = Class.extend({ return record.display_name.indexOf(str) !== -1; }); } - return _.map(records, function (record) { + var result = _.map(records, function (record) { return [record.id, record.display_name]; }); + if (args.limit) { + return result.slice(0, args.limit); + } + return result; }, /** * Simulate an 'onchange' rpc diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 492dfd4224c..bf5261c5b6e 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -669,6 +669,42 @@ QUnit.module('Views', { model.destroy(); }); + QUnit.test('default_get returning a non requested field', function (assert) { + // 'default_get' returns a default value for the fields given in + // arguments. It should not return a value for fields that have not be + // requested. However, it happens (e.g. res.users), and the webclient + // should not crash when this situation occurs (the field should simply + // be ignored). + assert.expect(2); + + this.params.context = {}; + this.params.fieldNames = ['category']; + this.params.res_id = undefined; + this.params.type = 'record'; + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + var result = this._super(route, args); + if (args.method === 'default_get') { + result.product_ids = [[6, 0, [37, 41]]]; + } + return result; + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.ok('category' in record.data, + "should have processed 'category'"); + assert.notOk('product_ids' in record.data, + "should have ignored 'product_ids'"); + }); + + model.destroy(); + }); + QUnit.test('can make a default_record with default relational values', function (assert) { assert.expect(7); diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js index 014435194f3..6ec4f50ab4b 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -1,8 +1,9 @@ odoo.define('web.view_dialogs_tests', function (require) { "use strict"; -var testUtils = require('web.test_utils'); var dialogs = require('web.view_dialogs'); +var ListController = require('web.ListController'); +var testUtils = require('web.test_utils'); var Widget = require('web.Widget'); QUnit.module('Views', { @@ -66,6 +67,41 @@ QUnit.module('Views', { parent.destroy(); }); + QUnit.test('formviewdialog buttons in footer are not duplicated', function (assert) { + assert.expect(2); + this.data.partner.fields.poney_ids = {string: "Poneys", type: "one2many", relation: 'partner'}; + this.data.partner.records[0].poney_ids = []; + + var parent = createParent({ + data: this.data, + archs: { + 'partner,false,form': + '<form string="Partner">' + + '<field name="poney_ids"><tree editable="top"><field name="display_name"/></tree></field>' + + '<footer><button string="Custom Button" type="object" class="btn-primary"/></footer>' + + '</form>', + }, + }); + + new dialogs.FormViewDialog(parent, { + res_model: 'partner', + res_id: 1, + }).open(); + + assert.strictEqual($('div.modal button.btn-primary').length, 1, + "should have 1 buttons in modal"); + + $('.o_field_x2many_list_row_add a').click(); + $('input.o_input').trigger($.Event('keydown', { + which: $.ui.keyCode.ESCAPE, + keyCode: $.ui.keyCode.ESCAPE, + })); + + assert.strictEqual($('div.modal button.btn-primary').length, 1, + "should still have 1 buttons in modal"); + parent.destroy(); + }); + QUnit.test('SelectCreateDialog use domain, group_by and search default', function (assert) { assert.expect(3); @@ -206,6 +242,63 @@ QUnit.module('Views', { parent.destroy(); }); + QUnit.test('SelectCreateDialog: save current search', function (assert) { + assert.expect(4); + + testUtils.patch(ListController, { + getContext: function () { + return { + shouldBeInFilterContext: true, + }; + }, + }); + + var parent = createParent({ + data: this.data, + archs: { + 'partner,false,list': + '<tree>' + + '<field name="display_name"/>' + + '</tree>', + 'partner,false,search': + '<search>' + + '<filter name="bar" help="Bar" domain="[(\'bar\', \'=\', True)]"/>' + + '</search>', + + }, + intercepts: { + create_filter: function (event) { + var filter = event.data.filter; + assert.deepEqual(filter.domain, [['bar', '=', true]], + "should save the correct domain"); + assert.deepEqual(filter.context, {shouldBeInFilterContext: true}, + "should save the correct context"); + }, + }, + }); + + var dialog = new dialogs.SelectCreateDialog(parent, { + context: {shouldNotBeInFilterContext: false}, + res_model: 'partner', + }).open(); + + assert.strictEqual(dialog.$('.o_data_row').length, 3, + "should contain 3 records"); + + // filter on bar + dialog.$('.o_filters_menu a:contains(Bar)').click(); + + assert.strictEqual(dialog.$('.o_data_row').length, 2, + "should contain 2 records"); + + // save filter + dialog.$('.o_save_search a').click(); // toggle 'Save current search' + dialog.$('.o_save_name input[type=text]').val('some name'); // name the filter + dialog.$('.o_save_name button').click(); // click on 'Save' + + testUtils.unpatch(ListController); + parent.destroy(); + }); }); }); |
