diff --git a/addons/web/__openerp__.py b/addons/web/__openerp__.py index e084c52cb78..00d21b1dbeb 100644 --- a/addons/web/__openerp__.py +++ b/addons/web/__openerp__.py @@ -49,7 +49,6 @@ This module provides the core of the OpenERP Web Client. "static/src/js/views.js", "static/src/js/data.js", "static/src/js/data_export.js", - "static/src/js/data_import.js", "static/src/js/search.js", "static/src/js/view_form.js", "static/src/js/view_list.js", @@ -64,7 +63,6 @@ This module provides the core of the OpenERP Web Client. "static/lib/jquery.textext/jquery.textext.css", "static/src/css/base.css", "static/src/css/data_export.css", - "static/src/css/data_import.css", "static/lib/cleditor/jquery.cleditor.css", ], 'qweb' : [ diff --git a/addons/web/common/http.py b/addons/web/common/http.py index 0ee8f9cfc8f..a03419d1101 100644 --- a/addons/web/common/http.py +++ b/addons/web/common/http.py @@ -7,6 +7,7 @@ import cgi import contextlib import functools import logging +import mimetypes import os import pprint import sys @@ -420,6 +421,10 @@ def session_context(request, storage_path, session_cookie='httpsessionid'): #---------------------------------------------------------- # OpenERP Web WSGI Application #---------------------------------------------------------- +# Add potentially missing (older ubuntu) font mime types +mimetypes.add_type('application/font-woff', '.woff') +mimetypes.add_type('application/vnd.ms-fontobject', '.eot') +mimetypes.add_type('application/x-font-ttf', '.ttf') class DisableCacheMiddleware(object): def __init__(self, app): self.app = app diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index cf7a7a06feb..b635f5abdcd 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -484,15 +484,13 @@ def fix_view_modes(action): if not action.get('views'): generate_views(action) - id_form = None - for index, (id, mode) in enumerate(action['views']): - if mode == 'form': - id_form = id - break - if action.pop('view_type', 'form') != 'form': return action + if 'view_mode' in action: + action['view_mode'] = ','.join( + mode if mode != 'tree' else 'list' + for mode in action['view_mode'].split(',')) action['views'] = [ [id, mode if mode != 'tree' else 'list'] for id, mode in action['views'] @@ -1351,27 +1349,6 @@ class View(openerpweb.Controller): def load(self, req, model, view_id, view_type, toolbar=False): return self.fields_view_get(req, model, view_id, view_type, toolbar=toolbar) -class ListView(View): - _cp_path = "/web/listview" - - def process_colors(self, view, row, context): - colors = view['arch']['attrs'].get('colors') - - if not colors: - return None - - color = [ - pair.split(':')[0] - for pair in colors.split(';') - if eval(pair.split(':')[1], dict(context, **row)) - ] - - if not color: - return None - elif len(color) == 1: - return color[0] - return 'maroon' - class TreeView(View): _cp_path = "/web/treeview" diff --git a/addons/web/static/src/css/base.css b/addons/web/static/src/css/base.css index 8698fdb9f94..f952dd298fd 100644 --- a/addons/web/static/src/css/base.css +++ b/addons/web/static/src/css/base.css @@ -1503,10 +1503,10 @@ .openerp .oe_view_manager_current > .oe_view_manager_header .oe_header_row:first-child td { padding-top: 8px; } -.openerp .oe_view_manager_inline { +.openerp .oe_view_manager_inline, .openerp .oe_view_manager_inlineview { height: 100%; } -.openerp .oe_view_manager_inline > .oe_view_manager_header { +.openerp .oe_view_manager_inline > .oe_view_manager_header, .openerp .oe_view_manager_inlineview > .oe_view_manager_header { display: none; } .openerp .oe_popup_form > .oe_formview > .oe_form_pager { @@ -2574,10 +2574,13 @@ .openerp .oe_form .oe_form_field_many2many > .oe_list .oe_list_pager_single_page { display: none; } +.openerp .oe_list_buttons .oe_alternative { + visibility: hidden; +} .openerp .oe_list_buttons .oe_list_save, .openerp .oe_list_buttons .oe_list_discard { display: none; } -.openerp .oe_list_buttons.oe_editing .oe_list_add, .openerp .oe_list_buttons.oe_editing .oe_list_button_import { +.openerp .oe_list_buttons.oe_editing .oe_list_add { display: none; } .openerp .oe_list_buttons.oe_editing .oe_list_save { @@ -2586,6 +2589,9 @@ .openerp .oe_list_buttons.oe_editing .oe_list_discard { display: inline; } +.openerp .oe_list_buttons.oe_editing .oe_alternative { + visibility: visible; +} .openerp .oe_list { position: relative; } diff --git a/addons/web/static/src/css/base.sass b/addons/web/static/src/css/base.sass index 374f3a6fc4e..1f332756cf7 100644 --- a/addons/web/static/src/css/base.sass +++ b/addons/web/static/src/css/base.sass @@ -1155,7 +1155,7 @@ $sheet-max-width: 860px padding-top: 8px // }}} // ViewManager inline {{{ - .oe_view_manager_inline + .oe_view_manager_inline, .oe_view_manager_inlineview height: 100% > .oe_view_manager_header display: none @@ -1971,15 +1971,19 @@ $sheet-max-width: 860px // }}} // ListView {{{ .oe_list_buttons + .oe_alternative + visibility: hidden .oe_list_save, .oe_list_discard display: none &.oe_editing - .oe_list_add, .oe_list_button_import + .oe_list_add display: none .oe_list_save display: inline-block .oe_list_discard display: inline + .oe_alternative + visibility: visible .oe_list position: relative diff --git a/addons/web/static/src/css/data_import.css b/addons/web/static/src/css/data_import.css deleted file mode 100644 index 47cd52aa081..00000000000 --- a/addons/web/static/src/css/data_import.css +++ /dev/null @@ -1,46 +0,0 @@ -.openerp .oe_import_grid { - border: none; - border-collapse: collapse; -} -.openerp .oe_import_grid-header .oe_import_grid-cell { - background: url(../img/gradientlinebg.gif) repeat-x #CCCCCC; - border-bottom: 1px solid #E3E3E3; - font-weight: bold; - text-align: left; -} -.openerp .oe_import_grid-row .oe_import_grid-cell { - border-bottom: 1px solid #E3E3E3; -} - -.openerp .oe_import_no_result .oe_import_result { - display: none; -} -.openerp .oe_import fieldset { - cursor: pointer; -} -.openerp .oe_import fieldset legend:before { - content: '\25BC '; -} -.openerp .oe_import fieldset.oe_closed legend:before { - content: '\25B6 '; -} -.openerp .oe_import fieldset.oe_closed table { - display: none; -} -.openerp .oe_import .separator.horizontal { - font-weight: bold; - border-bottom-width: 1px; - margin: 6px 4px 6px 1px; - height: 20px; -} -.openerp .duplicate_fld{ - background-color:#FF6666; -} -.openerp .select_fld{ - background: none repeat scroll 0 0 white; -} -.openerp .ui-autocomplete { - max-height: 300px; - overflow-y: auto; - padding-right: 20px; -} diff --git a/addons/web/static/src/js/data_import.js b/addons/web/static/src/js/data_import.js deleted file mode 100644 index 3c677b0ec26..00000000000 --- a/addons/web/static/src/js/data_import.js +++ /dev/null @@ -1,393 +0,0 @@ -openerp.web.data_import = function(instance) { -var QWeb = instance.web.qweb, - _t = instance.web._t; -/** - * Safari does not deal well at all with raw JSON data being returned. As a - * result, we're going to cheat by using a pseudo-jsonp: instead of getting - * JSON data in the iframe, we're getting a ``script`` tag which consists of a - * function call and the returned data (the json dump). - * - * The function is an auto-generated name bound to ``window``, which calls - * back into the callback provided here. - * - * @param {Object} form the form element (DOM or jQuery) to use in the call - * @param {Object} attributes jquery.form attributes object - * @param {Function} callback function to call with the returned data - */ -function jsonp(form, attributes, callback) { - attributes = attributes || {}; - var options = {jsonp: _.uniqueId('import_callback_')}; - window[options.jsonp] = function () { - delete window[options.jsonp]; - callback.apply(null, arguments); - }; - if ('data' in attributes) { - _.extend(attributes.data, options); - } else { - _.extend(attributes, {data: options}); - } - $(form).ajaxSubmit(attributes); -} - -instance.web.DataImport = instance.web.Dialog.extend({ - template: 'ImportDataView', - dialog_title: {toString: function () { return _t("Import Data"); }}, - init: function(parent, dataset){ - var self = this; - this._super(parent, {}); - this.model = parent.model; - this.fields = []; - this.all_fields = []; - this.fields_with_defaults = []; - this.required_fields = null; - - var convert_fields = function (root, prefix) { - prefix = prefix || ''; - _(root.fields).each(function (f) { - self.all_fields.push(prefix + f.name); - if (f.fields) { - convert_fields(f, prefix + f.id + '/'); - } - }); - }; - this.ready = $.Deferred.queue().then(function () { - self.required_fields = _(self.fields).chain() - .filter(function (field) { - return field.required && - !_.include(self.fields_with_defaults, field.id); }) - .pluck('id') - .uniq() - .value(); - convert_fields(self); - self.all_fields.sort(); - }); - }, - start: function() { - var self = this; - this._super(); - this.open({ - buttons: [ - {text: _t("Close"), click: function() { self.destroy(); }}, - {text: _t("Import File"), click: function() { self.do_import(); }, 'class': 'oe_import_dialog_button'} - ], - close: function(event, ui) { - self.destroy(); - } - }); - this.toggle_import_button(false); - this.$el.find('#csvfile').change(this.on_autodetect_data); - this.$el.find('fieldset').change(this.on_autodetect_data); - this.$el.delegate('fieldset legend', 'click', function() { - $(this).parent().toggleClass('oe_closed'); - }); - this.ready.push(new instance.web.DataSet(this, this.model).call( - 'fields_get', [], function (fields) { - self.graft_fields(fields); - self.ready.push(new instance.web.DataSet(self, self.model) - .default_get(_.pluck(self.fields, 'id')).then(function (fields) { - _.each(fields, function(val, key) { - if (val) { - self.fields_with_defaults.push(key); - } - }); - }) - ) - })); - }, - graft_fields: function (fields, parent, level) { - parent = parent || this; - level = level || 0; - - var self = this; - if (level === 0) { - parent.fields.push({ - id: 'id', - name: 'id', - string: _t('External ID'), - required: false - }); - } - _(fields).each(function (field, field_name) { - // Ignore spec for id field - // Don't import function fields (function and related) - if (field_name === 'id') { - return; - } - // Skip if there's no state which could disable @readonly, - // if a field is ever always readonly we can't import it - if (field.readonly) { - // no states at all - if (_.isEmpty(field.states)) { return; } - // no state altering @readonly - if (!_.any(field.states, function (modifiers) { - return _(modifiers).chain().pluck(0).contains('readonly').value(); - })) { return; } - } - - var f = { - id: field_name, - name: field_name, - string: field.string, - required: field.required - }; - - switch (field.type) { - case 'many2many': - case 'many2one': - // push a copy for the bare many2one field, to allow importing - // using name_search too - even if we default to exporting the XML ID - var many2one_field = _.extend({}, f) - parent.fields.push(many2one_field); - f.name += '/id'; - break; - case 'one2many': - f.name += '/id'; - f.fields = []; - // only fetch sub-fields to a depth of 2 levels - if (level < 2) { - self.ready.push(new instance.web.DataSet(self, field.relation).call( - 'fields_get', [], function (fields) { - self.graft_fields(fields, f, level+1); - })); - } - break; - } - parent.fields.push(f); - }); - }, - toggle_import_button: function (newstate) { - instance.web.dialog(this.$el, 'widget') - .find('.oe_import_dialog_button') - .button('option', 'disabled', !newstate); - }, - do_import: function() { - if(!this.$el.find('#csvfile').val()) { return; } - var lines_to_skip = parseInt(this.$el.find('#csv_skip').val(), 10); - var with_headers = this.$el.find('#file_has_headers').prop('checked'); - if (!lines_to_skip && with_headers) { - lines_to_skip = 1; - } - var indices = [], fields = []; - this.$el.find(".sel_fields").each(function(index, element) { - var val = element.value; - if (!val) { - return; - } - indices.push(index); - fields.push(val); - }); - - jsonp(this.$el.find('#import_data'), { - url: '/web/import/import_data', - data: { - model: this.model, - meta: JSON.stringify({ - skip: lines_to_skip, - indices: indices, - fields: fields - }) - } - }, this.on_import_results); - }, - on_autodetect_data: function() { - if(!this.$el.find('#csvfile').val()) { return; } - jsonp(this.$el.find('#import_data'), { - url: '/web/import/detect_data' - }, this.on_import_results); - }, - on_import_results: function(results) { - this.$el.find('#result').empty(); - var headers, result_node = this.$el.find("#result"); - - if (results['error']) { - result_node.append(QWeb.render('ImportView.error', { - 'error': results['error']})); - this.$el.find('fieldset').removeClass('oe_closed'); - return; - } - if (results['success']) { - if (this.getParent().getParent().active_view == "list") { - this.getParent().reload_content(); - } - this.destroy(); - return; - } - - if (results['records']) { - var lines_to_skip = parseInt(this.$el.find('#csv_skip').val(), 10), - with_headers = this.$el.find('#file_has_headers').prop('checked'); - headers = with_headers ? results.records[0] : null; - - result_node.append(QWeb.render('ImportView.result', { - 'headers': headers, - 'records': lines_to_skip ? results.records.slice(lines_to_skip) - : with_headers ? results.records.slice(1) - : results.records - })); - this.$el.find('fieldset').addClass('oe_closed'); - } - this.$el.find('form').removeClass('oe_import_no_result'); - - this.$el.delegate('.oe_m2o_drop_down_button', 'click', function () { - $(this).prev('input').focus(); - }); - - var self = this; - this.ready.then(function () { - var $fields = self.$el.find('.sel_fields').bind('blur', function () { - if (this.value && !_(self.all_fields).contains(this.value)) { - this.value = ''; - } - }).autocomplete({ - minLength: 0, - source: self.all_fields, - change: self.on_check_field_values - }).focus(function () { - $(this).autocomplete('search'); - }); - // Column auto-detection - _(headers).each(function (header, index) { - var field_name = self.match_column_to_field(header); - if (field_name) { - $fields.eq(index).val(field_name); - } - }); - self.on_check_field_values(); - }); - }, - /** - * Returns the name of the field (nested) matching the provided column name - * - * @param {String} name column name to look for - * @param {Array} [fields] fields to look into for the provided name - * @returns {String|undefined} - */ - match_column_to_field: function (name, fields) { - fields = fields || this.fields; - var f; - f = _(fields).detect(function (field) { - return field.name === name - }); - if (!f) { - f = _(fields).detect(function (field) { - // TODO: levenshtein between header and field.string - return field.string.toLowerCase() === name.toLowerCase(); - }); - } - if (f) { return f.name; } - - // if ``name`` is a path (o2m), we need to recurse through its .fields - var index = name.indexOf('/'); - if (index === -1) { return undefined; } - // Get the first path section, try to find the matching field - var column_name = name.substring(0, index); - f = _(fields).detect(function (field) { - // field.name for o2m is $foo/id, so we want to match on id - return field.id === column_name; - }); - if (!f) { - f = _(fields).detect(function (field) { - return field.string.toLowerCase() === column_name.toLowerCase(); - }); - } - if (!f) { return undefined; } - - // if we found a matching field for the first path section, recurse in - // its own .fields to try and get the rest of the path matched - var rest = this.match_column_to_field( - name.substring(index+1), f.fields); - if (!rest) { return undefined; } - return f.id + '/' + rest; - }, - /** - * Looks through all the field selections, and tries to find if two - * (or more) columns were matched to the same model field. - * - * Returns a map of the multiply-mapped fields to an array of offending - * columns (not actually columns, but the inputs containing the same field - * names). - * - * Also has the side-effect of marking the discovered inputs with the class - * ``duplicate_fld``. - * - * @returns {Object>} map of duplicate field matches to same-valued inputs - */ - find_duplicate_fields: function() { - // Maps values to DOM nodes, in order to discover duplicates - var values = {}, duplicates = {}; - this.$el.find(".sel_fields").each(function(index, element) { - var value = element.value; - var $el = $(element).removeClass('duplicate_fld'); - if (!value) { return; } - - if (!(value in values)) { - values[value] = element; - } else { - var same_valued_field = values[value]; - if (value in duplicates) { - duplicates[value].push(element); - } else { - duplicates[value] = [same_valued_field, element]; - } - $el.add(same_valued_field).addClass('duplicate_fld'); - } - }); - return duplicates; - }, - on_check_field_values: function () { - this.$el.find("#message, #msg").remove(); - - var required_valid = this.check_required(); - - var duplicates = this.find_duplicate_fields(); - if (_.isEmpty(duplicates)) { - this.toggle_import_button(required_valid); - } else { - var $err = $('
'+_t("Destination fields should only be selected once, some fields are selected more than once:")+'
').insertBefore(this.$el.find('#result')); - var $dupes = $('
').appendTo($err); - _(duplicates).each(function(elements, value) { - $('
').text(value).appendTo($dupes); - _(elements).each(function(element) { - var cell = $(element).closest('td'); - $('
').text(cell.parent().children().index(cell)).appendTo($dupes); - }); - }); - this.toggle_import_button(false); - } - - }, - check_required: function() { - var self = this; - if (!self.required_fields.length) { return true; } - - // Resolve field id based on column name, as there may be - // several ways to provide the value for a given field and - // thus satisfy the requirement. - // (e.g. m2o_id or m2o_id/id columns may be provided) - var resolve_field_id = function(column_name) { - var f = _.detect(self.fields, function(field) { - return field.name === column_name; - }); - if (!f) { return column_name; }; - return f.id; - }; - - var selected_fields = _(this.$el.find('.sel_fields').get()).chain() - .pluck('value') - .compact() - .map(resolve_field_id) - .value(); - - var missing_fields = _.difference(this.required_fields, selected_fields); - if (missing_fields.length) { - this.$el.find("#result").before('
' + _t("*Required Fields are not selected :") + missing_fields + '.
'); - return false; - } - return true; - }, - destroy: function() { - this.$el.remove(); - this._super(); - } -}); -}; diff --git a/addons/web/static/src/js/view_form.js b/addons/web/static/src/js/view_form.js index 744aa8d000d..08c9b96660e 100644 --- a/addons/web/static/src/js/view_form.js +++ b/addons/web/static/src/js/view_form.js @@ -2398,6 +2398,13 @@ instance.web.form.FieldText = instance.web.form.AbstractField.extend(instance.we */ instance.web.form.FieldTextHtml = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { template: 'FieldTextHtml', + init: function() { + this._super.apply(this, arguments); + if (this.field.type !== 'html' && ! this.options.safe) { + throw new Error(_.str.sprintf( + _t("Error with field %s, it is not allowed to use the widget 'html' with any other field type than 'html'"), this.string)); + } + }, initialize_content: function() { var self = this; if (! this.get("effective_readonly")) { diff --git a/addons/web/static/src/js/view_list.js b/addons/web/static/src/js/view_list.js index 22333898813..015cd1636f1 100644 --- a/addons/web/static/src/js/view_list.js +++ b/addons/web/static/src/js/view_list.js @@ -21,8 +21,6 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi // whether the view rows can be reordered (via vertical drag & drop) 'reorderable': true, 'action_buttons': true, - // if true, the 'Import', 'Export', etc... buttons will be shown - 'import_enabled': true, }, /** * Core class for list-type displays. @@ -281,7 +279,7 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi self.reload_content(); }); - // Add button and Import link + // Add button if (!this.$buttons) { this.$buttons = $(QWeb.render("ListView.buttons", {'widget':self})); if (this.options.$buttons) { @@ -292,10 +290,6 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi this.$buttons.find('.oe_list_add') .click(this.proxy('do_add_record')) .prop('disabled', grouped); - this.$buttons.on('click', '.oe_list_button_import', function() { - self.on_sidebar_import(); - return false; - }); } // Pager @@ -358,7 +352,6 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi this.sidebar = new instance.web.Sidebar(this); this.sidebar.appendTo(this.options.$sidebar); this.sidebar.add_items('other', _.compact([ - self.is_action_enabled('create') && { label: _t("Import"), callback: this.on_sidebar_import }, { label: _t("Export"), callback: this.on_sidebar_export }, self.is_action_enabled('delete') && { label: _t('Delete'), callback: this.do_delete_selected } ])); @@ -480,7 +473,7 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi if (this.embedded_view) { return $.Deferred().then(callback).resolve(this.embedded_view); } else { - return this.rpc('/web/listview/load', { + return this.rpc('/web/view/load', { model: this.model, view_id: this.view_id, view_type: "tree", diff --git a/addons/web/static/src/js/views.js b/addons/web/static/src/js/views.js index 90709276bdf..2dffe9520a1 100644 --- a/addons/web/static/src/js/views.js +++ b/addons/web/static/src/js/views.js @@ -1249,9 +1249,6 @@ instance.web.View = instance.web.Widget.extend({ }, do_search: function(view) { }, - on_sidebar_import: function() { - new instance.web.DataImport(this, this.dataset).open(); - }, on_sidebar_export: function() { new instance.web.DataExport(this, this.dataset).open(); }, diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index d067b7586fb..a595bb9e495 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -652,9 +652,6 @@ - - or Import - @@ -698,9 +695,10 @@ - - - Discard + + or + Discard + diff --git a/addons/web/static/test/list-editable.js b/addons/web/static/test/list-editable.js index 2a4dee3b6e7..0388e56048d 100644 --- a/addons/web/static/test/list-editable.js +++ b/addons/web/static/test/list-editable.js @@ -192,7 +192,7 @@ $(document).ready(function () { var records = {}; _.extend(instance.session.responses, { - '/web/listview/load': function () { + '/web/view/load': function () { return {result: { type: 'tree', fields: { @@ -268,7 +268,7 @@ $(document).ready(function () { setup: function () { baseSetup(); _.extend(instance.session.responses, { - '/web/listview/load': function () { + '/web/view/load': function () { return {result: { type: 'tree', fields: { diff --git a/addons/web/test/__init__.py b/addons/web/test/__init__.py deleted file mode 100644 index 40a96afc6ff..00000000000 --- a/addons/web/test/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# -*- coding: utf-8 -*- diff --git a/addons/web/test/test_dataset.py b/addons/web/test/test_dataset.py deleted file mode 100644 index b9fe5a3cca2..00000000000 --- a/addons/web/test/test_dataset.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- coding: utf-8 -*- -import mock -import unittest2 -import web.controllers.main - -class TestDataSetController(unittest2.TestCase): - def setUp(self): - self.dataset = web.controllers.main.DataSet() - self.request = mock.Mock() - self.read = self.request.session.model().read - self.search = self.request.session.model().search - - @unittest2.skip - def test_empty_find(self): - self.search.return_value = [] - self.read.return_value = [] - - self.assertFalse(self.dataset.do_search_read(self.request, 'fake.model')) - self.read.assert_called_once_with([], False, self.request.context) - - @unittest2.skip - def test_regular_find(self): - self.search.return_value = [1, 2, 3] - - self.dataset.do_search_read(self.request, 'fake.model') - self.read.assert_called_once_with([1, 2, 3], False, - self.request.context) - - @unittest2.skip - def test_ids_shortcut(self): - self.search.return_value = [1, 2, 3] - self.read.return_value = [ - {'id': 1, 'name': 'foo'}, - {'id': 2, 'name': 'bar'}, - {'id': 3, 'name': 'qux'} - ] - - self.assertEqual( - self.dataset.do_search_read(self.request, 'fake.model', ['id']), - [{'id': 1}, {'id': 2}, {'id': 3}]) - self.assertFalse(self.read.called) - - @unittest2.skip - def test_get(self): - self.read.return_value = [ - {'id': 1, 'name': 'baz'}, - {'id': 3, 'name': 'foo'}, - {'id': 2, 'name': 'bar'} - ] - - result = self.dataset.do_get( - self.request, 'fake.model', [3, 2, 1]) - self.read.assert_called_once_with( - [3, 2, 1], False) - self.assertFalse(self.search.called) - - self.assertEqual( - result, - [ - {'id': 3, 'name': 'foo'}, - {'id': 2, 'name': 'bar'}, - {'id': 1, 'name': 'baz'} - ] - ) - - def test_get_missing_result(self): - self.read.return_value = [ - {'id': 1, 'name': 'baz'}, - {'id': 2, 'name': 'bar'} - ] - - result = self.dataset.do_get( - self.request, 'fake.model', [3, 2, 1]) - - self.assertEqual( - result, - [ - {'id': 2, 'name': 'bar'}, - {'id': 1, 'name': 'baz'} - ] - ) diff --git a/addons/web/test/test_view.py b/addons/web/test/test_view.py deleted file mode 100644 index 0a778ccffd1..00000000000 --- a/addons/web/test/test_view.py +++ /dev/null @@ -1,248 +0,0 @@ -import copy -import xml.etree.ElementTree -import mock - -import unittest2 -import simplejson - -import web.controllers.main -from ..common import nonliterals, session as s - -def field_attrs(fields_view_get, fieldname): - (field,) = filter(lambda f: f['attrs'].get('name') == fieldname, - fields_view_get['arch']['children']) - return field['attrs'] - -#noinspection PyCompatibility -class DomainsAndContextsTest(unittest2.TestCase): - def setUp(self): - self.view = web.controllers.main.View() - - def test_convert_literal_domain(self): - e = xml.etree.ElementTree.Element( - 'field', domain=" [('somefield', '=', 3)] ") - self.view.parse_domains_and_contexts(e, None) - - self.assertEqual( - e.get('domain'), - [('somefield', '=', 3)]) - - def test_convert_complex_domain(self): - e = xml.etree.ElementTree.Element( - 'field', - domain="[('account_id.type','in',['receivable','payable'])," - "('reconcile_id','=',False)," - "('reconcile_partial_id','=',False)," - "('state', '=', 'valid')]" - ) - self.view.parse_domains_and_contexts(e, None) - - self.assertEqual( - e.get('domain'), - [('account_id.type', 'in', ['receivable', 'payable']), - ('reconcile_id', '=', False), - ('reconcile_partial_id', '=', False), - ('state', '=', 'valid')] - ) - - def test_retrieve_nonliteral_domain(self): - session = mock.Mock(spec=s.OpenERPSession) - session.domains_store = {} - domain_string = ("[('month','=',(datetime.date.today() - " - "datetime.timedelta(365/12)).strftime('%%m'))]") - e = xml.etree.ElementTree.Element( - 'field', domain=domain_string) - - self.view.parse_domains_and_contexts(e, session) - - self.assertIsInstance(e.get('domain'), nonliterals.Domain) - self.assertEqual( - nonliterals.Domain( - session, key=e.get('domain').key).get_domain_string(), - domain_string) - - def test_convert_literal_context(self): - e = xml.etree.ElementTree.Element( - 'field', context=" {'some_prop': 3} ") - self.view.parse_domains_and_contexts(e, None) - - self.assertEqual( - e.get('context'), - {'some_prop': 3}) - - def test_convert_complex_context(self): - e = xml.etree.ElementTree.Element( - 'field', - context="{'account_id.type': ['receivable','payable']," - "'reconcile_id': False," - "'reconcile_partial_id': False," - "'state': 'valid'}" - ) - self.view.parse_domains_and_contexts(e, None) - - self.assertEqual( - e.get('context'), - {'account_id.type': ['receivable', 'payable'], - 'reconcile_id': False, - 'reconcile_partial_id': False, - 'state': 'valid'} - ) - - def test_retrieve_nonliteral_context(self): - session = mock.Mock(spec=s.OpenERPSession) - session.contexts_store = {} - context_string = ("{'month': (datetime.date.today() - " - "datetime.timedelta(365/12)).strftime('%%m')}") - e = xml.etree.ElementTree.Element( - 'field', context=context_string) - - self.view.parse_domains_and_contexts(e, session) - - self.assertIsInstance(e.get('context'), nonliterals.Context) - self.assertEqual( - nonliterals.Context( - session, key=e.get('context').key).get_context_string(), - context_string) - -class AttrsNormalizationTest(unittest2.TestCase): - def setUp(self): - self.view = web.controllers.main.View() - - def test_identity(self): - web_view = """ -
- - - - - - - """ - - pristine = xml.etree.ElementTree.fromstring(web_view) - transformed = self.view.transform_view(web_view, None) - - self.assertEqual( - xml.etree.ElementTree.tostring(transformed), - xml.etree.ElementTree.tostring(pristine) - ) - - @unittest2.skip - def test_transform_states(self): - element = xml.etree.ElementTree.Element( - 'field', states="open,closed") - self.view.normalize_attrs(element, {}) - - self.assertIsNone(element.get('states')) - self.assertEqual( - simplejson.loads(element.get('attrs')), - {'invisible': [['state', 'not in', ['open', 'closed']]]}) - - @unittest2.skip - def test_transform_invisible(self): - element = xml.etree.ElementTree.Element( - 'field', invisible="context.get('invisible_country', False)") - - empty_context = copy.deepcopy(element) - self.view.normalize_attrs(empty_context, {}) - self.assertEqual(empty_context.get('invisible'), None) - - full_context = copy.deepcopy(element) - self.view.normalize_attrs(full_context, {'invisible_country': True}) - self.assertEqual(full_context.get('invisible'), '1') - - @unittest2.skip - def test_transform_invisible_list_column(self): - req = mock.Mock() - req.context = {'set_editable':True, 'set_visible':True, - 'gtd_visible':True, 'user_invisible':True} - req.session.evaluation_context = \ - s.OpenERPSession().evaluation_context - req.session.model('project.task').fields_view_get.return_value = { - 'arch': ''' - - - - - - - - - '''} - parsed_view = web.controllers.main.View().fields_view_get( - req, 'project.task', 42, 'tree') - - self.assertTrue(field_attrs(parsed_view, 'sequence')['invisible']) - self.assertTrue(field_attrs(parsed_view, 'user_id')['invisible']) - self.assertTrue( - field_attrs(parsed_view, 'delegated_user_id')['invisible']) - self.assertTrue(field_attrs(parsed_view, 'total_hours')['invisible']) - self.assertTrue( - field_attrs(parsed_view, 'date_deadline')['invisible']) - self.assertTrue(field_attrs(parsed_view, 'type_id')['invisible']) - -class ListViewTest(unittest2.TestCase): - def setUp(self): - self.view = web.controllers.main.ListView() - self.request = mock.Mock() - self.request.context = {'set_editable': True} - - @unittest2.skip - def test_no_editable_editable_context(self): - self.request.session.model('fake').fields_view_get.return_value = \ - {'arch': ''} - view = self.view.fields_view_get(self.request, 'fake', False, False) - - self.assertEqual(view['arch']['attrs']['editable'], - 'bottom') - - @unittest2.skip - def test_editable_top_editable_context(self): - self.request.session.model('fake').fields_view_get.return_value = \ - {'arch': ''} - view = self.view.fields_view_get(self.request, 'fake', False) - - self.assertEqual(view['arch']['attrs']['editable'], - 'top') - - @unittest2.skip - def test_editable_bottom_editable_context(self): - self.request.session.model('fake').fields_view_get.return_value = \ - {'arch': ''} - view = self.view.fields_view_get(self.request, 'fake', False) - - self.assertEqual(view['arch']['attrs']['editable'], - 'bottom') - - def test_color_nocolor(self): - self.assertEqual( - self.view.process_colors( - {'arch': {'attrs': {}, 'children': []}}, {}, {}), - None) - def test_color_literal(self): - self.assertEqual( - self.view.process_colors( - {'arch': {'attrs': {'colors': 'black:1'}}, 'children': []}, - {}, {}), - 'black') - def test_color_miss(self): - self.assertEqual( - self.view.process_colors( - {'arch': {'attrs': {'colors': "grey:state in ('cancelled','done');blue:state in ('pending')"}}, - 'children': [] - }, {'state': 'open'}, {}), - None) - def test_color_compute(self): - self.assertEqual( - self.view.process_colors( - {'arch': {'attrs': {'colors': "grey:state in ('cancelled','done');blue:state in ('pending')"}}, - 'children': [] - }, {'state': 'done'}, {}), - 'grey') - def test_color_multiple(self): - self.assertEqual( - self.view.process_colors( - {'arch': {'attrs': {'colors': "grey:state in ('cancelled','done');blue:state in ('done')"}}, - 'children': [] - }, {'state': 'done'}, {}), - 'maroon') diff --git a/addons/web/tests/__init__.py b/addons/web/tests/__init__.py new file mode 100644 index 00000000000..fb1a593bc78 --- /dev/null +++ b/addons/web/tests/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from . import test_dataset, test_menu, test_serving_base, test_view + +fast_suite = [] +checks = [ + test_dataset, + test_menu, + test_serving_base, + test_view, +] diff --git a/addons/web/tests/test_dataset.py b/addons/web/tests/test_dataset.py new file mode 100644 index 00000000000..8cd9634d5bf --- /dev/null +++ b/addons/web/tests/test_dataset.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +import mock +import unittest2 +import web.controllers.main + +class TestDataSetController(unittest2.TestCase): + def setUp(self): + self.dataset = web.controllers.main.DataSet() + self.request = mock.Mock() + self.read = self.request.session.model().read + self.search = self.request.session.model().search + + def test_empty_find(self): + self.search.return_value = [] + self.read.return_value = [] + + self.assertEqual( + self.dataset.do_search_read(self.request, 'fake.model'), + {'records': [], 'length': 0}) + self.read.assert_called_once_with( + [], False, self.request.session.eval_context()) + + def test_regular_find(self): + self.search.return_value = [1, 2, 3] + + self.dataset.do_search_read(self.request, 'fake.model') + self.read.assert_called_once_with( + [1, 2, 3], False,self.request.session.eval_context()) + + def test_ids_shortcut(self): + self.search.return_value = [1, 2, 3] + self.read.return_value = [ + {'id': 1, 'name': 'foo'}, + {'id': 2, 'name': 'bar'}, + {'id': 3, 'name': 'qux'} + ] + + self.assertEqual( + self.dataset.do_search_read(self.request, 'fake.model', ['id']), + {'records': [{'id': 1}, {'id': 2}, {'id': 3}], 'length': 3}) + self.assertFalse(self.read.called) diff --git a/addons/web/test/test_menu.py b/addons/web/tests/test_menu.py similarity index 65% rename from addons/web/test/test_menu.py rename to addons/web/tests/test_menu.py index 7e69442c4b8..1bfa89d9a64 100644 --- a/addons/web/test/test_menu.py +++ b/addons/web/tests/test_menu.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import collections import mock import unittest2 @@ -13,42 +14,56 @@ class Placeholder(object): class LoadTest(unittest2.TestCase): def setUp(self): self.menu = main.Menu() - self.menus_mock = mock.Mock() - self.request = Placeholder(session=OpenERPSession()) + self.request = mock.Mock() + + # Have self.request.session.model() return a different mock object for + # each model (but always the same mock for a given model name) + models = collections.defaultdict(mock.Mock) + model = self.request.session.model.side_effect = \ + lambda model_name: models[model_name] + + self.MockMenus = model('ir.ui.menu') + # Mock the absence of custom menu + model('res.users').read.return_value = [{ + 'menu_id': False + }] def tearDown(self): del self.request - del self.menus_mock + del self.MockMenus del self.menu - @unittest2.skip def test_empty(self): - self.menus_mock.search = mock.Mock(return_value=[]) - self.menus_mock.read = mock.Mock(return_value=[]) + self.MockMenus.search.return_value = [] + self.MockMenus.read.return_value = [] root = self.menu.do_load(self.request) - self.menus_mock.search.assert_called_with([]) - self.menus_mock.read.assert_called_with( - [], ['name', 'sequence', 'parent_id']) + self.MockMenus.search.assert_called_with( + [], 0, False, False, self.request.session.eval_context()) + self.MockMenus.read.assert_called_with( + [], ['name', 'sequence', 'parent_id', + 'action', 'needaction_enabled', 'needaction_counter'], + self.request.session.eval_context()) self.assertListEqual( root['children'], []) - @unittest2.skip def test_applications_sort(self): - self.menus_mock.search = mock.Mock(return_value=[1, 2, 3]) - self.menus_mock.read = mock.Mock(return_value=[ - {'id': 2, 'sequence': 3, 'parent_id': False}, - {'id': 3, 'sequence': 2, 'parent_id': False}, + self.MockMenus.search.return_value = [1, 2, 3] + self.MockMenus.read.side_effect = lambda *args: [ {'id': 1, 'sequence': 1, 'parent_id': False}, - ]) + {'id': 3, 'sequence': 2, 'parent_id': False}, + {'id': 2, 'sequence': 3, 'parent_id': False}, + ] root = self.menu.do_load(self.request) - self.menus_mock.read.assert_called_with( - [1, 2, 3], ['name', 'sequence', 'parent_id']) + self.MockMenus.read.assert_called_with( + [1, 2, 3], ['name', 'sequence', 'parent_id', + 'action', 'needaction_enabled', 'needaction_counter'], + self.request.session.eval_context()) self.assertEqual( root['children'], @@ -63,15 +78,18 @@ class LoadTest(unittest2.TestCase): 'parent_id': False, 'children': [] }]) - @unittest2.skip def test_deep(self): - self.menus_mock.search = mock.Mock(return_value=[1, 2, 3, 4]) - self.menus_mock.read = mock.Mock(return_value=[ - {'id': 1, 'sequence': 1, 'parent_id': False}, - {'id': 2, 'sequence': 2, 'parent_id': [1, '']}, - {'id': 3, 'sequence': 1, 'parent_id': [2, '']}, - {'id': 4, 'sequence': 2, 'parent_id': [2, '']}, - ]) + self.MockMenus.search.side_effect = lambda domain, *args: ( + [1] if domain == [('parent_id', '=', False)] else [1, 2, 3, 4]) + + root = {'id': 1, 'sequence': 1, 'parent_id': False} + self.MockMenus.read.side_effect = lambda ids, *args: ( + [root] if ids == [1] else [ + {'id': 1, 'sequence': 1, 'parent_id': False}, + {'id': 2, 'sequence': 2, 'parent_id': [1, '']}, + {'id': 3, 'sequence': 1, 'parent_id': [2, '']}, + {'id': 4, 'sequence': 2, 'parent_id': [2, '']}, + ]) root = self.menu.do_load(self.request) @@ -117,7 +135,6 @@ class ActionMungerTest(unittest2.TestCase): self.assertEqual(changed, action) - @unittest2.skip def test_list_view(self): action = { "views": [[False, "tree"], [False, "form"], @@ -135,7 +152,6 @@ class ActionMungerTest(unittest2.TestCase): "view_mode": "list,form,calendar" }) - @unittest2.skip def test_redundant_views(self): action = { diff --git a/addons/web/test/test_serving_base.py b/addons/web/tests/test_serving_base.py similarity index 90% rename from addons/web/test/test_serving_base.py rename to addons/web/tests/test_serving_base.py index 0bb7a83ac24..34cc7e10b62 100644 --- a/addons/web/test/test_serving_base.py +++ b/addons/web/tests/test_serving_base.py @@ -3,7 +3,7 @@ import random import unittest2 -from ..controllers.main import topological_sort +from ..controllers.main import module_topological_sort as sort def sample(population): return random.sample( @@ -22,7 +22,7 @@ class TestModulesLoading(unittest2.TestCase): ms = dict(modules) seen = set() - sorted_modules = topological_sort(ms) + sorted_modules = sort(ms) for module in sorted_modules: deps = ms[module] self.assertGreaterEqual( diff --git a/addons/web/tests/test_view.py b/addons/web/tests/test_view.py new file mode 100644 index 00000000000..beb6996b8e2 --- /dev/null +++ b/addons/web/tests/test_view.py @@ -0,0 +1,128 @@ +import copy +import xml.etree.ElementTree +import mock + +import unittest2 +import simplejson + +import web.controllers.main +from ..common import nonliterals, session as s + +def field_attrs(fields_view_get, fieldname): + (field,) = filter(lambda f: f['attrs'].get('name') == fieldname, + fields_view_get['arch']['children']) + return field['attrs'] + +#noinspection PyCompatibility +class DomainsAndContextsTest(unittest2.TestCase): + def setUp(self): + self.view = web.controllers.main.View() + + def test_convert_literal_domain(self): + e = xml.etree.ElementTree.Element( + 'field', domain=" [('somefield', '=', 3)] ") + self.view.parse_domains_and_contexts(e, None) + + self.assertEqual( + e.get('domain'), + [('somefield', '=', 3)]) + + def test_convert_complex_domain(self): + e = xml.etree.ElementTree.Element( + 'field', + domain="[('account_id.type','in',['receivable','payable'])," + "('reconcile_id','=',False)," + "('reconcile_partial_id','=',False)," + "('state', '=', 'valid')]" + ) + self.view.parse_domains_and_contexts(e, None) + + self.assertEqual( + e.get('domain'), + [('account_id.type', 'in', ['receivable', 'payable']), + ('reconcile_id', '=', False), + ('reconcile_partial_id', '=', False), + ('state', '=', 'valid')] + ) + + def test_retrieve_nonliteral_domain(self): + session = mock.Mock(spec=s.OpenERPSession) + session.domains_store = {} + domain_string = ("[('month','=',(datetime.date.today() - " + "datetime.timedelta(365/12)).strftime('%%m'))]") + e = xml.etree.ElementTree.Element( + 'field', domain=domain_string) + + self.view.parse_domains_and_contexts(e, session) + + self.assertIsInstance(e.get('domain'), nonliterals.Domain) + self.assertEqual( + nonliterals.Domain( + session, key=e.get('domain').key).get_domain_string(), + domain_string) + + def test_convert_literal_context(self): + e = xml.etree.ElementTree.Element( + 'field', context=" {'some_prop': 3} ") + self.view.parse_domains_and_contexts(e, None) + + self.assertEqual( + e.get('context'), + {'some_prop': 3}) + + def test_convert_complex_context(self): + e = xml.etree.ElementTree.Element( + 'field', + context="{'account_id.type': ['receivable','payable']," + "'reconcile_id': False," + "'reconcile_partial_id': False," + "'state': 'valid'}" + ) + self.view.parse_domains_and_contexts(e, None) + + self.assertEqual( + e.get('context'), + {'account_id.type': ['receivable', 'payable'], + 'reconcile_id': False, + 'reconcile_partial_id': False, + 'state': 'valid'} + ) + + def test_retrieve_nonliteral_context(self): + session = mock.Mock(spec=s.OpenERPSession) + session.contexts_store = {} + context_string = ("{'month': (datetime.date.today() - " + "datetime.timedelta(365/12)).strftime('%%m')}") + e = xml.etree.ElementTree.Element( + 'field', context=context_string) + + self.view.parse_domains_and_contexts(e, session) + + self.assertIsInstance(e.get('context'), nonliterals.Context) + self.assertEqual( + nonliterals.Context( + session, key=e.get('context').key).get_context_string(), + context_string) + +class AttrsNormalizationTest(unittest2.TestCase): + def setUp(self): + self.view = web.controllers.main.View() + + def test_identity(self): + web_view = """ +
+ + + + + + + """ + + pristine = xml.etree.ElementTree.fromstring(web_view) + transformed = self.view.transform_view(web_view, None) + + self.assertEqual( + xml.etree.ElementTree.tostring(transformed), + xml.etree.ElementTree.tostring(pristine) + ) diff --git a/addons/web_graph/static/src/css/graph.css b/addons/web_graph/static/src/css/graph.css index 367d1de5ef5..f3e69c4687a 100644 --- a/addons/web_graph/static/src/css/graph.css +++ b/addons/web_graph/static/src/css/graph.css @@ -72,14 +72,7 @@ left: 4px; top: -2px; } -li { +.graph-menu li { padding: 2px; margin-left: 10px; } -a .graph-menu-options { - color: white; -} -div .graph-menu-options { - float: right; - top: 0px; -} diff --git a/addons/web_kanban/static/src/css/kanban.css b/addons/web_kanban/static/src/css/kanban.css index 1b9530b0845..f63dd371604 100644 --- a/addons/web_kanban/static/src/css/kanban.css +++ b/addons/web_kanban/static/src/css/kanban.css @@ -1,3 +1,4 @@ +@charset "utf-8"; .openerp .oe_kanban_view { background: white; height: inherit; @@ -140,9 +141,6 @@ .openerp .oe_kanban_view .oe_kanban_add { float: right; } -.openerp .oe_kanban_view .oe_kanban_quick_create { - margin-bottom: 4px; -} .openerp .oe_kanban_view .oe_kanban_quick_create_buttons { margin: 4px 0; } @@ -151,10 +149,14 @@ padding: 10px; } .openerp .oe_kanban_view .oe_kanban_quick_create input { + outline: none; + border: none; + -moz-box-shadow: none; + -webkit-box-shadow: none; + -box-shadow: none; display: block; margin: 0 0 8px 0; font-size: 13px; - box-sizing: border-box; width: 100%; } .openerp .oe_kanban_view .oe_kanban_vignette { @@ -334,7 +336,8 @@ display: inline-block; line-height: 1em; } -.openerp .oe_kanban_view .oe_kanban_card { +.openerp .oe_kanban_view .oe_kanban_card, .openerp .oe_kanban_view .oe_kanban_quick_create { + margin-bottom: 4px; position: relative; display: block; background: white; @@ -346,27 +349,27 @@ -webkit-border-radius: 4px; border-radius: 4px; } -.openerp .oe_kanban_view .oe_kanban_card:hover { +.openerp .oe_kanban_view .oe_kanban_card:hover, .openerp .oe_kanban_view .oe_kanban_quick_create:hover { border: 1px solid #7c7bad; -moz-box-shadow: 0 0 4px #7c7bad; -webkit-box-shadow: 0 0 4px #7c7bad; -box-shadow: 0 0 4px #7c7bad; } -.openerp .oe_kanban_view .oe_kanban_card:hover .oe_dropdown_kanban > span { +.openerp .oe_kanban_view .oe_kanban_card:hover .oe_dropdown_kanban > span, .openerp .oe_kanban_view .oe_kanban_quick_create:hover .oe_dropdown_kanban > span { visibility: visible; } -.openerp .oe_kanban_view .oe_kanban_card h3 { +.openerp .oe_kanban_view .oe_kanban_card h3, .openerp .oe_kanban_view .oe_kanban_quick_create h3 { margin: 0 16px 0 0; color: #4c4c4c; text-decoration: none; } -.openerp .oe_kanban_view .oe_kanban_card h3:hover { +.openerp .oe_kanban_view .oe_kanban_card h3:hover, .openerp .oe_kanban_view .oe_kanban_quick_create h3:hover { text-decoration: none; } -.openerp .oe_kanban_view .oe_kanban_card .oe_dropdown_kanban { +.openerp .oe_kanban_view .oe_kanban_card .oe_dropdown_kanban, .openerp .oe_kanban_view .oe_kanban_quick_create .oe_dropdown_kanban { margin-top: 4px; } -.openerp .oe_kanban_view .oe_kanban_card .oe_dropdown_kanban .oe_kanban_project_times li { +.openerp .oe_kanban_view .oe_kanban_card .oe_dropdown_kanban .oe_kanban_project_times li, .openerp .oe_kanban_view .oe_kanban_quick_create .oe_dropdown_kanban .oe_kanban_project_times li { float: left; } .openerp .oe_kanban_view .oe_kanban_star { @@ -470,6 +473,10 @@ left: 0; top: 28px; min-width: 160px; + padding: 2px; +} +.openerp .oe_kanban_view .oe_dropdown_kanban .oe_dropdown_menu > li { + padding: 3px; } .openerp .oe_kanban_view .oe_dropdown_kanban.oe_opened > span { visibility: visible; diff --git a/addons/web_kanban/static/src/css/kanban.sass b/addons/web_kanban/static/src/css/kanban.sass index cd2b210a733..ddafee12581 100644 --- a/addons/web_kanban/static/src/css/kanban.sass +++ b/addons/web_kanban/static/src/css/kanban.sass @@ -153,18 +153,18 @@ .oe_kanban_add float: right - .oe_kanban_quick_create - margin-bottom: 4px .oe_kanban_quick_create_buttons margin: 4px 0 .oe_kanban_no_group .oe_kanban_quick_create width: 200px padding: 10px .oe_kanban_quick_create input + outline: none + border: none + @include box-shadow(none) display: block margin: 0 0 8px 0 font-size: 13px - box-sizing: border-box width: 100% // }}} // KanbanRecords {{{ @@ -302,7 +302,8 @@ white-space: nowrap display: inline-block line-height: 1em - .oe_kanban_card + .oe_kanban_card, .oe_kanban_quick_create + margin-bottom: 4px position: relative display: block background: white @@ -396,6 +397,9 @@ left: 0 top: 28px min-width: 160px + padding: 2px + > li + padding: 3px .oe_dropdown_kanban &.oe_opened > span visibility: visible diff --git a/addons/web_kanban/static/src/xml/web_kanban.xml b/addons/web_kanban/static/src/xml/web_kanban.xml index 0f0ab52b77b..8a1b27c9726 100644 --- a/addons/web_kanban/static/src/xml/web_kanban.xml +++ b/addons/web_kanban/static/src/xml/web_kanban.xml @@ -83,7 +83,7 @@
- +