[MERGE] from 7.0

bzr revid: xmo@openerp.com-20130313094909-u21ee88l2lak9p2x
This commit is contained in:
Xavier Morel 2013-03-13 10:49:09 +01:00
commit 94c2c42be6
22 changed files with 958 additions and 275 deletions

View File

@ -26,6 +26,7 @@ This module provides the core of the OpenERP Web Client.
"static/lib/spinjs/spin.js",
"static/lib/jquery.autosize/jquery.autosize.js",
"static/lib/jquery.blockUI/jquery.blockUI.js",
"static/lib/jquery.placeholder/jquery.placeholder.js",
"static/lib/jquery.ui/js/jquery-ui-1.9.1.custom.js",
"static/lib/jquery.ui.timepicker/js/jquery-ui-timepicker-addon.js",
"static/lib/jquery.ui.notify/js/jquery.notify.js",

View File

@ -15,6 +15,8 @@ import simplejson
import time
import urllib
import urllib2
import urlparse
import xmlrpclib
import zlib
from xml.etree import ElementTree
from cStringIO import StringIO
@ -91,16 +93,50 @@ def db_list(req):
dbs = [i for i in dbs if re.match(r, i)]
return dbs
def db_monodb(req):
# if only one db exists, return it else return False
def db_monodb_redirect(req):
db = False
redirect = False
# 1 try the db in the url
db_url = req.params.get('db')
if db_url:
return (db_url, False)
try:
dbs = db_list(req)
if len(dbs) == 1:
return dbs[0]
except Exception:
# ignore access denied
pass
return False
dbs = []
# 2 use the database from the cookie if it's listable and still listed
cookie_db = req.httprequest.cookies.get('last_used_database')
if cookie_db in dbs:
db = cookie_db
# 3 use the first db
if dbs and not db:
db = dbs[0]
# redirect to the chosen db if multiple are available
if db and len(dbs) > 1:
query = dict(urlparse.parse_qsl(req.httprequest.query_string, keep_blank_values=True))
query.update({ 'db': db })
redirect = req.httprequest.path + '?' + urllib.urlencode(query)
return (db, redirect)
def db_monodb(req):
# if only one db exists, return it else return False
return db_monodb_redirect(req)[0]
def redirect_with_hash(req, url, code=303):
if req.httprequest.user_agent.browser == 'msie':
try:
version = float(req.httprequest.user_agent.version)
if version < 10:
return "<html><head><script>window.location = '%s#' + location.hash;</script></head></html>" % url
except Exception:
pass
return werkzeug.utils.redirect(url, code)
def module_topological_sort(modules):
""" Return a list of module names sorted so that their dependencies of the
@ -291,6 +327,10 @@ def manifest_glob(req, extension, addons=None, db=None):
return r
def manifest_list(req, extension, mods=None, db=None):
""" list ressources to load specifying either:
mods: a comma separated string listing modules
db: a database name (return all installed modules in that database)
"""
if not req.debug:
path = '/web/webclient/' + extension
if mods is not None:
@ -299,12 +339,7 @@ def manifest_list(req, extension, mods=None, db=None):
path += '?' + urllib.urlencode({'db': db})
return [path]
files = manifest_glob(req, extension, addons=mods, db=db)
i_am_diabetic = req.httprequest.environ["QUERY_STRING"].count("no_sugar") >= 1 or \
req.httprequest.environ.get('HTTP_REFERER', '').count("no_sugar") >= 1
if i_am_diabetic:
return [wp for _fp, wp in files]
else:
return ['%s?debug=%s' % (wp, os.path.getmtime(fp)) for fp, wp in files]
return [wp for _fp, wp in files]
def get_last_modified(files):
""" Returns the modification time of the most recently modified
@ -534,6 +569,10 @@ class Home(openerpweb.Controller):
@openerpweb.httprequest
def index(self, req, s_action=None, db=None, **kw):
db, redir = db_monodb_redirect(req)
if redir:
return redirect_with_hash(req, redir)
js = "\n ".join('<script type="text/javascript" src="%s"></script>' % i for i in manifest_list(req, 'js', db=db))
css = "\n ".join('<link rel="stylesheet" href="%s">' % i for i in manifest_list(req, 'css', db=db))
@ -869,7 +908,7 @@ class Session(openerpweb.Controller):
"""
saved_actions = req.httpsession.get('saved_actions')
if not saved_actions:
saved_actions = {"next":0, "actions":{}}
saved_actions = {"next":1, "actions":{}}
req.httpsession['saved_actions'] = saved_actions
# we don't allow more than 10 stored actions
if len(saved_actions["actions"]) >= 10:

View File

@ -107,6 +107,12 @@ formatted differently). If an input *may* fetch multiple completion
items, it *should* prefix those with a section title using its own
name. This has no technical consequence but is clearer for users.
.. note::
If a field is :js:func:`invisible
<openerp.web.search.Input.visible>`, its completion function will
*not* be called.
Providing drawer/supplementary UI
+++++++++++++++++++++++++++++++++
@ -145,6 +151,11 @@ started only once (per view).
dynamically collects, lays out and renders filters? =>
exercises drawer thingies
.. note::
An :js:func:`invisible <openerp.web.search.Input.visible>` input
will not be inserted into the drawer.
Converting from facet objects
+++++++++++++++++++++++++++++

View File

@ -92,6 +92,14 @@ class WebRequest(object):
if not self.session:
self.session = session.OpenERPSession()
self.httpsession[self.session_id] = self.session
# set db/uid trackers - they're cleaned up at the WSGI
# dispatching phase in openerp.service.wsgi_server.application
if self.session._db:
threading.current_thread().dbname = self.session._db
if self.session._uid:
threading.current_thread().uid = self.session._uid
self.context = self.params.pop('context', {})
self.debug = self.params.pop('debug', False) is not False
# Determine self.lang

View File

@ -11,8 +11,8 @@ Date.CultureInfo = {
firstLetterDayNames: ["أ", "ا", "ث", "أ", "خ", "ج", "س"],
/* Month Name Strings */
monthNames: ["كانون الثاني", "شباط", "آذار", "نيسان", "أيار", "حزيران", "تموز", "آب", "أيلول", "تشرين الأول", "تشرين الثاني", "كانون الأول"],
abbreviatedMonthNames: ["كانون الثاني", "شباط", "آذار", "نيسان", "أيار", "حزيران", "تموز", "آب", "أيلول", "تشرين الأول", "تشرين الثاني", "كانون الأول"],
monthNames: ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو", "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"],
abbreviatedMonthNames: ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو", "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"],
/* AM/PM Designators */
amDesignator: "ص",
@ -82,18 +82,18 @@ Date.CultureInfo = {
* As well, please review the list of "Future Strings" section below.
*/
regexPatterns: {
jan: /^كانون الثاني/i,
feb: /^شباط/i,
mar: /^آذار/i,
apr: /^نيسان/i,
may: /^أيار/i,
jun: /^حزيران/i,
jul: /^تموز/i,
aug: /^آب/i,
sep: /^أيلول/i,
oct: /^تشرين الأول/i,
nov: /^تشرين الثاني/i,
dec: /^كانون الأول/i,
jan: /^يناير/i,
feb: /^فبراير/i,
mar: /^مارس/i,
apr: /^أبريل/i,
may: /^مايو/i,
jun: /^يونيو/i,
jul: /^يوليو/i,
aug: /^أغسطس/i,
sep: /^سبتمبر/i,
oct: /^أكتوبر/i,
nov: /^نوفمبر/i,
dec: /^ديسمبر/i,
sun: /^الاحد/i,
mon: /^ا(1)?/i,
@ -192,4 +192,4 @@ Date.CultureInfo = {
* end end
* long long
* short short
*/
*/

View File

@ -0,0 +1,157 @@
/*! http://mths.be/placeholder v2.0.7 by @mathias */
;(function(window, document, $) {
var isInputSupported = 'placeholder' in document.createElement('input'),
isTextareaSupported = 'placeholder' in document.createElement('textarea'),
prototype = $.fn,
valHooks = $.valHooks,
hooks,
placeholder;
if (isInputSupported && isTextareaSupported) {
placeholder = prototype.placeholder = function() {
return this;
};
placeholder.input = placeholder.textarea = true;
} else {
placeholder = prototype.placeholder = function() {
var $this = this;
$this
.filter((isInputSupported ? 'textarea' : ':input') + '[placeholder]')
.not('.placeholder')
.bind({
'focus.placeholder': clearPlaceholder,
'blur.placeholder': setPlaceholder
})
.data('placeholder-enabled', true)
.trigger('blur.placeholder');
return $this;
};
placeholder.input = isInputSupported;
placeholder.textarea = isTextareaSupported;
hooks = {
'get': function(element) {
var $element = $(element);
return $element.data('placeholder-enabled') && $element.hasClass('placeholder') ? '' : element.value;
},
'set': function(element, value) {
var $element = $(element);
if (!$element.data('placeholder-enabled')) {
return element.value = value;
}
if (value == '') {
element.value = value;
// Issue #56: Setting the placeholder causes problems if the element continues to have focus.
if (element != document.activeElement) {
// We can't use `triggerHandler` here because of dummy text/password inputs :(
setPlaceholder.call(element);
}
} else if ($element.hasClass('placeholder')) {
clearPlaceholder.call(element, true, value) || (element.value = value);
} else {
element.value = value;
}
// `set` can not return `undefined`; see http://jsapi.info/jquery/1.7.1/val#L2363
return $element;
}
};
isInputSupported || (valHooks.input = hooks);
isTextareaSupported || (valHooks.textarea = hooks);
$(function() {
// Look for forms
$(document).delegate('form', 'submit.placeholder', function() {
// Clear the placeholder values so they don't get submitted
var $inputs = $('.placeholder', this).each(clearPlaceholder);
setTimeout(function() {
$inputs.each(setPlaceholder);
}, 10);
});
});
// Clear placeholder values upon page reload
$(window).bind('beforeunload.placeholder', function() {
$('.placeholder').each(function() {
this.value = '';
});
});
}
function args(elem) {
// Return an object of element attributes
var newAttrs = {},
rinlinejQuery = /^jQuery\d+$/;
$.each(elem.attributes, function(i, attr) {
if (attr.specified && !rinlinejQuery.test(attr.name)) {
newAttrs[attr.name] = attr.value;
}
});
return newAttrs;
}
function clearPlaceholder(event, value) {
var input = this,
$input = $(input);
if (input.value == $input.attr('placeholder') && $input.hasClass('placeholder')) {
if ($input.data('placeholder-password')) {
$input = $input.hide().next().show().attr('id', $input.removeAttr('id').data('placeholder-id'));
// If `clearPlaceholder` was called from `$.valHooks.input.set`
if (event === true) {
return $input[0].value = value;
}
$input.focus();
} else {
input.value = '';
$input.removeClass('placeholder');
input == document.activeElement && input.select();
}
}
}
function setPlaceholder() {
var $replacement,
input = this,
$input = $(input),
$origInput = $input,
id = this.id;
if (input.value == '') {
if (input.type == 'password') {
if (!$input.data('placeholder-textinput')) {
try {
$replacement = $input.clone().attr({ 'type': 'text' });
} catch(e) {
$replacement = $('<input>').attr($.extend(args(this), { 'type': 'text' }));
}
$replacement
.removeAttr('name')
.data({
'placeholder-password': true,
'placeholder-id': id
})
.bind('focus.placeholder', clearPlaceholder);
$input
.data({
'placeholder-textinput': $replacement,
'placeholder-id': id
})
.before($replacement);
}
$input = $input.removeAttr('id').hide().prev().attr('id', id).show();
// Note: `$input[0] != input` now!
}
$input.addClass('placeholder');
$input[0].value = $input.attr('placeholder');
} else {
$input.removeClass('placeholder');
}
}
}(this, document, jQuery));

View File

@ -1436,7 +1436,7 @@ $sheet-padding: 16px
.oe_searchview_drawer
position: absolute
z-index: 100
z-index: 2
// detach drawer from field slightly
margin-top: 4px
top: 100%
@ -1832,6 +1832,12 @@ $sheet-padding: 16px
.oe_form
.oe_form_field_text
width: 100%
.oe_form_text_content
text-overflow: ellipsis
display: inline-block
white-space: pre-wrap
overflow-x: hidden
width: 100%
.oe_form_field_char input,
.oe_form_field_url input,
.oe_form_field_email input,
@ -2502,6 +2508,9 @@ div.ui-widget-overlay
// Internet Explorer 9+ specifics {{{
.openerp_ie
.placeholder
color: $tag-border !important
font-style: italic !important
.oe_form_field_boolean input
background: #fff
.db_option_table .oe_form_field_selection
@ -2517,6 +2526,8 @@ div.ui-widget-overlay
button.oe_highlight
padding-top: 0
padding-bottom: 0
.oe_view_manager_view_kanban
display: table-cell
.oe_view_manager_buttons
button.oe_write_full
padding-top: 0

View File

@ -530,7 +530,16 @@ instance.web.DatabaseManager = instance.web.Widget.extend({
'login': 'admin',
'password': form_obj['create_admin_pwd'],
'login_successful': function() {
self.do_action("reload");
var action = {
type: "ir.actions.client",
tag: 'reload',
params: {
url_search : {
db: form_obj['db_name'],
},
}
};
self.do_action(action);
},
},
_push_me: false,
@ -641,6 +650,11 @@ instance.web.client_actions.add("database_manager", "instance.web.DatabaseManage
instance.web.Login = instance.web.Widget.extend({
template: "Login",
remember_credentials: true,
events: {
'change input[name=db],select[name=db]': function(ev) {
this.set('database_selector', $(ev.currentTarget).val());
},
},
init: function(parent, action) {
this._super(parent);
@ -652,18 +666,18 @@ instance.web.Login = instance.web.Widget.extend({
if (_.isEmpty(this.params)) {
this.params = $.bbq.getState(true);
}
if (action && action.params && action.params.db) {
this.params.db = action.params.db;
} else if ($.deparam.querystring().db) {
this.params.db = $.deparam.querystring().db;
}
if (this.params.db) {
this.selected_db = this.params.db;
}
if (this.params.login_successful) {
this.on('login_successful', this, this.params.login_successful);
}
if (this.has_local_storage && this.remember_credentials) {
this.selected_db = localStorage.getItem('last_db_login_success');
this.selected_login = localStorage.getItem('last_login_login_success');
if (jQuery.deparam(jQuery.param.querystring()).debug !== undefined) {
this.selected_password = localStorage.getItem('last_password_login_success');
}
}
},
start: function() {
var self = this;
@ -671,10 +685,10 @@ instance.web.Login = instance.web.Widget.extend({
self.$el.find('.oe_login_manage_db').click(function() {
self.do_action("database_manager");
});
self.on('change:database_selector', this, function() {
this.database_selected(this.get('database_selector'));
});
var d = $.when();
if ($.deparam.querystring().db) {
self.params.db = $.deparam.querystring().db;
}
if ($.param.fragment().token) {
self.params.token = $.param.fragment().token;
}
@ -682,16 +696,44 @@ instance.web.Login = instance.web.Widget.extend({
if (self.params.db && self.params.login && self.params.password) {
d = self.do_login(self.params.db, self.params.login, self.params.password);
} else {
if (self.params.db) {
self.on_db_loaded([self.params.db])
} else {
d = self.rpc("/web/database/get_list", {}).done(self.on_db_loaded).fail(self.on_db_failed);
}
d = self.rpc("/web/database/get_list", {})
.done(self.on_db_loaded)
.fail(self.on_db_failed)
.always(function() {
if (self.selected_db && self.has_local_storage && self.remember_credentials) {
self.$("[name=login]").val(localStorage.getItem(self.selected_db + '|last_login') || '');
if (self.session.debug) {
self.$("[name=password]").val(localStorage.getItem(self.selected_db + '|last_password') || '');
}
}
});
}
return d;
},
remember_last_used_database: function(db) {
// This cookie will be used server side in order to avoid db reloading on first visit
var ttl = 24 * 60 * 60 * 365;
document.cookie = [
'last_used_database=' + db,
'path=/',
'max-age=' + ttl,
'expires=' + new Date(new Date().getTime() + ttl * 1000).toGMTString()
].join(';');
},
database_selected: function(db) {
var params = $.deparam.querystring();
params.db = db;
this.remember_last_used_database(db);
this.$('.oe_login_dbpane').empty().text(_t('Loading...'));
this.$('[name=login], [name=password]').prop('readonly', true);
instance.web.redirect('/?' + $.param(params));
},
on_db_loaded: function (result) {
var self = this;
this.db_list = result;
if (!this.selected_db) {
this.selected_db = result[0];
}
this.$("[name=db]").replaceWith(QWeb.render('Login.dblist', { db_list: this.db_list, selected_db: this.selected_db}));
if(this.db_list.length === 0) {
this.do_action("database_manager");
@ -732,17 +774,11 @@ instance.web.Login = instance.web.Widget.extend({
self.hide_error();
self.$(".oe_login_pane").fadeOut("slow");
return this.session.session_authenticate(db, login, password).then(function() {
if (self.has_local_storage) {
if(self.remember_credentials) {
localStorage.setItem('last_db_login_success', db);
localStorage.setItem('last_login_login_success', login);
if (jQuery.deparam(jQuery.param.querystring()).debug !== undefined) {
localStorage.setItem('last_password_login_success', password);
}
} else {
localStorage.setItem('last_db_login_success', '');
localStorage.setItem('last_login_login_success', '');
localStorage.setItem('last_password_login_success', '');
self.remember_last_used_database(db);
if (self.has_local_storage && self.remember_credentials) {
localStorage.setItem(db + '|last_login', login);
if (self.session.debug) {
localStorage.setItem(db + '|last_password', password);
}
}
self.trigger('login_successful');
@ -800,6 +836,9 @@ instance.web.Reload = function(parent, action) {
var sobj = $.deparam(l.search.substr(1));
sobj.ts = new Date().getTime();
if (params.url_search) {
sobj = _.extend(sobj, params.url_search);
}
var search = '?' + $.param(sobj);
var hash = l.hash;
@ -1303,7 +1342,7 @@ instance.web.WebClient = instance.web.Client.extend({
},
logo_edit: function(ev) {
var self = this;
new self.alive(instance.web.Model("res.users").get_func("read")(this.session.uid, ["company_id"])).then(function(res) {
self.alive(new instance.web.Model("res.users").get_func("read")(this.session.uid, ["company_id"])).then(function(res) {
self.rpc("/web/action/load", { action_id: "base.action_res_company_form" }).done(function(result) {
result.res_id = res['company_id'][0];
result.target = "new";

View File

@ -232,13 +232,17 @@ instance.web.ParentedMixin = {
Utility method to only execute asynchronous actions if the current
object has not been destroyed.
@param {Promise} promise The promise representing the asynchronous action.
@param {bool} reject Defaults to false. If true, the returned promise will be
rejected with no arguments if the current object is destroyed. If false,
the returned promise will never be resolved nor rejected.
@returns {Promise} A promise that will mirror the given promise if everything goes
fine but will either be rejected with no arguments or never resolved if the
current object is destroyed.
@param {$.Deferred} promise The promise representing the asynchronous
action.
@param {bool} [reject=false] If true, the returned promise will be
rejected with no arguments if the current
object is destroyed. If false, the
returned promise will never be resolved
or rejected.
@returns {$.Deferred} A promise that will mirror the given promise if
everything goes fine but will either be rejected
with no arguments or never resolved if the
current object is destroyed.
*/
alive: function(promise, reject) {
var def = $.Deferred();

View File

@ -89,6 +89,10 @@ instance.web.Session = instance.web.JsonRPC.extend( /** @lends instance.web.Sess
});
},
session_is_valid: function() {
var db = $.deparam.querystring().db;
if (db && this.db !== db) {
return false;
}
return !!this.uid;
},
/**

View File

@ -481,9 +481,12 @@ instance.web.DataSet = instance.web.Class.extend(instance.web.PropertiesMixin,
* Creates a new record in db
*
* @param {Object} data field values to set on the new record
* @param {Object} options Dictionary that can contain the following keys:
* - readonly_fields: Values from readonly fields that were updated by
* on_changes. Only used by the BufferedDataSet to make the o2m work correctly.
* @returns {$.Deferred}
*/
create: function(data) {
create: function(data, options) {
return this._model.call('create', [data], {context: this.get_context()});
},
/**
@ -491,8 +494,10 @@ instance.web.DataSet = instance.web.Class.extend(instance.web.PropertiesMixin,
*
* @param {Number|String} id identifier for the record to alter
* @param {Object} data field values to write into the record
* @param {Function} callback function called with operation result
* @param {Function} error_callback function called in case of write error
* @param {Object} options Dictionary that can contain the following keys:
* - context: The context to use in the server-side call.
* - readonly_fields: Values from readonly fields that were updated by
* on_changes. Only used by the BufferedDataSet to make the o2m work correctly.
* @returns {$.Deferred}
*/
write: function (id, data, options) {
@ -732,10 +737,13 @@ instance.web.BufferedDataSet = instance.web.DataSetStatic.extend({
self.last_default_get = res;
});
},
create: function(data) {
var cached = {id:_.uniqueId(this.virtual_id_prefix), values: data,
defaults: this.last_default_get};
this.to_create.push(_.extend(_.clone(cached), {values: _.clone(cached.values)}));
create: function(data, options) {
var cached = {
id:_.uniqueId(this.virtual_id_prefix),
values: _.extend({}, data, (options || {}).readonly_fields || {}),
defaults: this.last_default_get
};
this.to_create.push(_.extend(_.clone(cached), {values: _.clone(data)}));
this.cache.push(cached);
return $.Deferred().resolve(cached.id).promise();
},
@ -762,7 +770,7 @@ instance.web.BufferedDataSet = instance.web.DataSetStatic.extend({
cached = {id: id, values: {}};
this.cache.push(cached);
}
$.extend(cached.values, record.values);
$.extend(cached.values, _.extend({}, record.values, (options || {}).readonly_fields || {}));
if (dirty)
this.trigger("dataset_changed", id, data, options);
return $.Deferred().resolve(true).promise();
@ -860,8 +868,16 @@ instance.web.BufferedDataSet = instance.web.DataSetStatic.extend({
}
return completion.promise();
},
call_button: function (method, args) {
var id = args[0][0], index;
/**
* Invalidates caching of a record in the dataset to ensure the next read
* of that record will hit the server.
*
* Of use when an action is going to remote-alter a record which will then
* need to be reloaded, e.g. action button.
*
* @param {Object} id record to remove from the BDS's cache
*/
evict_record: function (id) {
for(var i=0, len=this.cache.length; i<len; ++i) {
var record = this.cache[i];
// if record we call the button upon is in the cache
@ -871,8 +887,15 @@ instance.web.BufferedDataSet = instance.web.DataSetStatic.extend({
break;
}
}
},
call_button: function (method, args) {
this.evict_record(args[0][0]);
return this._super(method, args);
},
exec_workflow: function (id, signal) {
this.evict_record(id);
return this._super(id, signal);
},
alter_ids: function(n_ids) {
this._super(n_ids);
this.trigger("dataset_changed", n_ids);
@ -903,9 +926,9 @@ instance.web.ProxyDataSet = instance.web.DataSetSearch.extend({
return this._super.apply(this, arguments);
}
},
create: function(data) {
create: function(data, options) {
if (this.create_function) {
return this.create_function(data, this._super);
return this.create_function(data, options, this._super);
} else {
return this._super.apply(this, arguments);
}

View File

@ -324,7 +324,10 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea
e.preventDefault();
break;
}
}
},
'autocompleteopen': function () {
this.$el.autocomplete('widget').css('z-index', 3);
},
},
/**
* @constructs instance.web.SearchView
@ -352,7 +355,7 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea
this.has_defaults = !_.isEmpty(this.defaults);
this.inputs = [];
this.controls = {};
this.controls = [];
this.headless = this.options.hidden && !this.has_defaults;
@ -492,6 +495,7 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea
*/
complete_global_search: function (req, resp) {
$.when.apply(null, _(this.inputs).chain()
.filter(function (input) { return input.visible(); })
.invoke('complete', req.term)
.value()).then(function () {
resp(_(_(arguments).compact()).flatten(true));
@ -580,18 +584,18 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea
*
* @param {Array} items a list of nodes to convert to widgets
* @param {Object} fields a mapping of field names to (ORM) field attributes
* @param {String} [group_name] name of the group to put the new controls in
* @param {Object} [group] group to put the new controls in
*/
make_widgets: function (items, fields, group_name) {
group_name = group_name || null;
if (!(group_name in this.controls)) {
this.controls[group_name] = [];
make_widgets: function (items, fields, group) {
if (!group) {
group = new instance.web.search.Group(
this, 'q', {attrs: {string: _t("Filters")}});
}
var self = this, group = this.controls[group_name];
var self = this;
var filters = [];
_.each(items, function (item) {
if (filters.length && item.tag !== 'filter') {
group.push(new instance.web.search.FilterGroup(filters, this));
group.push(new instance.web.search.FilterGroup(filters, group));
filters = [];
}
@ -599,15 +603,18 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea
case 'separator': case 'newline':
break;
case 'filter':
filters.push(new instance.web.search.Filter(item, this));
filters.push(new instance.web.search.Filter(item, group));
break;
case 'group':
self.make_widgets(item.children, fields, item.attrs.string);
self.make_widgets(item.children, fields,
new instance.web.search.Group(group, 'w', item));
break;
case 'field':
group.push(this.make_field(item, fields[item['attrs'].name]));
var field = this.make_field(
item, fields[item['attrs'].name], group);
group.push(field);
// filters
self.make_widgets(item.children, fields, group_name);
self.make_widgets(item.children, fields, group);
break;
}
}, this);
@ -622,12 +629,13 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea
*
* @param {Object} item fields_view_get node for the field
* @param {Object} field fields_get result for the field
* @param {Object} [parent]
* @returns instance.web.search.Field
*/
make_field: function (item, field) {
make_field: function (item, field, parent) {
var obj = instance.web.search.fields.get_any( [item.attrs.widget, field.type]);
if(obj) {
return new (obj) (item, field, this);
return new (obj) (item, field, parent || this);
} else {
console.group('Unknown field type ' + field.type);
console.error('View node', item);
@ -862,13 +870,18 @@ instance.web.search.Widget = instance.web.Widget.extend( /** @lends instance.web
* @constructs instance.web.search.Widget
* @extends instance.web.Widget
*
* @param view the ancestor view of this widget
* @param parent parent of this widget
*/
init: function (view) {
this._super(view);
this.view = view;
init: function (parent) {
this._super(parent);
var ancestor = parent;
do {
this.view = ancestor;
} while (!(ancestor instanceof instance.web.SearchView)
&& (ancestor = (ancestor.getParent && ancestor.getParent())));
}
});
instance.web.search.add_expand_listener = function($root) {
$root.find('a.searchview_group_string').click(function (e) {
$root.toggleClass('folded expanded');
@ -877,13 +890,24 @@ instance.web.search.add_expand_listener = function($root) {
});
};
instance.web.search.Group = instance.web.search.Widget.extend({
template: 'SearchView.group',
init: function (view_section, view, fields) {
this._super(view);
this.attrs = view_section.attrs;
this.lines = view.make_widgets(
view_section.children, fields);
}
init: function (parent, icon, node) {
this._super(parent);
var attrs = node.attrs;
this.modifiers = attrs.modifiers =
attrs.modifiers ? JSON.parse(attrs.modifiers) : {};
this.attrs = attrs;
this.icon = icon;
this.name = attrs.string;
this.children = [];
this.view.controls.push(this);
},
push: function (input) {
this.children.push(input);
},
visible: function () {
return !this.modifiers.invisible;
},
});
instance.web.search.Input = instance.web.search.Widget.extend( /** @lends instance.web.search.Input# */{
@ -892,12 +916,12 @@ instance.web.search.Input = instance.web.search.Widget.extend( /** @lends instan
* @constructs instance.web.search.Input
* @extends instance.web.search.Widget
*
* @param view
* @param parent
*/
init: function (view) {
this._super(view);
init: function (parent) {
this._super(parent);
this.load_attrs({});
this.view.inputs.push(this);
this.style = undefined;
},
/**
* Fetch auto-completion values for the widget.
@ -945,15 +969,30 @@ instance.web.search.Input = instance.web.search.Widget.extend( /** @lends instan
"get_domain not implemented for widget " + this.attrs.type);
},
load_attrs: function (attrs) {
if (attrs.modifiers) {
attrs.modifiers = JSON.parse(attrs.modifiers);
attrs.invisible = attrs.modifiers.invisible || false;
if (attrs.invisible) {
this.style = 'display: none;'
attrs.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {};
this.attrs = attrs;
},
/**
* Returns whether the input is "visible". The default behavior is to
* query the ``modifiers.invisible`` flag on the input's description or
* view node.
*
* @returns {Boolean}
*/
visible: function () {
if (this.attrs.modifiers.invisible) {
return false;
}
var parent = this;
while ((parent = parent.getParent()) &&
( (parent instanceof instance.web.search.Group)
|| (parent instanceof instance.web.search.Input))) {
if (!parent.visible()) {
return false;
}
}
this.attrs = attrs;
}
return true;
},
});
instance.web.search.FilterGroup = instance.web.search.Input.extend(/** @lends instance.web.search.FilterGroup# */{
template: 'SearchView.filters',
@ -967,17 +1006,19 @@ instance.web.search.FilterGroup = instance.web.search.Input.extend(/** @lends in
* @extends instance.web.search.Input
*
* @param {Array<instance.web.search.Filter>} filters elements of the group
* @param {instance.web.SearchView} view view in which the filters are contained
* @param {instance.web.SearchView} parent parent in which the filters are contained
*/
init: function (filters, view) {
init: function (filters, parent) {
// If all filters are group_by and we're not initializing a GroupbyGroup,
// create a GroupbyGroup instead of the current FilterGroup
if (!(this instanceof instance.web.search.GroupbyGroup) &&
_(filters).all(function (f) {
return f.attrs.context && f.attrs.context.group_by; })) {
return new instance.web.search.GroupbyGroup(filters, view);
if (!f.attrs.context) { return false; }
var c = instance.web.pyeval.eval('context', f.attrs.context);
return !_.isEmpty(c.group_by);})) {
return new instance.web.search.GroupbyGroup(filters, parent);
}
this._super(view);
this._super(parent);
this.filters = filters;
this.view.query.on('add remove change reset', this.proxy('search_change'));
},
@ -1096,6 +1137,7 @@ instance.web.search.FilterGroup = instance.web.search.Input.extend(/** @lends in
var self = this;
item = item.toLowerCase();
var facet_values = _(this.filters).chain()
.filter(function (filter) { return filter.visible(); })
.filter(function (filter) {
var at = {
string: filter.attrs.string || '',
@ -1122,8 +1164,8 @@ instance.web.search.FilterGroup = instance.web.search.Input.extend(/** @lends in
instance.web.search.GroupbyGroup = instance.web.search.FilterGroup.extend({
icon: 'w',
completion_label: _lt("Group by: %s"),
init: function (filters, view) {
this._super(filters, view);
init: function (filters, parent) {
this._super(filters, parent);
// Not flanders: facet unicity is handled through the
// (category, field) pair of facet attributes. This is all well and
// good for regular filter groups where a group matches a facet, but for
@ -1131,8 +1173,8 @@ instance.web.search.GroupbyGroup = instance.web.search.FilterGroup.extend({
// view which proxies to the first GroupbyGroup, so it can be used
// for every GroupbyGroup and still provides the various methods needed
// by the search view. Use weirdo name to avoid risks of conflicts
if (!this.getParent()._s_groupby) {
this.getParent()._s_groupby = {
if (!this.view._s_groupby) {
this.view._s_groupby = {
help: "See GroupbyGroup#init",
get_context: this.proxy('get_context'),
get_domain: this.proxy('get_domain'),
@ -1141,14 +1183,14 @@ instance.web.search.GroupbyGroup = instance.web.search.FilterGroup.extend({
}
},
match_facet: function (facet) {
return facet.get('field') === this.getParent()._s_groupby;
return facet.get('field') === this.view._s_groupby;
},
make_facet: function (values) {
return {
category: _t("GroupBy"),
icon: this.icon,
values: values,
field: this.getParent()._s_groupby
field: this.view._s_groupby
};
}
});
@ -1166,10 +1208,10 @@ instance.web.search.Filter = instance.web.search.Input.extend(/** @lends instanc
* @extends instance.web.search.Input
*
* @param node
* @param view
* @param parent
*/
init: function (node, view) {
this._super(view);
init: function (node, parent) {
this._super(parent);
this.load_attrs(node.attrs);
},
facet_for: function () { return $.when(null); },
@ -1185,10 +1227,10 @@ instance.web.search.Field = instance.web.search.Input.extend( /** @lends instanc
*
* @param view_section
* @param field
* @param view
* @param parent
*/
init: function (view_section, field, view) {
this._super(view);
init: function (view_section, field, parent) {
this._super(parent);
this.load_attrs(_.extend({}, field, view_section.attrs));
},
facet_for: function (value) {
@ -1228,7 +1270,7 @@ instance.web.search.Field = instance.web.search.Input.extend( /** @lends instanc
*
* @param {String} name the field's name
* @param {String} operator the field's operator (either attribute-specified or default operator for the field
* @param {Number|String} value parsed value for the field
* @param {Number|String} facet parsed value for the field
* @returns {Array<Array>} domain to include in the resulting search
*/
make_domain: function (name, operator, facet) {
@ -1460,8 +1502,8 @@ instance.web.search.DateTimeField = instance.web.search.DateField.extend(/** @le
});
instance.web.search.ManyToOneField = instance.web.search.CharField.extend({
default_operator: {},
init: function (view_section, field, view) {
this._super(view_section, field, view);
init: function (view_section, field, parent) {
this._super(view_section, field, parent);
this.model = new instance.web.Model(this.attrs.relation);
},
complete: function (needle) {
@ -1673,6 +1715,13 @@ instance.web.search.CustomFilters = instance.web.search.Input.extend({
if (!_.isEmpty(results.group_by)) {
results.context.group_by = results.group_by;
}
// Don't save user_context keys in the custom filter, otherwise end
// up with e.g. wrong uid or lang stored *and used in subsequent
// reqs*
var ctx = results.context;
_(_.keys(instance.session.user_context)).each(function (key) {
delete ctx[key];
});
var filter = {
name: $name.val(),
user_id: private_filter ? instance.session.uid : false,
@ -1702,22 +1751,28 @@ instance.web.search.Filters = instance.web.search.Input.extend({
var running_count = 0;
// get total filters count
var is_group = function (i) { return i instanceof instance.web.search.FilterGroup; };
var filters_count = _(this.view.controls).chain()
var visible_filters = _(this.view.controls).chain().reject(function (group) {
return _(_(group.children).filter(is_group)).isEmpty()
|| group.modifiers.invisible;
});
var filters_count = visible_filters
.pluck('children')
.flatten()
.filter(is_group)
.map(function (i) { return i.filters.length; })
.sum()
.value();
var col1 = [], col2 = _(this.view.controls).map(function (inputs, group) {
var filters = _(inputs).filter(is_group);
return {
name: group === 'null' ? "<span class='oe_i'>q</span> " + _t("Filters") : "<span class='oe_i'>w</span> " + group,
filters: filters,
length: _(filters).chain().map(function (i) {
return i.filters.length; }).sum().value()
};
});
var col1 = [], col2 = visible_filters.map(function (group) {
var filters = _(group.children).filter(is_group);
return {
name: _.str.sprintf("<span class='oe_i'>%s</span> %s",
group.icon, group.name),
filters: filters,
length: _(filters).chain().map(function (i) {
return i.filters.length; }).sum().value()
};
}).value();
while (col2.length) {
// col1 + group should be smaller than col2 + group

View File

@ -810,7 +810,8 @@ instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerM
try {
var form_invalid = false,
values = {},
first_invalid_field = null;
first_invalid_field = null,
readonly_values = {};
for (var f in self.fields) {
if (!self.fields.hasOwnProperty(f)) { continue; }
f = self.fields[f];
@ -819,11 +820,15 @@ instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerM
if (!first_invalid_field) {
first_invalid_field = f;
}
} else if (f.name !== 'id' && !f.get("readonly") && (!self.datarecord.id || f._dirty_flag)) {
} else if (f.name !== 'id' && (!self.datarecord.id || f._dirty_flag)) {
// Special case 'id' field, do not save this field
// on 'create' : save all non readonly fields
// on 'edit' : save non readonly modified fields
values[f.name] = f.get_value();
if (!f.get("readonly")) {
values[f.name] = f.get_value();
} else {
readonly_values[f.name] = f.get_value();
}
}
}
if (form_invalid) {
@ -836,7 +841,7 @@ instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerM
var save_deferral;
if (!self.datarecord.id) {
// Creation save
save_deferral = self.dataset.create(values).then(function(r) {
save_deferral = self.dataset.create(values, {readonly_fields: readonly_values}).then(function(r) {
return self.record_created(r, prepend_on_create);
}, null);
} else if (_.isEmpty(values)) {
@ -844,7 +849,7 @@ instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerM
save_deferral = $.Deferred().resolve({}).promise();
} else {
// Write save
save_deferral = self.dataset.write(self.datarecord.id, values, {}).then(function(r) {
save_deferral = self.dataset.write(self.datarecord.id, values, {readonly_fields: readonly_values}).then(function(r) {
return self.record_saved(r);
}, null);
}
@ -1887,10 +1892,11 @@ instance.web.form.WidgetButton = instance.web.form.FormWidget.extend({
if (this.node.attrs.icon && (! /\//.test(this.node.attrs.icon))) {
this.node.attrs.icon = '/web/static/src/img/icons/' + this.node.attrs.icon + '.png';
}
this.view.on('view_content_has_changed', this, this.check_disable);
},
start: function() {
this._super.apply(this, arguments);
this.view.on('view_content_has_changed', this, this.check_disable);
this.check_disable();
this.$el.click(this.on_click);
if (this.node.attrs.help || instance.session.debug) {
this.do_attach_tooltip();
@ -1916,18 +1922,20 @@ instance.web.form.WidgetButton = instance.web.form.FormWidget.extend({
modal: true,
buttons: [
{text: _t("Cancel"), click: function() {
def.resolve();
$(this).dialog("close");
}
},
{text: _t("Ok"), click: function() {
self.on_confirmed().done(function() {
def.resolve();
var self2 = this;
self.on_confirmed().always(function() {
$(self2).dialog("close");
});
$(this).dialog("close");
}
}
]
],
beforeClose: function() {
def.resolve();
},
});
return def.promise();
} else {
@ -2218,6 +2226,18 @@ instance.web.form.ReinitializeFieldMixin = _.extend({}, instance.web.form.Reini
},
});
/**
Some hack to make placeholders work in ie9.
*/
if ($.browser.msie && $.browser.version === "9.0") {
document.addEventListener("DOMNodeInserted",function(event){
var nodename = event.target.nodeName.toLowerCase();
if ( nodename === "input" || nodename == "textarea" ) {
$(event.target).placeholder();
}
});
}
instance.web.form.FieldChar = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, {
template: 'FieldChar',
widget_class: 'oe_form_field_char',
@ -2382,7 +2402,6 @@ instance.web.DateTimeWidget = instance.web.Widget.extend({
type_of_date: "datetime",
events: {
'change .oe_datepicker_master': 'change_datetime',
'dragstart img.oe_datepicker_trigger': function () { return false; },
},
init: function(parent) {
this._super(parent);
@ -2546,42 +2565,45 @@ instance.web.form.FieldText = instance.web.form.AbstractField.extend(instance.we
},
'change textarea': 'store_dom_value',
},
init: function (field_manager, node) {
this._super(field_manager, node);
},
initialize_content: function() {
var self = this;
this.$textarea = this.$el.find('textarea');
this.auto_sized = false;
this.default_height = this.$textarea.css('height');
if (this.get("effective_readonly")) {
this.$textarea.attr('disabled', 'disabled');
if (! this.get("effective_readonly")) {
this.$textarea = this.$el.find('textarea');
this.auto_sized = false;
this.default_height = this.$textarea.css('height');
if (this.get("effective_readonly")) {
this.$textarea.attr('disabled', 'disabled');
}
this.setupFocus(this.$textarea);
} else {
this.$textarea = undefined;
}
this.setupFocus(this.$textarea);
},
commit_value: function () {
this.store_dom_value();
if (! this.get("effective_readonly") && this.$textarea) {
this.store_dom_value();
}
return this._super();
},
store_dom_value: function () {
if (!this.get('effective_readonly') && this.$('textarea').length) {
this.internal_set_value(
instance.web.parse_value(
this.$textarea.val(),
this));
}
this.internal_set_value(instance.web.parse_value(this.$textarea.val(), this));
},
render_value: function() {
var show_value = instance.web.format_value(this.get('value'), this, '');
if (show_value === '') {
this.$textarea.css('height', parseInt(this.default_height)+"px");
}
this.$textarea.val(show_value);
if (! this.auto_sized) {
this.auto_sized = true;
this.$textarea.autosize();
if (! this.get("effective_readonly")) {
var show_value = instance.web.format_value(this.get('value'), this, '');
if (show_value === '') {
this.$textarea.css('height', parseInt(this.default_height)+"px");
}
this.$textarea.val(show_value);
if (! this.auto_sized) {
this.auto_sized = true;
this.$textarea.autosize();
} else {
this.$textarea.trigger("autosize");
}
} else {
this.$textarea.trigger("autosize");
var txt = this.get("value");
this.$(".oe_form_text_content").text(txt);
}
},
is_syntax_valid: function() {
@ -2599,14 +2621,18 @@ instance.web.form.FieldText = instance.web.form.AbstractField.extend(instance.we
return this.get('value') === '' || this._super();
},
focus: function($el) {
this.$textarea[0].focus();
if (!this.get("effective_readonly") && this.$textarea) {
this.$textarea[0].focus();
}
},
set_dimensions: function (height, width) {
this._super(height, width);
this.$textarea.css({
width: width,
minHeight: height
});
if (!this.get("effective_readonly") && this.$textarea) {
this.$textarea.css({
width: width,
minHeight: height
});
}
},
});
@ -2970,8 +2996,6 @@ instance.web.form.FieldMany2One = instance.web.form.AbstractField.extend(instanc
e.stopPropagation();
}
},
'dragstart .oe_m2o_drop_down_button img': function () { return false; },
'dragstart .oe_m2o_cm_button': function () { return false; }
},
init: function(field_manager, node) {
this._super(field_manager, node);
@ -3002,6 +3026,26 @@ instance.web.form.FieldMany2One = instance.web.form.AbstractField.extend(instanc
if (!this.get("effective_readonly"))
this.render_editable();
},
destroy_content: function () {
if (this.$drop_down) {
this.$drop_down.off('click');
delete this.$drop_down;
}
if (this.$input) {
this.$input.closest(".ui-dialog .ui-dialog-content").off('scroll');
this.$input.off('keyup blur autocompleteclose autocompleteopen ' +
'focus focusout change keydown');
delete this.$input;
}
if (this.$follow_button) {
this.$follow_button.off('blur focus click');
delete this.$follow_button;
}
},
destroy: function () {
this.destroy_content();
return this._super();
},
init_error_displayer: function() {
// nothing
},
@ -3263,7 +3307,7 @@ instance.web.form.FieldMany2One = instance.web.form.AbstractField.extend(instanc
},
focus: function () {
if (!this.get('effective_readonly')) {
this.$input[0].focus();
this.$input && this.$input[0].focus();
}
},
_quick_create: function() {
@ -3682,8 +3726,8 @@ instance.web.form.One2ManyViewManager = instance.web.ViewManager.extend({
var pop = new instance.web.form.FormOpenPopup(this);
pop.show_element(self.o2m.field.relation, id, self.o2m.build_context(), {
title: _t("Open: ") + self.o2m.string,
create_function: function(data) {
return self.o2m.dataset.create(data).done(function(r) {
create_function: function(data, options) {
return self.o2m.dataset.create(data, options).done(function(r) {
self.o2m.dataset.set_ids(self.o2m.dataset.ids.concat([r]));
self.o2m.dataset.trigger("dataset_changed", r);
});
@ -3742,8 +3786,12 @@ instance.web.form.One2ManyListView = instance.web.ListView.extend({
this.o2m.trigger_on_change();
},
is_valid: function () {
var form = this.editor.form;
var editor = this.editor;
var form = editor.form;
// If no edition is pending, the listview can not be invalid (?)
if (!editor.record) {
return true
}
// If the form has not been modified, the view can only be valid
// NB: is_dirty will also be set on defaults/onchanges/whatever?
// oe_form_dirty seems to only be set on actual user actions
@ -3773,11 +3821,11 @@ instance.web.form.One2ManyListView = instance.web.ListView.extend({
title: _t("Create: ") + self.o2m.string,
initial_view: "form",
alternative_form_view: self.o2m.field.views ? self.o2m.field.views["form"] : undefined,
create_function: function(data, callback, error_callback) {
return self.o2m.dataset.create(data).done(function(r) {
create_function: function(data, options) {
return self.o2m.dataset.create(data, options).done(function(r) {
self.o2m.dataset.set_ids(self.o2m.dataset.ids.concat([r]));
self.o2m.dataset.trigger("dataset_changed", r);
}).done(callback).fail(error_callback);
});
},
read_function: function() {
return self.o2m.dataset.read_ids.apply(self.o2m.dataset, arguments);
@ -3990,6 +4038,7 @@ instance.web.form.FieldMany2ManyTags = instance.web.form.AbstractField.extend(in
if (this.get("effective_readonly"))
return;
var self = this;
var ignore_blur = false;
self.$text = this.$("textarea");
self.$text.textext({
plugins : 'tags arrow autocomplete',
@ -4008,6 +4057,7 @@ instance.web.form.FieldMany2ManyTags = instance.web.form.AbstractField.extend(in
if (data.id) {
self.add_id(data.id);
} else {
ignore_blur = true;
data.action();
}
},
@ -4058,10 +4108,13 @@ instance.web.form.FieldMany2ManyTags = instance.web.form.AbstractField.extend(in
self.$text
.focusin(function () {
self.trigger('focused');
ignore_blur = false;
})
.focusout(function() {
self.$text.trigger("setInputData", "");
self.trigger('blurred');
if (!ignore_blur) {
self.trigger('blurred');
}
}).keydown(function(e) {
if (e.which === $.ui.keyCode.TAB && self._drop_shown) {
self.$text.textext()[0].autocomplete().selectFromDropdown();
@ -4268,6 +4321,7 @@ instance.web.form.Many2ManyListView = instance.web.ListView.extend(/** @lends in
});
}
},
is_action_enabled: function () { return true; },
});
instance.web.form.FieldMany2ManyKanban = instance.web.form.AbstractField.extend(instance.web.form.CompletionFieldMixin, {
@ -4502,9 +4556,9 @@ instance.web.form.AbstractFormPopup = instance.web.Widget.extend({
this.created_elements = [];
this.dataset = new instance.web.ProxyDataSet(this, this.model, this.context);
this.dataset.read_function = this.options.read_function;
this.dataset.create_function = function(data, sup) {
this.dataset.create_function = function(data, options, sup) {
var fct = self.options.create_function || sup;
return fct.call(this, data).done(function(r) {
return fct.call(this, data, options).done(function(r) {
self.trigger('create_completed saved', r);
self.created_elements.push(r);
});
@ -5216,11 +5270,11 @@ instance.web.form.FieldStatus = instance.web.form.AbstractField.extend({
this.calc_domain();
this.on("change:value", this, this.get_selection);
this.on("change:evaluated_selection_domain", this, this.get_selection);
this.get_selection();
this.on("change:selection", this, function() {
this.selection = this.get("selection");
this.render_value();
});
this.get_selection();
if (this.options.clickable) {
this.$el.on('click','li',this.on_click_stage);
}
@ -5339,10 +5393,10 @@ instance.web.form.FieldMonetary = instance.web.form.FieldFloat.extend({
});
},
parse_value: function(val, def) {
return instance.web.parse_value(val, {type: "float"}, def);
return instance.web.parse_value(val, {type: "float", digits: (this.node.attrs || {}).digits || this.field.digits}, def);
},
format_value: function(val, def) {
return instance.web.format_value(val, {type: "float"}, def);
return instance.web.format_value(val, {type: "float", digits: (this.node.attrs || {}).digits || this.field.digits}, def);
},
});

View File

@ -321,9 +321,9 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
.appendTo($this.empty())
.click(function (e) {e.stopPropagation();})
.append('<option value="80">80</option>' +
'<option value="100">100</option>' +
'<option value="200">200</option>' +
'<option value="500">500</option>' +
'<option value="2000">2000</option>' +
'<option value="NaN">' + _t("Unlimited") + '</option>')
.change(function () {
var val = parseInt($select.val(), 10);

View File

@ -96,7 +96,8 @@ openerp.web.list_editable = function (instance) {
});
},
editable: function () {
return !this.options.disable_editable_mode
return !this.grouped
&& !this.options.disable_editable_mode
&& (this.fields_view.arch.attrs.editable
|| this._context_editable
|| this.options.editable);

View File

@ -1446,11 +1446,11 @@ instance.web.View = instance.web.Widget.extend({
* Performs a fields_view_get and apply postprocessing.
* return a {$.Deferred} resolved with the fvg
*
* @param {Object} [args]
* @param {Object} args
* @param {String|Object} args.model instance.web.Model instance or string repr of the model
* @param {null|Object} args.context context if args.model is a string
* @param {null|Number} args.view_id id of the view to be loaded, default view if null
* @param {null|String} args.view_type type of view to be loaded if view_id is null
* @param {Object} [args.context] context if args.model is a string
* @param {Number} [args.view_id] id of the view to be loaded, default view if null
* @param {String} [args.view_type] type of view to be loaded if view_id is null
* @param {Boolean} [args.toolbar=false] get the toolbar definition
*/
instance.web.fields_view_get = function(args) {
@ -1555,12 +1555,17 @@ instance.web.xml_to_str = function(node) {
} else {
throw new Error(_t("Could not serialize XML"));
}
// Browsers won't deal with self closing tags except br, hr, input, ...
// http://stackoverflow.com/questions/97522/what-are-all-the-valid-self-closing-elements-in-xhtml-as-implemented-by-the-maj
//
// Browsers won't deal with self closing tags except void elements:
// http://www.w3.org/TR/html-markup/syntax.html
var void_elements = 'area base br col command embed hr img input keygen link meta param source track wbr'.split(' ');
// The following regex is a bit naive but it's ok for the xmlserializer output
str = str.replace(/<([a-z]+)([^<>]*)\s*\/\s*>/g, function(match, tag, attrs) {
return "<" + tag + attrs + "></" + tag + ">";
if (void_elements.indexOf(tag) < 0) {
return "<" + tag + attrs + "></" + tag + ">";
} else {
return match;
}
});
return str;
};

View File

@ -40,7 +40,7 @@
<td>
<p>
<t t-js="d">
var message = d.message ? d.message : d.error.data.message;
var message = d.message ? d.message : d.error.data.fault_code;
d.html_error = context.engine.tools.html_escape(message)
.replace(/\n/g, '<br/>');
</t>
@ -71,9 +71,9 @@
</div>
<ul>
<li>Username</li>
<li><input name="login" type="text" t-att-value="widget.selected_login || ''" autofocus="autofocus"/></li>
<li><input name="login" type="text" value="" autofocus="autofocus"/></li>
<li>Password</li>
<li><input name="password" type="password" t-att-value="widget.selected_password || ''"/></li>
<li><input name="password" type="password" value=""/></li>
<li><button name="submit">Log in</button></li>
</ul>
</form>
@ -118,7 +118,8 @@
you will be able to install your first application.
</p>
<p class="oe_grey" style="margin: 10px">
By default, the master password is 'admin' if you did not changed it.
By default, the master password is 'admin'. This password
is required to created, delete dump or restore databases.
</p>
<table class="db_option_table" style="margin: 10px">
<tr>
@ -129,7 +130,9 @@
</tr>
<tr>
<td><label for="db_name">Select a database name:</label></td>
<td><input type="text" name="db_name" class="required" matches="^[a-zA-Z0-9][a-zA-Z0-9_-]+$" autofocus="true" placeholder="e.g. mycompany"/></td>
<td>
<input type="text" name="db_name" class="required" matches="^[a-zA-Z0-9][a-zA-Z0-9_-]+$" autofocus="true" placeholder="e.g. mycompany"/>
</td>
</tr>
<tr>
<td><label for="demo_data">Load demonstration data:</label></td>
@ -1047,17 +1050,22 @@
</t>
<t t-name="FieldText">
<div class="oe_form_field oe_form_field_text" t-att-style="widget.node.attrs.style">
<textarea rows="6"
t-att-name="widget.name"
class="field_text"
t-att-tabindex="widget.node.attrs.tabindex"
t-att-autofocus="widget.node.attrs.autofocus"
t-att-placeholder="! widget.get('effective_readonly') ? widget.node.attrs.placeholder : ''"
t-att-maxlength="widget.field.size"
></textarea><img class="oe_field_translate oe_input_icon"
t-if="widget.field.translate and !widget.get('effective_readonly')"
t-att-src='_s + "/web/static/src/img/icons/terp-translate.png"' width="16" height="16" border="0"
/>
<t t-if="!widget.get('effective_readonly')">
<textarea rows="6"
t-att-name="widget.name"
class="field_text"
t-att-tabindex="widget.node.attrs.tabindex"
t-att-autofocus="widget.node.attrs.autofocus"
t-att-placeholder="! widget.get('effective_readonly') ? widget.node.attrs.placeholder : ''"
t-att-maxlength="widget.field.size"
></textarea><img class="oe_field_translate oe_input_icon"
t-if="widget.field.translate and !widget.get('effective_readonly')"
t-att-src='_s + "/web/static/src/img/icons/terp-translate.png"' width="16" height="16" border="0"
/>
</t>
<t t-if="widget.get('effective_readonly')">
<span class="oe_form_text_content"></span>
</t>
</div>
</t>
<t t-name="FieldTextHtml">
@ -1076,8 +1084,9 @@
t-att-name="widget.name"
t-att-placeholder="placeholder"
class="oe_datepicker_master"
/><img class="oe_input_icon oe_datepicker_trigger" t-att-src='_s + "/web/static/src/img/ui/field_calendar.png"'
title="Select date" width="16" height="16" border="0"/>
/><img class="oe_input_icon oe_datepicker_trigger" draggable="false"
t-att-src='_s + "/web/static/src/img/ui/field_calendar.png"'
title="Select date" width="16" height="16" border="0"/>
</span>
</t>
<t t-name="FieldDate">
@ -1111,7 +1120,7 @@
</t>
<t t-if="!widget.get('effective_readonly')">
<a t-if="! widget.options.no_open" href="#" tabindex="-1"
class="oe_m2o_cm_button oe_e">/</a>
class="oe_m2o_cm_button oe_e" draggable="false">/</a>
<div>
<input type="text"
t-att-id="widget.id_for_label"
@ -1120,7 +1129,7 @@
t-att-placeholder="widget.node.attrs.placeholder"
/>
<span class="oe_m2o_drop_down_button">
<img t-att-src='_s + "/web/static/src/img/down-arrow.png"'/>
<img t-att-src='_s + "/web/static/src/img/down-arrow.png"' draggable="false"/>
</span>
</div>
</t>
@ -1515,7 +1524,7 @@
<t t-esc="attrs.string"/>
</button>
<ul t-name="SearchView.filters">
<li t-foreach="widget.filters" t-as="filter"
<li t-foreach="widget.filters" t-as="filter" t-if="filter.visible()"
t-att-title="filter.attrs.string ? filter.attrs.help : undefined">
<t t-esc="filter.attrs.string or filter.attrs.help or filter.attrs.name or 'Ω'"/>
</li>
@ -1603,15 +1612,6 @@
</div>
</div>
</t>
<t t-name="SearchView.group">
<t t-call="SearchView.util.expand">
<t t-set="expand" t-value="attrs.expand"/>
<t t-set="label" t-value="attrs.string"/>
<t t-set="content">
<t t-call="SearchView.render_lines"/>
</t>
</t>
</t>
<div t-name="SearchView.Filters" class="oe_searchview_filters oe_searchview_section">
</div>
@ -1842,7 +1842,7 @@
<tr>
<td t-foreach="records[0]" t-as="column">
<input class="sel_fields" placeholder="--- Don't Import ---"/><span class="oe_m2o_drop_down_button">
<img t-att-src='_s + "/web/static/src/img/down-arrow.png"' /></span>
<img t-att-src='_s + "/web/static/src/img/down-arrow.png"' draggable="false"/></span>
</td>
</tr>
<tr t-foreach="records" t-as="record" class="oe_import_grid-row">

View File

@ -1,4 +1,4 @@
openerp.testing.section('query', {
openerp.testing.section('search.query', {
dependencies: ['web.search']
}, function (test) {
test('Adding a facet to the query creates a facet and a value', function (instance) {
@ -180,7 +180,7 @@ var makeSearchView = function (instance, dummy_widget_attributes, defaults) {
});
return view;
};
openerp.testing.section('defaults', {
openerp.testing.section('search.defaults', {
dependencies: ['web.search'],
rpc: 'mock',
templates: true,
@ -362,7 +362,7 @@ openerp.testing.section('defaults', {
"should not accept multiple default values");
})
});
openerp.testing.section('completions', {
openerp.testing.section('search.completions', {
dependencies: ['web.search'],
rpc: 'mock',
templates: true
@ -615,7 +615,7 @@ openerp.testing.section('completions', {
return f.complete("bob");
});
});
openerp.testing.section('search-serialization', {
openerp.testing.section('search.serialization', {
dependencies: ['web.search'],
rpc: 'mock',
templates: true
@ -855,11 +855,11 @@ openerp.testing.section('search-serialization', {
test('FilterGroup', {asserts: 6}, function (instance) {
var view = {inputs: [], query: {on: function () {}}};
var filter_a = new instance.web.search.Filter(
{attrs: {name: 'a', context: 'c1', domain: 'd1'}}, view);
{attrs: {name: 'a', context: '{"c1": True}', domain: 'd1'}}, view);
var filter_b = new instance.web.search.Filter(
{attrs: {name: 'b', context: 'c2', domain: 'd2'}}, view);
{attrs: {name: 'b', context: '{"c2": True}', domain: 'd2'}}, view);
var filter_c = new instance.web.search.Filter(
{attrs: {name: 'c', context: 'c3', domain: 'd3'}}, view);
{attrs: {name: 'c', context: '{"c3": True}', domain: 'd3'}}, view);
var group = new instance.web.search.FilterGroup(
[filter_a, filter_b, filter_c], view);
return group.facet_for_defaults({a: true, c: true})
@ -880,7 +880,7 @@ openerp.testing.section('search-serialization', {
equal(context.__ref, 'compound_context',
"context should be compound");
deepEqual(context.__contexts, [
'c1', 'c3'
'{"c1": True}', '{"c3": True}'
], "context should merge all filter contexts");
ok(!context.get_eval_context(), "context should have no evaluation context");
});
@ -922,7 +922,7 @@ openerp.testing.section('search-serialization', {
return $.when(t1, t2);
});
});
openerp.testing.section('removal', {
openerp.testing.section('search.removal', {
dependencies: ['web.search'],
rpc: 'mock',
templates: true
@ -945,7 +945,7 @@ openerp.testing.section('removal', {
});
});
});
openerp.testing.section('drawer', {
openerp.testing.section('search.drawer', {
dependencies: ['web.search'],
rpc: 'mock',
templates: true
@ -961,7 +961,7 @@ openerp.testing.section('drawer', {
});
});
});
openerp.testing.section('filters', {
openerp.testing.section('search.filters', {
dependencies: ['web.search'],
rpc: 'mock',
templates: true,
@ -1046,7 +1046,104 @@ openerp.testing.section('filters', {
});
});
});
openerp.testing.section('saved_filters', {
openerp.testing.section('search.groupby', {
dependencies: ['web.search'],
rpc: 'mock',
templates: true,
}, function (test) {
test('basic', {
asserts: 7,
setup: function (instance, $s, mock) {
mock('dummy.model:fields_view_get', function () {
return {
type: 'search',
fields: {},
arch: [
'<search>',
'<filter string="Foo" context="{\'group_by\': \'foo\'}"/>',
'<filter string="Bar" context="{\'group_by\': \'bar\'}"/>',
'<filter string="Baz" context="{\'group_by\': \'baz\'}"/>',
'</search>'
].join(''),
}
});
}
}, function (instance, $fix) {
var view = makeSearchView(instance);
return view.appendTo($fix)
.done(function () {
// 3 filters, 1 filtergroup group, 1 custom filter, 1 advanced, 1 Filters
equal(view.inputs.length, 7,
'should have 7 inputs total');
var group = _.find(view.inputs, function (f) {
return f instanceof instance.web.search.GroupbyGroup
});
ok(group, "should have a GroupbyGroup input");
strictEqual(group.getParent(), view,
"group's parent should be view");
group.toggle(group.filters[0]);
group.toggle(group.filters[2]);
var results = view.build_search_data();
deepEqual(results.errors, [], "should have no errors");
deepEqual(results.domains, [], "should have no domain");
deepEqual(results.contexts, [
new instance.web.CompoundContext(
"{'group_by': 'foo'}", "{'group_by': 'baz'}")
], "should have compound contexts");
deepEqual(results.groupbys, [
"{'group_by': 'foo'}",
"{'group_by': 'baz'}"
], "should have sequence of contexts")
});
});
test('unified multiple groupby groups', {
asserts: 4,
setup: function (instance, $s, mock) {
mock('dummy.model:fields_view_get', function () {
return {
type: 'search',
fields: {},
arch: [
'<search>',
'<filter string="Foo" context="{\'group_by\': \'foo\'}"/>',
'<separator/>',
'<filter string="Bar" context="{\'group_by\': \'bar\'}"/>',
'<separator/>',
'<filter string="Baz" context="{\'group_by\': \'baz\'}"/>',
'</search>'
].join(''),
}
});
}
}, function (instance, $fix) {
var view = makeSearchView(instance);
return view.appendTo($fix)
.done(function () {
// 3 filters, 3 filtergroups, 1 custom filter, 1 advanced, 1 Filters
equal(view.inputs.length, 9, "should have 9 inputs total");
var groups = _.filter(view.inputs, function (f) {
return f instanceof instance.web.search.GroupbyGroup
});
equal(groups.length, 3, "should have 3 GroupbyGroups");
groups[0].toggle(groups[0].filters[0]);
groups[2].toggle(groups[2].filters[0]);
equal(view.query.length, 1,
"should have unified groupby groups in single facet");
deepEqual(view.build_search_data(), {
errors: [],
domains: [],
contexts: [new instance.web.CompoundContext(
"{'group_by': 'foo'}", "{'group_by': 'baz'}")],
groupbys: [ "{'group_by': 'foo'}", "{'group_by': 'baz'}" ],
}, "should only have contexts & groupbys in search data");
});
});
});
openerp.testing.section('search.filters.saved', {
dependencies: ['web.search'],
rpc: 'mock',
templates: true
@ -1127,8 +1224,30 @@ openerp.testing.section('saved_filters', {
"should have selected second filter");
});
});
test('creation', {asserts: 2}, function (instance, $fix, mock) {
// force a user context
instance.session.user_context = {foo: 'bar'};
var view = makeSearchView(instance);
var done = $.Deferred();
mock('ir.filters:get_filters', function () { return []; });
mock('ir.filters:create_or_replace', function (args) {
var filter = args[0];
deepEqual(filter.context, {}, "should have empty context");
deepEqual(filter.domain, [], "should have empty domain");
done.resolve();
});
return view.appendTo($fix)
.then(function () {
$fix.find('.oe_searchview_custom input#oe_searchview_custom_input')
.text("filter name")
.end()
.find('.oe_searchview_custom button').click();
return done.promise();
});
});
});
openerp.testing.section('advanced', {
openerp.testing.section('search.advanced', {
dependencies: ['web.search'],
rpc: 'mock',
templates: true
@ -1209,3 +1328,159 @@ openerp.testing.section('advanced', {
});
// TODO: UI tests?
});
openerp.testing.section('search.invisible', {
dependencies: ['web.search'],
rpc: 'mock',
templates: true,
}, function (test) {
var registerTestField = function (instance, methods) {
instance.web.search.fields.add('test', 'instance.testing.TestWidget');
instance.testing = {
TestWidget: instance.web.search.Field.extend(methods),
};
};
var makeView = function (instance, mock, fields, arch, defaults) {
mock('ir.filters:get_filters', function () { return []; });
mock('test.model:fields_get', function () { return fields; });
mock('test.model:fields_view_get', function () {
return { type: 'search', fields: fields, arch: arch };
});
var ds = new instance.web.DataSet(null, 'test.model');
return new instance.web.SearchView(null, ds, false, defaults);
};
// Invisible fields should not auto-complete
test('invisible-field-no-autocomplete', {asserts: 1}, function (instance, $fix, mock) {
registerTestField(instance, {
complete: function () {
return $.when([{label: this.attrs.string}]);
},
});
var view = makeView(instance, mock, {
field0: {type: 'test', string: 'Field 0'},
field1: {type: 'test', string: 'Field 1'},
}, ['<search>',
'<field name="field0"/>',
'<field name="field1" modifiers="{&quot;invisible&quot;: true}"/>',
'</search>'].join());
return view.appendTo($fix)
.then(function () {
var done = $.Deferred();
view.complete_global_search({term: 'test'}, function (comps) {
done.resolve(comps);
});
return done;
}).then(function (completions) {
deepEqual(completions, [{label: 'Field 0'}],
"should only complete the visible field");
});
});
// Invisible filters should not appear in the drawer
test('invisible-filter-no-drawer', {asserts: 4}, function (instance, $fix, mock) {
var view = makeView(instance, mock, {}, [
'<search>',
'<filter string="filter 0"/>',
'<filter string="filter 1" modifiers="{&quot;invisible&quot;: true}"/>',
'</search>'].join());
return view.appendTo($fix)
.then(function () {
var $fs = $fix.find('.oe_searchview_filters ul');
strictEqual($fs.children().length,
1,
"should only display one filter");
strictEqual(_.str.trim($fs.children().text()),
"filter 0",
"should only display filter 0");
var done = $.Deferred();
view.complete_global_search({term: 'filter'}, function (comps) {
done.resolve();
strictEqual(comps.length, 1, "should only complete visible filter");
strictEqual(comps[0].label, "Filter on: filter 0",
"should complete filter 0");
});
return done;
});
});
// Invisible filter groups should not appear in the drawer
// Group invisibility should be inherited by children
test('group-invisibility', {asserts: 6}, function (instance, $fix, mock) {
registerTestField(instance, {
complete: function () {
return $.when([{label: this.attrs.string}]);
},
});
var view = makeView(instance, mock, {
field0: {type: 'test', string: 'Field 0'},
field1: {type: 'test', string: 'Field 1'},
}, [
'<search>',
'<group string="Visibles">',
'<field name="field0"/>',
'<filter string="Filter 0"/>',
'</group>',
'<group string="Invisibles" modifiers="{&quot;invisible&quot;: true}">',
'<field name="field1"/>',
'<filter string="Filter 1"/>',
'</group>',
'</search>'
].join(''));
return view.appendTo($fix)
.then(function () {
strictEqual($fix.find('.oe_searchview_filters h3').length,
1,
"should only display one group");
strictEqual($fix.find('.oe_searchview_filters h3').text(),
'w Visibles',
"should only display the Visibles group (and its icon char)");
var $fs = $fix.find('.oe_searchview_filters ul');
strictEqual($fs.children().length, 1,
"should only have one filter in the drawer");
strictEqual(_.str.trim($fs.text()), "Filter 0",
"should have filter 0 as sole filter");
var done = $.Deferred();
view.complete_global_search({term: 'filter'}, function (compls) {
done.resolve();
strictEqual(compls.length, 2,
"should have 2 completions");
deepEqual(_.pluck(compls, 'label'),
['Field 0', 'Filter on: Filter 0'],
"should complete on field 0 and filter 0");
});
return done;
});
});
// Default on invisible fields should still work, for fields and filters both
test('invisible-defaults', {asserts: 1}, function (instance, $fix, mock) {
var view = makeView(instance, mock, {
field: {type: 'char', string: "Field"},
field2: {type: 'char', string: "Field 2"},
}, [
'<search>',
'<field name="field2"/>',
'<filter name="filter2" string="Filter"',
' domain="[[\'qwa\', \'=\', 42]]"/>',
'<group string="Invisibles" modifiers="{&quot;invisible&quot;: true}">',
'<field name="field"/>',
'<filter name="filter" string="Filter"',
' domain="[[\'whee\', \'=\', \'42\']]"/>',
'</group>',
'</search>'
].join(''), {field: "foo", filter: true});
return view.appendTo($fix)
.then(function () {
deepEqual(view.build_search_data(), {
errors: [],
groupbys: [],
contexts: [],
domains: [
// Generated from field
[['field', 'ilike', 'foo']],
// generated from filter
"[['whee', '=', '42']]"
],
}, "should yield invisible fields selected by defaults");
});
});
});

View File

@ -1,5 +1,5 @@
.openerp a.dropdown-menu-icon {
z-index: 10;
z-index: 1;
position: absolute;
color: #4c4c4c;
right: 8px;
@ -23,7 +23,7 @@
padding: 8px;
border: 1px solid #afafb6;
background: white;
z-index: 900;
z-index: 1;
min-width: 160px;
overflow-x: hidden;
-moz-border-radius: 3px;

View File

@ -246,7 +246,7 @@ instance.web_graph.GraphView = instance.web.View.extend({
var result = [];
var ticks = {};
return this.alive(obj.call("fields_view_get", [view_id, 'graph']).then(function(tmp) {
return this.alive(obj.call("fields_view_get", [view_id, 'graph', context]).then(function(tmp) {
view_get = tmp;
fields = view_get['fields'];
var toload = _.select(group_by, function(x) { return fields[x] === undefined });

View File

@ -7,6 +7,15 @@
background: url(/web/static/src/img/form_sheetbg.png);
width: 100%;
}
.openerp .oe_kanban_view .oe_kanban_group_length {
text-align: center;
display: none;
}
.openerp .oe_kanban_view .oe_kanban_group_length .oe_tag {
position: relative;
top: 8px;
font-weight: bold;
}
.openerp .oe_kanban_view .ui-sortable-placeholder {
border: 1px solid rgba(0, 0, 0, 0.1);
visibility: visible !important;
@ -92,15 +101,6 @@
overflow: hidden;
text-overflow: ellipsis;
}
.openerp .oe_kanban_view .oe_kanban_group_length {
text-align: center;
display: none;
}
.openerp .oe_kanban_view .oe_kanban_group_length .oe_tag {
position: relative;
top: +8px;
font-weight: bold;
}
.openerp .oe_kanban_view .oe_fold_column .oe_kanban_group_length {
position: absolute;
top: -1px;
@ -108,9 +108,6 @@
float: right;
display: block;
}
.openerp .oe_kanban_view .oe_kanban_header:hover .oe_kanban_group_length {
display: none;
}
.openerp .oe_kanban_view.oe_kanban_grouped .oe_kanban_column, .openerp .oe_kanban_view.oe_kanban_grouped .oe_kanban_group_header {
width: 185px;
min-width: 185px;
@ -150,8 +147,7 @@
.openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_group_title, .openerp .oe_kanban_view .oe_kanban_group_folded.oe_kanban_column *, .openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_aggregates, .openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_add {
display: none;
}
.openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_group_title_vertical,
.openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_group_length {
.openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_group_title_vertical, .openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_group_length {
display: block;
}
.openerp .oe_kanban_view .oe_kanban_group_folded .oe_dropdown_kanban {

View File

@ -130,8 +130,8 @@
position: absolute
top: -1px
right: -14px
display: block
float: right
display: block
&.oe_kanban_grouped
.oe_kanban_column, .oe_kanban_group_header
width: 185px
@ -197,7 +197,7 @@
top: -8px
.oe_kanban_header .oe_dropdown_toggle
top: -2px
height: 14px;
height: 14px
.oe_kanban_card, .oe_dropdown_toggle
cursor: pointer
display: inline-block