Master chintan hiren mayank 13072018

This commit is contained in:
ChintAn Ambaliya 2018-07-13 09:51:12 +00:00 committed by Parthiv Patel
parent 604cf9e3e0
commit 7dd719a67f
194 changed files with 4249 additions and 1091 deletions

View File

@ -12,6 +12,9 @@ _logger = logging.getLogger(__name__)
try: try:
import stdnum.eu.vat as stdnum_vat import stdnum.eu.vat as stdnum_vat
if not hasattr(stdnum_vat, "country_codes"):
# stdnum version >= 1.9
stdnum_vat.country_codes = stdnum_vat._country_codes
except ImportError: except ImportError:
_logger.warning('Python `stdnum` library not found, unable to call VIES service to detect address based on VAT number.') _logger.warning('Python `stdnum` library not found, unable to call VIES service to detect address based on VAT number.')
stdnum_vat = None stdnum_vat = None
@ -29,6 +32,11 @@ class ResPartner(models.Model):
cp = lines.pop() cp = lines.pop()
city = lines.pop() city = lines.pop()
return (cp, city) return (cp, city)
elif country == 'SE':
result = re.match('([0-9]{3}\s?[0-9]{2})\s?([A-Z]+)', lines[-1])
if result:
lines.pop()
return (result.group(1), result.group(2))
else: else:
result = re.match('((?:L-|AT-)?[0-9\-]+[A-Z]{,2}) (.+)', lines[-1]) result = re.match('((?:L-|AT-)?[0-9\-]+[A-Z]{,2}) (.+)', lines[-1])
if result: if result:

View File

@ -11,7 +11,7 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='vat']" position="replace" /> <xpath expr="//field[@name='vat']" position="replace" />
<field name="partner_id" position="after"> <field name="partner_id" position="after">
<field name="vat" /> <field name="vat" string="VAT"/>
</field> </field>
</field> </field>
</record> </record>

View File

@ -18,7 +18,7 @@
<h3 class="oe_slogan">Gather all your Flectra reports in one app</h3> <h3 class="oe_slogan">Gather all your Flectra reports in one app</h3>
<div class="col-md-6"> <div class="col-md-6">
<p class="oe_mt32"> <p class="oe_mt32">
After creating reports in each Odoo app, you can choose to make them as Favorite and adding those on your 'Dashboard'. Odoo Dashboard is the reporting module that gathers all your favorite graphs and tabs in one place. You company has no secret for you anymore, all your important figures are in front of you. After creating reports in each Flectra app, you can choose to make them as Favorite and adding those on your 'Dashboard'. Flectra Dashboard is the reporting module that gathers all your favorite graphs and tabs in one place. You company has no secret for you anymore, all your important figures are in front of you.
</p> </p>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
@ -40,7 +40,7 @@ After creating reports in each Odoo app, you can choose to make them as Favorite
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<p class="oe_mt32"> <p class="oe_mt32">
In each Odoo App, you can create detailed reports and graphs in any format you like without the need of an external program. Get statistics on any numbers in your company, from fuel costs in projects all the way to revenues in sales channels, and with inventory, and everything in between. In each Flectra App, you can create detailed reports and graphs in any format you like without the need of an external program. Get statistics on any numbers in your company, from fuel costs in projects all the way to revenues in sales channels, and with inventory, and everything in between.
</p> </p>
</div> </div>
</div> </div>
@ -52,7 +52,7 @@ In each Odoo App, you can create detailed reports and graphs in any format you l
<h3 class="oe_slogan">Filter all results to fit your field of research</h3> <h3 class="oe_slogan">Filter all results to fit your field of research</h3>
<div class="col-md-12"> <div class="col-md-12">
<p class="oe_mt32"> <p class="oe_mt32">
In each Odoo app, you'll need to create a report. In the reports you can filter and group each analysis using built-in filters, and create custom filters to gather only the information you are looking for. Save the filters you created in your favorites to access them anytime in just a click either from the concerned app, or in comparison with you other reports in the Odoo Dashboard app. In each Flectra app, you'll need to create a report. In the reports you can filter and group each analysis using built-in filters, and create custom filters to gather only the information you are looking for. Save the filters you created in your favorites to access them anytime in just a click either from the concerned app, or in comparison with you other reports in the Flectra Dashboard app.
</p> </p>
</div> </div>
</div> </div>

View File

@ -20,7 +20,7 @@ FormView.include({
*/ */
init: function (viewInfo) { init: function (viewInfo) {
this._super.apply(this, arguments); this._super.apply(this, arguments);
this.controllerParams.viewID = viewInfo.view_id; this.controllerParams.customViewID = viewInfo.custom_view_id;
}, },
}); });
@ -33,7 +33,7 @@ FormController.include({
}), }),
init: function (parent, model, renderer, params) { init: function (parent, model, renderer, params) {
this._super.apply(this, arguments); this._super.apply(this, arguments);
this.viewID = params.viewID; this.customViewID = params.customViewID;
}, },
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
@ -63,9 +63,9 @@ FormController.include({
var board = this.renderer.getBoard(); var board = this.renderer.getBoard();
var arch = QWeb.render('DashBoard.xml', _.extend({}, board)); var arch = QWeb.render('DashBoard.xml', _.extend({}, board));
return this._rpc({ return this._rpc({
route: '/web/view/add_custom', route: '/web/view/edit_custom',
params: { params: {
view_id: this.viewID, custom_id: this.customViewID,
arch: arch, arch: arch,
} }
}).then(dataManager.invalidate.bind(dataManager)); }).then(dataManager.invalidate.bind(dataManager));
@ -139,6 +139,31 @@ FormRenderer.include({
this._super.apply(this, arguments); this._super.apply(this, arguments);
this.noContentHelp = params.noContentHelp; this.noContentHelp = params.noContentHelp;
this.actionsDescr = {}; this.actionsDescr = {};
this._boardSubcontrollers = []; // for board: controllers of subviews
},
/**
* Call `on_attach_callback` for each subview
*
* @override
*/
on_attach_callback: function () {
_.each(this._boardSubcontrollers, function (controller) {
if ('on_attach_callback' in controller) {
controller.on_attach_callback();
}
});
},
/**
* Call `on_detach_callback` for each subview
*
* @override
*/
on_detach_callback: function () {
_.each(this._boardSubcontrollers, function (controller) {
if ('on_detach_callback' in controller) {
controller.on_detach_callback();
}
});
}, },
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
@ -241,6 +266,7 @@ FormRenderer.include({
hasSelectors: false, hasSelectors: false,
}); });
return view.getController(self).then(function (controller) { return view.getController(self).then(function (controller) {
self._boardSubcontrollers.push(controller);
return controller.appendTo(params.$node); return controller.appendTo(params.$node);
}); });
}); });

View File

@ -94,7 +94,10 @@ FavoriteMenu.include({
}) })
.then(function (r) { .then(function (r) {
if (r) { if (r) {
self.do_notify(_.str.sprintf(_t("'%s' added to dashboard"), name), ''); self.do_notify(
_.str.sprintf(_t("'%s' added to dashboard"), name),
_t('Please refresh your browser for the changes to take effect.')
);
} else { } else {
self.do_warn(_t("Could not add filter to dashboard")); self.do_warn(_t("Could not add filter to dashboard"));
} }

View File

@ -3,6 +3,7 @@ flectra.define('board.dashboard_tests', function (require) {
var testUtils = require('web.test_utils'); var testUtils = require('web.test_utils');
var FormView = require('web.FormView'); var FormView = require('web.FormView');
var ListRenderer = require('web.ListRenderer');
var createView = testUtils.createView; var createView = testUtils.createView;
@ -123,8 +124,8 @@ QUnit.test('basic functionality, with one sub action', function (assert) {
if (route === '/web/dataset/search_read') { if (route === '/web/dataset/search_read') {
assert.deepEqual(args.domain, [['foo', '!=', 'False']], "the domain should be passed"); assert.deepEqual(args.domain, [['foo', '!=', 'False']], "the domain should be passed");
} }
if (route === '/web/view/add_custom') { if (route === '/web/view/edit_custom') {
assert.step('add custom'); assert.step('edit custom');
return $.when(true); return $.when(true);
} }
return this._super.apply(this, arguments); return this._super.apply(this, arguments);
@ -155,7 +156,7 @@ QUnit.test('basic functionality, with one sub action', function (assert) {
form.$('.oe_fold').click(); form.$('.oe_fold').click();
assert.ok(form.$('.oe_content').is(':visible'), "content is visible again"); assert.ok(form.$('.oe_content').is(':visible'), "content is visible again");
assert.verifySteps(['load action', 'add custom', 'add custom']); assert.verifySteps(['load action', 'edit custom', 'edit custom']);
assert.strictEqual($('.modal').length, 0, "should have no modal open"); assert.strictEqual($('.modal').length, 0, "should have no modal open");
@ -183,7 +184,7 @@ QUnit.test('basic functionality, with one sub action', function (assert) {
assert.strictEqual($('.modal').length, 0, "should have no modal open"); assert.strictEqual($('.modal').length, 0, "should have no modal open");
assert.strictEqual(form.$('.oe_action').length, 0, "should have no displayed action"); assert.strictEqual(form.$('.oe_action').length, 0, "should have no displayed action");
assert.verifySteps(['load action', 'add custom', 'add custom', 'add custom', 'add custom']); assert.verifySteps(['load action', 'edit custom', 'edit custom', 'edit custom', 'edit custom']);
form.destroy(); form.destroy();
}); });
@ -292,8 +293,8 @@ QUnit.test('can drag and drop a view', function (assert) {
views: [[4, 'list']], views: [[4, 'list']],
}); });
} }
if (route === '/web/view/add_custom') { if (route === '/web/view/edit_custom') {
assert.step('add custom'); assert.step('edit custom');
return $.when(true); return $.when(true);
} }
return this._super.apply(this, arguments); return this._super.apply(this, arguments);
@ -339,8 +340,8 @@ QUnit.test('twice the same action in a dashboard', function (assert) {
views: [[4, 'list'],[5, 'kanban']], views: [[4, 'list'],[5, 'kanban']],
}); });
} }
if (route === '/web/view/add_custom') { if (route === '/web/view/edit_custom') {
assert.step('add custom'); assert.step('edit custom');
return $.when(true); return $.when(true);
} }
return this._super.apply(this, arguments); return this._super.apply(this, arguments);
@ -454,5 +455,48 @@ QUnit.test('clicking on a kanban\'s button should trigger the action', function
form.destroy(); form.destroy();
}); });
QUnit.test('subviews are aware of attach in or detach from the DOM', function (assert) {
assert.expect(2);
// patch list renderer `on_attach_callback` for the test only
testUtils.patch(ListRenderer, {
on_attach_callback: function () {
assert.step('subview on_attach_callback');
}
});
var form = createView({
View: FormView,
model: 'board',
data: this.data,
arch: '<form string="My Dashboard">' +
'<board style="2-1">' +
'<column>' +
'<action context="{}" view_mode="list" string="ABC" name="51" domain="[]"></action>' +
'</column>' +
'</board>' +
'</form>',
mockRPC: function (route) {
if (route === '/web/action/load') {
return $.when({
res_model: 'partner',
views: [[4, 'list']],
});
}
return this._super.apply(this, arguments);
},
archs: {
'partner,4,list':
'<list string="Partner"><field name="foo"/></list>',
},
});
assert.verifySteps(['subview on_attach_callback']);
// restore on_attach_callback of ListRenderer
testUtils.unpatch(ListRenderer);
form.destroy();
});
}); });

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models from . import models

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
from . import main

View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
import flectra
from flectra import http
from flectra.http import request
from flectra.osv import expression
from flectra.addons.web.controllers.main import WebClient, Home
class Routing(Home):
@http.route('/website/translations', type='json', auth="public", website=True)
def get_website_translations(self, lang, mods=None):
Modules = request.env['ir.module.module'].sudo()
IrHttp = request.env['ir.http'].sudo()
domain = IrHttp._get_translation_frontend_modules_domain()
modules = Modules.search(
expression.AND([domain, [('state', '=', 'installed')]])
).mapped('name')
if mods:
modules += mods
return WebClient().translations(mods=modules, lang=lang)

View File

@ -0,0 +1,64 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os.path
try:
import GeoIP # Legacy
except ImportError:
GeoIP = None
try:
import geoip2
import geoip2.database
except ImportError:
geoip2 = None
class GeoIPResolver(object):
def __init__(self, fname):
self.fname = fname
try:
self._db = geoip2.database.Reader(fname)
self.version = 2
except Exception:
try:
self._db = GeoIP.open(fname, GeoIP.GEOIP_STANDARD)
self.version = 1
assert self._db.database_info is not None
except Exception:
raise ValueError('Invalid GeoIP database: %r' % fname)
def __del__(self):
if self.version == 2:
self._db.close()
@classmethod
def open(cls, fname):
if not GeoIP and not geoip2:
return None
if not os.path.exists(fname):
return None
return GeoIPResolver(fname)
def resolve(self, ip):
if self.version == 1:
return self._db.record_by_addr(ip) or {}
elif self.version == 2:
try:
r = self._db.city(ip)
except (ValueError, geoip2.errors.AddressNotFoundError):
return {}
# Compatibility with Legacy database.
# Some ips cannot be located to a specific country. Legacy DB used to locate them in
# continent instead of country. Do the same to not change behavior of existing code.
country, attr = (r.country, 'iso_code') if r.country.geoname_id else (r.continent, 'code')
return {
'city': r.city.name,
'country_code': getattr(country, attr),
'country_name': country.name,
'region': r.subdivisions[0].iso_code if r.subdivisions else None,
'time_zone': r.location.time_zone,
}
# compat
def record_by_addr(self, addr):
return self.resolve(addr)

View File

@ -18,6 +18,8 @@ from flectra.addons.base.ir.ir_http import RequestUID, ModelConverter
from flectra.http import request from flectra.http import request
from flectra.tools import config, ustr, pycompat from flectra.tools import config, ustr, pycompat
from ..geoipresolver import GeoIPResolver
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# global resolver (GeoIP API is thread-safe, for multithreaded workers) # global resolver (GeoIP API is thread-safe, for multithreaded workers)
@ -229,6 +231,13 @@ class IrHttp(models.AbstractModel):
return request.env['res.lang'].search([('code', '=', lang_code)], limit=1) return request.env['res.lang'].search([('code', '=', lang_code)], limit=1)
return request.env['res.lang'].search([], limit=1) return request.env['res.lang'].search([], limit=1)
@classmethod
def _get_translation_frontend_modules_domain(cls):
""" Return a domain to list the domain adding web-translations and
dynamic resources that may be used frontend views
"""
return []
bots = "bot|crawl|slurp|spider|curl|wget|facebookexternalhit".split("|") bots = "bot|crawl|slurp|spider|curl|wget|facebookexternalhit".split("|")
@classmethod @classmethod
@ -258,25 +267,18 @@ class IrHttp(models.AbstractModel):
# Lazy init of GeoIP resolver # Lazy init of GeoIP resolver
if flectra._geoip_resolver is not None: if flectra._geoip_resolver is not None:
return return
geofile = config.get('geoip_database')
try: try:
import GeoIP flectra._geoip_resolver = GeoIPResolver.open(geofile) or False
# updated database can be downloaded on MaxMind website except Exception as e:
# http://dev.maxmind.com/geoip/legacy/install/city/ _logger.warning('Cannot load GeoIP: %s', ustr(e))
geofile = config.get('geoip_database')
if os.path.exists(geofile):
flectra._geoip_resolver = GeoIP.open(geofile, GeoIP.GEOIP_STANDARD)
else:
flectra._geoip_resolver = False
_logger.warning('GeoIP database file %r does not exists, apt-get install geoip-database-contrib or download it from http://dev.maxmind.com/geoip/legacy/install/city/', geofile)
except ImportError:
flectra._geoip_resolver = False
@classmethod @classmethod
def _geoip_resolve(cls): def _geoip_resolve(cls):
if 'geoip' not in request.session: if 'geoip' not in request.session:
record = {} record = {}
if flectra._geoip_resolver and request.httprequest.remote_addr: if flectra._geoip_resolver and request.httprequest.remote_addr:
record = flectra._geoip_resolver.record_by_addr(request.httprequest.remote_addr) or {} record = flectra._geoip_resolver.resolve(request.httprequest.remote_addr) or {}
request.session['geoip'] = record request.session['geoip'] = record
@classmethod @classmethod

View File

@ -8,6 +8,7 @@
<field name="name">Pos Orders Inalterability Check</field> <field name="name">Pos Orders Inalterability Check</field>
<field name="model_id" ref="point_of_sale.model_pos_order"/> <field name="model_id" ref="point_of_sale.model_pos_order"/>
<field name="type">ir.actions.server</field> <field name="type">ir.actions.server</field>
<field name="state">code</field>
<field name="code"> <field name="code">
action = env['pos.order']._check_hash_integrity(env.user.company_id.id) action = env['pos.order']._check_hash_integrity(env.user.company_id.id)
</field> </field>

View File

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
from flectra import models from flectra import models, api
from flectra.tools.translate import _ from flectra.tools.translate import _
from flectra.exceptions import UserError from flectra.exceptions import UserError
@ -8,6 +8,7 @@ from flectra.exceptions import UserError
class AccountBankStatement(models.Model): class AccountBankStatement(models.Model):
_inherit = 'account.bank.statement' _inherit = 'account.bank.statement'
@api.multi
def unlink(self): def unlink(self):
for statement in self.filtered(lambda s: s.company_id._is_accounting_unalterable() and s.journal_id.journal_user): for statement in self.filtered(lambda s: s.company_id._is_accounting_unalterable() and s.journal_id.journal_user):
raise UserError(_('You cannot modify anything on a bank statement (name: %s) that was created by point of sale operations.') % (statement.name,)) raise UserError(_('You cannot modify anything on a bank statement (name: %s) that was created by point of sale operations.') % (statement.name,))
@ -17,6 +18,7 @@ class AccountBankStatement(models.Model):
class AccountBankStatementLine(models.Model): class AccountBankStatementLine(models.Model):
_inherit = 'account.bank.statement.line' _inherit = 'account.bank.statement.line'
@api.multi
def unlink(self): def unlink(self):
for line in self.filtered(lambda s: s.company_id._is_accounting_unalterable() and s.journal_id.journal_user): for line in self.filtered(lambda s: s.company_id._is_accounting_unalterable() and s.journal_id.journal_user):
raise UserError(_('You cannot modify anything on a bank statement line (name: %s) that was created by point of sale operations.') % (line.name,)) raise UserError(_('You cannot modify anything on a bank statement line (name: %s) that was created by point of sale operations.') % (line.name,))

View File

@ -1,2 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
def uninstall_hook(cr, registry):
cr.execute(
"DELETE FROM ir_model_data WHERE module = 'l10n_generic_coa'"
)

View File

@ -29,4 +29,5 @@ Install some generic chart of accounts.
'../account/demo/account_invoice_demo.yml', '../account/demo/account_invoice_demo.yml',
], ],
'website': 'https://flectrahq.com/page/accounting', 'website': 'https://flectrahq.com/page/accounting',
'uninstall_hook': 'uninstall_hook',
} }

View File

@ -98,7 +98,7 @@
<t t-set="total_cess" t-value="total_cess + tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> <t t-set="total_cess" t-value="total_cess + tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/>
</t> </t>
</t> </t>
<t t-esc="total_cess"/> <t t-esc="total_cess" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/>
</td> </td>
</t> </t>
</tr> </tr>

View File

@ -48,7 +48,7 @@
</h3> </h3>
<img class="oe_picture oe_screenshot" src="note_sc_03a.png"> <img class="oe_picture oe_screenshot" src="note_sc_03a.png">
<p class="text-justify"> <p class="text-justify">
Odoo Notes helps you stay organized and focused on your work with a structured and accessible system. Break down your tasks and projects into actionable work items and achieve your goals more efficiently Flectra Notes helps you stay organized and focused on your work with a structured and accessible system. Break down your tasks and projects into actionable work items and achieve your goals more efficiently
</p> </p>
</div> </div>
<div class="col-md-4 oe_mt32"> <div class="col-md-4 oe_mt32">
@ -78,7 +78,7 @@
<h3 class="oe_slogan">Flectra Notes adapts to your needs and habits</h3> <h3 class="oe_slogan">Flectra Notes adapts to your needs and habits</h3>
<div class="col-md-6"> <div class="col-md-6">
<p class="oe_mt32 text-justify"> <p class="oe_mt32 text-justify">
Odoo Notes' smart kanban view allows every user to customize their own steps in order to process their to-do lists and notes. Choose the default template or create your own steps/stages of the process. Flectra Notes' smart kanban view allows every user to customize their own steps in order to process their to-do lists and notes. Choose the default template or create your own steps/stages of the process.
</p> </p>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">

View File

@ -114,7 +114,7 @@
<!-- title --> <!-- title -->
<field name="name"/> <field name="name"/>
<div class="oe_kanban_bottom_right"> <div class="oe_kanban_bottom_right">
<div class="o_kanban_inline_block mt16 mr4"> <div class="o_kanban_inline_block mr4">
<field name="activity_ids" widget="kanban_activity" /> <field name="activity_ids" widget="kanban_activity" />
</div> </div>
</div> </div>

View File

@ -10,7 +10,6 @@ import requests
from flectra import api, models, _ from flectra import api, models, _
from flectra.exceptions import UserError from flectra.exceptions import UserError
from flectra.tools import html2plaintext
from ..py_etherpad import EtherpadLiteClient from ..py_etherpad import EtherpadLiteClient
@ -76,7 +75,7 @@ class PadCommon(models.AbstractModel):
@api.model @api.model
def pad_get_content(self, url): def pad_get_content(self, url):
company = self.env.user.sudo().company_id company = self.env.user.sudo().company_id
myPad = EtherpadLiteClient(company.pad_key, company.pad_server + '/api') myPad = EtherpadLiteClient(company.pad_key, (company.pad_server or '') + '/api')
content = '' content = ''
if url: if url:
split_url = url.split('/p/') split_url = url.split('/p/')
@ -133,7 +132,7 @@ class PadCommon(models.AbstractModel):
for k, field in self._fields.items(): for k, field in self._fields.items():
if hasattr(field, 'pad_content_field') and vals.get(field.pad_content_field) and self[k]: if hasattr(field, 'pad_content_field') and vals.get(field.pad_content_field) and self[k]:
company = self.env.user.sudo().company_id company = self.env.user.sudo().company_id
myPad = EtherpadLiteClient(company.pad_key, company.pad_server + '/api') myPad = EtherpadLiteClient(company.pad_key, (company.pad_server or '') + '/api')
path = self[k].split('/p/')[1] path = self[k].split('/p/')[1]
myPad.setHtmlFallbackText(path, vals[field.pad_content_field]) myPad.setHtmlFallbackText(path, vals[field.pad_content_field])

View File

@ -9,7 +9,7 @@
"node":"*" "node":"*"
}, },
"author":{ "author":{
"name":"Flectra S.A. - Hitesh Trivedi", "name":"Odoo S.A. - Hitesh Trivedi",
"email":"thiteshm155@gmail.com" "email":"thiteshm155@gmail.com"
} }
} }

View File

@ -17,4 +17,4 @@ module.exports = function(grunt) {
grunt.registerTask('default', ['jshint']); grunt.registerTask('default', ['jshint']);
}; };

View File

@ -28,6 +28,7 @@ import werkzeug.wsgi
from collections import OrderedDict from collections import OrderedDict
from werkzeug.urls import url_decode, iri_to_uri from werkzeug.urls import url_decode, iri_to_uri
from xml.etree import ElementTree from xml.etree import ElementTree
import unicodedata
import flectra import flectra
@ -1007,14 +1008,17 @@ class DataSet(http.Controller):
class View(http.Controller): class View(http.Controller):
@http.route('/web/view/add_custom', type='json', auth="user") @http.route('/web/view/edit_custom', type='json', auth="user")
def add_custom(self, view_id, arch): def edit_custom(self, custom_id, arch):
CustomView = request.env['ir.ui.view.custom'] """
CustomView.create({ Edit a custom view
'user_id': request.session.uid,
'ref_id': view_id, :param int custom_id: the id of the edited custom view
'arch': arch :param str arch: the edited arch of the custom view
}) :returns: dict with acknowledged operation (result set to True)
"""
custom_view = request.env['ir.ui.view.custom'].browse(custom_id)
custom_view.write({ 'arch': arch })
return {'result': True} return {'result': True}
class Binary(http.Controller): class Binary(http.Controller):
@ -1039,7 +1043,7 @@ class Binary(http.Controller):
'/web/content/<string:model>/<int:id>/<string:field>/<string:filename>'], type='http', auth="public") '/web/content/<string:model>/<int:id>/<string:field>/<string:filename>'], type='http', auth="public")
def content_common(self, xmlid=None, model='ir.attachment', id=None, field='datas', def content_common(self, xmlid=None, model='ir.attachment', id=None, field='datas',
filename=None, filename_field='datas_fname', unique=None, mimetype=None, filename=None, filename_field='datas_fname', unique=None, mimetype=None,
download=None, data=None, token=None, access_token=None): download=None, data=None, token=None, access_token=None, **kw):
status, headers, content = binary_content( status, headers, content = binary_content(
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename, xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
filename_field=filename_field, download=download, mimetype=mimetype, filename_field=filename_field, download=download, mimetype=mimetype,
@ -1089,8 +1093,12 @@ class Binary(http.Controller):
elif status != 200 and download: elif status != 200 and download:
return request.not_found() return request.not_found()
height = int(height or 0) if headers and dict(headers).get('Content-Type', '') == 'image/svg+xml': # we shan't resize svg images
width = int(width or 0) height = 0
width = 0
else:
height = int(height or 0)
width = int(width or 0)
if crop and (width or height): if crop and (width or height):
content = crop_image(content, type='center', size=(width, height), ratio=(1, 1)) content = crop_image(content, type='center', size=(width, height), ratio=(1, 1))
@ -1153,20 +1161,27 @@ class Binary(http.Controller):
</script>""" </script>"""
args = [] args = []
for ufile in files: for ufile in files:
filename = ufile.filename
if request.httprequest.user_agent.browser == 'safari':
# Safari sends NFD UTF-8 (where é is composed by 'e' and [accent])
# we need to send it the same stuff, otherwise it'll fail
filename = unicodedata.normalize('NFD', ufile.filename)
try: try:
attachment = Model.create({ attachment = Model.create({
'name': ufile.filename, 'name': filename,
'datas': base64.encodestring(ufile.read()), 'datas': base64.encodestring(ufile.read()),
'datas_fname': ufile.filename, 'datas_fname': filename,
'res_model': model, 'res_model': model,
'res_id': int(id) 'res_id': int(id)
}) })
except Exception: except Exception:
args = args.append({'error': _("Something horrible happened")}) args.append({'error': _("Something horrible happened")})
_logger.exception("Fail to upload attachment %s" % ufile.filename) _logger.exception("Fail to upload attachment %s" % ufile.filename)
else: else:
args.append({ args.append({
'filename': ufile.filename, 'filename': filename,
'mimetype': ufile.content_type, 'mimetype': ufile.content_type,
'id': attachment.id 'id': attachment.id
}) })
@ -1428,7 +1443,7 @@ class ExportFormat(object):
model, fields, ids, domain, import_compat = \ model, fields, ids, domain, import_compat = \
operator.itemgetter('model', 'fields', 'ids', 'domain', 'import_compat')(params) operator.itemgetter('model', 'fields', 'ids', 'domain', 'import_compat')(params)
Model = request.env[model].with_context(**params.get('context', {})) Model = request.env[model].with_context(import_compat=import_compat, **params.get('context', {}))
records = Model.browse(ids) or Model.search(domain, offset=0, limit=False, order=False) records = Model.browse(ids) or Model.search(domain, offset=0, limit=False, order=False)
if not Model._is_an_ordinary_table(): if not Model._is_an_ordinary_table():

View File

@ -2,6 +2,7 @@
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
import hashlib import hashlib
from collections import OrderedDict
from flectra import api, models from flectra import api, models
from flectra.tools import pycompat from flectra.tools import pycompat
@ -56,6 +57,24 @@ class Image(models.AbstractModel):
elif options.get('zoom'): elif options.get('zoom'):
src_zoom = options['zoom'] src_zoom = options['zoom']
img = '<img class="%s" src="%s" style="%s"%s%s/>' % \ atts = OrderedDict()
(classes, src, options.get('style', ''), ' alt="%s"' % alt if alt else '', ' data-zoom="1" data-zoom-image="%s"' % src_zoom if src_zoom else '') atts["src"] = src
return pycompat.to_text(img) atts["class"] = classes
atts["style"] = options.get('style')
atts["alt"] = alt
atts["data-zoom"] = src_zoom and u'1' or None
atts["data-zoom-image"] = src_zoom
atts = self.env['ir.qweb']._post_processing_att('img', atts, options.get('template_options'))
img = ['<img']
for name, value in atts.items():
if value:
img.append(' ')
img.append(escape(pycompat.to_text(name)))
img.append('="')
img.append(escape(pycompat.to_text(value)))
img.append('"')
img.append('/>')
return u''.join(img)

View File

@ -170,4 +170,4 @@ hr {
[role="button"] { [role="button"] {
cursor: pointer; cursor: pointer;
} }

View File

@ -0,0 +1,26 @@
<templates>
<t t-name="date-simple"><t t-esc='value' /></t>
<params id="date-simple">{"value": "1988-09-16"}</params>
<result id="date-simple">1988-09-16</result>
<t t-name="datetime-simple"><t t-esc='value' /></t>
<params id="datetime-simple">{"value": "1988-09-16 14:00:00"}</params>
<result id="datetime-simple">1988-09-16 14:00:00</result>
<t t-name="datetime-widget-datetime"><t t-esc='value' t-options="{'widget': 'datetime'}" /></t>
<params id="datetime-widget-datetime">{"value": "1988-09-16 14:00:00"}</params>
<result id="datetime-widget-datetime">09/16/1988 16:00:00</result>
<t t-name="datetime-widget-date"><t t-esc='value' t-options="{'widget': 'date'}" /></t>
<params id="datetime-widget-date">{"value": "1988-09-16 14:00:00"}</params>
<result id="datetime-widget-date">09/16/1988</result>
<t t-name="datetime-widget-date-tz2"><t t-esc='value' t-options="{'widget': 'date'}" /></t>
<params id="datetime-widget-date-tz2">{"value": "1988-09-16 01:00:00"}</params>
<result id="datetime-widget-date-tz2">09/16/1988</result>
<t t-name="datetime-widget-date-tz"><t t-esc='value' t-options="{'widget': 'date'}" /></t>
<params id="datetime-widget-date-tz">{"value": "1988-09-16 23:00:00"}</params>
<result id="datetime-widget-date-tz">09/17/1988</result>
</templates>

View File

@ -165,7 +165,7 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, {
core.bus.on('connection_restored', this, this.on_connection_restored); core.bus.on('connection_restored', this, this.on_connection_restored);
// crash manager integration // crash manager integration
session.on('error', crash_manager, crash_manager.rpc_error); core.bus.on('rpc_error', crash_manager, crash_manager.rpc_error);
window.onerror = function (message, file, line, col, error) { window.onerror = function (message, file, line, col, error) {
// Scripts injected in DOM (eg: google API's js files) won't return a clean error on window.onerror. // Scripts injected in DOM (eg: google API's js files) won't return a clean error on window.onerror.
// The browser will just give you a 'Script error.' as message and nothing else for security issue. // The browser will just give you a 'Script error.' as message and nothing else for security issue.

View File

@ -26,9 +26,9 @@ var Loading = Widget.extend({
this._super(parent); this._super(parent);
this.count = 0; this.count = 0;
this.blocked_ui = false; this.blocked_ui = false;
session.on("request", this, this.request_call); core.bus.on('rpc_request', this, this.request_call);
session.on("response", this, this.response_call); core.bus.on("rpc_response", this, this.response_call);
session.on("response_failed", this, this.response_call); core.bus.on("rpc_response_failed", this, this.response_call);
}, },
destroy: function() { destroy: function() {
this.on_rpc_event(-this.count); this.on_rpc_event(-this.count);

View File

@ -267,11 +267,14 @@ var Menu = Widget.extend({
* @param {Number} id the action_id to match * @param {Number} id the action_id to match
* @param {Number} [menuID] a menu ID that may match with provided action * @param {Number} [menuID] a menu ID that may match with provided action
*/ */
open_action: function (id) { open_action: function (id, menuID) {
var $menu = this.$el.add(this.$secondary_menus).find('a[data-action-id="' + id + '"]'); var $menu = this.$el.add(this.$secondary_menus).find('a[data-action-id="' + id + '"]');
var menu_id = $menu.data('menu'); if (!(menuID && $menu.filter("[data-menu='" + menuID + "']").length)) {
if (menu_id) { // menuID doesn't match action, so pick first menu_item
this.open_menu(menu_id); menuID = $menu.data('menu');
}
if (menuID) {
this.open_menu(menuID);
} }
}, },
/** /**

View File

@ -263,7 +263,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
self.active_view = view; self.active_view = view;
if (!view.loaded) { if (!view.loaded || view.loaded.state() === 'rejected') {
view_options = _.extend({}, view.options, view_options, self.env); view_options = _.extend({}, view.options, view_options, self.env);
view.loaded = $.Deferred(); view.loaded = $.Deferred();
self.create_view(view, view_options).then(function(controller) { self.create_view(view, view_options).then(function(controller) {
@ -638,7 +638,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
* active controller. * active controller.
* *
* @private * @private
* @param {OdooEvent} ev * @param {FlectraEvent} ev
* @param {function} ev.data.callback used to send the requested context * @param {function} ev.data.callback used to send the requested context
*/ */
_onGetControllerContext: function (ev) { _onGetControllerContext: function (ev) {

View File

@ -103,6 +103,10 @@ return AbstractWebClient.extend({
bind_hashchange: function() { bind_hashchange: function() {
var self = this; var self = this;
$(window).bind('hashchange', this.on_hashchange); $(window).bind('hashchange', this.on_hashchange);
var didHashChanged = false;
$(window).one('hashchange', function () {
didHashChanged = true;
});
var state = $.bbq.getState(true); var state = $.bbq.getState(true);
if (_.isEmpty(state) || state.action === "login") { if (_.isEmpty(state) || state.action === "login") {
@ -113,6 +117,9 @@ return AbstractWebClient.extend({
args: [[session.uid], ['action_id']], args: [[session.uid], ['action_id']],
}) })
.done(function(result) { .done(function(result) {
if (didHashChanged) {
return;
}
var data = result[0]; var data = result[0];
if(data.action_id) { if(data.action_id) {
self.action_manager.do_action(data.action_id[0]); self.action_manager.do_action(data.action_id[0]);

View File

@ -5,7 +5,12 @@ var core = require('web.core');
var utils = require('web.utils'); var utils = require('web.utils');
var time = require('web.time'); var time = require('web.time');
function genericJsonRpc (fct_name, params, fct) { function genericJsonRpc (fct_name, params, settings, fct) {
var shadow = settings.shadow || false;
delete settings.shadow;
if (! shadow)
core.bus.trigger('rpc_request');
var data = { var data = {
jsonrpc: "2.0", jsonrpc: "2.0",
method: fct_name, method: fct_name,
@ -30,11 +35,49 @@ function genericJsonRpc (fct_name, params, fct) {
}); });
// FIXME: jsonp? // FIXME: jsonp?
result.abort = function () { if (xhr.abort) xhr.abort(); }; result.abort = function () { if (xhr.abort) xhr.abort(); };
return result;
var p = result.then(function (result) {
if (!shadow) {
core.bus.trigger('rpc_response');
}
return result;
}, function (type, error, textStatus, errorThrown) {
if (type === "server") {
if (!shadow) {
core.bus.trigger('rpc_response');
}
if (error.code === 100) {
core.bus.trigger('invalidate_session');
}
return $.Deferred().reject(error, $.Event());
} else {
if (!shadow) {
core.bus.trigger('rpc_response_failed');
}
var nerror = {
code: -32098,
message: "XmlHttpRequestError " + errorThrown,
data: {
type: "xhr"+textStatus,
debug: error.responseText,
objects: [error, errorThrown]
},
};
return $.Deferred().reject(nerror, $.Event());
}
});
return p.fail(function () { // Allow deferred user to disable rpc_error call in fail
p.fail(function (error, event) {
if (!event.isDefaultPrevented()) {
core.bus.trigger('rpc_error', error, event);
}
});
});
} }
function jsonRpc(url, fct_name, params, settings) { function jsonRpc(url, fct_name, params, settings) {
return genericJsonRpc(fct_name, params, function(data) { settings = settings || {};
return genericJsonRpc(fct_name, params, settings, function(data) {
return $.ajax(url, _.extend({}, settings, { return $.ajax(url, _.extend({}, settings, {
url: url, url: url,
dataType: 'json', dataType: 'json',
@ -47,7 +90,7 @@ function jsonRpc(url, fct_name, params, settings) {
function jsonpRpc(url, fct_name, params, settings) { function jsonpRpc(url, fct_name, params, settings) {
settings = settings || {}; settings = settings || {};
return genericJsonRpc(fct_name, params, function(data) { return genericJsonRpc(fct_name, params, settings, function(data) {
var payload_str = JSON.stringify(data, time.date_to_utc); var payload_str = JSON.stringify(data, time.date_to_utc);
var payload_url = $.param({r:payload_str}); var payload_url = $.param({r:payload_str});
var force2step = settings.force2step || false; var force2step = settings.force2step || false;
@ -260,8 +303,20 @@ function get_file(options) {
if (options.error) { if (options.error) {
var body = this.contentDocument.body; var body = this.contentDocument.body;
var nodes = body.children.length === 0 ? body.childNodes : body.children; var nodes = body.children.length === 0 ? body.childNodes : body.children;
var node = nodes[1] || nodes[0]; var errorParams = {};
options.error(JSON.parse(node.textContent));
try { // Case of a serialized Flectra Exception: It is Json Parsable
var node = nodes[1] || nodes[0];
errorParams = JSON.parse(node.textContent);
} catch (e) { // Arbitrary uncaught python side exception
errorParams = {
message: nodes.length > 1 ? nodes[1].textContent : '',
data: {
title: nodes.length > 0 ? nodes[0].textContent : '',
}
}
}
options.error(errorParams);
} }
} finally { } finally {
complete(); complete();

View File

@ -52,19 +52,19 @@ return {
if (options.method === 'read_group') { if (options.method === 'read_group') {
if (!(params.args && params.args[0] !== undefined)) { if (!(params.args && params.args[0] !== undefined)) {
params.kwargs.domain = options.domain || params.domain || params.kwargs.domain || []; params.kwargs.domain = options.domain || params.domain || params.kwargs.domain || [];
} }
if (!(params.args && params.args[1] !== undefined)) { if (!(params.args && params.args[1] !== undefined)) {
params.kwargs.fields = options.fields || params.fields || params.kwargs.fields || []; params.kwargs.fields = options.fields || params.fields || params.kwargs.fields || [];
} }
if (!(params.args && params.args[2] !== undefined)) { if (!(params.args && params.args[2] !== undefined)) {
params.kwargs.groupby = options.groupBy || params.groupBy || params.kwargs.groupby || []; params.kwargs.groupby = options.groupBy || params.groupBy || params.kwargs.groupby || [];
} }
params.kwargs.offset = options.offset || params.offset || params.kwargs.offset; params.kwargs.offset = options.offset || params.offset || params.kwargs.offset;
params.kwargs.limit = options.limit || params.limit || params.kwargs.limit; params.kwargs.limit = options.limit || params.limit || params.kwargs.limit;
// In kwargs, we look for "orderby" rather than "orderBy" (note the absence of capital B), // In kwargs, we look for "orderby" rather than "orderBy" (note the absence of capital B),
// since the Python argument to the actual function is "orderby". // since the Python argument to the actual function is "orderby".
var orderBy = options.orderBy || params.orderBy || params.kwargs.orderby; var orderBy = options.orderBy || params.orderBy || params.kwargs.orderby;
params.kwargs.orderby = orderBy ? this._serializeSort(orderBy) : orderBy; params.kwargs.orderby = orderBy ? this._serializeSort(orderBy) : orderBy;
params.kwargs.lazy = 'lazy' in options ? options.lazy : params.lazy; params.kwargs.lazy = 'lazy' in options ? options.lazy : params.lazy;
} }
@ -72,7 +72,7 @@ return {
if (options.method === 'search_read') { if (options.method === 'search_read') {
// call the model method // call the model method
params.kwargs.domain = options.domain || params.domain || params.kwargs.domain; params.kwargs.domain = options.domain || params.domain || params.kwargs.domain;
params.kwargs.fields = options.fields || params.fields || params.kwargs.fields; params.kwargs.fields = options.fields || params.fields || params.kwargs.fields;
params.kwargs.offset = options.offset || params.offset || params.kwargs.offset; params.kwargs.offset = options.offset || params.offset || params.kwargs.offset;
params.kwargs.limit = options.limit || params.limit || params.kwargs.limit; params.kwargs.limit = options.limit || params.limit || params.kwargs.limit;
// In kwargs, we look for "order" rather than "orderBy" since the Python // In kwargs, we look for "order" rather than "orderBy" since the Python

View File

@ -49,6 +49,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
this.qweb_mutex = new concurrency.Mutex(); this.qweb_mutex = new concurrency.Mutex();
this.currencies = {}; this.currencies = {};
this._groups_def = {}; this._groups_def = {};
core.bus.on('invalidate_session', this, this._onInvalidateSession);
}, },
setup: function (origin, options) { setup: function (origin, options) {
// must be able to customize server // must be able to customize server
@ -327,23 +328,17 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
rpc: function (url, params, options) { rpc: function (url, params, options) {
var self = this; var self = this;
options = _.clone(options || {}); options = _.clone(options || {});
var shadow = options.shadow || false;
options.headers = _.extend({}, options.headers); options.headers = _.extend({}, options.headers);
if (flectra.debug) { if (flectra.debug) {
options.headers["X-Debug-Mode"] = $.deparam($.param.querystring()).debug; options.headers["X-Debug-Mode"] = $.deparam($.param.querystring()).debug;
} }
delete options.shadow;
return self.check_session_id().then(function () { return self.check_session_id().then(function () {
// TODO: remove // TODO: remove
if (! _.isString(url)) { if (! _.isString(url)) {
_.extend(options, url); _.extend(options, url);
url = url.url; url = url.url;
} }
// TODO correct handling of timeouts
if (! shadow)
self.trigger('request');
var fct; var fct;
if (self.origin_server) { if (self.origin_server) {
fct = ajax.jsonRpc; fct = ajax.jsonRpc;
@ -362,37 +357,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
url = self.url(url, null); url = self.url(url, null);
options.session_id = self.session_id || ''; options.session_id = self.session_id || '';
} }
var p = fct(url, "call", params, options); return fct(url, "call", params, options);
p = p.then(function (result) {
if (! shadow)
self.trigger('response');
return result;
}, function (type, error, textStatus, errorThrown) {
if (type === "server") {
if (! shadow)
self.trigger('response');
if (error.code === 100) {
self.uid = false;
}
return $.Deferred().reject(error, $.Event());
} else {
if (! shadow)
self.trigger('response_failed');
var nerror = {
code: -32098,
message: "XmlHttpRequestError " + errorThrown,
data: {type: "xhr"+textStatus, debug: error.responseText, objects: [error, errorThrown] }
};
return $.Deferred().reject(nerror, $.Event());
}
});
return p.fail(function () { // Allow deferred user to disable rpc_error call in fail
p.fail(function (error, event) {
if (!event.isDefaultPrevented()) {
self.trigger('error', error, event);
}
});
});
}); });
}, },
url: function (path, params) { url: function (path, params) {
@ -418,6 +383,17 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
getTZOffset: function (date) { getTZOffset: function (date) {
return -new Date(date).getTimezoneOffset(); return -new Date(date).getTimezoneOffset();
}, },
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onInvalidateSession: function () {
this.uid = false;
},
}); });
return Session; return Session;

View File

@ -468,7 +468,13 @@ var FieldDate = InputField.extend({
* @private * @private
*/ */
_makeDatePicker: function () { _makeDatePicker: function () {
return new datepicker.DateWidget(this, {defaultDate: this.value}); return new datepicker.DateWidget(
this,
_.defaults(
this.nodeOptions.datepicker || {},
{defaultDate: this.value}
)
);
}, },
/** /**
@ -1013,6 +1019,20 @@ var FieldPhone = FieldEmail.extend({
this.$el.removeClass('o_text_overflow'); this.$el.removeClass('o_text_overflow');
}, },
/**
* Remove possibly present &shy; characters when saving number
*
* @override
* @private
*/
_setValue: function (value, options) {
if (value) {
// remove possibly pasted &shy; characters
value = value.replace(/\u00AD/g, '');
}
return this._super(value, options);
},
/** /**
* Phone fields are clickable in readonly on small screens ~= on phones. * Phone fields are clickable in readonly on small screens ~= on phones.
* This can be overriden by call-capable modules to display a clickable * This can be overriden by call-capable modules to display a clickable
@ -1068,6 +1088,7 @@ var UrlWidget = InputField.extend({
_renderReadonly: function () { _renderReadonly: function () {
this.$el.text(this.attrs.text || this.value) this.$el.text(this.attrs.text || this.value)
.addClass('o_form_uri o_text_overflow') .addClass('o_form_uri o_text_overflow')
.attr('target', '_blank')
.attr('href', this.value); .attr('href', this.value);
} }
}); });
@ -1211,9 +1232,6 @@ var FieldBinaryImage = AbstractFieldBinary.extend({
self.do_warn(_t("Image"), _t("Could not display the selected image.")); self.do_warn(_t("Image"), _t("Could not display the selected image."));
}); });
}, },
isSet: function () {
return true;
},
}); });
var FieldBinaryFile = AbstractFieldBinary.extend({ var FieldBinaryFile = AbstractFieldBinary.extend({
@ -2412,6 +2430,21 @@ var AceEditor = DebouncedField.extend({
// Private // Private
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
/**
* Format value
*
* Note: We have to overwrite this method to always return a string.
* AceEditor works with string and not boolean value.
*
* @override
* @private
* @param {boolean|string} value
* @returns {string}
*/
_formatValue: function (value) {
return this._super.apply(this, arguments) || '';
},
/** /**
* @override * @override
* @private * @private
@ -2434,6 +2467,7 @@ var AceEditor = DebouncedField.extend({
this.aceSession.setValue(newValue); this.aceSession.setValue(newValue);
} }
}, },
/** /**
* Starts the ace library on the given DOM element. This initializes the * Starts the ace library on the given DOM element. This initializes the
* ace editor option according to the edit/readonly mode and binds ace * ace editor option according to the edit/readonly mode and binds ace

View File

@ -26,6 +26,7 @@ registry
.add('datetime', basic_fields.FieldDateTime) .add('datetime', basic_fields.FieldDateTime)
.add('domain', basic_fields.FieldDomain) .add('domain', basic_fields.FieldDomain)
.add('text', basic_fields.FieldText) .add('text', basic_fields.FieldText)
.add('html', basic_fields.FieldText)
.add('float', basic_fields.FieldFloat) .add('float', basic_fields.FieldFloat)
.add('char', basic_fields.FieldChar) .add('char', basic_fields.FieldChar)
.add('link_button', basic_fields.LinkButton) .add('link_button', basic_fields.LinkButton)

View File

@ -340,8 +340,8 @@ var FieldMany2One = AbstractField.extend({
_renderReadonly: function () { _renderReadonly: function () {
var value = _.escape((this.m2o_value || "").trim()).split("\n").join("<br/>"); var value = _.escape((this.m2o_value || "").trim()).split("\n").join("<br/>");
this.$el.html(value); this.$el.html(value);
if (!this.nodeOptions.no_open) { if (!this.nodeOptions.no_open && this.value) {
this.$el.attr('href', '#'); this.$el.attr('href', _.str.sprintf('#id=%s&model=%s', this.value.res_id, this.field.relation));
this.$el.addClass('o_form_uri'); this.$el.addClass('o_form_uri');
} }
}, },
@ -1029,7 +1029,7 @@ var FieldX2Many = AbstractField.extend({
*/ */
_onEditLine: function (ev) { _onEditLine: function (ev) {
ev.stopPropagation(); ev.stopPropagation();
this.trigger_up('freeze_order', {id: this.value.id}); this.trigger_up('edited_list', { id: this.value.id });
var editedRecord = this.value.data[ev.data.index]; var editedRecord = this.value.data[ev.data.index];
this.renderer.setRowMode(editedRecord.id, 'edit') this.renderer.setRowMode(editedRecord.id, 'edit')
.done(ev.data.onSuccess); .done(ev.data.onSuccess);
@ -1117,7 +1117,7 @@ var FieldX2Many = AbstractField.extend({
_onResequence: function (event) { _onResequence: function (event) {
event.stopPropagation(); event.stopPropagation();
var self = this; var self = this;
this.trigger_up('freeze_order', {id: this.value.id}); this.trigger_up('edited_list', { id: this.value.id });
var rowIDs = event.data.rowIDs.slice(); var rowIDs = event.data.rowIDs.slice();
var rowID = rowIDs.pop(); var rowID = rowIDs.pop();
var defs = _.map(rowIDs, function (rowID, index) { var defs = _.map(rowIDs, function (rowID, index) {
@ -1261,7 +1261,7 @@ var FieldOne2Many = FieldX2Many.extend({
} }
} else if (!this.creatingRecord) { } else if (!this.creatingRecord) {
this.creatingRecord = true; this.creatingRecord = true;
this.trigger_up('freeze_order', {id: this.value.id}); this.trigger_up('edited_list', { id: this.value.id });
this._setValue({ this._setValue({
operation: 'CREATE', operation: 'CREATE',
position: this.editable, position: this.editable,
@ -1291,12 +1291,24 @@ var FieldOne2Many = FieldX2Many.extend({
// we don't want interference with the components upstream. // we don't want interference with the components upstream.
ev.stopPropagation(); ev.stopPropagation();
var self = this;
var id = ev.data.id; var id = ev.data.id;
// trigger an empty 'UPDATE' operation when the user clicks on 'Save' in var onSaved = function (record) {
// the dialog, to notify the main record that a subrecord of this if (_.some(self.value.data, {id: record.id})) {
// relational field has changed (those changes will be already stored on // the record already exists in the relation, so trigger an
// that subrecord, thanks to the 'Save'). // empty 'UPDATE' operation when the user clicks on 'Save' in
var onSaved = this._setValue.bind(this, { operation: 'UPDATE', id: id }, {}); // the dialog, to notify the main record that a subrecord of
// this relational field has changed (those changes will be
// already stored on that subrecord, thanks to the 'Save').
self._setValue({ operation: 'UPDATE', id: record.id });
} else {
// the record isn't in the relation yet, so add it ; this can
// happen if the user clicks on 'Save & New' in the dialog (the
// opened record will be updated, and other records will be
// created)
self._setValue({ operation: 'ADD', id: record.id });
}
};
this._openFormDialog({ this._openFormDialog({
id: id, id: id,
on_saved: onSaved, on_saved: onSaved,
@ -1551,6 +1563,7 @@ var FieldMany2ManyBinaryMultiFiles = AbstractField.extend({
this.$('form.o_form_binary_form').submit(); this.$('form.o_form_binary_form').submit();
this.$('.oe_fileupload').hide(); this.$('.oe_fileupload').hide();
ev.target.value = "";
}, },
/** /**
* @private * @private
@ -2265,6 +2278,21 @@ var FieldReference = FieldMany2One.extend({
return this._super.apply(this, arguments); return this._super.apply(this, arguments);
}, },
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
* @returns {jQuery}
*/
getFocusableElement: function () {
if (this.mode === 'edit' && !this.field.relation) {
return this.$('select');
}
return this._super.apply(this, arguments);
},
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// Private // Private
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------

View File

@ -92,10 +92,11 @@ var AbstractFieldUpgrade = {
*/ */
_render: function () { _render: function () {
this._super.apply(this, arguments); this._super.apply(this, arguments);
this._insertEnterpriseLabel($("<span>", { // disable enterprise tags
text: "Enterprise", // this._insertEnterpriseLabel($("<span>", {
'class': "label label-primary oe_inline o_enterprise_label" // text: "Enterprise",
})); // 'class': "label label-primary oe_inline o_enterprise_label"
// }));
}, },
/** /**
* This function is meant to be overriden to reset the $el to its initial * This function is meant to be overriden to reset the $el to its initial

View File

@ -41,7 +41,8 @@ var CrashManager = core.Class.extend({
core.bus.trigger('connection_lost'); core.bus.trigger('connection_lost');
this.connection_lost = true; this.connection_lost = true;
var timeinterval = setInterval(function() { var timeinterval = setInterval(function() {
ajax.jsonRpc('/web/webclient/version_info').then(function() { var options = {shadow: true};
ajax.jsonRpc('/web/webclient/version_info', 'call', {}, options).then(function () {
clearInterval(timeinterval); clearInterval(timeinterval);
core.bus.trigger('connection_restored'); core.bus.trigger('connection_restored');
self.connection_lost = false; self.connection_lost = false;
@ -103,7 +104,7 @@ var CrashManager = core.Class.extend({
if (!this.active) { if (!this.active) {
return; return;
} }
new Dialog(this, { return new Dialog(this, {
size: 'medium', size: 'medium',
title: _.str.capitalize(error.type || error.message) || _t("Flectra Warning"), title: _.str.capitalize(error.type || error.message) || _t("Flectra Warning"),
subtitle: error.data.title, subtitle: error.data.title,

View File

@ -12,6 +12,7 @@ flectra.define('web.AbstractController', function (require) {
* reading localstorage, ...) has to go through the controller. * reading localstorage, ...) has to go through the controller.
*/ */
var concurrency = require('web.concurrency');
var Widget = require('web.Widget'); var Widget = require('web.Widget');
@ -41,6 +42,9 @@ var AbstractController = Widget.extend({
this.handle = params.handle; this.handle = params.handle;
this.activeActions = params.activeActions; this.activeActions = params.activeActions;
this.initialState = params.initialState; this.initialState = params.initialState;
// use a DropPrevious to correctly handle concurrent updates
this.dp = new concurrency.DropPrevious();
}, },
/** /**
* Simply renders and updates the url. * Simply renders and updates the url.
@ -62,6 +66,18 @@ var AbstractController = Widget.extend({
} }
return this._super.apply(this, arguments); return this._super.apply(this, arguments);
}, },
/**
* Called each time the controller is attached into the DOM.
*/
on_attach_callback: function () {
this.renderer.on_attach_callback();
},
/**
* Called each time the controller is detached from the DOM.
*/
on_detach_callback: function () {
this.renderer.on_detach_callback();
},
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// Public // Public
@ -194,11 +210,11 @@ var AbstractController = Widget.extend({
var self = this; var self = this;
var shouldReload = (options && 'reload' in options) ? options.reload : true; var shouldReload = (options && 'reload' in options) ? options.reload : true;
var def = shouldReload ? this.model.reload(this.handle, params) : $.when(); var def = shouldReload ? this.model.reload(this.handle, params) : $.when();
return def.then(function (handle) { return this.dp.add(def).then(function (handle) {
self.handle = handle || self.handle; // update handle if we reloaded self.handle = handle || self.handle; // update handle if we reloaded
var state = self.model.get(self.handle); var state = self.model.get(self.handle);
var localState = self.renderer.getLocalState(); var localState = self.renderer.getLocalState();
return self.renderer.updateState(state, params).then(function () { return self.dp.add(self.renderer.updateState(state, params)).then(function () {
self.renderer.setLocalState(localState); self.renderer.setLocalState(localState);
self._update(state); self._update(state);
}); });

View File

@ -36,6 +36,14 @@ return Widget.extend({
this.$el.addClass(this.arch.attrs.class); this.$el.addClass(this.arch.attrs.class);
return $.when(this._render(), this._super()); return $.when(this._render(), this._super());
}, },
/**
* Called each time the renderer is attached into the DOM.
*/
on_attach_callback: function () {},
/**
* Called each time the renderer is detached from the DOM.
*/
on_detach_callback: function () {},
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// Public // Public

View File

@ -224,6 +224,14 @@ var AbstractView = Class.extend({
_.each(this.loadParams.fieldsInfo.form, function (attrs, fieldName) { _.each(this.loadParams.fieldsInfo.form, function (attrs, fieldName) {
var field = fields[fieldName]; var field = fields[fieldName];
if (!field) {
// when a one2many record is opened in a form view, the fields
// of the main one2many view (list or kanban) are added to the
// fieldsInfo of its form view, but those fields aren't in the
// loadParams.fields, as they are not displayed in the view, so
// we can ignore them.
return;
}
if (field.type !== 'one2many' && field.type !== 'many2many') { if (field.type !== 'one2many' && field.type !== 'many2many') {
return; return;
} }

View File

@ -306,7 +306,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
* *
* @private * @private
*/ */
_disableButtons: function () { _disableButtons: function () {
if (this.$buttons) { if (this.$buttons) {
this.$buttons.find('button').attr('disabled', true); this.$buttons.find('button').attr('disabled', true);
} }
@ -338,10 +338,8 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
return; return;
} }
self.model.discardChanges(recordID); self.model.discardChanges(recordID);
if (self.model.isNew(recordID)) { if (self.model.canBeAbandoned(recordID)) {
if (self.model.canBeAbandoned(recordID)) { self._abandonRecord(recordID);
self._abandonRecord(recordID);
}
return; return;
} }
return self._confirmSave(recordID); return self._confirmSave(recordID);
@ -352,7 +350,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
* *
* @private * @private
*/ */
_enableButtons: function () { _enableButtons: function () {
if (this.$buttons) { if (this.$buttons) {
this.$buttons.find('button').removeAttr('disabled'); this.$buttons.find('button').removeAttr('disabled');
} }

View File

@ -347,6 +347,7 @@ var BasicModel = AbstractModel.extend({
var element = this.localData[id]; var element = this.localData[id];
var isNew = this.isNew(id); var isNew = this.isNew(id);
var rollback = 'rollback' in options ? options.rollback : isNew; var rollback = 'rollback' in options ? options.rollback : isNew;
var initialOffset = element.offset;
this._visitChildren(element, function (elem) { this._visitChildren(element, function (elem) {
if (rollback && elem._savePoint) { if (rollback && elem._savePoint) {
if (elem._savePoint instanceof Array) { if (elem._savePoint instanceof Array) {
@ -365,6 +366,7 @@ var BasicModel = AbstractModel.extend({
delete elem.tempLimitIncrement; delete elem.tempLimitIncrement;
} }
}); });
element.offset = initialOffset;
}, },
/** /**
* Duplicate a record (by calling the 'copy' route) * Duplicate a record (by calling the 'copy' route)
@ -375,11 +377,12 @@ var BasicModel = AbstractModel.extend({
duplicateRecord: function (recordID) { duplicateRecord: function (recordID) {
var self = this; var self = this;
var record = this.localData[recordID]; var record = this.localData[recordID];
var context = this._getContext(record);
return this._rpc({ return this._rpc({
model: record.model, model: record.model,
method: 'copy', method: 'copy',
args: [record.data.id], args: [record.data.id],
context: this._getContext(record), context: context,
}) })
.then(function (res_id) { .then(function (res_id) {
var index = record.res_ids.indexOf(record.res_id); var index = record.res_ids.indexOf(record.res_id);
@ -391,6 +394,7 @@ var BasicModel = AbstractModel.extend({
res_id: res_id, res_id: res_id,
res_ids: record.res_ids.slice(0), res_ids: record.res_ids.slice(0),
viewType: record.viewType, viewType: record.viewType,
context: context,
}); });
}); });
}, },
@ -567,10 +571,18 @@ var BasicModel = AbstractModel.extend({
return _t("New"); return _t("New");
}, },
/** /**
* Returns true if a record can be abandoned from a list datapoint. * Returns true if a record can be abandoned.
* *
* A record cannot be abandonned if it has been registered as "added" * Case for not abandoning the record:
* in the parent's savepoint, otherwise it can be abandonned. *
* 1. flagged as 'no abandon' (i.e. during a `default_get`, including any
* `onchange` from a `default_get`)
* 2. registered in a list on addition
* 2.1. registered as non-new addition
* 2.2. registered as new additon on update
* 3. record is not new
*
* Otherwise, the record can be abandoned.
* *
* This is useful when discarding changes on this record, as it means that * This is useful when discarding changes on this record, as it means that
* we must keep the record even if some fields are invalids (e.g. required * we must keep the record even if some fields are invalids (e.g. required
@ -580,15 +592,29 @@ var BasicModel = AbstractModel.extend({
* @returns {boolean} * @returns {boolean}
*/ */
canBeAbandoned: function (id) { canBeAbandoned: function (id) {
var data = this.localData[id]; // 1. no drop if flagged
var parent = this.localData[data.parentID]; if (this.localData[id]._noAbandon) {
var abandonable = true; return false;
if (parent) {
abandonable = !_.some(parent._savePoint, function (entry) {
return entry.operation === 'ADD' && entry.id === id;
});
} }
return abandonable; // 2. no drop in a list on "ADD in some cases
var record = this.localData[id];
var parent = this.localData[record.parentID];
if (parent) {
var entry = _.findWhere(parent._savePoint, {operation: 'ADD', id: id});
if (entry) {
// 2.1. no drop on non-new addition in list
if (!entry.isNew) {
return false;
}
// 2.2. no drop on new addition on "UPDATE"
var lastEntry = _.last(parent._savePoint);
if (lastEntry.operation === 'UPDATE' && lastEntry.id === id) {
return false;
}
}
}
// 3. drop new records
return this.isNew(id);
}, },
/** /**
* Returns true if a record is dirty. A record is considered dirty if it has * Returns true if a record is dirty. A record is considered dirty if it has
@ -874,6 +900,15 @@ var BasicModel = AbstractModel.extend({
// x2Many case: the new record has been stored in _changes, as a // x2Many case: the new record has been stored in _changes, as a
// command so we remove the command(s) related to that record // command so we remove the command(s) related to that record
parent._changes = _.filter(parent._changes, function (change) { parent._changes = _.filter(parent._changes, function (change) {
if (change.id === elementID &&
change.operation === 'ADD' && // For now, only an ADD command increases limits
parent.tempLimitIncrement) {
// The record will be deleted from the _changes.
// So we won't be passing into the logic of _applyX2ManyOperations anymore
// implying that we have to cancel out the effects of an ADD command here
parent.tempLimitIncrement--;
parent.limit--;
}
return change.id !== elementID; return change.id !== elementID;
}); });
} else { } else {
@ -949,11 +984,11 @@ var BasicModel = AbstractModel.extend({
* @returns {Deferred} * @returns {Deferred}
* Resolved with the list of field names (whose value has been modified) * Resolved with the list of field names (whose value has been modified)
*/ */
save: function (record_id, options) { save: function (recordID, options) {
var self = this; var self = this;
return this.mutex.exec(function () { return this.mutex.exec(function () {
options = options || {}; options = options || {};
var record = self.localData[record_id]; var record = self.localData[recordID];
if (options.savePoint) { if (options.savePoint) {
self._visitChildren(record, function (rec) { self._visitChildren(record, function (rec) {
var newValue = rec._changes || rec.data; var newValue = rec._changes || rec.data;
@ -971,7 +1006,7 @@ var BasicModel = AbstractModel.extend({
}); });
} }
var shouldReload = 'reload' in options ? options.reload : true; var shouldReload = 'reload' in options ? options.reload : true;
var method = self.isNew(record_id) ? 'create' : 'write'; var method = self.isNew(recordID) ? 'create' : 'write';
if (record._changes) { if (record._changes) {
// id never changes, and should not be written // id never changes, and should not be written
delete record._changes.id; delete record._changes.id;
@ -1053,7 +1088,13 @@ var BasicModel = AbstractModel.extend({
addFieldsInfo: function (recordID, viewInfo) { addFieldsInfo: function (recordID, viewInfo) {
var record = this.localData[recordID]; var record = this.localData[recordID];
record.fields = _.extend({}, record.fields, viewInfo.fields); record.fields = _.extend({}, record.fields, viewInfo.fields);
record.fieldsInfo = _.extend({}, record.fieldsInfo, viewInfo.fieldsInfo); // complete the given fieldsInfo with the fields of the main view, so
// that those field will be reloaded if a reload is triggered by the
// secondary view
var fieldsInfo = _.mapObject(viewInfo.fieldsInfo, function (fieldsInfo) {
return _.defaults({}, fieldsInfo, record.fieldsInfo[record.viewType]);
});
record.fieldsInfo = _.extend({}, record.fieldsInfo, fieldsInfo);
}, },
/** /**
* For list resources, this freezes the current records order. * For list resources, this freezes the current records order.
@ -1535,7 +1576,10 @@ var BasicModel = AbstractModel.extend({
rec = self._makeDataPoint(params); rec = self._makeDataPoint(params);
list._cache[rec.res_id] = rec.id; list._cache[rec.res_id] = rec.id;
} }
// Do not abandon the record if it has been created
// from `default_get`. The list has a savepoint only
// after having fully executed `default_get`.
rec._noAbandon = !list._savePoint;
list._changes.push({operation: 'ADD', id: rec.id}); list._changes.push({operation: 'ADD', id: rec.id});
if (command[0] === 1) { if (command[0] === 1) {
list._changes.push({operation: 'UPDATE', id: rec.id}); list._changes.push({operation: 'UPDATE', id: rec.id});
@ -1953,6 +1997,7 @@ var BasicModel = AbstractModel.extend({
var records = []; var records = [];
var ids = []; var ids = [];
list = this._applyX2ManyOperations(list); list = this._applyX2ManyOperations(list);
_.each(list.data, function (localId) { _.each(list.data, function (localId) {
var record = self.localData[localId]; var record = self.localData[localId];
var data = record._changes || record.data; var data = record._changes || record.data;
@ -1963,16 +2008,22 @@ var BasicModel = AbstractModel.extend({
ids.push(many2oneRecord.res_id); ids.push(many2oneRecord.res_id);
model = many2oneRecord.model; model = many2oneRecord.model;
}); });
if (!ids.length) {
return $.when();
}
return this._rpc({ return this._rpc({
model: model, model: model,
method: 'name_get', method: 'name_get',
args: [ids], args: [_.uniq(ids)],
context: list.context, context: list.context,
}) })
.then(function (name_gets) { .then(function (name_gets) {
for (var i = 0; i < name_gets.length; i++) { _.each(records, function (record) {
records[i].data.display_name = name_gets[i][1]; var nameGet = _.find(name_gets, function (nameGet) {
} return nameGet[0] === record.data.id;
});
record.data.display_name = nameGet[1];
});
}); });
}, },
/** /**
@ -1990,7 +2041,8 @@ var BasicModel = AbstractModel.extend({
*/ */
_fetchRecord: function (record, options) { _fetchRecord: function (record, options) {
var self = this; var self = this;
var fieldNames = options && options.fieldNames || record.getFieldNames(); options = options || {};
var fieldNames = options.fieldNames || record.getFieldNames(options);
fieldNames = _.uniq(fieldNames.concat(['display_name'])); fieldNames = _.uniq(fieldNames.concat(['display_name']));
return this._rpc({ return this._rpc({
model: record.model, model: record.model,
@ -2457,7 +2509,16 @@ var BasicModel = AbstractModel.extend({
var self = this; var self = this;
var def; var def;
if (list.static) { if (list.static) {
def = this._readUngroupedList(list); def = this._readUngroupedList(list).then(function () {
if (list.parentID && self.isNew(list.parentID)) {
// list from a default_get, so fetch display_name for many2one fields
var many2ones = self._getMany2OneFieldNames(list);
var defs = _.map(many2ones, function (name) {
return self._fetchNameGets(list, name);
});
return $.when.apply($, defs);
}
});
} else { } else {
def = this._searchReadUngroupedList(list); def = this._searchReadUngroupedList(list);
} }
@ -2490,8 +2551,9 @@ var BasicModel = AbstractModel.extend({
_fetchX2Manys: function (record, options) { _fetchX2Manys: function (record, options) {
var self = this; var self = this;
var defs = []; var defs = [];
var fieldNames = options && options.fieldNames || record.getFieldNames(); options = options || {};
var viewType = options && options.viewType || record.viewType; var fieldNames = options.fieldNames || record.getFieldNames(options);
var viewType = options.viewType || record.viewType;
_.each(fieldNames, function (fieldName) { _.each(fieldNames, function (fieldName) {
var field = record.fields[fieldName]; var field = record.fields[fieldName];
if (field.type === 'one2many' || field.type === 'many2many') { if (field.type === 'one2many' || field.type === 'many2many') {
@ -2516,6 +2578,11 @@ var BasicModel = AbstractModel.extend({
relationField: field.relation_field, relationField: field.relation_field,
viewType: view ? view.type : fieldInfo.viewType, viewType: view ? view.type : fieldInfo.viewType,
}); });
// set existing changes to the list
if (record._changes && record._changes[fieldName]) {
list._changes = self.localData[record._changes[fieldName]]._changes;
record._changes[fieldName] = list.id;
}
record.data[fieldName] = list.id; record.data[fieldName] = list.id;
if (!fieldInfo.__no_fetch) { if (!fieldInfo.__no_fetch) {
var def = self._readUngroupedList(list).then(function () { var def = self._readUngroupedList(list).then(function () {
@ -3014,11 +3081,32 @@ var BasicModel = AbstractModel.extend({
* default view type. * default view type.
* *
* @param {Object} element an element from the localData * @param {Object} element an element from the localData
* @param {Object} [options]
* @param {Object} [options.viewType] current viewType. If not set, we will
* assume main viewType from the record
* @returns {string[]} the list of field names * @returns {string[]} the list of field names
*/ */
_getFieldNames: function (element) { _getFieldNames: function (element, options) {
var fieldsInfo = element.fieldsInfo; var fieldsInfo = element.fieldsInfo;
return Object.keys(fieldsInfo && fieldsInfo[element.viewType] || {}); var viewType = options && options.viewType || element.viewType;
return Object.keys(fieldsInfo && fieldsInfo[viewType] || {});
},
/**
* Get many2one fields names in a datapoint. This is useful in order to
* fetch their names in the case of a default_get.
*
* @private
* @param {Object} datapoint a valid resource object
* @returns {string[]} list of field names that are many2one
*/
_getMany2OneFieldNames: function (datapoint) {
var many2ones = [];
_.each(datapoint.fields, function (field, name) {
if (field.type === 'many2one') {
many2ones.push(name);
}
});
return many2ones;
}, },
/** /**
* Evaluate the record evaluation context. This method is supposed to be * Evaluate the record evaluation context. This method is supposed to be
@ -3072,6 +3160,15 @@ var BasicModel = AbstractModel.extend({
// when sent to the server, the x2manys values must be a list // when sent to the server, the x2manys values must be a list
// of commands in a context, but the list of ids in a domain // of commands in a context, but the list of ids in a domain
ids.toJSON = _generateX2ManyCommands.bind(null, fieldName); ids.toJSON = _generateX2ManyCommands.bind(null, fieldName);
} else if (field.type === 'one2many') { // Ids are evaluated as a list of ids
/* Filtering out virtual ids from the ids list
* The server will crash if there are virtual ids in there
* The webClient doesn't do literal id list comparison like ids == list
* Only relevant in o2m: m2m does create actual records in db
*/
ids = _.filter(ids, function (id) {
return typeof id !== 'string';
});
} }
context[fieldName] = ids; context[fieldName] = ids;
} }
@ -3166,6 +3263,9 @@ var BasicModel = AbstractModel.extend({
* @param {Object} [options] * @param {Object} [options]
* @param {string[]} [options.fieldNames] the fields to fetch for a record * @param {string[]} [options.fieldNames] the fields to fetch for a record
* @param {boolean} [options.onlyGroups=false] * @param {boolean} [options.onlyGroups=false]
* @param {boolean} [options.keepEmptyGroups=false] if set, the groups not
* present in the read_group anymore (empty groups) will stay in the
* datapoint (used to mimic the kanban renderer behaviour for example)
* @returns {Deferred} * @returns {Deferred}
*/ */
_load: function (dataPoint, options) { _load: function (dataPoint, options) {
@ -3318,9 +3418,29 @@ var BasicModel = AbstractModel.extend({
*/ */
_makeDefaultRecord: function (modelName, params) { _makeDefaultRecord: function (modelName, params) {
var self = this; var self = this;
var determineExtraFields = function() {
// Fields that are present in the originating view, that need to be initialized
// Hence preventing their value to crash when getting back to the originating view
var parentRecord = self.localData[params.parentID];
var originView = parentRecord && parentRecord.fieldsInfo;
if (!originView || !originView[parentRecord.viewType])
return [];
var fieldsFromOrigin = _.filter(Object.keys(originView[parentRecord.viewType]),
function(fieldname) {
return params.fields[fieldname] !== undefined;
});
return fieldsFromOrigin;
}
var fieldNames = Object.keys(params.fieldsInfo[params.viewType]); var fieldNames = Object.keys(params.fieldsInfo[params.viewType]);
var fields_key = _.without(fieldNames, '__last_update'); var fields_key = _.without(fieldNames, '__last_update');
var extraFields = determineExtraFields();
return this._rpc({ return this._rpc({
model: modelName, model: modelName,
method: 'default_get', method: 'default_get',
@ -3338,7 +3458,7 @@ var BasicModel = AbstractModel.extend({
viewType: params.viewType, viewType: params.viewType,
}); });
return self.applyDefaultValues(record.id, result) return self.applyDefaultValues(record.id, result, {fieldNames: _.union(fieldNames, extraFields)})
.then(function () { .then(function () {
var def = $.Deferred(); var def = $.Deferred();
self._performOnChange(record, fields_key).always(function () { self._performOnChange(record, fields_key).always(function () {
@ -3475,13 +3595,16 @@ var BasicModel = AbstractModel.extend({
* @see _fetchRecord @see _makeDefaultRecord * @see _fetchRecord @see _makeDefaultRecord
* *
* @param {Object} record * @param {Object} record
* @param {Object} record * @param {Object} [options]
* @param {Object} [options.viewType] current viewType. If not set, we will
* assume main viewType from the record
* @returns {Deferred<Object>} resolves to the finished resource * @returns {Deferred<Object>} resolves to the finished resource
*/ */
_postprocess: function (record, options) { _postprocess: function (record, options) {
var self = this; var self = this;
var defs = []; var defs = [];
_.each(record.getFieldNames(), function (name) {
_.each(record.getFieldNames(options), function (name) {
var field = record.fields[name]; var field = record.fields[name];
var fieldInfo = record.fieldsInfo[record.viewType][name] || {}; var fieldInfo = record.fieldsInfo[record.viewType][name] || {};
var options = fieldInfo.options || {}; var options = fieldInfo.options || {};
@ -3572,6 +3695,7 @@ var BasicModel = AbstractModel.extend({
parentID: x2manyList.id, parentID: x2manyList.id,
viewType: viewType, viewType: viewType,
}); });
r._noAbandon = true;
x2manyList._changes.push({operation: 'ADD', id: r.id}); x2manyList._changes.push({operation: 'ADD', id: r.id});
x2manyList._cache[r.res_id] = r.id; x2manyList._cache[r.res_id] = r.id;
@ -3589,8 +3713,9 @@ var BasicModel = AbstractModel.extend({
if (isFieldInView) { if (isFieldInView) {
var field = r.fields[fieldName]; var field = r.fields[fieldName];
var fieldType = field.type; var fieldType = field.type;
var rec;
if (fieldType === 'many2one') { if (fieldType === 'many2one') {
var rec = self._makeDataPoint({ rec = self._makeDataPoint({
context: r.context, context: r.context,
modelName: field.relation, modelName: field.relation,
data: {id: r._changes[fieldName]}, data: {id: r._changes[fieldName]},
@ -3598,9 +3723,21 @@ var BasicModel = AbstractModel.extend({
}); });
r._changes[fieldName] = rec.id; r._changes[fieldName] = rec.id;
many2ones[fieldName] = true; many2ones[fieldName] = true;
} else if (fieldType === 'reference') {
var reference = r._changes[fieldName].split(',');
rec = self._makeDataPoint({
context: r.context,
modelName: reference[0],
data: {id: parseInt(reference[1])},
parentID: r.id,
});
r._changes[fieldName] = rec.id;
many2ones[fieldName] = true;
} else if (_.contains(['one2many', 'many2many'], fieldType)) { } else if (_.contains(['one2many', 'many2many'], fieldType)) {
var x2mCommands = commands[0][2][fieldName]; var x2mCommands = value[2][fieldName];
defs.push(self._processX2ManyCommands(r, fieldName, x2mCommands)); defs.push(self._processX2ManyCommands(r, fieldName, x2mCommands));
} else {
r._changes[fieldName] = self._parseServerValue(field, r._changes[fieldName]);
} }
} }
} }
@ -3795,12 +3932,28 @@ var BasicModel = AbstractModel.extend({
defs.push(self._load(newGroup, options)); defs.push(self._load(newGroup, options));
} }
}); });
if (options && options.keepEmptyGroups) {
// Find the groups that were available in a previous
// readGroup but are not there anymore.
// Note that these groups are put after existing groups so
// the order is not conserved. A sort *might* be useful.
var emptyGroupsIDs = _.difference(_.pluck(previousGroups, 'id'), list.data);
_.each(emptyGroupsIDs, function (groupID) {
list.data.push(groupID);
var emptyGroup = self.localData[groupID];
// this attribute hasn't been updated in the previous
// loop for empty groups
emptyGroup.aggregateValues = {};
});
}
return $.when.apply($, defs).then(function () { return $.when.apply($, defs).then(function () {
// generate the res_ids of the main list, being the concatenation if (!options || !options.onlyGroups) {
// of the fetched res_ids in each group // generate the res_ids of the main list, being the concatenation
list.res_ids = _.flatten(_.map(arguments, function (group) { // of the fetched res_ids in each group
return group ? group.res_ids : []; list.res_ids = _.flatten(_.map(arguments, function (group) {
})); return group ? group.res_ids : [];
}));
}
return list; return list;
}); });
}); });
@ -4030,10 +4183,20 @@ var BasicModel = AbstractModel.extend({
var r2 = self.localData[record2ID]; var r2 = self.localData[record2ID];
var data1 = _.extend({}, r1.data, r1._changes); var data1 = _.extend({}, r1.data, r1._changes);
var data2 = _.extend({}, r2.data, r2._changes); var data2 = _.extend({}, r2.data, r2._changes);
if (data1[order.name] < data2[order.name]) {
// Default value to sort against: the value of the field
var orderData1 = data1[order.name];
var orderData2 = data2[order.name];
// If the field is a relation, sort on the display_name of those records
if (list.fields[order.name].type === 'many2one') {
orderData1 = orderData1 ? self.localData[orderData1].data.display_name : "";
orderData2 = orderData2 ? self.localData[orderData2].data.display_name : "";
}
if (orderData1 < orderData2) {
return order.asc ? -1 : 1; return order.asc ? -1 : 1;
} }
if (data1[order.name] > data2[order.name]) { if (orderData1 > orderData2) {
return order.asc ? 1 : -1; return order.asc ? 1 : -1;
} }
return compareRecords(record1ID, record2ID, level + 1); return compareRecords(record1ID, record2ID, level + 1);

View File

@ -704,11 +704,11 @@ var BasicRenderer = AbstractRenderer.extend({
/** /**
* When someone presses the TAB/UP/DOWN/... key in a widget, it is nice to * When someone presses the TAB/UP/DOWN/... key in a widget, it is nice to
* be able to navigate in the view (default browser behaviors are disabled * be able to navigate in the view (default browser behaviors are disabled
* by Odoo). * by Flectra).
* *
* @abstract * @abstract
* @private * @private
* @param {OdooEvent} ev * @param {FlectraEvent} ev
*/ */
_onNavigationMove: function (ev) {}, _onNavigationMove: function (ev) {},
}); });

View File

@ -253,7 +253,10 @@ return AbstractModel.extend({
* @param {Moment} start * @param {Moment} start
*/ */
setDate: function (start) { setDate: function (start) {
this.data.start_date = this.data.end_date = this.data.target_date = this.data.highlight_date = start; // keep highlight/target_date in localtime
this.data.highlight_date = this.data.target_date = start.clone();
// set dates in UTC with timezone applied manually
this.data.start_date = this.data.end_date = start;
this.data.start_date.utc().add(this.getSession().getTZOffset(this.data.start_date), 'minutes'); this.data.start_date.utc().add(this.getSession().getTZOffset(this.data.start_date), 'minutes');
switch (this.data.scale) { switch (this.data.scale) {
@ -386,7 +389,7 @@ return AbstractModel.extend({
monthNamesShort: moment.monthsShort(), monthNamesShort: moment.monthsShort(),
dayNames: moment.weekdays(), dayNames: moment.weekdays(),
dayNamesShort: moment.weekdaysShort(), dayNamesShort: moment.weekdaysShort(),
firstDay: moment().startOf('week').isoWeekday(), firstDay: moment()._locale._week.dow,
}; };
}, },
/** /**
@ -539,6 +542,7 @@ return AbstractModel.extend({
}); });
var fs = []; var fs = [];
var undefined_fs = [];
_.each(events, function (event) { _.each(events, function (event) {
var data = event.record[fieldName]; var data = event.record[fieldName];
if (!_.contains(['many2many', 'one2many'], field.type)) { if (!_.contains(['many2many', 'one2many'], field.type)) {
@ -548,15 +552,18 @@ return AbstractModel.extend({
} }
_.each(data, function (_value) { _.each(data, function (_value) {
var value = _.isArray(_value) ? _value[0] : _value; var value = _.isArray(_value) ? _value[0] : _value;
fs.push({ var f = {
'color_index': self.model_color === (field.relation || element.model) ? value : false, 'color_index': self.model_color === (field.relation || element.model) ? value : false,
'value': value, 'value': value,
'label': fieldUtils.format[field.type](_value, field), 'label': fieldUtils.format[field.type](_value, field) || _t("Undefined"),
'avatar_model': field.relation || element.model, 'avatar_model': field.relation || element.model,
}); };
// if field used as color does not have value then push filter in undefined_fs,
// such filters should come last in filter list with Undefined string, later merge it with fs
value ? fs.push(f) : undefined_fs.push(f);
}); });
}); });
_.each(fs, function (f) { _.each(_.union(fs, undefined_fs), function (f) {
var f1 = _.findWhere(filter.filters, f); var f1 = _.findWhere(filter.filters, f);
if (f1) { if (f1) {
f1.display = true; f1.display = true;
@ -611,7 +618,8 @@ return AbstractModel.extend({
var date_start; var date_start;
var date_stop; var date_stop;
var date_delay = evt[this.mapping.date_delay] || 1.0, var date_delay = evt[this.mapping.date_delay] || 1.0,
all_day = this.mapping.all_day ? evt[this.mapping.all_day] : false, all_day = this.fields[this.mapping.date_start].type === 'date' ||
this.mapping.all_day && evt[this.mapping.all_day] || false,
the_title = '', the_title = '',
attendees = []; attendees = [];

View File

@ -112,8 +112,20 @@ var CalendarView = AbstractView.extend({
//if quick_add = is NOT False and IS specified in view, we this one for widgets.QuickCreate' //if quick_add = is NOT False and IS specified in view, we this one for widgets.QuickCreate'
this.controllerParams.quickAddPop = (!('quick_add' in attrs) || utils.toBoolElse(attrs.quick_add+'', true)); this.controllerParams.quickAddPop = (!('quick_add' in attrs) || utils.toBoolElse(attrs.quick_add+'', true));
this.controllerParams.disableQuickCreate = params.disable_quick_create || !this.controllerParams.quickAddPop; this.controllerParams.disableQuickCreate = params.disable_quick_create || !this.controllerParams.quickAddPop;
// If this field is set ot true, we don't open the event in form view, but in a popup with the view_id passed by this parameter
this.controllerParams.formViewId = !attrs.form_view_id || !utils.toBoolElse(attrs.form_view_id, true) ? false : attrs.form_view_id; // If form_view_id is set, then the calendar view will open a form view
// with this id, when it needs to edit or create an event.
this.controllerParams.formViewId =
attrs.form_view_id ? parseInt(attrs.form_view_id, 10) : false;
if (!this.controllerParams.formViewId && params.action) {
var formViewDescr = _.find(params.action.views, function (v) {
return v[1] === 'form';
});
if (formViewDescr) {
this.controllerParams.formViewId = formViewDescr[0];
}
}
this.controllerParams.readonlyFormViewId = !attrs.readonly_form_view_id || !utils.toBoolElse(attrs.readonly_form_view_id, true) ? false : attrs.readonly_form_view_id; this.controllerParams.readonlyFormViewId = !attrs.readonly_form_view_id || !utils.toBoolElse(attrs.readonly_form_view_id, true) ? false : attrs.readonly_form_view_id;
this.controllerParams.eventOpenPopup = utils.toBoolElse(attrs.event_open_popup || '', false); this.controllerParams.eventOpenPopup = utils.toBoolElse(attrs.event_open_popup || '', false);
this.controllerParams.mapping = mapping; this.controllerParams.mapping = mapping;

View File

@ -14,7 +14,7 @@ var FormController = BasicController.extend({
custom_events: _.extend({}, BasicController.prototype.custom_events, { custom_events: _.extend({}, BasicController.prototype.custom_events, {
bounce_edit: '_onBounceEdit', bounce_edit: '_onBounceEdit',
button_clicked: '_onButtonClicked', button_clicked: '_onButtonClicked',
freeze_order: '_onFreezeOrder', edited_list: '_onEditedList',
open_one2many_record: '_onOpenOne2ManyRecord', open_one2many_record: '_onOpenOne2ManyRecord',
open_record: '_onOpenRecord', open_record: '_onOpenRecord',
toggle_column_order: '_onToggleColumnOrder', toggle_column_order: '_onToggleColumnOrder',
@ -100,6 +100,7 @@ var FormController = BasicController.extend({
* @todo convert to new style * @todo convert to new style
*/ */
on_attach_callback: function () { on_attach_callback: function () {
this._super.apply(this, arguments);
this.autofocus(); this.autofocus();
}, },
/** /**
@ -213,6 +214,16 @@ var FormController = BasicController.extend({
return changedFields; return changedFields;
}); });
}, },
/**
* Overrides to force the viewType to 'form', so that we ensure that the
* correct fields are reloaded (this is only useful for one2many form views).
*
* @override
*/
update: function (params, options) {
params = _.extend({viewType: 'form'}, params);
return this._super(params, options);
},
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// Private // Private
@ -467,11 +478,15 @@ var FormController = BasicController.extend({
* in a x2many list view * in a x2many list view
* *
* @private * @private
* @param {FlectraEvent} event * @param {FlectraEvent} ev
* @param {integer} ev.id of the list to freeze while editing a line
*/ */
_onFreezeOrder: function (event) { _onEditedList: function (ev) {
event.stopPropagation(); ev.stopPropagation();
this.model.freezeOrder(event.data.id); if (ev.data.id) {
this.model.save(ev.data.id, {savePoint: true});
}
this.model.freezeOrder(ev.data.id);
}, },
/** /**
* Opens a one2many record (potentially new) in a dialog. This handler is * Opens a one2many record (potentially new) in a dialog. This handler is

View File

@ -43,6 +43,30 @@ return AbstractRenderer.extend({
nv.utils.offWindowResize(this.to_remove); nv.utils.offWindowResize(this.to_remove);
this._super(); this._super();
}, },
/**
* The graph view uses the nv(d3) lib to render the graph. This lib requires
* that the rendering is done directly into the DOM (so that it can correctly
* compute positions). However, the views are always rendered in fragments,
* and appended to the DOM once ready (to prevent them from flickering). We
* here use the on_attach_callback hook, called when the widget is attached
* to the DOM, to perform the rendering. This ensures that the rendering is
* always done in the DOM.
*
* @override
*/
on_attach_callback: function () {
this._super.apply(this, arguments);
this.isInDOM = true;
this._renderGraph();
},
/**
* @override
*/
on_detach_callback: function () {
this._super.apply(this, arguments);
this.isInDOM = false;
},
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// Private // Private
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
@ -51,10 +75,9 @@ return AbstractRenderer.extend({
* Render the chart. * Render the chart.
* *
* Note that This method is synchronous, but the actual rendering is done * Note that This method is synchronous, but the actual rendering is done
* asynchronously (in a setTimeout). The reason for that is that nvd3/d3 * asynchronously. The reason for that is that nvd3/d3 needs to be in the
* needs to be in the DOM to correctly render itself. So, we trick Flectra by * DOM to correctly render itself. So, we trick Flectra by returning
* returning immediately, then wait a tiny interval before actually * immediately, then we render the chart when the widget is in the DOM.
* displaying the data.
* *
* @returns {Deferred} The _super deferred is actually resolved immediately * @returns {Deferred} The _super deferred is actually resolved immediately
*/ */
@ -76,21 +99,15 @@ return AbstractRenderer.extend({
"Try to add some records, or make sure that " + "Try to add some records, or make sure that " +
"there is no active filter in the search bar."), "there is no active filter in the search bar."),
})); }));
} else { } else if (this.isInDOM) {
var self = this; // only render the graph if the widget is already in the DOM (this
setTimeout(function () { // happens typically after an update), otherwise, it will be
self.$el.empty(); // rendered when the widget will be attached to the DOM (see
var chart = self['_render' + _.str.capitalize(self.state.mode) + 'Chart'](); // 'on_attach_callback')
if (chart && chart.tooltip.chartContainer) { this._renderGraph();
self.to_remove = chart.update;
nv.utils.onWindowResize(chart.update);
chart.tooltip.chartContainer(self.el);
}
}, 0);
} }
return this._super.apply(this, arguments); return this._super.apply(this, arguments);
}, },
/** /**
* Helper function to set up data properly for the multiBarChart model in * Helper function to set up data properly for the multiBarChart model in
* nvd3. * nvd3.
@ -327,6 +344,21 @@ return AbstractRenderer.extend({
chart(svg); chart(svg);
return chart; return chart;
}, },
/**
* Renders the graph according to its type. This function must be called
* when the renderer is in the DOM (for nvd3 to render the graph correctly).
*
* @private
*/
_renderGraph: function () {
this.$el.empty();
var chart = this['_render' + _.str.capitalize(this.state.mode) + 'Chart']();
if (chart && chart.tooltip.chartContainer) {
this.to_remove = chart.update;
nv.utils.onWindowResize(chart.update);
chart.tooltip.chartContainer(this.el);
}
},
}); });
}); });

View File

@ -10,6 +10,7 @@ flectra.define('web.KanbanController', function (require) {
var BasicController = require('web.BasicController'); var BasicController = require('web.BasicController');
var Context = require('web.Context'); var Context = require('web.Context');
var core = require('web.core'); var core = require('web.core');
var Domain = require('web.Domain');
var view_dialogs = require('web.view_dialogs'); var view_dialogs = require('web.view_dialogs');
var _t = core._t; var _t = core._t;
@ -106,6 +107,16 @@ var KanbanController = BasicController.extend({
var groupedByM2o = groupByField && (groupByField.type === 'many2one'); var groupedByM2o = groupByField && (groupByField.type === 'many2one');
return groupedByM2o; return groupedByM2o;
}, },
/**
* @param {number[]} ids
* @private
* @returns {Deferred}
*/
_resequenceColumns: function (ids) {
var state = this.model.get(this.handle, {raw: true});
var model = state.fields[state.groupedBy[0]].relation;
return this.model.resequence(model, ids, this.handle);
},
/** /**
* This method calls the server to ask for a resequence. Note that this * This method calls the server to ask for a resequence. Note that this
* does not rerender the user interface, because in most case, the * does not rerender the user interface, because in most case, the
@ -155,6 +166,10 @@ var KanbanController = BasicController.extend({
_onAddColumn: function (event) { _onAddColumn: function (event) {
var self = this; var self = this;
this.model.createGroup(event.data.value, this.handle).then(function () { this.model.createGroup(event.data.value, this.handle).then(function () {
var state = self.model.get(self.handle, {raw: true});
var ids = _.pluck(state.data, 'res_id').filter(_.isNumber);
return self._resequenceColumns(ids);
}).then(function () {
return self.update({}, {reload: false}); return self.update({}, {reload: false});
}).then(function () { }).then(function () {
self._updateButtons(); self._updateButtons();
@ -228,10 +243,57 @@ var KanbanController = BasicController.extend({
resIDs: record.res_ids, resIDs: record.res_ids,
}, },
on_closed: function () { on_closed: function () {
var recordModel = self.model.localData[record.id];
var group = self.model.localData[recordModel.parentID];
var parent = self.model.localData[group.parentID];
self.model.reload(record.id).then(function (db_id) { self.model.reload(record.id).then(function (db_id) {
var data = self.model.get(db_id); var data = self.model.get(db_id);
var kanban_record = event.target; var kanban_record = event.target;
kanban_record.update(data); kanban_record.update(data);
// Check if we still need to display the record. Some fields of the domain are
// not guaranteed to be in data. This is for example the case if the action
// contains a domain on a field which is not in the Kanban view. Therefore,
// we need to handle multiple cases based on 3 variables:
// domInData: all domain fields are in the data
// activeInDomain: 'active' is already in the domain
// activeInData: 'active' is available in the data
var domain = (parent ? parent.domain : group.domain) || [];
var domInData = _.every(domain, function (d) {
return d[0] in data.data;
});
var activeInDomain = _.pluck(domain, 0).indexOf('active') !== -1;
var activeInData = 'active' in data.data;
// Case # | domInData | activeInDomain | activeInData
// 1 | true | true | true => no domain change
// 2 | true | true | false => not possible
// 3 | true | false | true => add active in domain
// 4 | true | false | false => no domain change
// 5 | false | true | true => no evaluation
// 6 | false | true | false => no evaluation
// 7 | false | false | true => replace domain
// 8 | false | false | false => no evaluation
// There are 3 cases which cannot be evaluated since we don't have all the
// necessary information. The complete solution would be to perform a RPC in
// these cases, but this is out of scope. A simpler one is to do a try / catch.
if (domInData && !activeInDomain && activeInData) {
domain = domain.concat([['active', '=', true]]);
} else if (!domInData && !activeInDomain && activeInData) {
domain = [['active', '=', true]];
}
try {
var visible = new Domain(domain).compute(data.evalContext);
} catch (e) {
return;
}
if (!visible) {
kanban_record.destroy();
}
}); });
}, },
}); });
@ -380,9 +442,7 @@ var KanbanController = BasicController.extend({
*/ */
_onResequenceColumn: function (event) { _onResequenceColumn: function (event) {
var self = this; var self = this;
var state = this.model.get(this.handle, {raw: true}); this._resequenceColumns(event.data.ids).then(function () {
var model = state.fields[state.groupedBy[0]].relation;
this.model.resequence(model, event.data.ids, this.handle).then(function () {
self._updateEnv(); self._updateEnv();
}); });
}, },

View File

@ -348,9 +348,8 @@ var KanbanModel = BasicModel.extend({
return $.when(); return $.when();
}, },
/** /**
* Reloads all progressbar data if the given id is a record's one. This is * Reloads all progressbar data. This is done after given deferred and
* done after given deferred and insures that the given deferred's result is * insures that the given deferred's result is not lost.
* not lost.
* *
* @private * @private
* @param {string} recordID * @param {string} recordID
@ -359,7 +358,9 @@ var KanbanModel = BasicModel.extend({
*/ */
_reloadProgressBarGroupFromRecord: function (recordID, def) { _reloadProgressBarGroupFromRecord: function (recordID, def) {
var element = this.localData[recordID]; var element = this.localData[recordID];
if (element.type !== 'record') { if (element.type === 'list' && !element.parentID) {
// we are reloading the whole view, so there is no need to manually
// reload the progressbars
return def; return def;
} }
@ -369,7 +370,10 @@ var KanbanModel = BasicModel.extend({
while (element) { while (element) {
if (element.progressBar) { if (element.progressBar) {
return def.then(function (data) { return def.then(function (data) {
return self._load(element, {onlyGroups: true}).then(function () { return self._load(element, {
keepEmptyGroups: true,
onlyGroups: true,
}).then(function () {
return data; return data;
}); });
}); });

View File

@ -531,8 +531,13 @@ ListRenderer.include({
* @returns {Deferred} this deferred is resolved immediately * @returns {Deferred} this deferred is resolved immediately
*/ */
_renderView: function () { _renderView: function () {
var self = this;
this.currentRow = null; this.currentRow = null;
return this._super.apply(this, arguments); return this._super.apply(this, arguments).then(function () {
if (self._isEditable()) {
self.$('table').addClass('o_editable_list');
}
});
}, },
/** /**
* Force the resequencing of the items in the list. * Force the resequencing of the items in the list.

View File

@ -17,6 +17,7 @@ flectra.define('web.PivotModel', function (require) {
*/ */
var AbstractModel = require('web.AbstractModel'); var AbstractModel = require('web.AbstractModel');
var concurrency = require('web.concurrency');
var core = require('web.core'); var core = require('web.core');
var session = require('web.session'); var session = require('web.session');
var utils = require('web.utils'); var utils = require('web.utils');
@ -32,6 +33,7 @@ var PivotModel = AbstractModel.extend({
this._super.apply(this, arguments); this._super.apply(this, arguments);
this.numbering = {}; this.numbering = {};
this.data = null; this.data = null;
this._loadDataDropPrevious = new concurrency.DropPrevious();
}, },
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
@ -250,6 +252,7 @@ var PivotModel = AbstractModel.extend({
this.data.colGroupBys = params.context.pivot_column_groupby || this.data.colGroupBys; this.data.colGroupBys = params.context.pivot_column_groupby || this.data.colGroupBys;
this.data.groupedBy = params.context.pivot_row_groupby || this.data.groupedBy; this.data.groupedBy = params.context.pivot_row_groupby || this.data.groupedBy;
this.data.measures = this._processMeasures(params.context.pivot_measures) || this.data.measures; this.data.measures = this._processMeasures(params.context.pivot_measures) || this.data.measures;
this.defaultGroupedBy = this.data.groupedBy.length ? this.data.groupedBy : this.defaultGroupedBy;
} }
if ('domain' in params) { if ('domain' in params) {
this.data.domain = params.domain; this.data.domain = params.domain;
@ -278,7 +281,7 @@ var PivotModel = AbstractModel.extend({
self._updateTree(old_col_root, self.data.main_col.root); self._updateTree(old_col_root, self.data.main_col.root);
new_groupby_length = self._getHeaderDepth(self.data.main_col.root) - 1; new_groupby_length = self._getHeaderDepth(self.data.main_col.root) - 1;
self.data.main_row.groupbys = old_col_root.groupbys.slice(0, new_groupby_length); self.data.main_row.groupbys = old_row_root.groupbys.slice(0, new_groupby_length);
}); });
}, },
/** /**
@ -531,7 +534,7 @@ var PivotModel = AbstractModel.extend({
} }
} }
return $.when.apply(null, groupBys.map(function (groupBy) { return this._loadDataDropPrevious.add($.when.apply(null, groupBys.map(function (groupBy) {
return self._rpc({ return self._rpc({
model: self.modelName, model: self.modelName,
method: 'read_group', method: 'read_group',
@ -541,7 +544,7 @@ var PivotModel = AbstractModel.extend({
groupBy: groupBy, groupBy: groupBy,
lazy: false, lazy: false,
}); });
})).then(function () { }))).then(function () {
var data = Array.prototype.slice.call(arguments); var data = Array.prototype.slice.call(arguments);
if (data[0][0].__count === 0) { if (data[0][0].__count === 0) {
self.data.has_data = false; self.data.has_data = false;

View File

@ -430,7 +430,7 @@ var SelectCreateDialog = ViewDialog.extend({
* list controller. * list controller.
* *
* @private * @private
* @param {OdooEvent} ev * @param {FlectraEvent} ev
* @param {function} ev.data.callback used to send the requested context * @param {function} ev.data.callback used to send the requested context
*/ */
_onGetControllerContext: function (ev) { _onGetControllerContext: function (ev) {

View File

@ -79,8 +79,15 @@ var DateWidget = Widget.extend({
var oldValue = this.getValue(); var oldValue = this.getValue();
this._setValueFromUi(); this._setValueFromUi();
var newValue = this.getValue(); var newValue = this.getValue();
var hasChanged = !oldValue !== !newValue;
if (!oldValue !== !newValue || oldValue && newValue && !oldValue.isSame(newValue)) { if (oldValue && newValue) {
var formattedOldValue = oldValue.format(time.getLangDatetimeFormat());
var formattedNewValue = newValue.format(time.getLangDatetimeFormat())
if (formattedNewValue !== formattedOldValue) {
hasChanged = true;
}
}
if (hasChanged) {
// The condition is strangely written; this is because the // The condition is strangely written; this is because the
// values can be false/undefined // values can be false/undefined
this.trigger("datetime_changed"); this.trigger("datetime_changed");

View File

@ -178,6 +178,7 @@ var DebugManager = Widget.extend({
}) })
.then(function (views) { .then(function (views) {
var view = views[0]; var view = views[0];
view.type = view.type === 'tree' ? 'list' : view.type; // ignore tree view
self.do_action({ self.do_action({
type: 'ir.actions.act_window', type: 'ir.actions.act_window',
name: view.name, name: view.name,

View File

@ -176,6 +176,8 @@
} }
} }
@icon-font-path: "/web/static/lib/bootstrap/fonts/";
@btn-default-color: #fff; @btn-default-color: #fff;
@btn-default-bg: @gray-light; @btn-default-bg: @gray-light;
@btn-default-border: transparent; @btn-default-border: transparent;

View File

@ -17,11 +17,12 @@
.o_datepicker_input { .o_datepicker_input {
width: 100%; width: 100%;
cursor: pointer;
} }
.o_datepicker_button { .o_datepicker_button {
.o-position-absolute(2px, 4px); .o-position-absolute(2px, 4px);
cursor: pointer; pointer-events: none; // let click events go through the underlying input
&:after { &:after {
.o-caret-down; .o-caret-down;
} }

View File

@ -39,9 +39,7 @@
// Flex fields (inline) // Flex fields (inline)
&.o_field_many2one, &.o_field_radio, &.o_field_many2manytags, &.o_field_percent_pie, &.o_field_monetary, &.o_field_binary_file { &.o_field_many2one, &.o_field_radio, &.o_field_many2manytags, &.o_field_percent_pie, &.o_field_monetary, &.o_field_binary_file {
@media (min-width: @screen-sm-min) { .o-inline-flex-display();
.o-inline-flex-display();
}
> span, > button { > span, > button {
.o-flex(0, 0, auto); .o-flex(0, 0, auto);
} }
@ -135,7 +133,7 @@
display: inline-block; display: inline-block;
padding: 0; padding: 0;
margin: 0; margin: 0;
vertical-align: middle; vertical-align: baseline;
> .o_priority_star { > .o_priority_star {
display: inline-block; display: inline-block;
font-size: 1.35em; font-size: 1.35em;
@ -185,6 +183,9 @@
// Radio buttons // Radio buttons
&.o_field_radio { &.o_field_radio {
@media (max-width: @screen-xs-max) {
display: inline-block;
}
.o_radio_input { .o_radio_input {
outline: none; outline: none;
} }
@ -309,6 +310,10 @@
&:hover .o_form_image_controls { &:hover .o_form_image_controls {
opacity: 0.8; opacity: 0.8;
} }
&.o_field_invalid > img {
border: 1px solid @brand-danger;
}
} }
// Input loading/file // Input loading/file

View File

@ -248,6 +248,10 @@
border-left-color: @gray-lighter; border-left-color: @gray-lighter;
} }
} }
.caret {
margin-top: -2px;
}
} }
.o-status-more { .o-status-more {

View File

@ -91,6 +91,7 @@
margin-left: 3%; margin-left: 3%;
color: @headings-color; color: @headings-color;
text-align: right; text-align: right;
white-space: nowrap;
.o-transform-origin(right, center); .o-transform-origin(right, center);
&.o_kanban_grow { &.o_kanban_grow {

View File

@ -223,6 +223,7 @@
} }
.o_favorite { .o_favorite {
top: 3px;
left: 0; left: 0;
right: auto; right: auto;
} }

View File

@ -6,6 +6,14 @@
background-color: @flectra-view-background-color; background-color: @flectra-view-background-color;
margin-bottom: 0px; margin-bottom: 0px;
// Checkbox in editable list
// should be clickable and activate the row
&.o_editable_list .o_data_row:not(.o_selected_row) .o_data_cell {
.o_checkbox:not(.o_readonly_modifier) {
pointer-events: none;
}
}
&.table { &.table {
td, th { td, th {
vertical-align: middle; vertical-align: middle;
@ -37,12 +45,14 @@
.user-select(none); // Prevent unwanted selection while sorting .user-select(none); // Prevent unwanted selection while sorting
&::after { &::after {
margin-left: 6px;
position: absolute;
font-family: FontAwesome; font-family: FontAwesome;
content: "\f0d7"; content: "\f0d7";
opacity: 0; opacity: 0;
} }
&:not(:empty)::after {
margin-left: 6px;
position: absolute;
}
&.o-sort-up { &.o-sort-up {
cursor: n-resize; cursor: n-resize;
&::after { &::after {
@ -74,7 +84,6 @@
padding: 0px; padding: 0px;
background-style: none; background-style: none;
border-style: none; border-style: none;
height: 0px;
display: table-cell; display: table-cell;
} }

View File

@ -76,4 +76,4 @@
// ------------------------------------------------------------------ // ------------------------------------------------------------------
.o_pivot_measures_list { .o_pivot_measures_list {
.o-selected-li; .o-selected-li;
} }

View File

@ -71,11 +71,6 @@
} }
} }
} }
.o_target_date:not(.fc-today) {
background-color: @flectra-brand-primary;
opacity: 0.1;
}
} }
.o_calendar_sidebar_container { .o_calendar_sidebar_container {

View File

@ -18,4 +18,4 @@ flectra.__DEBUG__.didLogInfo.then(function() {
}); });
})(); })();

View File

@ -1698,7 +1698,6 @@ QUnit.module('basic_fields', {
assert.strictEqual(form.$('div[name="document"] > img').attr('width'), "90", assert.strictEqual(form.$('div[name="document"] > img').attr('width'), "90",
"the image should correctly set its attributes"); "the image should correctly set its attributes");
form.destroy(); form.destroy();
}); });
QUnit.test('image fields in subviews are loaded correctly', function (assert) { QUnit.test('image fields in subviews are loaded correctly', function (assert) {
@ -1750,6 +1749,34 @@ QUnit.module('basic_fields', {
form.destroy(); form.destroy();
}); });
QUnit.test('image fields with required attribute', function (assert) {
assert.expect(2);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="document" required="1" widget="image"/>' +
'</form>',
mockRPC: function (route, args) {
if (args.method === 'create') {
throw new Error("Should not do a create RPC with unset required image field");
}
return this._super.apply(this, arguments);
},
});
form.$buttons.find('.o_form_button_save').click();
assert.ok(form.$('.o_form_view').hasClass('o_form_editable'),
"form view should still be editable");
assert.ok(form.$('.o_field_widget').hasClass('o_field_invalid'),
"image field should be displayed as invalid");
form.destroy();
});
QUnit.module('JournalDashboardGraph', { QUnit.module('JournalDashboardGraph', {
beforeEach: function () { beforeEach: function () {
_.extend(this.data.partner.fields, { _.extend(this.data.partner.fields, {
@ -2262,6 +2289,38 @@ QUnit.module('basic_fields', {
form.destroy(); form.destroy();
}); });
QUnit.test('datetime field in form view', function (assert) {
assert.expect(1);
this.data.partner.fields.datetime.default = "2017-08-02 12:00:05";
this.data.partner.fields.datetime.required = true;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners"><field name="datetime"/></form>',
res_id: 1,
translateParameters: { // Avoid issues due to localization formats
date_format: '%m/%d/%Y',
time_format: '%H:%M',
},
});
testUtils.patch(basicFields.FieldDate, {
_setValue: function () {
throw "The time format of the language must be taken into account."
return this._super.apply(this, arguments);
},
});
form.$buttons.find('.o_form_button_create').click();
var expectedDateString = "08/02/2017 12:00"; // 10:00:00 without timezone
assert.strictEqual(form.$('.o_field_date input').val(), expectedDateString,
'the datetime should be correctly displayed in readonly');
testUtils.unpatch(basicFields.FieldDate);
form.destroy();
});
QUnit.test('datetime field in editable list view', function (assert) { QUnit.test('datetime field in editable list view', function (assert) {
assert.expect(8); assert.expect(8);
@ -3010,7 +3069,7 @@ QUnit.module('basic_fields', {
QUnit.module('PhoneWidget'); QUnit.module('PhoneWidget');
QUnit.test('phone field in form view on extra small screens', function (assert) { QUnit.test('phone field in form view on extra small screens', function (assert) {
assert.expect(7); assert.expect(8);
var form = createView({ var form = createView({
View: FormView, View: FormView,
@ -3058,6 +3117,14 @@ QUnit.module('basic_fields', {
assert.strictEqual($phoneLink.attr('href'), 'tel:new', assert.strictEqual($phoneLink.attr('href'), 'tel:new',
"should still have proper tel prefix"); "should still have proper tel prefix");
// save phone with &shy; and verify it is removed
form.$buttons.find('.o_form_button_edit').click();
form.$('input[type="text"].o_field_widget').val('h\u00ADi').trigger('input');
form.$buttons.find('.o_form_button_save').click();
$phoneLink = form.$('a.o_form_uri.o_field_widget');
assert.strictEqual($phoneLink.attr('href'), 'tel:hi',
"U+00AD should have been removed");
form.destroy(); form.destroy();
}); });

File diff suppressed because it is too large Load Diff

View File

@ -110,14 +110,14 @@ var MockServer = Class.extend({
console.log('%c[rpc] response' + route, 'color: blue; font-weight: bold;', JSON.parse(resultString)); console.log('%c[rpc] response' + route, 'color: blue; font-weight: bold;', JSON.parse(resultString));
} }
return JSON.parse(resultString); return JSON.parse(resultString);
}).fail(function (result) { }, function (result, event) {
var errorString = JSON.stringify(result || false); var errorString = JSON.stringify(result || false);
if (logLevel === 1) { if (logLevel === 1) {
console.log('Mock: (ERROR)' + route, JSON.parse(errorString)); console.log('Mock: (ERROR)' + route, JSON.parse(errorString));
} else if (logLevel === 2) { } else if (logLevel === 2) {
console.log('%c[rpc] response (error) ' + route, 'color: orange; font-weight: bold;', JSON.parse(errorString)); console.log('%c[rpc] response (error) ' + route, 'color: orange; font-weight: bold;', JSON.parse(errorString));
} }
return JSON.parse(errorString); return $.Deferred().reject(errorString, event || $.Event());
}); });
}, },
@ -229,7 +229,6 @@ var MockServer = Class.extend({
if (node.attrs.attrs) { if (node.attrs.attrs) {
var attrs = pyeval.py_eval(node.attrs.attrs); var attrs = pyeval.py_eval(node.attrs.attrs);
_.extend(modifiers, attrs); _.extend(modifiers, attrs);
delete node.attrs.attrs;
} }
if (node.attrs.states) { if (node.attrs.states) {
if (!modifiers.invisible) { if (!modifiers.invisible) {
@ -740,7 +739,7 @@ var MockServer = Class.extend({
*/ */
_mockReadProgressBar: function (model, kwargs) { _mockReadProgressBar: function (model, kwargs) {
var domain = kwargs.domain; var domain = kwargs.domain;
var groupBy = kwargs.groupBy; var groupBy = kwargs.group_by;
var progress_bar = kwargs.progress_bar; var progress_bar = kwargs.progress_bar;
var records = this._getRecords(model, domain || []); var records = this._getRecords(model, domain || []);
@ -921,6 +920,9 @@ var MockServer = Class.extend({
case '/web/dataset/search_read': case '/web/dataset/search_read':
return $.when(this._mockSearchReadController(args)); return $.when(this._mockSearchReadController(args));
case '/web/dataset/resequence':
return $.when();
} }
if (route.indexOf('/web/image') >= 0 || _.contains(['.png', '.jpg'], route.substr(route.length - 4))) { if (route.indexOf('/web/image') >= 0 || _.contains(['.png', '.jpg'], route.substr(route.length - 4))) {
return $.when(); return $.when();

View File

@ -146,7 +146,19 @@ QUnit.module('Views', {
}; };
QUnit.test('simple calendar rendering', function (assert) { QUnit.test('simple calendar rendering', function (assert) {
assert.expect(22); assert.expect(24);
this.data.event.records.push({
id: 7,
user_id: session.uid,
partner_id: false,
name: "event 7",
start: "2016-12-18 09:00:00",
stop: "2016-12-18 10:00:00",
allday: false,
partner_ids: [2],
type: 1
});
var calendar = createView({ var calendar = createView({
View: CalendarView, View: CalendarView,
@ -187,7 +199,7 @@ QUnit.module('Views', {
assert.strictEqual($sidebar.find('.o_selected_range').length, 1, "should highlight the target day in mini calendar"); assert.strictEqual($sidebar.find('.o_selected_range').length, 1, "should highlight the target day in mini calendar");
calendar.$buttons.find('.o_calendar_button_month').trigger('click'); // display all the month calendar.$buttons.find('.o_calendar_button_month').trigger('click'); // display all the month
assert.strictEqual(calendar.$('.fc-event').length, 6, "should display 6 events on the month (5 events + 2 week event - 1 'event 6' is filtered)"); assert.strictEqual(calendar.$('.fc-event').length, 7, "should display 7 events on the month (5 events + 2 week event - 1 'event 6' is filtered + 1 'Undefined event')");
assert.strictEqual($sidebar.find('.o_selected_range').length, 31, "month scale should highlight all days in mini calendar"); assert.strictEqual($sidebar.find('.o_selected_range').length, 31, "month scale should highlight all days in mini calendar");
// test filters // test filters
@ -196,15 +208,19 @@ QUnit.module('Views', {
var $typeFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(user))'); var $typeFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(user))');
assert.ok($typeFilter.length, "should display 'user' filter"); assert.ok($typeFilter.length, "should display 'user' filter");
assert.strictEqual($typeFilter.find('.o_calendar_filter_item').length, 2, "should display 2 filter items for 'user'"); assert.strictEqual($typeFilter.find('.o_calendar_filter_item').length, 3, "should display 3 filter items for 'user'");
// filters which has no value should show with string "Undefined" and should show at the last
assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last').data('value'), false, "filters having false value should be displayed at last in filter items");
assert.strictEqual($typeFilter.find('.o_calendar_filter_item:last > span').text(), "Undefined", "filters having false value should display 'Undefined' string");
var $attendeesFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(attendees))'); var $attendeesFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(attendees))');
assert.ok($attendeesFilter.length, "should display 'attendees' filter"); assert.ok($attendeesFilter.length, "should display 'attendees' filter");
assert.strictEqual($attendeesFilter.find('.o_calendar_filter_item').length, 3, "should display 3 filter items for 'attendees' who use write_model (2 saved + Everything)"); assert.strictEqual($attendeesFilter.find('.o_calendar_filter_item').length, 3, "should display 3 filter items for 'attendees' who use write_model (2 saved + Everything)");
assert.ok($attendeesFilter.find('.o_field_many2one').length, "should display one2many search bar for 'attendees' filter"); assert.ok($attendeesFilter.find('.o_field_many2one').length, "should display one2many search bar for 'attendees' filter");
assert.strictEqual(calendar.$('.fc-event').length, 6, assert.strictEqual(calendar.$('.fc-event').length, 7,
"should display 6 events ('event 5' counts for 2 because it spans two weeks and thus generate two fc-event elements)"); "should display 7 events ('event 5' counts for 2 because it spans two weeks and thus generate two fc-event elements)");
calendar.$('.o_calendar_filter .o_checkbox input').first().click(); // Disable first filter calendar.$('.o_calendar_filter .o_checkbox input').first().click(); // Disable first filter
assert.strictEqual(calendar.$('.fc-event').length, 4, "should now only display 4 event"); assert.strictEqual(calendar.$('.fc-event').length, 4, "should now only display 4 event");
calendar.$('.o_calendar_filter .o_checkbox input').eq(1).click(); // Disable second filter calendar.$('.o_calendar_filter .o_checkbox input').eq(1).click(); // Disable second filter
@ -1715,6 +1731,136 @@ QUnit.module('Views', {
testUtils.unpatch(mixins.ParentedMixin); testUtils.unpatch(mixins.ParentedMixin);
}); });
QUnit.test('timezone does not affect current day', function (assert) {
assert.expect(2);
var calendar = createView({
View: CalendarView,
model: 'event',
data: this.data,
arch:
'<calendar date_start="start_date">'+
'<field name="name"/>'+
'</calendar>',
archs: archs,
viewOptions: {
initialDate: initialDate,
},
session: {
getTZOffset: function () {
return -2400; // 40 hours timezone
},
},
});
var $sidebar = calendar.$('.o_calendar_sidebar');
assert.strictEqual($sidebar.find('.ui-datepicker-current-day').text(), "12", "should highlight the target day");
// go to previous day
$sidebar.find('.ui-datepicker-current-day').prev().click();
assert.strictEqual($sidebar.find('.ui-datepicker-current-day').text(), "11", "should highlight the selected day");
calendar.destroy();
});
QUnit.test('form_view_id attribute works (for creating events)', function (assert) {
assert.expect(1);
var calendar = createView({
View: CalendarView,
model: 'event',
data: this.data,
arch: '<calendar class="o_calendar_test" '+
'date_start="start" '+
'date_stop="stop" '+
'mode="month" '+
'form_view_id="42">'+
'<field name="name"/>'+
'</calendar>',
archs: archs,
viewOptions: {
initialDate: initialDate,
},
mockRPC: function (route, args) {
if (args.method === "create") {
// we simulate here the case where a create call with just
// the field name fails. This is a normal flow, the server
// reject the create rpc (quick create), then the web client
// fall back to a form view. This happens typically when a
// model has required fields
return $.Deferred().reject('None shall pass!');
}
return this._super(route, args);
},
intercepts: {
do_action: function (event) {
assert.strictEqual(event.data.action.views[0][0], 42,
"should do a do_action with view id 42");
},
},
});
var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
testUtils.triggerMouseEvent($cell, "mousedown");
testUtils.triggerMouseEvent($cell, "mouseup");
var $input = $('.modal-body input:first');
$input.val("It's just a fleshwound").trigger('input');
$('.modal button.btn:contains(Create)').trigger('click');
calendar.destroy();
});
QUnit.test('calendar fallback to form view id in action if necessary', function (assert) {
assert.expect(1);
var calendar = createView({
View: CalendarView,
model: 'event',
data: this.data,
arch: '<calendar class="o_calendar_test" '+
'date_start="start" '+
'date_stop="stop" '+
'mode="month"> '+
'<field name="name"/>'+
'</calendar>',
archs: archs,
viewOptions: {
initialDate: initialDate,
action: {views: [[1, 'kanban'], [43, 'form']]}
},
mockRPC: function (route, args) {
if (args.method === "create") {
// we simulate here the case where a create call with just
// the field name fails. This is a normal flow, the server
// reject the create rpc (quick create), then the web client
// fall back to a form view. This happens typically when a
// model has required fields
return $.Deferred().reject('None shall pass!');
}
return this._super(route, args);
},
intercepts: {
do_action: function (event) {
assert.strictEqual(event.data.action.views[0][0], 43,
"should do a do_action with view id 43");
},
},
});
var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
testUtils.triggerMouseEvent($cell, "mousedown");
testUtils.triggerMouseEvent($cell, "mouseup");
var $input = $('.modal-body input:first');
$input.val("It's just a fleshwound").trigger('input');
$('.modal button.btn:contains(Create)').trigger('click');
calendar.destroy();
});
}); });
}); });

View File

@ -1623,6 +1623,37 @@ QUnit.module('Views', {
form.destroy(); form.destroy();
}); });
QUnit.test('duplicating a record preserve the context', function (assert) {
assert.expect(2);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="foo"/>' +
'</form>',
res_id: 1,
viewOptions: {sidebar: true, context: {hey: 'hoy'}},
mockRPC: function (route, args) {
if (args.method === 'read') {
// should have 2 read, one for initial load, second for
// read after duplicating
assert.strictEqual(args.kwargs.context.hey, 'hoy',
"should have send the correct context");
}
if (args.method === 'search_read' && args.model === 'ir.attachment') {
return $.when([]);
}
return this._super.apply(this, arguments);
},
});
form.sidebar.$('a:contains(Duplicate)').click();
form.destroy();
});
QUnit.test('cannot duplicate a record', function (assert) { QUnit.test('cannot duplicate a record', function (assert) {
assert.expect(2); assert.expect(2);
@ -6494,5 +6525,35 @@ QUnit.module('Views', {
testUtils.unpatch(mixins.ParentedMixin); testUtils.unpatch(mixins.ParentedMixin);
}); });
QUnit.test('do not change pager when discarding current record', function (assert) {
assert.expect(2);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="foo"/>' +
'</form>',
viewOptions: {
ids: [1, 2],
index: 0,
},
res_id: 2,
});
assert.strictEqual(form.pager.$('.o_pager_counter').text().trim(), '2 / 2',
'pager should indicate that we are on second record');
form.$buttons.find('.o_form_button_edit').click();
form.$buttons.find('.o_form_button_cancel').click();
assert.strictEqual(form.pager.$('.o_pager_counter').text().trim(), '2 / 2',
'pager should not have changed');
form.destroy();
});
}); });
}); });

View File

@ -1130,7 +1130,7 @@ QUnit.module('Views', {
}); });
QUnit.test('create a column in grouped on m2o', function (assert) { QUnit.test('create a column in grouped on m2o', function (assert) {
assert.expect(13); assert.expect(14);
var nbRPCs = 0; var nbRPCs = 0;
var kanban = createView({ var kanban = createView({
@ -1149,6 +1149,11 @@ QUnit.module('Views', {
if (args.method === 'name_create') { if (args.method === 'name_create') {
assert.ok(true, "should call name_create"); assert.ok(true, "should call name_create");
} }
//Create column will call resequence to set column order
if (route === '/web/dataset/resequence') {
assert.ok(true, "should call resequence");
return $.when(true);
}
return this._super(route, args); return this._super(route, args);
}, },
}); });
@ -1229,7 +1234,7 @@ QUnit.module('Views', {
}); });
QUnit.test('delete a column in grouped on m2o', function (assert) { QUnit.test('delete a column in grouped on m2o', function (assert) {
assert.expect(32); assert.expect(33);
testUtils.patch(KanbanRenderer, { testUtils.patch(KanbanRenderer, {
_renderGrouped: function () { _renderGrouped: function () {
@ -1716,6 +1721,73 @@ QUnit.module('Views', {
kanban.destroy(); kanban.destroy();
}); });
QUnit.test('button executes action and check domain', function (assert) {
assert.expect(2);
var data = this.data;
data.partner.fields.active = {string: "Active", type: "boolean", default: true};
for (var k in this.data.partner.records) {
data.partner.records[k].active = true;
}
var kanban = createView({
View: KanbanView,
model: "partner",
data: data,
arch:
'<kanban>' +
'<templates><div t-name="kanban-box">' +
'<field name="foo"/>' +
'<field name="active"/>' +
'<button type="object" name="a1" />' +
'<button type="object" name="toggle_active" />' +
'</div></templates>' +
'</kanban>',
});
testUtils.intercept(kanban, 'execute_action', function (event) {
data.partner.records[0].active = false;
event.data.on_closed();
});
assert.strictEqual(kanban.$('.o_kanban_record:contains(yop)').length, 1, "should display 'yop' record");
kanban.$('.o_kanban_record:contains(yop) button[data-name="toggle_active"]').click();
assert.strictEqual(kanban.$('.o_kanban_record:contains(yop)').length, 0, "should remove 'yop' record from the view");
kanban.destroy();
});
QUnit.test('button executes action with domain field not in view', function (assert) {
assert.expect(1);
var kanban = createView({
View: KanbanView,
model: "partner",
data: this.data,
domain: [['bar', '=', true]],
arch:
'<kanban>' +
'<templates><div t-name="kanban-box">' +
'<field name="foo"/>' +
'<button type="object" name="a1" />' +
'<button type="object" name="toggle_action" />' +
'</div></templates>' +
'</kanban>',
});
testUtils.intercept(kanban, 'execute_action', function (event) {
event.data.on_closed();
});
try {
kanban.$('.o_kanban_record:contains(yop) button[data-name="toggle_action"]').click();
assert.strictEqual(true, true, 'Everything went fine');
} catch (e) {
assert.strictEqual(true, false, 'Error triggered at action execution');
}
kanban.destroy();
});
QUnit.test('rendering date and datetime', function (assert) { QUnit.test('rendering date and datetime', function (assert) {
assert.expect(2); assert.expect(2);
@ -1948,7 +2020,7 @@ QUnit.module('Views', {
}); });
QUnit.test('archive new kanban column', function (assert) { QUnit.test('archive new kanban column', function (assert) {
assert.expect(15); assert.expect(16);
this.data.partner.fields.active = {string: 'Active', type: 'char', default: true}; this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
@ -1977,6 +2049,7 @@ QUnit.module('Views', {
kanban.$('.o_column_quick_create input').val('new colum'); kanban.$('.o_column_quick_create input').val('new colum');
kanban.$('.o_column_quick_create button.o_kanban_add').click(); kanban.$('.o_column_quick_create button.o_kanban_add').click();
rpcs.push('/web/dataset/call_kw/product/name_create'); rpcs.push('/web/dataset/call_kw/product/name_create');
rpcs.push('/web/dataset/resequence');
assert.verifySteps(rpcs); assert.verifySteps(rpcs);
// add a record inside // add a record inside
@ -2477,6 +2550,176 @@ QUnit.module('Views', {
kanban.destroy(); kanban.destroy();
}); });
QUnit.test('column progressbars on archiving records update counter', function (assert) {
assert.expect(4);
// add active field on partner model and make all records active
this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch:
'<kanban>' +
'<field name="active"/>' +
'<field name="bar"/>' +
'<field name="int_field"/>' +
'<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' +
'<templates><t t-name="kanban-box">' +
'<div>' +
'<field name="name"/>' +
'</div>' +
'</t></templates>' +
'</kanban>',
groupBy: ['bar'],
});
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_side').text(), "36",
"counter should contain the correct value");
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_progress > .progress-bar:first').data('originalTitle'), "1 yop",
"the counter progressbars should be correctly displayed");
// archive all records of the second columns
kanban.$('.o_kanban_group:eq(1) .o_column_archive').click();
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_side').text(), "0",
"counter should contain the correct value");
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_progress > .progress-bar:first').data('originalTitle'), "0 yop",
"the counter progressbars should have been correctly updated");
kanban.destroy();
});
QUnit.test('kanban with progressbars: correctly update env when archiving records', function (assert) {
assert.expect(3);
// add active field on partner model and make all records active
this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch:
'<kanban>' +
'<field name="active"/>' +
'<field name="bar"/>' +
'<field name="int_field"/>' +
'<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' +
'<templates><t t-name="kanban-box">' +
'<div>' +
'<field name="name"/>' +
'</div>' +
'</t></templates>' +
'</kanban>',
groupBy: ['bar'],
intercepts: {
env_updated: function (ev) {
assert.step(ev.data.ids);
},
},
});
// archive all records of the first column
kanban.$('.o_kanban_group:first .o_column_archive').click();
assert.verifySteps([
[1, 2, 3, 4],
[1, 2, 3],
]);
kanban.destroy();
});
QUnit.test('RPCs when (re)loading kanban view progressbars', function (assert) {
assert.expect(9);
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch:
'<kanban>' +
'<field name="bar"/>' +
'<field name="int_field"/>' +
'<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' +
'<templates><t t-name="kanban-box">' +
'<div>' +
'<field name="name"/>' +
'</div>' +
'</t></templates>' +
'</kanban>',
groupBy: ['bar'],
mockRPC: function (route, args) {
assert.step(args.method || route);
return this._super.apply(this, arguments);
},
});
kanban.reload();
assert.verifySteps([
// initial load
'read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
'read_progress_bar',
// reload
'read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
'read_progress_bar',
]);
kanban.destroy();
});
QUnit.test('drag & drop records grouped by m2o with progressbar', function (assert) {
assert.expect(4);
this.data.partner.records[0].product_id = false;
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch:
'<kanban>' +
'<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' +
'<templates><t t-name="kanban-box">' +
'<div>' +
'<field name="int_field"/>' +
'</div>' +
'</t></templates>' +
'</kanban>',
groupBy: ['product_id'],
mockRPC: function (route, args) {
if (route === '/web/dataset/resequence') {
return $.when(true);
}
return this._super(route, args);
},
});
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_counter_side').text(), "1",
"counter should contain the correct value");
testUtils.dragAndDrop(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)'), kanban.$('.o_kanban_group:eq(1)'));
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_counter_side').text(), "0",
"counter should contain the correct value");
testUtils.dragAndDrop(kanban.$('.o_kanban_group:eq(1) .o_kanban_record:eq(2)'), kanban.$('.o_kanban_group:eq(0)'));
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_counter_side').text(), "1",
"counter should contain the correct value");
testUtils.dragAndDrop(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:eq(0)'), kanban.$('.o_kanban_group:eq(1)'));
assert.strictEqual(kanban.$('.o_kanban_group:eq(0) .o_kanban_counter_side').text(), "0",
"counter should contain the correct value");
kanban.destroy();
});
QUnit.test('keep adding quickcreate in first column after a record from this column was moved', function (assert) { QUnit.test('keep adding quickcreate in first column after a record from this column was moved', function (assert) {
assert.expect(2); assert.expect(2);

View File

@ -3310,7 +3310,6 @@ QUnit.module('Views', {
list.destroy(); list.destroy();
}); });
QUnit.test('basic support for widgets', function (assert) { QUnit.test('basic support for widgets', function (assert) {
assert.expect(1); assert.expect(1);
@ -3416,6 +3415,46 @@ QUnit.module('Views', {
testUtils.unpatch(mixins.ParentedMixin); testUtils.unpatch(mixins.ParentedMixin);
}); });
QUnit.test('concurrent reloads finishing in inverse order', function (assert) {
assert.expect(3);
var blockSearchRead = false;
var def = $.Deferred();
var list = createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree><field name="foo"/></tree>',
mockRPC: function (route) {
var result = this._super.apply(this, arguments);
if (route === '/web/dataset/search_read' && blockSearchRead) {
return $.when(def).then(_.constant(result));
}
return result;
},
});
assert.strictEqual(list.$('.o_list_view .o_data_row').length, 4,
"list view should contain 4 records");
// reload with a domain (this request is blocked)
blockSearchRead = true;
list.reload({domain: [['foo', '=', 'yop']]});
assert.strictEqual(list.$('.o_list_view .o_data_row').length, 4,
"list view should still contain 4 records (search_read being blocked)");
// reload without the domain
blockSearchRead = false;
list.reload({domain: []});
// unblock the RPC
def.resolve();
assert.strictEqual(list.$('.o_list_view .o_data_row').length, 4,
"list view should still contain 4 records");
list.destroy();
});
}); });
}); });

View File

@ -960,4 +960,78 @@ QUnit.module('Views', {
pivot.destroy(); pivot.destroy();
}); });
QUnit.test('Row and column groupbys plus a domain', function (assert) {
assert.expect(3);
var pivot = createView({
View: PivotView,
model: "partner",
data: this.data,
arch: '<pivot>' +
'<field name="foo" type="measure"/>' +
'</pivot>',
});
// Set a column groupby
pivot.$('thead .o_pivot_header_cell_closed').click();
pivot.$('.o_field_selection li[data-field=customer] a').click();
// Set a Row groupby
pivot.$('tbody .o_pivot_header_cell_closed').click();
pivot.$('.o_pivot_field_menu li[data-field=product_id] a').click();
// Set a domain
pivot.update({domain: [['product_id', '=', 41]]});
var expectedContext = {pivot_column_groupby: ['customer'],
pivot_measures: ['foo'],
pivot_row_groupby: ['product_id']};
// Mock 'save as favorite'
assert.deepEqual(pivot.getContext(), expectedContext,
'The pivot view should have the right context');
var $xpadHeader = pivot.$('tbody .o_pivot_header_cell_closed[data-original-title=Product]');
assert.equal($xpadHeader.length, 1,
'There should be only one product line because of the domain');
assert.equal($xpadHeader.text(), 'xpad',
'The product should be the right one');
pivot.destroy();
});
QUnit.test('parallel data loading should discard all but the last one', function (assert) {
assert.expect(2);
var def;
var pivot = createView({
View: PivotView,
model: "partner",
data: this.data,
arch: '<pivot>' +
'<field name="foo" type="measure"/>' +
'</pivot>',
mockRPC: function (route, args) {
var result = this._super.apply(this, arguments);
if (args.method === 'read_group') {
return $.when(def).then(_.constant(result));
}
return result;
},
});
def = $.Deferred();
pivot.update({groupBy: ['product_id']});
pivot.update({groupBy: ['product_id', 'customer']});
def.resolve();
assert.strictEqual(pivot.$('.o_pivot_cell_value').length, 6,
"should have 6 cells");
assert.strictEqual(pivot.$('tbody tr').length, 6,
"should have 6 rows");
pivot.destroy();
});
});}); });});

View File

@ -33,6 +33,6 @@ class WebSuite(flectra.tests.HttpCase):
filename = asset['filename'] filename = asset['filename']
if not filename or asset['atype'] != 'text/javascript': if not filename or asset['atype'] != 'text/javascript':
continue continue
with open(filename, 'r') as fp: with open(filename, 'rb') as fp:
if RE_ONLY.search(fp.read()): if RE_ONLY.search(fp.read().decode('utf-8')):
self.fail("`QUnit.only()` used in file %r" % asset['url']) self.fail("`QUnit.only()` used in file %r" % asset['url'])

View File

@ -67,6 +67,12 @@
<t t-call-assets="web_editor.summernote" t-css="false"/> <t t-call-assets="web_editor.summernote" t-css="false"/>
<t t-call-assets="web_editor.assets_editor" t-css="false"/> <t t-call-assets="web_editor.assets_editor" t-css="false"/>
<t t-call-assets="web.report_assets_editor" t-css="false"/> <t t-call-assets="web.report_assets_editor" t-css="false"/>
<script type="text/javascript" src="/web/static/src/js/services/session.js"></script>
<script type="text/javascript" src="/web_editor/static/src/js/content/body_manager.js"/>
<script type="text/javascript" src="/web_editor/static/src/js/root_widget.js"/>
<script type="text/javascript" src="/web_editor/static/src/js/iframe.js"></script>
<script t-if="enable_editor and inline_mode" type="text/javascript" src="/web_editor/static/src/js/inline.js"></script>
</t> </t>
</head> </head>
<body class="container"> <body class="container">

View File

@ -400,7 +400,7 @@
<t t-if="not disable_database_manager"> <t t-if="not disable_database_manager">
<a class="" href="/web/database/manager">Manage Databases</a> <!--|--> <a class="" href="/web/database/manager">Manage Databases</a> <!--|-->
</t> </t>
<!-- <a href="https://www.flectra.com" target="_blank">Powered by <span>Flectra</span></a> --> <!--<a href="https://www.flectra.com" target="_blank">Powered by <span>Flectra</span></a>-->
</div> </div>
</div> </div>
</div> </div>
@ -813,7 +813,7 @@
</div> </div>
</nav> </nav>
<div class="o_main"> <div class="o_main">
<div class="f_launcher"> <div class="f_launcher" groups="base.group_user,base.group_portal">
<t t-call="web.menu_launcher"/> <t t-call="web.menu_launcher"/>
</div> </div>
<div class="o_main_content"/> <div class="o_main_content"/>

View File

@ -160,7 +160,7 @@ class Web_Editor(http.Controller):
'name': name, 'name': name,
'type': 'url', 'type': 'url',
'url': url, 'url': url,
'public': True, 'public': res_model == 'ir.ui.view',
'res_id': res_id, 'res_id': res_id,
'res_model': res_model, 'res_model': res_model,
}) })
@ -187,7 +187,7 @@ class Web_Editor(http.Controller):
'name': c_file.filename, 'name': c_file.filename,
'datas': base64.b64encode(data), 'datas': base64.b64encode(data),
'datas_fname': c_file.filename, 'datas_fname': c_file.filename,
'public': True, 'public': res_model == 'ir.ui.view',
'res_id': res_id, 'res_id': res_id,
'res_model': res_model, 'res_model': res_model,
}) })
@ -244,7 +244,7 @@ class Web_Editor(http.Controller):
## @param bundles - True if the bundles views must be fetched (default to False) ## @param bundles - True if the bundles views must be fetched (default to False)
## @param bundles_restriction - Names of the bundle in which to look for less files (if empty, search in all of them) ## @param bundles_restriction - Names of the bundle in which to look for less files (if empty, search in all of them)
## @returns a dictionary with views info in the views key and style info in the less key ## @returns a dictionary with views info in the views key and style info in the less key
@http.route("/web_editor/get_assets_editor_resources", type="json", auth="user") @http.route("/web_editor/get_assets_editor_resources", type="json", auth="user", website=True)
def get_assets_editor_resources(self, key, get_views=True, get_less=True, bundles=False, bundles_restriction=[]): def get_assets_editor_resources(self, key, get_views=True, get_less=True, bundles=False, bundles_restriction=[]):
# Related views must be fetched if the user wants the views and/or the style # Related views must be fetched if the user wants the views and/or the style
views = request.env["ir.ui.view"].get_related_views(key, bundles=bundles) views = request.env["ir.ui.view"].get_related_views(key, bundles=bundles)
@ -361,16 +361,17 @@ class Web_Editor(http.Controller):
# Check if the file to save had already been modified # Check if the file to save had already been modified
custom_attachment = IrAttachment.search([("url", "=", custom_url)]) custom_attachment = IrAttachment.search([("url", "=", custom_url)])
datas = base64.b64encode((content or "\n").encode("utf-8"))
if custom_attachment: if custom_attachment:
# If it was already modified, simply override the corresponding attachment content # If it was already modified, simply override the corresponding attachment content
custom_attachment.write({"datas": base64.b64encode(content.encode("utf-8"))}) custom_attachment.write({"datas": datas})
else: else:
# If not, create a new attachment to copy the original LESS file content, with its modifications # If not, create a new attachment to copy the original LESS file content, with its modifications
IrAttachment.create(dict( IrAttachment.create(dict(
name = custom_url, name = custom_url,
type = "binary", type = "binary",
mimetype = "text/less", mimetype = "text/less",
datas = base64.b64encode(content.encode("utf-8")), datas = datas,
datas_fname = url.split("/")[-1], datas_fname = url.split("/")[-1],
url = custom_url, # Having an attachment of "binary" type with an non empty "url" field url = custom_url, # Having an attachment of "binary" type with an non empty "url" field
# is quite of an hack. This allows to fetch the "datas" field by adding # is quite of an hack. This allows to fetch the "datas" field by adding

View File

@ -3,6 +3,7 @@
from flectra import models from flectra import models
from flectra.http import request from flectra.http import request
from flectra.osv import expression
class IrHttp(models.AbstractModel): class IrHttp(models.AbstractModel):
@ -19,3 +20,8 @@ class IrHttp(models.AbstractModel):
context['translatable'] = True context['translatable'] = True
request.context = context request.context = context
return super(IrHttp, cls)._dispatch() return super(IrHttp, cls)._dispatch()
@classmethod
def _get_translation_frontend_modules_domain(cls):
domain = super(IrHttp, cls)._get_translation_frontend_modules_domain()
return expression.OR([domain, [('name', '=', 'web_editor')]])

View File

@ -173,6 +173,7 @@ class Contact(models.AbstractModel):
@api.model @api.model
def attributes(self, record, field_name, options, values): def attributes(self, record, field_name, options, values):
attrs = super(Contact, self).attributes(record, field_name, options, values) attrs = super(Contact, self).attributes(record, field_name, options, values)
options.pop('template_options') # remove options not specific to this widget
attrs['data-oe-contact-options'] = json.dumps(options) attrs['data-oe-contact-options'] = json.dumps(options)
return attrs return attrs

View File

@ -472,7 +472,8 @@ define([
onImageUpload: options.onImageUpload, onImageUpload: options.onImageUpload,
onImageUploadError: options.onImageUploadError, onImageUploadError: options.onImageUploadError,
onMediaDelete: options.onMediaDelete, onMediaDelete: options.onMediaDelete,
onToolbarClick: options.onToolbarClick onToolbarClick: options.onToolbarClick,
onUpload: options.onUpload,
}); });
var styleInfo = modules.editor.styleFromNode(layoutInfo.editable()); var styleInfo = modules.editor.styleFromNode(layoutInfo.editable());

View File

@ -1,256 +1,41 @@
define([ define([
'summernote/core/list', 'summernote/core/list',
'summernote/core/dom', 'summernote/core/dom',
'summernote/core/key', 'summernote/core/key',
'summernote/core/agent', 'summernote/core/agent',
'summernote/core/range' 'summernote/core/range'
], function (list, dom, key, agent, range) { ], function (list, dom, key, agent, range) {
var Clipboard = function (handler) { // FLECTRA override: use 0.8.10 version of this, adapted for the old summernote
var $paste; // version flectra is using
var Clipboard = function (handler) {
/**
* paste by clipboard event
*
* @param {Event} event
*/
var pasteByEvent = function (event) {
if (["INPUT", "TEXTAREA"].indexOf(event.target.tagName) !== -1) {
// ODOO override: from old summernote version
return;
}
this.attach = function (layoutInfo) { var clipboardData = event.originalEvent.clipboardData;
// [workaround] getting image from clipboard var layoutInfo = dom.makeLayoutInfo(event.currentTarget || event.target);
// - IE11 and Firefox: CTRL+v hook var $editable = layoutInfo.editable();
// - Webkit: event.clipboardData
if (agent.isMSIE && agent.browserVersion > 10) {
$paste = $('<div />').attr('contenteditable', true).css({
position : 'absolute',
left : -100000,
opacity : 0
});
layoutInfo.editable().on('keydown', function (e) { if (clipboardData && clipboardData.items && clipboardData.items.length) {
if (e.ctrlKey && e.keyCode === key.code.V) { var item = list.head(clipboardData.items);
handler.invoke('saveRange', layoutInfo.editable()); if (item.kind === 'file' && item.type.indexOf('image/') !== -1) {
$paste.focus(); handler.insertImages(layoutInfo, [item.getAsFile()]);
}
handler.invoke('editor.afterCommand', $editable);
}
};
setTimeout(function () { this.attach = function (layoutInfo) {
pasteByHook(layoutInfo); layoutInfo.editable().on('paste', pasteByEvent);
}, 0); };
}
});
layoutInfo.editable().before($paste);
} else {
layoutInfo.editable().on('paste', pasteByEvent);
}
}; };
var pasteByHook = function (layoutInfo) { return Clipboard;
var $editable = layoutInfo.editable();
var node = $paste[0].firstChild;
if (dom.isImg(node)) {
var dataURI = node.src;
var decodedData = atob(dataURI.split(',')[1]);
var array = new Uint8Array(decodedData.length);
for (var i = 0; i < decodedData.length; i++) {
array[i] = decodedData.charCodeAt(i);
}
var blob = new Blob([array], { type : 'image/png' });
blob.name = 'clipboard.png';
handler.invoke('restoreRange', $editable);
handler.invoke('focus', $editable);
handler.insertImages(layoutInfo, [blob]);
} else {
var pasteContent = $('<div />').html($paste.html()).html();
handler.invoke('restoreRange', $editable);
handler.invoke('focus', $editable);
if (pasteContent) {
handler.invoke('pasteHTML', $editable, pasteContent);
}
}
$paste.empty();
};
/**
* paste by clipboard event
*
* @param {Event} event
*/
var pasteByEvent = function (event) {
var clipboardData = event.originalEvent.clipboardData;
var layoutInfo = dom.makeLayoutInfo(event.currentTarget || event.target);
var $editable = layoutInfo.editable();
if (["INPUT", "TEXTAREA"].indexOf(event.target.tagName) !== -1) {
return;
}
if (clipboardData && clipboardData.items && clipboardData.items.length) {
var item = list.head(clipboardData.items);
if (item.kind === 'file' && item.type.indexOf('image/') !== -1) {
handler.insertImages(layoutInfo, [item.getAsFile()]);
}
handler.invoke('editor.afterCommand', $editable);
}
event.preventDefault();
var html = clipboardData.getData("text/html");
var $node = $('<div/>').html(html);
// if copying source did not provide html, default to plain text
if(!html) {
$node.text(clipboardData.getData("text/plain")).html(function(_, html){
return html.replace(/\r?\n/g,'<br>');
});
}
pasteContent($node, layoutInfo, $editable);
};
/*
remove undesirable tag
filter classes and style attributes
remove undesirable attributes
*/
var filter_tag = function ($nodes, $editable) {
return $nodes.each(function() {
var $node = $(this);
if ($node.attr('style')) {
var style = _.filter(_.compact($node.attr('style').split(/\s*;\s*/)), function (style) {
style = style.split(/\s*:\s*/);
return /width|height|color|background-color|font-weight|text-align|font-style|text-decoration/i.test(style[0]) &&
!(style[1] === 'initial' || style[1] === 'inherit' || $node.css(style[0]) === $editable.css(style[0]) ||
(style[0] === 'background-color' && style[1] === 'rgb(255, 255, 255)') ||
(style[0] === 'color' && style[1] === 'rgb(0, 0, 0)'));
}).join(';');
if (style.length) {
$node.attr('style', style);
} else {
$node.removeAttr('style');
}
}
if ($node.attr('class')) {
var classes = _.filter($node.attr('class').split(/\s+/), function (style) {
return /(^|\s)(fa|pull|text|bg)(\s|-|$)/.test(style);
}).join(' ');
if (classes.length) {
$node.attr('class', classes);
} else {
$node.removeAttr('class');
}
}
});
};
var pasteContent = function ($node, layoutInfo, $editable) {
$node.find('meta, script, style').remove();
filter_tag($node.find('*'), $editable).removeAttr('title', 'alt', 'id', 'contenteditable');
/*
remove unless span and unwant font
*/
$node.find('span, font').filter(':not([class]):not([style])').each(function () {
$(this).replaceWith($(this).contents());
});
$node.find('span + span').each(function () {
if (dom.isText(this.previousSibling)) {
if (dom.isVisibleText(this.previousSibling)) {
return;
} else { // keep space between 2 tags, but can merge the both tags
$(this).prev().append(this.previousSibling);
}
}
if ($(this).attr('class') === $(this).prev().attr('class') && $(this).attr('style') === $(this).prev().attr('style')) {
$(this).prev().append($(this).contents());
$(this).remove();
}
});
// remove empty table row and td
var $tdr;
while(($tdr = $node.find('tr:empty, td:empty, th:empty, tbody:empty, t-head:empty, table:empty')) && $tdr.length) {
$tdr.remove();
}
/*
reset architecture HTML node and add <p> tag
*/
var $arch = $('<div/>');
var $last = $arch;
$node.contents().each(function () {
if (dom.isBR(this)) {
$(this).remove();
$last = $('<p/>');
$arch.append($last);
} else if (/h[0-9]+|li|table|p/i.test(this.tagName)) {
$last = $('<p/>');
$arch.append(this).append($last);
} else if ($arch.is(':empty') && dom.isText(this)) {
$last = $('<p/>').append(this);
$arch.append($last);
} else if (this.nodeType !== Node.COMMENT_NODE) {
$last.append(this);
}
});
$arch.find(':not([class]):not([style]):empty, p:empty').remove();
/*
history
*/
$editable.data('NoteHistory').recordUndo($editable, "paste");
/*
remove selected content
*/
var r = range.create();
if (!r.isCollapsed()) {
r = r.deleteContents();
r.select();
}
// If only pasting a <p/> element in an unique <p/> element, only paste
// the <p/> element text
var $p = $arch.children('p');
var onlyAP = ($p.length === 1 && $arch.children().length === 1);
if (onlyAP) {
var $p1 = $(r.sc).closest('p');
var $p2 = $(r.ec).closest('p');
if ($p1.length && $p2.length && $p1[0] === $p2[0]) {
$arch.html($p.text());
}
}
/*
insert content
*/
var $nodes = $();
$editable.on('DOMNodeInserted', function (event) {
$nodes = $nodes.add(event.originalEvent.target);
});
window.document.execCommand('insertHTML', false, $arch.html());
$editable.off('DOMNodeInserted');
/*
clean insterted content
*/
var $span = $nodes.filter('span');
$span = $span.first().add($span.last());
$span = $span.add($span.prev('span'));
$span = $span.add($span.next('span'));
filter_tag($span, $editable);
$span.not('[span], [style]').each(function () {
_.each(this.childNodes, function (node) {
$(node.parentNode).after(node);
});
$(this).remove();
});
r = range.create();
if (!dom.isText(r.ec)) {
r = range.create(r.sc.childNodes[r.so], dom.nodeLength(r.sc.childNodes[r.so]));
}
r.clean().select();
$editable.trigger('content_changed');
};
};
return Clipboard;
}); });

View File

@ -628,9 +628,9 @@ define([
} }
var anchors = []; var anchors = [];
// FLECTRA: adding this branch to modify existing anchor // FLECTRA: adding this branch to modify existing anchor if it fully contains the range
var ancestor_anchor = dom.ancestor(rng.sc, dom.isAnchor); var ancestor_anchor = dom.ancestor(rng.sc, dom.isAnchor);
if(ancestor_anchor) { if(ancestor_anchor && ancestor_anchor === dom.ancestor(rng.ec, dom.isAnchor)) {
anchors.push($(ancestor_anchor).html(linkText).get(0)); anchors.push($(ancestor_anchor).html(linkText).get(0));
} else if (isTextChanged) { } else if (isTextChanged) {
// Create a new link when text changed. // Create a new link when text changed.

View File

@ -83,12 +83,50 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi
// Private // Private
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
/**
* Returns the domain for attachments used in media dialog.
* We look for attachments related to the current document. If there is a value for the model
* field, it is used to search attachments, and the attachments from the current document are
* filtered to display only user-created documents.
* In the case of a wizard such as mail, we have the documents uploaded and those of the model
*
* @private
* @returns {Array} "ir.attachment" odoo domain.
*/
_getAttachmentsDomain: function () {
var domain = ['|', ['id', 'in', _.pluck(this.attachments, 'id')]];
var attachedDocumentDomain = [
'&',
['res_model', '=', this.model],
['res_id', '=', this.res_id|0]
];
// if the document is not yet created, do not see the documents of other users
if (!this.res_id) {
attachedDocumentDomain.unshift('&');
attachedDocumentDomain.push(['create_uid', '=', session.uid]);
}
if (this.recordData.model) {
var relatedDomain = ['&',
['res_model', '=', this.recordData.model],
['res_id', '=', this.recordData.res_id|0]];
if (!this.recordData.res_id) {
relatedDomain.unshift('&');
relatedDomain.push(['create_uid', '=', session.uid]);
}
domain = domain.concat(['|'], attachedDocumentDomain, relatedDomain);
} else {
domain = domain.concat(attachedDocumentDomain);
}
return domain;
},
/** /**
* @private * @private
* @returns {Object} the summernote configuration * @returns {Object} the summernote configuration
*/ */
_getSummernoteConfig: function () { _getSummernoteConfig: function () {
var summernoteConfig = { var summernoteConfig = {
model: this.model,
id: this.res_id,
focus: false, focus: false,
height: 180, height: 180,
toolbar: [ toolbar: [
@ -107,6 +145,22 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi
lang: "flectra", lang: "flectra",
onChange: this._doDebouncedAction.bind(this), onChange: this._doDebouncedAction.bind(this),
}; };
var fieldNameAttachment =_.chain(this.recordData)
.pairs()
.find(function (value) {
return _.isObject(value[1]) && value[1].model === "ir.attachment";
})
.first()
.value();
if (fieldNameAttachment) {
this.fieldNameAttachment = fieldNameAttachment;
this.attachments = [];
summernoteConfig.onUpload = this._onUpload.bind(this);
summernoteConfig.getMediaDomain = this._getAttachmentsDomain.bind(this);
}
if (config.debug) { if (config.debug) {
summernoteConfig.toolbar.splice(7, 0, ['view', ['codeview']]); summernoteConfig.toolbar.splice(7, 0, ['view', ['codeview']]);
} }
@ -124,6 +178,34 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi
} }
return this.$content.html(); return this.$content.html();
}, },
/**
* trigger_up 'field_changed' add record into the "ir.attachment" field found in the view.
* This method is called when an image is uploaded by the media dialog.
*
* For e.g. when sending email, this allows people to add attachments with the content
* editor interface and that they appear in the attachment list.
* The new documents being attached to the email, they will not be erased by the CRON
* when closing the wizard.
*
* @private
*/
_onUpload: function (attachments) {
var self = this;
attachments = _.filter(attachments, function (attachment) {
return !_.findWhere(self.attachments, {id: attachment.id});
});
if (!attachments.length) {
return;
}
this.attachments = this.attachments.concat(attachments);
this.trigger_up('field_changed', {
dataPointID: this.dataPointID,
changes: _.object([this.fieldNameAttachment], [{
operation: 'ADD_M2M',
ids: attachments
}])
});
},
/** /**
* @override * @override
* @private * @private
@ -134,8 +216,6 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi
this.$textarea.summernote(this._getSummernoteConfig()); this.$textarea.summernote(this._getSummernoteConfig());
this.$content = this.$('.note-editable:first'); this.$content = this.$('.note-editable:first');
this.$content.html(this._textToHtml(this.value)); this.$content.html(this._textToHtml(this.value));
this.$content.data('oe-id', this.recordData.res_id || this.res_id);
this.$content.data('oe-model', this.recordData.model || this.model);
// trigger a mouseup to refresh the editor toolbar // trigger a mouseup to refresh the editor toolbar
this.$content.trigger('mouseup'); this.$content.trigger('mouseup');
if (this.nodeOptions['style-inline']) { if (this.nodeOptions['style-inline']) {

View File

@ -47,8 +47,8 @@ var EditorMenuBar = Widget.extend({
var self = this; var self = this;
var defs = [this._super.apply(this, arguments)]; var defs = [this._super.apply(this, arguments)];
core.bus.on('editor_save_request', this, this._onSaveRequest); core.bus.on('editor_save_request', this, this.save);
core.bus.on('editor_discard_request', this, this._onDiscardRequest); core.bus.on('editor_discard_request', this, this.cancel);
$('.dropdown-toggle').dropdown(); $('.dropdown-toggle').dropdown();

View File

@ -281,7 +281,7 @@ var RTEWidget = Widget.extend({
.each(function () { .each(function () {
var $node = $(this); var $node = $(this);
// fallback for firefox iframe display:none see // fallback for firefox iframe display:none see https://github.com/odoo/odoo/pull/22610
var computedStyles = window.getComputedStyle(this) || window.parent.getComputedStyle(this); var computedStyles = window.getComputedStyle(this) || window.parent.getComputedStyle(this);
// add class to display inline-block for empty t-field // add class to display inline-block for empty t-field
if (computedStyles.display === 'inline' && $node.data('oe-type') !== 'image') { if (computedStyles.display === 'inline' && $node.data('oe-type') !== 'image') {
@ -290,9 +290,12 @@ var RTEWidget = Widget.extend({
}); });
// start element observation // start element observation
$(document).on('content_changed', '.o_editable', function (event) { $(document).on('content_changed', '.o_editable', function (ev) {
self.trigger_up('rte_change', {target: event.target}); self.trigger_up('rte_change', {target: ev.target});
$(this).addClass('o_dirty'); if (!ev.__isDirtyHandled) {
$(this).addClass('o_dirty');
ev.__isDirtyHandled = true;
}
}); });
$('#wrapwrap, .o_editable').on('click.rte', '*', this, this._onClick.bind(this)); $('#wrapwrap, .o_editable').on('click.rte', '*', this, this._onClick.bind(this));
@ -376,10 +379,11 @@ var RTEWidget = Widget.extend({
* @param {boolean} internal_history * @param {boolean} internal_history
*/ */
historyRecordUndo: function ($target, event, internal_history) { historyRecordUndo: function ($target, event, internal_history) {
$target = $($target);
var rng = range.create(); var rng = range.create();
var $editable = $(rng && rng.sc).closest('.o_editable'); var $editable = $(rng && rng.sc).closest('.o_editable');
if (!rng || !$editable.length) { if (!rng || !$editable.length) {
$editable = $($target).closest('.o_editable'); $editable = $target.closest('.o_editable');
rng = range.create($target.closest('*')[0],0); rng = range.create($target.closest('*')[0],0);
} else { } else {
rng = $editable.data('range') || rng; rng = $editable.data('range') || rng;
@ -407,6 +411,10 @@ var RTEWidget = Widget.extend({
save: function (context) { save: function (context) {
var self = this; var self = this;
$('.o_editable')
.destroy()
.removeClass('o_editable o_is_inline_editable');
var $dirty = $('.o_dirty'); var $dirty = $('.o_dirty');
$dirty $dirty
.removeAttr('contentEditable') .removeAttr('contentEditable')

View File

@ -394,6 +394,9 @@ eventHandler.modules.imageDialog.showImageDialog = function ($editable) {
core.bus.trigger('media_dialog_demand', { core.bus.trigger('media_dialog_demand', {
$editable: $editable, $editable: $editable,
media: media, media: media,
options : {
onUpload: $editable.data('callbacks').onUpload,
},
}); });
return new $.Deferred().reject(); return new $.Deferred().reject();
}; };
@ -469,7 +472,7 @@ function prettify_html(html) {
while (i--) space += ' '; while (i--) space += ' ';
return space; return space;
}, },
reg = /^<\/?(a|span|font|strong|u|i|strong|b)(\s|>)/i, reg = /^<\/?(a|span|font|u|em|i|strong|b)(\s|>)/i,
inline_level = Infinity, inline_level = Infinity,
tokens = _.compact(_.flatten(_.map(html.split(/</), function (value) { tokens = _.compact(_.flatten(_.map(html.split(/</), function (value) {
value = value.replace(/\s+/g, ' ').split(/>/); value = value.replace(/\s+/g, ' ').split(/>/);
@ -660,9 +663,17 @@ function summernote_mousedown(event) {
} }
// restore range if range lost after clicking on non-editable area // restore range if range lost after clicking on non-editable area
r = range.create(); try {
r = range.create();
} catch (e) {
// If this code is running inside an iframe-editor and that the range
// is outside of this iframe, this will fail as the iframe does not have
// the permission to check the outside content this way. In that case,
// we simply ignore the exception as it is as if there was no range.
return;
}
var editables = $(".o_editable[contenteditable], .note-editable[contenteditable]"); var editables = $(".o_editable[contenteditable], .note-editable[contenteditable]");
var r_editable = editables.has((r||{}).sc); var r_editable = editables.has((r||{}).sc).addBack(editables.filter((r||{}).sc));
if (!r_editable.closest('.note-editor').is($editable) && !r_editable.filter('.o_editable').is(editables)) { if (!r_editable.closest('.note-editor').is($editable) && !r_editable.filter('.o_editable').is(editables)) {
var saved_editable = editables.has((remember_selection||{}).sc); var saved_editable = editables.has((remember_selection||{}).sc);
if ($editable.length && !saved_editable.closest('.o_editable, .note-editor').is($editable)) { if ($editable.length && !saved_editable.closest('.o_editable, .note-editor').is($editable)) {
@ -784,6 +795,14 @@ eventHandler.attach = function (oLayoutInfo, options) {
$(document).on("keyup", reRangeSelectKey); $(document).on("keyup", reRangeSelectKey);
var clone_data = false; var clone_data = false;
if (options.model) {
oLayoutInfo.editable().data({'oe-model': options.model, 'oe-id': options.id});
}
if (options.getMediaDomain) {
oLayoutInfo.editable().data('oe-media-domain', options.getMediaDomain);
}
var $node = oLayoutInfo.editor(); var $node = oLayoutInfo.editor();
if ($node.data('oe-model') || $node.data('oe-translation-id')) { if ($node.data('oe-model') || $node.data('oe-translation-id')) {
$node.on('content_changed', function () { $node.on('content_changed', function () {
@ -1085,6 +1104,7 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, {
_.extend({ _.extend({
res_model: data.$editable.data('oe-model'), res_model: data.$editable.data('oe-model'),
res_id: data.$editable.data('oe-id'), res_id: data.$editable.data('oe-id'),
domain: data.$editable.data('oe-media-domain'),
}, data.options), }, data.options),
data.$editable, data.$editable,
data.media data.media

View File

@ -1800,12 +1800,17 @@ $.summernote.pluginEvents.indent = function (event, editor, layoutInfo, outdent)
var $dom = $(ancestor); var $dom = $(ancestor);
if (!dom.isList(ancestor)) { if (!dom.isList(ancestor)) {
// to indent a selection, we indent the child nodes of the common
// ancestor that contains this selection
$dom = $(dom.node(ancestor)).children(); $dom = $(dom.node(ancestor)).children();
} }
if (!$dom.length) { if (!$dom.not('br').length) {
$dom = $(dom.ancestor(r.sc, dom.isList) || dom.ancestor(r.sc, dom.isCell)); // if selection is inside a list, we indent its list items
$dom = $(dom.ancestor(r.sc, dom.isList));
if (!$dom.length) { if (!$dom.length) {
$dom = $(r.sc).closest(options.styleTags.join(',')); // if the selection is contained in a single HTML node, we indent
// the first ancestor 'content block' (P, H1, PRE, ...) or TD
$dom = $(r.sc).closest(options.styleTags.join(',')+',td');
} }
} }
@ -1970,7 +1975,13 @@ eventHandler.modules.toolbar.button.updateRecentColor = function (elBtn, sEvent,
}; };
$(document).on('click keyup', function () { $(document).on('click keyup', function () {
var $popover = $((range.create()||{}).sc).closest('[contenteditable]'); var current_range = {};
try {
current_range = range.create() || {};
} catch (e) {
// if range is on Restricted element ignore error
}
var $popover = $(current_range.sc).closest('[contenteditable]');
var popover_history = ($popover.data()||{}).NoteHistory; var popover_history = ($popover.data()||{}).NoteHistory;
if (!popover_history || popover_history === history) return; if (!popover_history || popover_history === history) return;
var editor = $popover.parent('.note-editor'); var editor = $popover.parent('.note-editor');
@ -2257,7 +2268,7 @@ $.summernote.pluginEvents.backColor = function (event, editor, layoutInfo, backC
}; };
options.onCreateLink = function (sLinkUrl) { options.onCreateLink = function (sLinkUrl) {
if (sLinkUrl.indexOf('mailto:') === 0) { if (sLinkUrl.indexOf('mailto:') === 0 || sLinkUrl.indexOf('tel:') === 0) {
// pass // pass
} else if (sLinkUrl.indexOf('@') !== -1 && sLinkUrl.indexOf(':') === -1) { } else if (sLinkUrl.indexOf('@') !== -1 && sLinkUrl.indexOf(':') === -1) {
sLinkUrl = 'mailto:' + sLinkUrl; sLinkUrl = 'mailto:' + sLinkUrl;

View File

@ -105,25 +105,52 @@ function getMatchedCSSRules(a) {
delete style.display; delete style.display;
} }
_.each(['margin', 'padding'], function (p) { // The css generates all the attributes separately and not in simplified form.
if (style[p+'-top'] || style[p+'-right'] || style[p+'-bottom'] || style[p+'-left']) { // In order to have a better compatibility (outlook for example) we simplify the css tags.
if (style[p+'-top'] === style[p+'-right'] && style[p+'-top'] === style[p+'-bottom'] && style[p+'-top'] === style[p+'-left']) { // e.g. border-left-style: none; border-bottom-s .... will be simplified in border-style = none
_.each([
{property: 'margin'},
{property: 'padding'},
{property: 'border', propertyEnd: '-style', defaultValue: 'none'},
], function (propertyInfo) {
var p = propertyInfo.property;
var e = propertyInfo.propertyEnd || '';
var defVal = propertyInfo.defaultValue || 0;
if (style[p+'-top'+e] || style[p+'-right'+e] || style[p+'-bottom'+e] || style[p+'-left'+e]) {
if (style[p+'-top'+e] === style[p+'-right'+e] && style[p+'-top'+e] === style[p+'-bottom'+e] && style[p+'-top'+e] === style[p+'-left'+e]) {
// keep => property: [top/right/bottom/left value]; // keep => property: [top/right/bottom/left value];
style[p] = style[p+'-top']; style[p+e] = style[p+'-top'+e];
} }
else { else {
// keep => property: [top value] [right value] [bottom value] [left value]; // keep => property: [top value] [right value] [bottom value] [left value];
style[p] = (style[p+'-top'] || 0) + ' ' + (style[p+'-right'] || 0) + ' ' + (style[p+'-bottom'] || 0) + ' ' + (style[p+'-left'] || 0); style[p+e] = (style[p+'-top'+e] || defVal) + ' ' + (style[p+'-right'+e] || defVal) + ' ' + (style[p+'-bottom'+e] || defVal) + ' ' + (style[p+'-left'+e] || defVal);
if (style[p].indexOf('inherit') !== -1 || style[p].indexOf('initial') !== -1) { if (style[p+e].indexOf('inherit') !== -1 || style[p+e].indexOf('initial') !== -1) {
// keep => property-top: [top value]; property-right: [right value]; property-bottom: [bottom value]; property-left: [left value]; // keep => property-top: [top value]; property-right: [right value]; property-bottom: [bottom value]; property-left: [left value];
delete style[p]; delete style[p+e];
return; return;
} }
} }
delete style[p+'-top']; delete style[p+'-top'+e];
delete style[p+'-right']; delete style[p+'-right'+e];
delete style[p+'-bottom']; delete style[p+'-bottom'+e];
delete style[p+'-left']; delete style[p+'-left'+e];
}
});
if (style['border-bottom-left-radius']) {
style['border-radius'] = style['border-bottom-left-radius'];
delete style['border-bottom-left-radius'];
delete style['border-bottom-right-radius'];
delete style['border-top-left-radius'];
delete style['border-top-right-radius'];
}
// if the border styling is initial we remove it to simplify the css tags for compatibility.
// Also, since we do not send a css style tag, the initial value of the border is useless.
_.each(_.keys(style), function (k) {
if (k.indexOf('border') !== -1 && style[k] === 'initial') {
delete style[k];
} }
}); });
@ -205,6 +232,26 @@ function imgToFont($editable) {
}); });
} }
/*
* Utility function to apply function over descendants elements
*
* This is needed until the following issue of jQuery is solved:
* https://github.com./jquery/sizzle/issues/403
*
* @param {Element} node The root Element node
* @param {Function} func The function applied over descendants
*/
function applyOverDescendants(node, func) {
node = node.firstChild;
while (node) {
if (node.nodeType === 1) {
func(node);
applyOverDescendants(node, func);
}
node = node.nextSibling;
}
}
/** /**
* Converts css style to inline style (leave the classes on elements but forces * Converts css style to inline style (leave the classes on elements but forces
* the style they give as inline style). * the style they give as inline style).
@ -215,9 +262,9 @@ function classToStyle($editable) {
if (!rulesCache.length) { if (!rulesCache.length) {
getMatchedCSSRules($editable[0]); getMatchedCSSRules($editable[0]);
} }
$editable.find('*').each(function () { applyOverDescendants($editable[0], function (node) {
var $target = $(this); var $target = $(node);
var css = getMatchedCSSRules(this); var css = getMatchedCSSRules(node);
var style = $target.attr('style') || ''; var style = $target.attr('style') || '';
_.each(css, function (v,k) { _.each(css, function (v,k) {
if (!(new RegExp('(^|;)\s*' + k).test(style))) { if (!(new RegExp('(^|;)\s*' + k).test(style))) {
@ -229,6 +276,34 @@ function classToStyle($editable) {
} else { } else {
$target.attr('style', style); $target.attr('style', style);
} }
// Apple Mail
if (node.nodeName === 'TD' && !node.childNodes.length) {
node.innerHTML = '&nbsp;';
}
// Outlook
if (node.nodeName === 'A' && $target.hasClass('btn') && !$target.hasClass('btn-link') && !$target.children().length) {
var $hack = $('<table class="o_outlook_hack" style="display: inline-table;"><tr><td></td></tr></table>');
$hack.find('td')
.attr('height', $target.outerHeight())
.css({
'text-align': $target.parent().css('text-align'),
'margin': $target.css('padding'),
'border-radius': $target.css('border-radius'),
'background-color': $target.css('background-color'),
});
$target.after($hack);
$target.appendTo($hack.find('td'));
// the space add a line when it's a table but it's invisible when it's a link
node = $hack[0].previousSibling;
if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) {
$(node).remove();
}
node = $hack[0].nextSibling;
if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) {
$(node).remove();
}
}
}); });
} }
@ -239,13 +314,18 @@ function classToStyle($editable) {
* @param {jQuery} $editable * @param {jQuery} $editable
*/ */
function styleToClass($editable) { function styleToClass($editable) {
// Outlook revert
$editable.find('table.o_outlook_hack').each(function () {
$(this).after($('a', this));
}).remove();
getMatchedCSSRules($editable[0]); getMatchedCSSRules($editable[0]);
var $c = $('<span/>').appendTo(document.body); var $c = $('<span/>').appendTo(document.body);
$editable.find('*').each(function () { applyOverDescendants($editable[0], function (node) {
var $target = $(this); var $target = $(node);
var css = getMatchedCSSRules(this); var css = getMatchedCSSRules(node);
var style = ''; var style = '';
_.each(css, function (v,k) { _.each(css, function (v,k) {
if (!(new RegExp('(^|;)\s*' + k).test(style))) { if (!(new RegExp('(^|;)\s*' + k).test(style))) {

View File

@ -178,7 +178,7 @@ tour.register('rte', {
content: "insert a link url", content: "insert a link url",
trigger: '#o_link_dialog_url_input', trigger: '#o_link_dialog_url_input',
extra_trigger: 'a#link-preview.btn', extra_trigger: 'a#link-preview.btn',
run: "text http://www.flectra.com", run: "text http://www.flectrahq.com",
}, { }, {
content: "change text label", content: "change text label",
trigger: '#o_link_dialog_label_input', trigger: '#o_link_dialog_label_input',

View File

@ -122,9 +122,11 @@ var ImageDialog = Widget.extend({
this._super.apply(this, arguments); this._super.apply(this, arguments);
this.options = options || {}; this.options = options || {};
this.accept = this.options.accept || this.options.document ? "*/*" : "image/*"; this.accept = this.options.accept || this.options.document ? "*/*" : "image/*";
if (this.options.res_id) { if (options.domain) {
this.domain = typeof options.domain === 'function' ? options.domain() : options.domain;
} else if (options.res_id) {
this.domain = ['|', this.domain = ['|',
'&', ['res_model', '=', this.options.res_model], ['res_id', '=', this.options.res_id], '&', ['res_model', '=', options.res_model], ['res_id', '=', options.res_id],
['res_model', '=', 'ir.ui.view']]; ['res_model', '=', 'ir.ui.view']];
} else { } else {
this.domain = [['res_model', '=', 'ir.ui.view']]; this.domain = [['res_model', '=', 'ir.ui.view']];
@ -188,8 +190,7 @@ var ImageDialog = Widget.extend({
var img = this.images[0]; var img = this.images[0];
if (!img) { if (!img) {
var id = this.$(".existing-attachments [data-src]:first").data('id'); return this.media;
img = _.find(this.images, function (img) { return img.id === id;});
} }
var def = $.when(); var def = $.when();
@ -239,6 +240,11 @@ var ImageDialog = Widget.extend({
var style = self.style; var style = self.style;
if (style) { $(self.media).css(style); } if (style) { $(self.media).css(style); }
if (self.options.onUpload) {
// We consider that when selecting an image it is as if we upload it in the html content.
self.options.onUpload([img]);
}
return self.media; return self.media;
}); });
}, },
@ -290,6 +296,10 @@ var ImageDialog = Widget.extend({
for (var i=0; i<attachments.length; i++) { for (var i=0; i<attachments.length; i++) {
self.file_selected(attachments[i], error); self.file_selected(attachments[i], error);
} }
if (self.options.onUpload) {
self.options.onUpload(attachments);
}
}; };
}, },
file_selection: function () { file_selection: function () {
@ -1185,6 +1195,8 @@ var LinkDialog = Dialog.extend({
if (dom.ancestor(nodes[i], dom.isImg)) { if (dom.ancestor(nodes[i], dom.isImg)) {
this.data.images.push(dom.ancestor(nodes[i], dom.isImg)); this.data.images.push(dom.ancestor(nodes[i], dom.isImg));
text += '[IMG]'; text += '[IMG]';
} else if (!is_link && nodes[i].nodeType === 1) {
// just use text nodes from listBetween
} else if (!is_link && i===0) { } else if (!is_link && i===0) {
text += nodes[i].textContent.slice(so, Infinity); text += nodes[i].textContent.slice(so, Infinity);
} else if (!is_link && i===nodes.length-1) { } else if (!is_link && i===nodes.length-1) {

View File

@ -5,13 +5,16 @@
font-family: inherit !important; font-family: inherit !important;
line-height: initial !important; line-height: initial !important;
color: initial !important; color: initial !important;
p { p, div {
font-family: 'Lucida Grande', Helvetica, Verdana, Arial, sans-serif; font-family: 'Lucida Grande', Helvetica, Verdana, Arial, sans-serif;
font-size: 13px; font-size: 13px;
} }
a, a:hover { a, a:hover {
color: initial; color: initial;
} }
ul > li > p {
margin: 0px;
}
} }
.o_readonly { .o_readonly {
min-height: 1em; min-height: 1em;
@ -20,6 +23,9 @@
padding: 0; padding: 0;
border: 0; border: 0;
word-wrap: break-word; word-wrap: break-word;
ul > li > p {
margin: 0px;
}
} }
} }
.oe_form_field_html iframe { .oe_form_field_html iframe {

View File

@ -110,7 +110,7 @@
<span class="text-muted">— or — </span> <span class="text-muted">— or — </span>
<label for="iamgeurl">Add an image URL</label> <label for="iamgeurl">Add an image URL</label>
<div class="form-group btn-group"> <div class="form-group btn-group">
<input class="form-control url pull-left" id="iamgeurl" name="url" placeholder="https://www.flectra.com/logo.png" style="width: 320px;" type="text"/> <input class="form-control url pull-left" id="iamgeurl" name="url" placeholder="https://www.flectrahq.com/logo.png" style="width: 320px;" type="text"/>
<button class="btn btn-default" type="submit">Add</button> <button class="btn btn-default" type="submit">Add</button>
</div> </div>
</div> </div>
@ -147,8 +147,9 @@
<div class="existing-attachments"> <div class="existing-attachments">
<div class="row mt16" t-as="row" t-foreach="rows"> <div class="row mt16" t-as="row" t-foreach="rows">
<div class="col-sm-2 o_existing_attachment_cell" t-as="attachment" t-foreach="row"> <div class="col-sm-2 o_existing_attachment_cell" t-as="attachment" t-foreach="row">
<i class="fa fa-times o_existing_attachment_remove" t-att-data-id="attachment.id"/> <i t-if="attachment.res_model === 'ir.ui.view'" class="fa fa-times o_existing_attachment_remove" title="This file is a public view attachment" t-att-data-id="attachment.id"/>
<div class="o_attachment_border"><div t-att-data-src="attachment.src" t-att-data-url="attachment.url" t-att-alt="attachment.name" t-att-title="attachment.name" t-att-data-id="attachment.id" t-att-data-mimetype="attachment.mimetype" class="o_image"/></div> <i t-else="" class="fa fa-times o_existing_attachment_remove" title="This file is attached to the current record" t-att-data-id="attachment.id"/>
<div class="o_attachment_border" t-att-style="attachment.res_model === 'ir.ui.view' ? null : 'border: 1px solid #5cb85c;'"><div t-att-data-src="attachment.src" t-att-data-url="attachment.url" t-att-alt="attachment.name" t-att-title="attachment.name" t-att-data-id="attachment.id" t-att-data-mimetype="attachment.mimetype" class="o_image"/></div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -211,7 +211,7 @@ QUnit.test('html_frame does not crash when saving in readonly', function (assert
if (_.str.startsWith(route, '/logo')) { if (_.str.startsWith(route, '/logo')) {
// manually call the callback to simulate that the iframe has // manually call the callback to simulate that the iframe has
// been loaded (note: just the content, not the editor) // been loaded (note: just the content, not the editor)
window.odoo[$.deparam(route).callback + '_content'].call(); window.flectra[$.deparam(route).callback + '_content'].call();
return $.when(); return $.when();
} }
return this._super.apply(this, arguments); return this._super.apply(this, arguments);
@ -248,7 +248,7 @@ QUnit.test('html_frame does not crash when saving in edit mode (editor not loade
if (_.str.startsWith(route, '/logo')) { if (_.str.startsWith(route, '/logo')) {
// manually call the callback to simulate that the iframe has // manually call the callback to simulate that the iframe has
// been partially loaded (just the content, not the editor) // been partially loaded (just the content, not the editor)
window.odoo[$.deparam(route).callback + '_content'](); window.flectra[$.deparam(route).callback + '_content']();
return $.when(); return $.when();
} }
return this._super.apply(this, arguments); return this._super.apply(this, arguments);

View File

@ -16,6 +16,7 @@ var PlannerLauncher = planner.PlannerLauncher.extend({
return this._rpc({ return this._rpc({
model: 'web.planner', model: 'web.planner',
method: 'search_read', method: 'search_read',
kwargs: {context: session.user_context},
}) })
.then(function (records) { .then(function (records) {
_.each(records, function (planner) { _.each(records, function (planner) {

View File

@ -43,10 +43,6 @@ var PlannerDialog = Dialog.extend({
e.preventDefault(); e.preventDefault();
this._display_page($(e.currentTarget).attr("href").replace("#", "")); this._display_page($(e.currentTarget).attr("href").replace("#", ""));
}, },
"click a[href^=\"#show_enterprise\"]": function (e) {
e.preventDefault();
this.show_enterprise();
},
}, },
init: function (parent, options, planner) { init: function (parent, options, planner) {
this._super.apply(this, arguments); this._super.apply(this, arguments);
@ -363,36 +359,6 @@ var PlannerDialog = Dialog.extend({
args: [this.planner.id, {'data': JSON.stringify(this.planner.data), 'progress': this.planner.progress}], args: [this.planner.id, {'data': JSON.stringify(this.planner.data), 'progress': this.planner.progress}],
}); });
}, },
show_enterprise: function () {
var buttons = [{
text: _t("Upgrade now"),
classes: 'btn-primary',
close: true,
click: function () {
rpc.query({
model: "res.users",
method: "search_count",
args: [[["share", "=", false]]],
})
.then(function (data) {
window.location = "https://www.flectra.com/flectra-enterprise/upgrade?utm_medium=community_upgrade&num_users=" + data;
});
},
}, {
text: _t("Cancel"),
close: true,
}];
var dialog = new Dialog(this, {
size: 'medium',
buttons: buttons,
$content: $('<div>', {
html: QWeb.render('EnterpriseUpgrade'),
}),
title: _t("Flectra Enterprise"),
}).open();
return dialog;
},
}); });
var PlannerLauncher = Widget.extend({ var PlannerLauncher = Widget.extend({

View File

@ -2,14 +2,14 @@
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
{ {
'name': 'Odoo Settings Dashboard', 'name': 'Flectra Settings Dashboard',
'author': 'Odoo S.A.', 'author': 'Odoo S.A.',
'version': '1.0', 'version': '1.0',
'summary': 'Quick actions for installing new app, adding users, completing planners, etc.', 'summary': 'Quick actions for installing new app, adding users, completing planners, etc.',
'category': 'Extra Tools', 'category': 'Extra Tools',
'description': 'description':
""" """
Odoo dashboard Flectra dashboard
============== ==============
* Quick access to install apps * Quick access to install apps
* Quick users add * Quick users add

View File

@ -231,7 +231,6 @@ var DashboardApps = Widget.extend({
events: { events: {
'click .o_browse_apps': 'on_new_apps', 'click .o_browse_apps': 'on_new_apps',
'click .o_confirm_upgrade': 'confirm_upgrade',
}, },
init: function(parent, data){ init: function(parent, data){
@ -242,18 +241,11 @@ var DashboardApps = Widget.extend({
start: function() { start: function() {
this._super.apply(this, arguments); this._super.apply(this, arguments);
if (flectra.db_info && _.last(flectra.db_info.server_version_info) !== 'e') {
$(QWeb.render("DashboardEnterprise")).appendTo(this.$el);
}
}, },
on_new_apps: function(){ on_new_apps: function(){
this.do_action('base.open_module_tree'); this.do_action('base.open_module_tree');
}, },
confirm_upgrade: function() {
framework.redirect("https://www.flectra.com/flectra-enterprise/upgrade?num_users=" + (this.data.enterprise_users || 1));
},
}); });
var DashboardShare = Widget.extend({ var DashboardShare = Widget.extend({
@ -268,7 +260,7 @@ var DashboardShare = Widget.extend({
init: function(parent, data){ init: function(parent, data){
this.data = data; this.data = data;
this.parent = parent; this.parent = parent;
this.share_url = 'https://www.flectra.com'; this.share_url = 'https://www.flectrahq.com';
this.share_text = encodeURIComponent("I am using #Flectra - Awesome open source business apps."); this.share_text = encodeURIComponent("I am using #Flectra - Awesome open source business apps.");
}, },
@ -283,7 +275,7 @@ var DashboardShare = Widget.extend({
}, },
share_linkedin: function(){ share_linkedin: function(){
var popup_url = _.str.sprintf('http://www.linkedin.com/shareArticle?mini=true&url=%s&title=I am using flectra&summary=%s&source=www.flectra.com', encodeURIComponent(this.share_url), this.share_text); var popup_url = _.str.sprintf('http://www.linkedin.com/shareArticle?mini=true&url=%s&title=I am using flectra&summary=%s&source=www.flectrahq.com', encodeURIComponent(this.share_url), this.share_text);
this.sharer(popup_url); this.sharer(popup_url);
}, },

View File

@ -105,7 +105,7 @@
<t t-set="planners" t-value="widget.planners"/> <t t-set="planners" t-value="widget.planners"/>
<t t-call="DashboardPlanner.PlannersList"/> <t t-call="DashboardPlanner.PlannersList"/>
<hr/> <hr/>
Need more help? <a target="_blank" href="https://www.flectra.com/documentation/user">Browse the documentation.</a> Need more help? <a target="_blank" href="https://www.flectrahq.com/documentation/user">Browse the documentation.</a>
</div> </div>
</t> </t>
@ -179,20 +179,6 @@
</div> </div>
</t> </t>
<t t-name="DashboardEnterprise">
<hr class="mt16"/>
<div class="text-center o_web_settings_dashboard_enterprise">
<div class="text-center o_web_settings_dashboard_enterprise">
<div class="text-center o_web_settings_dashboard_header">Flectra Enterprise</div>
<div class="mb16"><a href="http://www.flectra.com/editions" target="_blank">Get more features with the Enterprise Edition!</a></div>
<div><img class="img img-responsive" t-att-src='_s + "/web/static/src/img/enterprise_upgrade.jpg"'/></div>
<div>
<a class="btn btn-primary btn-block o_confirm_upgrade" role="button"><strong>Upgrade Now</strong></a>
</div>
</div>
</div>
</t>
<t t-name="DashboardTranslations"> <t t-name="DashboardTranslations">
<div class="text-center o_web_settings_dashboard_translations mt8"> <div class="text-center o_web_settings_dashboard_translations mt8">
<i class="fa fa-globe fa-4x text-muted"></i> <i class="fa fa-globe fa-4x text-muted"></i>

View File

@ -18,8 +18,9 @@ from flectra import http, models, fields, _
from flectra.http import request from flectra.http import request
from flectra.tools import pycompat, OrderedSet from flectra.tools import pycompat, OrderedSet
from flectra.addons.http_routing.models.ir_http import slug, _guess_mimetype from flectra.addons.http_routing.models.ir_http import slug, _guess_mimetype
from flectra.addons.web.controllers.main import WebClient, Binary, Home from flectra.addons.web.controllers.main import WebClient, Binary
from flectra.addons.portal.controllers.portal import pager as portal_pager from flectra.addons.portal.controllers.portal import pager as portal_pager
from flectra.addons.portal.controllers.web import Home
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -306,17 +307,6 @@ class Website(Home):
modules.button_immediate_upgrade() modules.button_immediate_upgrade()
return request.redirect(redirect) return request.redirect(redirect)
@http.route('/website/translations', type='json', auth="public", website=True)
def get_website_translations(self, lang, mods=None):
Modules = request.env['ir.module.module'].sudo()
modules = Modules.search([
'|', ('name', 'ilike', 'website'), ('name', '=', 'web_editor'),
('state', '=', 'installed')
]).mapped('name')
if mods:
modules += mods
return WebClient().translations(mods=modules, lang=lang)
@http.route(['/website/publish'], type='json', auth="public", website=True) @http.route(['/website/publish'], type='json', auth="public", website=True)
def publish(self, id, object): def publish(self, id, object):
Model = request.env[object] Model = request.env[object]

View File

@ -130,5 +130,118 @@ response = request.render("website.template_partner_comment", {
<field name="name">Website 0.0.0.0</field> <field name="name">Website 0.0.0.0</field>
<field name="domain">0.0.0.0</field> <field name="domain">0.0.0.0</field>
</record> </record>
<record id="website2_homepage" model="ir.ui.view">
<field name="name">Home</field>
<field name="type">qweb</field>
<field name="key">website2.homepage</field>
<field name="arch" type="xml">
<t name="Home" priority="29" t-name="website2.homepage">
<t t-call="website.layout">
<div id="wrap" class="oe_structure oe_empty">
<div class="carousel slide mb32" id="myCarousel0" style="height: 320px;">
<ol class="carousel-indicators hidden">
<li class="active" data-slide-to="0" data-target="#myCarousel0"/>
</ol>
<div class="carousel-inner">
<div class="item image_text oe_img_bg active" style="background-image: url(http://0.0.0.0:8069/web/image/website.s_background_image_11);">
<div class="container">
<div class="row content">
<div class="carousel-content col-md-6 col-sm-12">
<h2>Homepage 0.0.0.0</h2>
<h3>Click to customize this text</h3>
<p>
<a class="btn btn-success btn-large" href="/contactus">Contact us</a>
</p>
</div>
<span class="carousel-img col-md-6 hidden-sm hidden-xs"> </span>
</div>
</div>
</div>
</div>
<div class="carousel-control left hidden" data-slide="prev" data-target="#myCarousel0" href="#myCarousel0" style="width: 10%">
<i class="fa fa-chevron-left"/>
</div>
<div class="carousel-control right hidden" data-slide="next" data-target="#myCarousel0" href="#myCarousel0" style="width: 10%">
<i class="fa fa-chevron-right"/>
</div>
</div>
</div>
</t>
</t>
</field>
</record>
<record id="website2_homepage_page" model="website.page">
<field name="website_published">True</field>
<field name="url">/</field>
<field name="view_id" ref="website2_homepage" />
<field name="website_ids" eval="[(4, ref('website2'))]" />
</record>
<record id="website2_contactus" model="ir.ui.view">
<field name="name">Contact Us</field>
<field name="type">qweb</field>
<field name="key">website2.contactus</field>
<field name="arch" type="xml">
<t name="Contact Us" t-name="website2.contactus">
<t t-call="website.layout">
<div id="wrap">
<div class="oe_structure"/>
<div class="container">
<h1>Contact us</h1>
<div class="row">
<div class="col-md-8">
<div class="oe_structure">
<div>
<p>Contact us about anything related to our company or services.</p>
<p>We'll do our best to get back to you as soon as possible.</p>
</div>
</div>
<div class="text-center mt64" name="mail_button">
<a t-attf-href="mailto:{{ res_company.email }}" class="btn btn-primary" id="o_contact_mail">Send us an email</a>
</div>
</div>
<div class="col-md-4 mb32">
<t t-call="website.company_description"/>
</div>
</div>
</div>
<div class="oe_structure"/>
</div>
</t>
</t>
</field>
</record>
<record id="website2_contactus_page" model="website.page">
<field name="website_published">True</field>
<field name="url">/contactus</field>
<field name="view_id" ref="website2_contactus" />
<field name="website_ids" eval="[(4, ref('website2'))]" />
</record>
<!-- Menu & Homepage -->
<record id="website2" model="website">
<field name="homepage_id" ref="website2_homepage_page" />
</record>
<record id="website2_main_menu" model="website.menu">
<field name="name">Top Menu</field>
<field name="website_id" ref="website2"/>
</record>
<record id="website2_menu_homepage" model="website.menu">
<field name="name">Home</field>
<field name="url">/</field>
<field name="parent_id" ref="website.website2_main_menu"/>
<field name="sequence" type="int">10</field>
<field name="website_id" ref="website2"/>
<field name="page_id" ref="website2_homepage_page" />
</record>
<record id="website2_menu_contactus" model="website.menu">
<field name="name">Contact us</field>
<field name="url">/contactus</field>
<field name="parent_id" ref="website.website2_main_menu"/>
<field name="sequence" type="int">60</field>
<field name="website_id" ref="website2"/>
<field name="page_id" ref="website2_contactus_page" />
</record>
</data> </data>
</flectra> </flectra>

Some files were not shown because too many files have changed in this diff Show More