summaryrefslogtreecommitdiff
path: root/addons/portal/static/src/js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/portal/static/src/js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/portal/static/src/js')
-rw-r--r--addons/portal/static/src/js/portal.js245
-rw-r--r--addons/portal/static/src/js/portal_chatter.js311
-rw-r--r--addons/portal/static/src/js/portal_composer.js173
-rw-r--r--addons/portal/static/src/js/portal_sidebar.js75
-rw-r--r--addons/portal/static/src/js/portal_signature.js197
5 files changed, 1001 insertions, 0 deletions
diff --git a/addons/portal/static/src/js/portal.js b/addons/portal/static/src/js/portal.js
new file mode 100644
index 00000000..610e58b5
--- /dev/null
+++ b/addons/portal/static/src/js/portal.js
@@ -0,0 +1,245 @@
+odoo.define('portal.portal', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+const Dialog = require('web.Dialog');
+const {_t, qweb} = require('web.core');
+const ajax = require('web.ajax');
+
+publicWidget.registry.portalDetails = publicWidget.Widget.extend({
+ selector: '.o_portal_details',
+ events: {
+ 'change select[name="country_id"]': '_onCountryChange',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+
+ this.$state = this.$('select[name="state_id"]');
+ this.$stateOptions = this.$state.filter(':enabled').find('option:not(:first)');
+ this._adaptAddressForm();
+
+ return def;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adaptAddressForm: function () {
+ var $country = this.$('select[name="country_id"]');
+ var countryID = ($country.val() || 0);
+ this.$stateOptions.detach();
+ var $displayedState = this.$stateOptions.filter('[data-country_id=' + countryID + ']');
+ var nb = $displayedState.appendTo(this.$state).show().length;
+ this.$state.parent().toggle(nb >= 1);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onCountryChange: function () {
+ this._adaptAddressForm();
+ },
+});
+
+publicWidget.registry.PortalHomeCounters = publicWidget.Widget.extend({
+ selector: '.o_portal_my_home',
+
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+ this._updateCounters();
+ return def;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _updateCounters(elem) {
+ const numberRpc = 3;
+ const needed = this.$('[data-placeholder_count]')
+ .map((i, o) => $(o).data('placeholder_count'))
+ .toArray();
+ const counterByRpc = Math.ceil(needed.length / numberRpc); // max counter, last can be less
+
+ const proms = [...Array(Math.min(numberRpc, needed.length)).keys()].map(async i => {
+ await this._rpc({
+ route: "/my/counters",
+ params: {
+ counters: needed.slice(i * counterByRpc, (i + 1) * counterByRpc)
+ },
+ }).then(data => {
+ Object.keys(data).map(k => this.$("[data-placeholder_count='" + k + "']").text(data[k]));
+ });
+ });
+ return Promise.all(proms);
+ },
+});
+
+publicWidget.registry.portalSearchPanel = publicWidget.Widget.extend({
+ selector: '.o_portal_search_panel',
+ events: {
+ 'click .search-submit': '_onSearchSubmitClick',
+ 'click .dropdown-item': '_onDropdownItemClick',
+ 'keyup input[name="search"]': '_onSearchInputKeyup',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+ this._adaptSearchLabel(this.$('.dropdown-item.active'));
+ return def;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adaptSearchLabel: function (elem) {
+ var $label = $(elem).clone();
+ $label.find('span.nolabel').remove();
+ this.$('input[name="search"]').attr('placeholder', $label.text().trim());
+ },
+ /**
+ * @private
+ */
+ _search: function () {
+ var search = $.deparam(window.location.search.substring(1));
+ search['search_in'] = this.$('.dropdown-item.active').attr('href').replace('#', '');
+ search['search'] = this.$('input[name="search"]').val();
+ window.location.search = $.param(search);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onSearchSubmitClick: function () {
+ this._search();
+ },
+ /**
+ * @private
+ */
+ _onDropdownItemClick: function (ev) {
+ ev.preventDefault();
+ var $item = $(ev.currentTarget);
+ $item.closest('.dropdown-menu').find('.dropdown-item').removeClass('active');
+ $item.addClass('active');
+
+ this._adaptSearchLabel(ev.currentTarget);
+ },
+ /**
+ * @private
+ */
+ _onSearchInputKeyup: function (ev) {
+ if (ev.keyCode === $.ui.keyCode.ENTER) {
+ this._search();
+ }
+ },
+});
+
+/**
+ * Wraps an RPC call in a check for the result being an identity check action
+ * descriptor. If no such result is found, just returns the wrapped promise's
+ * result as-is; otherwise shows an identity check dialog and resumes the call
+ * on success.
+ *
+ * Warning: does not in and of itself trigger an identity check, a promise which
+ * never triggers and identity check internally will do nothing of use.
+ *
+ * @param {Function} rpc Widget#_rpc bound do the widget
+ * @param {Promise} wrapped promise to check for an identity check request
+ * @returns {Promise} result of the original call
+ */
+function handleCheckIdentity(rpc, wrapped) {
+ return wrapped.then((r) => {
+ if (!_.isMatch(r, {type: 'ir.actions.act_window', res_model: 'res.users.identitycheck'})) {
+ return r;
+ }
+ const check_id = r.res_id;
+ return ajax.loadXML('/portal/static/src/xml/portal_security.xml', qweb).then(() => new Promise((resolve, reject) => {
+ const d = new Dialog(null, {
+ title: _t("Security Control"),
+ $content: qweb.render('portal.identitycheck'),
+ buttons: [{
+ text: _t("Confirm Password"), classes: 'btn btn-primary',
+ // nb: if click & close, waits for click to resolve before closing
+ click() {
+ const password_input = this.el.querySelector('[name=password]');
+ if (!password_input.reportValidity()) {
+ password_input.classList.add('is-invalid');
+ return;
+ }
+ return rpc({
+ model: 'res.users.identitycheck',
+ method: 'write',
+ args: [check_id, {password: password_input.value}]
+ }).then(() => rpc({
+ model: 'res.users.identitycheck',
+ method: 'run_check',
+ args: [check_id]
+ })).then((r) => {
+ this.close();
+ resolve(r);
+ }, (err) => {
+ err.event.preventDefault(); // suppress crashmanager
+ password_input.classList.add('is-invalid');
+ password_input.setCustomValidity(_t("Check failed"));
+ password_input.reportValidity();
+ });
+ }
+ }, {
+ text: _t('Cancel'), close: true
+ }, {
+ text: _t('Forgot password?'), classes: 'btn btn-link',
+ click() { window.location.href = "/web/reset_password/"; }
+ }]
+ }).on('close', null, () => {
+ // unlink wizard object?
+ reject();
+ });
+ d.opened(() => {
+ const pw = d.el.querySelector('[name="password"]');
+ pw.focus();
+ pw.addEventListener('input', () => {
+ pw.classList.remove('is-invalid');
+ pw.setCustomValidity('');
+ });
+ d.el.addEventListener('submit', (e) => {
+ e.preventDefault();
+ d.$footer.find('.btn-primary').click();
+ });
+ });
+ d.open();
+ }));
+ });
+}
+return {
+ handleCheckIdentity,
+}
+});
diff --git a/addons/portal/static/src/js/portal_chatter.js b/addons/portal/static/src/js/portal_chatter.js
new file mode 100644
index 00000000..134efbf6
--- /dev/null
+++ b/addons/portal/static/src/js/portal_chatter.js
@@ -0,0 +1,311 @@
+odoo.define('portal.chatter', function (require) {
+'use strict';
+
+var core = require('web.core');
+const dom = require('web.dom');
+var publicWidget = require('web.public.widget');
+var time = require('web.time');
+var portalComposer = require('portal.composer');
+
+var qweb = core.qweb;
+var _t = core._t;
+
+/**
+ * Widget PortalChatter
+ *
+ * - Fetch message fron controller
+ * - Display chatter: pager, total message, composer (according to access right)
+ * - Provider API to filter displayed messages
+ */
+var PortalChatter = publicWidget.Widget.extend({
+ template: 'portal.Chatter',
+ xmlDependencies: ['/portal/static/src/xml/portal_chatter.xml'],
+ events: {
+ 'click .o_portal_chatter_pager_btn': '_onClickPager',
+ 'click .o_portal_chatter_js_is_internal': 'async _onClickUpdateIsInternal',
+ },
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options) {
+ var self = this;
+ this.options = {};
+ this._super.apply(this, arguments);
+
+ // underscorize the camelcased option keys
+ _.each(options, function (val, key) {
+ self.options[_.str.underscored(key)] = val;
+ });
+ // set default options
+ this.options = _.defaults(this.options, {
+ 'allow_composer': true,
+ 'display_composer': false,
+ 'csrf_token': odoo.csrf_token,
+ 'message_count': 0,
+ 'pager_step': 10,
+ 'pager_scope': 5,
+ 'pager_start': 1,
+ 'is_user_public': true,
+ 'is_user_employee': false,
+ 'is_user_publisher': false,
+ 'hash': false,
+ 'pid': false,
+ 'domain': [],
+ });
+
+ this.set('messages', []);
+ this.set('message_count', this.options['message_count']);
+ this.set('pager', {});
+ this.set('domain', this.options['domain']);
+ this._currentPage = this.options['pager_start'];
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ return Promise.all([
+ this._super.apply(this, arguments),
+ this._chatterInit()
+ ]);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ // bind events
+ this.on("change:messages", this, this._renderMessages);
+ this.on("change:message_count", this, function () {
+ this._renderMessageCount();
+ this.set('pager', this._pager(this._currentPage));
+ });
+ this.on("change:pager", this, this._renderPager);
+ this.on("change:domain", this, this._onChangeDomain);
+ // set options and parameters
+ this.set('message_count', this.options['message_count']);
+ this.set('messages', this.preprocessMessages(this.result['messages']));
+
+
+ var defs = [];
+ defs.push(this._super.apply(this, arguments));
+
+ // instanciate and insert composer widget
+ if (this.options['display_composer']) {
+ this._composer = new portalComposer.PortalComposer(this, this.options);
+ defs.push(this._composer.replace(this.$('.o_portal_chatter_composer')));
+ }
+
+ return Promise.all(defs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Fetch the messages and the message count from the server for the
+ * current page and current domain.
+ *
+ * @param {Array} domain
+ * @returns {Promise}
+ */
+ messageFetch: function (domain) {
+ var self = this;
+ return this._rpc({
+ route: '/mail/chatter_fetch',
+ params: self._messageFetchPrepareParams(),
+ }).then(function (result) {
+ self.set('messages', self.preprocessMessages(result['messages']));
+ self.set('message_count', result['message_count']);
+ });
+ },
+ /**
+ * Update the messages format
+ *
+ * @param {Array<Object>}
+ * @returns {Array}
+ */
+ preprocessMessages: function (messages) {
+ _.each(messages, function (m) {
+ m['author_avatar_url'] = _.str.sprintf('/web/image/%s/%s/author_avatar/50x50', 'mail.message', m.id);
+ m['published_date_str'] = _.str.sprintf(_t('Published on %s'), moment(time.str_to_datetime(m.date)).format('MMMM Do YYYY, h:mm:ss a'));
+ });
+ return messages;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Deferred}
+ */
+ _chatterInit: function () {
+ var self = this;
+ return this._rpc({
+ route: '/mail/chatter_init',
+ params: this._messageFetchPrepareParams()
+ }).then(function (result) {
+ self.result = result;
+ self.options = _.extend(self.options, self.result['options'] || {});
+ return result;
+ });
+ },
+ /**
+ * Change the current page by refreshing current domain
+ *
+ * @private
+ * @param {Number} page
+ * @param {Array} domain
+ */
+ _changeCurrentPage: function (page, domain) {
+ this._currentPage = page;
+ var d = domain ? domain : _.clone(this.get('domain'));
+ this.set('domain', d); // trigger fetch message
+ },
+ _messageFetchPrepareParams: function () {
+ var self = this;
+ var data = {
+ 'res_model': this.options['res_model'],
+ 'res_id': this.options['res_id'],
+ 'limit': this.options['pager_step'],
+ 'offset': (this._currentPage - 1) * this.options['pager_step'],
+ 'allow_composer': this.options['allow_composer'],
+ };
+ // add token field to allow to post comment without being logged
+ if (self.options['token']) {
+ data['token'] = self.options['token'];
+ }
+ // add domain
+ if (this.get('domain')) {
+ data['domain'] = this.get('domain');
+ }
+ return data;
+ },
+ /**
+ * Generate the pager data for the given page number
+ *
+ * @private
+ * @param {Number} page
+ * @returns {Object}
+ */
+ _pager: function (page) {
+ page = page || 1;
+ var total = this.get('message_count');
+ var scope = this.options['pager_scope'];
+ var step = this.options['pager_step'];
+
+ // Compute Pager
+ var pageCount = Math.ceil(parseFloat(total) / step);
+
+ page = Math.max(1, Math.min(parseInt(page), pageCount));
+ scope -= 1;
+
+ var pmin = Math.max(page - parseInt(Math.floor(scope / 2)), 1);
+ var pmax = Math.min(pmin + scope, pageCount);
+
+ if (pmax - scope > 0) {
+ pmin = pmax - scope;
+ } else {
+ pmin = 1;
+ }
+
+ var pages = [];
+ _.each(_.range(pmin, pmax + 1), function (index) {
+ pages.push(index);
+ });
+
+ return {
+ "page_count": pageCount,
+ "offset": (page - 1) * step,
+ "page": page,
+ "page_start": pmin,
+ "page_previous": Math.max(pmin, page - 1),
+ "page_next": Math.min(pmax, page + 1),
+ "page_end": pmax,
+ "pages": pages
+ };
+ },
+ _renderMessages: function () {
+ this.$('.o_portal_chatter_messages').html(qweb.render("portal.chatter_messages", {widget: this}));
+ },
+ _renderMessageCount: function () {
+ this.$('.o_message_counter').replaceWith(qweb.render("portal.chatter_message_count", {widget: this}));
+ },
+ _renderPager: function () {
+ this.$('.o_portal_chatter_pager').replaceWith(qweb.render("portal.pager", {widget: this}));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ _onChangeDomain: function () {
+ var self = this;
+ this.messageFetch().then(function () {
+ var p = self._currentPage;
+ self.set('pager', self._pager(p));
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onClickPager: function (ev) {
+ ev.preventDefault();
+ var page = $(ev.currentTarget).data('page');
+ this._changeCurrentPage(page);
+ },
+
+ /**
+ * Toggle is_internal state of message. Update both node data and
+ * classes to ensure DOM is updated accordingly to RPC call result.
+ * @private
+ * @returns {Promise}
+ */
+ _onClickUpdateIsInternal: function (ev) {
+ ev.preventDefault();
+
+ var $elem = $(ev.currentTarget);
+ return this._rpc({
+ route: '/mail/update_is_internal',
+ params: {
+ message_id: $elem.data('message-id'),
+ is_internal: ! $elem.data('is-internal'),
+ },
+ }).then(function (result) {
+ $elem.data('is-internal', result);
+ if (result === true) {
+ $elem.addClass('o_portal_message_internal_on');
+ $elem.removeClass('o_portal_message_internal_off');
+ } else {
+ $elem.addClass('o_portal_message_internal_off');
+ $elem.removeClass('o_portal_message_internal_on');
+ }
+ });
+ },
+});
+
+publicWidget.registry.portalChatter = publicWidget.Widget.extend({
+ selector: '.o_portal_chatter',
+
+ /**
+ * @override
+ */
+ async start() {
+ const proms = [this._super.apply(this, arguments)];
+ const chatter = new PortalChatter(this, this.$el.data());
+ proms.push(chatter.appendTo(this.$el));
+ await Promise.all(proms);
+ // scroll to the right place after chatter loaded
+ if (window.location.hash === `#${this.el.id}`) {
+ dom.scrollTo(this.el, {duration: 0});
+ }
+ },
+});
+
+return {
+ PortalChatter: PortalChatter,
+};
+});
diff --git a/addons/portal/static/src/js/portal_composer.js b/addons/portal/static/src/js/portal_composer.js
new file mode 100644
index 00000000..68fb9189
--- /dev/null
+++ b/addons/portal/static/src/js/portal_composer.js
@@ -0,0 +1,173 @@
+odoo.define('portal.composer', function (require) {
+'use strict';
+
+var ajax = require('web.ajax');
+var core = require('web.core');
+var publicWidget = require('web.public.widget');
+
+var qweb = core.qweb;
+var _t = core._t;
+
+/**
+ * Widget PortalComposer
+ *
+ * Display the composer (according to access right)
+ *
+ */
+var PortalComposer = publicWidget.Widget.extend({
+ template: 'portal.Composer',
+ xmlDependencies: ['/portal/static/src/xml/portal_chatter.xml'],
+ events: {
+ 'change .o_portal_chatter_file_input': '_onFileInputChange',
+ 'click .o_portal_chatter_attachment_btn': '_onAttachmentButtonClick',
+ 'click .o_portal_chatter_attachment_delete': 'async _onAttachmentDeleteClick',
+ 'click .o_portal_chatter_composer_btn': 'async _onSubmitButtonClick',
+ },
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.options = _.defaults(options || {}, {
+ 'allow_composer': true,
+ 'display_composer': false,
+ 'csrf_token': odoo.csrf_token,
+ 'token': false,
+ 'res_model': false,
+ 'res_id': false,
+ });
+ this.attachments = [];
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.$attachmentButton = this.$('.o_portal_chatter_attachment_btn');
+ this.$fileInput = this.$('.o_portal_chatter_file_input');
+ this.$sendButton = this.$('.o_portal_chatter_composer_btn');
+ this.$attachments = this.$('.o_portal_chatter_composer_form .o_portal_chatter_attachments');
+ this.$attachmentIds = this.$('.o_portal_chatter_attachment_ids');
+ this.$attachmentTokens = this.$('.o_portal_chatter_attachment_tokens');
+
+ return this._super.apply(this, arguments).then(function () {
+ if (self.options.default_attachment_ids) {
+ self.attachments = self.options.default_attachment_ids || [];
+ _.each(self.attachments, function(attachment) {
+ attachment.state = 'done';
+ });
+ self._updateAttachments();
+ }
+ return Promise.resolve();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAttachmentButtonClick: function () {
+ this.$fileInput.click();
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ * @returns {Promise}
+ */
+ _onAttachmentDeleteClick: function (ev) {
+ var self = this;
+ var attachmentId = $(ev.currentTarget).closest('.o_portal_chatter_attachment').data('id');
+ var accessToken = _.find(this.attachments, {'id': attachmentId}).access_token;
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ this.$sendButton.prop('disabled', true);
+
+ return this._rpc({
+ route: '/portal/attachment/remove',
+ params: {
+ 'attachment_id': attachmentId,
+ 'access_token': accessToken,
+ },
+ }).then(function () {
+ self.attachments = _.reject(self.attachments, {'id': attachmentId});
+ self._updateAttachments();
+ self.$sendButton.prop('disabled', false);
+ });
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _onFileInputChange: function () {
+ var self = this;
+
+ this.$sendButton.prop('disabled', true);
+
+ return Promise.all(_.map(this.$fileInput[0].files, function (file) {
+ return new Promise(function (resolve, reject) {
+ var data = {
+ 'name': file.name,
+ 'file': file,
+ 'res_id': self.options.res_id,
+ 'res_model': self.options.res_model,
+ 'access_token': self.options.token,
+ };
+ ajax.post('/portal/attachment/add', data).then(function (attachment) {
+ attachment.state = 'pending';
+ self.attachments.push(attachment);
+ self._updateAttachments();
+ resolve();
+ }).guardedCatch(function (error) {
+ self.displayNotification({
+ message: _.str.sprintf(_t("Could not save file <strong>%s</strong>"),
+ _.escape(file.name)),
+ type: 'warning',
+ sticky: true,
+ });
+ resolve();
+ });
+ });
+ })).then(function () {
+ // ensures any selection triggers a change, even if the same files are selected again
+ self.$fileInput[0].value = null;
+ self.$sendButton.prop('disabled', false);
+ });
+ },
+ /**
+ * Returns a Promise that is never resolved to prevent sending the form
+ * twice when clicking twice on the button, in combination with the `async`
+ * in the event definition.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _onSubmitButtonClick: function () {
+ return new Promise(function (resolve, reject) {});
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _updateAttachments: function () {
+ this.$attachmentIds.val(_.pluck(this.attachments, 'id'));
+ this.$attachmentTokens.val(_.pluck(this.attachments, 'access_token'));
+ this.$attachments.html(qweb.render('portal.Chatter.Attachments', {
+ attachments: this.attachments,
+ showDelete: true,
+ }));
+ },
+});
+
+return {
+ PortalComposer: PortalComposer,
+};
+});
diff --git a/addons/portal/static/src/js/portal_sidebar.js b/addons/portal/static/src/js/portal_sidebar.js
new file mode 100644
index 00000000..d081ddec
--- /dev/null
+++ b/addons/portal/static/src/js/portal_sidebar.js
@@ -0,0 +1,75 @@
+odoo.define('portal.PortalSidebar', function (require) {
+'use strict';
+
+var core = require('web.core');
+var publicWidget = require('web.public.widget');
+var time = require('web.time');
+var session = require('web.session');
+
+var _t = core._t;
+
+var PortalSidebar = publicWidget.Widget.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this._setDelayLabel();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------------
+
+ /**
+ * Set the due/delay information according to the given date
+ * like : <span class="o_portal_sidebar_timeago" t-att-datetime="invoice.date_due"/>
+ *
+ * @private
+ */
+ _setDelayLabel: function () {
+ var $sidebarTimeago = this.$el.find('.o_portal_sidebar_timeago');
+ _.each($sidebarTimeago, function (el) {
+ var dateTime = moment(time.auto_str_to_date($(el).attr('datetime'))),
+ today = moment().startOf('day'),
+ diff = dateTime.diff(today, 'days', true),
+ displayStr;
+
+ session.is_bound.then(function (){
+ if (diff === 0) {
+ displayStr = _t('Due today');
+ } else if (diff > 0) {
+ // Workaround: force uniqueness of these two translations. We use %1d because the string
+ // with %d is already used in mail and mail's translations are not sent to the frontend.
+ displayStr = _.str.sprintf(_t('Due in %1d days'), Math.abs(diff));
+ } else {
+ displayStr = _.str.sprintf(_t('%1d days overdue'), Math.abs(diff));
+ }
+ $(el).text(displayStr);
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {string} href
+ */
+ _printIframeContent: function (href) {
+ // due to this issue : https://bugzilla.mozilla.org/show_bug.cgi?id=911444
+ // open a new window with pdf for print in Firefox (in other system: http://printjs.crabbly.com)
+ if ($.browser.mozilla) {
+ window.open(href, '_blank');
+ return;
+ }
+ if (!this.printContent) {
+ this.printContent = $('<iframe id="print_iframe_content" src="' + href + '" style="display:none"></iframe>');
+ this.$el.append(this.printContent);
+ this.printContent.on('load', function () {
+ $(this).get(0).contentWindow.print();
+ });
+ } else {
+ this.printContent.get(0).contentWindow.print();
+ }
+ },
+});
+return PortalSidebar;
+});
diff --git a/addons/portal/static/src/js/portal_signature.js b/addons/portal/static/src/js/portal_signature.js
new file mode 100644
index 00000000..eac46519
--- /dev/null
+++ b/addons/portal/static/src/js/portal_signature.js
@@ -0,0 +1,197 @@
+odoo.define('portal.signature_form', function (require) {
+'use strict';
+
+var core = require('web.core');
+var publicWidget = require('web.public.widget');
+var NameAndSignature = require('web.name_and_signature').NameAndSignature;
+var qweb = core.qweb;
+
+var _t = core._t;
+
+/**
+ * This widget is a signature request form. It uses
+ * @see NameAndSignature for the input fields, adds a submit
+ * button, and handles the RPC to save the result.
+ */
+var SignatureForm = publicWidget.Widget.extend({
+ template: 'portal.portal_signature',
+ xmlDependencies: ['/portal/static/src/xml/portal_signature.xml'],
+ events: {
+ 'click .o_portal_sign_submit': 'async _onClickSignSubmit',
+ },
+ custom_events: {
+ 'signature_changed': '_onChangeSignature',
+ },
+
+ /**
+ * Overridden to allow options.
+ *
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} options
+ * @param {string} options.callUrl - make RPC to this url
+ * @param {string} [options.sendLabel='Accept & Sign'] - label of the
+ * send button
+ * @param {Object} [options.rpcParams={}] - params for the RPC
+ * @param {Object} [options.nameAndSignatureOptions={}] - options for
+ * @see NameAndSignature.init()
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+
+ this.csrf_token = odoo.csrf_token;
+
+ this.callUrl = options.callUrl || '';
+ this.rpcParams = options.rpcParams || {};
+ this.sendLabel = options.sendLabel || _t("Accept & Sign");
+
+ this.nameAndSignature = new NameAndSignature(this,
+ options.nameAndSignatureOptions || {});
+ },
+ /**
+ * Overridden to get the DOM elements
+ * and to insert the name and signature.
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.$confirm_btn = this.$('.o_portal_sign_submit');
+ this.$controls = this.$('.o_portal_sign_controls');
+ var subWidgetStart = this.nameAndSignature.replace(this.$('.o_web_sign_name_and_signature'));
+ return Promise.all([subWidgetStart, this._super.apply(this, arguments)]).then(function () {
+ self.nameAndSignature.resetSignature();
+ });
+ },
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Focuses the name.
+ *
+ * @see NameAndSignature.focusName();
+ */
+ focusName: function () {
+ this.nameAndSignature.focusName();
+ },
+ /**
+ * Resets the signature.
+ *
+ * @see NameAndSignature.resetSignature();
+ */
+ resetSignature: function () {
+ return this.nameAndSignature.resetSignature();
+ },
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * Handles click on the submit button.
+ *
+ * This will get the current name and signature and validate them.
+ * If they are valid, they are sent to the server, and the reponse is
+ * handled. If they are invalid, it will display the errors to the user.
+ *
+ * @private
+ * @param {Event} ev
+ * @returns {Deferred}
+ */
+ _onClickSignSubmit: function (ev) {
+ var self = this;
+ ev.preventDefault();
+
+ if (!this.nameAndSignature.validateSignature()) {
+ return;
+ }
+
+ var name = this.nameAndSignature.getName();
+ var signature = this.nameAndSignature.getSignatureImage()[1];
+
+ return this._rpc({
+ route: this.callUrl,
+ params: _.extend(this.rpcParams, {
+ 'name': name,
+ 'signature': signature,
+ }),
+ }).then(function (data) {
+ if (data.error) {
+ self.$('.o_portal_sign_error_msg').remove();
+ self.$controls.prepend(qweb.render('portal.portal_signature_error', {widget: data}));
+ } else if (data.success) {
+ var $success = qweb.render('portal.portal_signature_success', {widget: data});
+ self.$el.empty().append($success);
+ }
+ if (data.force_refresh) {
+ if (data.redirect_url) {
+ window.location = data.redirect_url;
+ } else {
+ window.location.reload();
+ }
+ // no resolve if we reload the page
+ return new Promise(function () { });
+ }
+ });
+ },
+ /**
+ * Toggles the submit button depending on the signature state.
+ *
+ * @private
+ */
+ _onChangeSignature: function () {
+ var isEmpty = this.nameAndSignature.isSignatureEmpty();
+ this.$confirm_btn.prop('disabled', isEmpty);
+ },
+});
+
+publicWidget.registry.SignatureForm = publicWidget.Widget.extend({
+ selector: '.o_portal_signature_form',
+
+ /**
+ * @private
+ */
+ start: function () {
+ var hasBeenReset = false;
+
+ var callUrl = this.$el.data('call-url');
+ var nameAndSignatureOptions = {
+ defaultName: this.$el.data('default-name'),
+ mode: this.$el.data('mode'),
+ displaySignatureRatio: this.$el.data('signature-ratio'),
+ signatureType: this.$el.data('signature-type'),
+ fontColor: this.$el.data('font-color') || 'black',
+ };
+ var sendLabel = this.$el.data('send-label');
+
+ var form = new SignatureForm(this, {
+ callUrl: callUrl,
+ nameAndSignatureOptions: nameAndSignatureOptions,
+ sendLabel: sendLabel,
+ });
+
+ // Correctly set up the signature area if it is inside a modal
+ this.$el.closest('.modal').on('shown.bs.modal', function (ev) {
+ if (!hasBeenReset) {
+ // Reset it only the first time it is open to get correct
+ // size. After we want to keep its content on reopen.
+ hasBeenReset = true;
+ form.resetSignature();
+ } else {
+ form.focusName();
+ }
+ });
+
+ return Promise.all([
+ this._super.apply(this, arguments),
+ form.appendTo(this.$el)
+ ]);
+ },
+});
+
+return {
+ SignatureForm: SignatureForm,
+};
+});