diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/portal/static/src | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/portal/static/src')
| -rw-r--r-- | addons/portal/static/src/js/portal.js | 245 | ||||
| -rw-r--r-- | addons/portal/static/src/js/portal_chatter.js | 311 | ||||
| -rw-r--r-- | addons/portal/static/src/js/portal_composer.js | 173 | ||||
| -rw-r--r-- | addons/portal/static/src/js/portal_sidebar.js | 75 | ||||
| -rw-r--r-- | addons/portal/static/src/js/portal_signature.js | 197 | ||||
| -rw-r--r-- | addons/portal/static/src/scss/bootstrap.extend.scss | 13 | ||||
| -rw-r--r-- | addons/portal/static/src/scss/bootstrap_overridden.scss | 34 | ||||
| -rw-r--r-- | addons/portal/static/src/scss/portal.scss | 563 | ||||
| -rw-r--r-- | addons/portal/static/src/scss/primary_variables.scss | 5 | ||||
| -rw-r--r-- | addons/portal/static/src/xml/portal_chatter.xml | 162 | ||||
| -rw-r--r-- | addons/portal/static/src/xml/portal_security.xml | 13 | ||||
| -rw-r--r-- | addons/portal/static/src/xml/portal_signature.xml | 35 |
12 files changed, 1826 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, +}; +}); diff --git a/addons/portal/static/src/scss/bootstrap.extend.scss b/addons/portal/static/src/scss/bootstrap.extend.scss new file mode 100644 index 00000000..21eacb47 --- /dev/null +++ b/addons/portal/static/src/scss/bootstrap.extend.scss @@ -0,0 +1,13 @@ +@each $breakpoint in map-keys($grid-breakpoints) { + @include media-breakpoint-up($breakpoint) { + $infix: breakpoint-infix($breakpoint, $grid-breakpoints); + + @if $infix != '' { // Standard ones are already created by bootstrap + @each $prop, $abbrev in (width: w, height: h) { + @each $size, $length in $sizes { + .#{$abbrev}#{$infix}-#{$size} { #{$prop}: $length !important; } + } + } + } + } +} diff --git a/addons/portal/static/src/scss/bootstrap_overridden.scss b/addons/portal/static/src/scss/bootstrap_overridden.scss new file mode 100644 index 00000000..243cff2d --- /dev/null +++ b/addons/portal/static/src/scss/bootstrap_overridden.scss @@ -0,0 +1,34 @@ +// This variable affects the `.h-*` and `.w-*` classes. +$sizes: () !default; +$sizes: map-merge(( + 0: 0, +), $sizes); + +// Body +// +// Settings for the `<body>` element. + +$body-bg: $o-portal-default-body-bg !default; + +// Fonts +// +// Font, line-height, and color for body text, headings, and more. + +$font-size-sm: (12 / 16) * 1rem !default; + +// Buttons +// +// For each of Bootstrap's buttons, define text, background, and border color. + +$btn-padding-y-sm: (1 / 16) * 1rem !default; +$btn-padding-x-sm: (5 / 16) * 1rem !default; + +// Navbar + +$navbar-dark-toggler-border-color: transparent; +$navbar-light-toggler-border-color: transparent; + +// Modals + +$modal-lg: $o-modal-lg; +$modal-md: $o-modal-md; diff --git a/addons/portal/static/src/scss/portal.scss b/addons/portal/static/src/scss/portal.scss new file mode 100644 index 00000000..3aa87818 --- /dev/null +++ b/addons/portal/static/src/scss/portal.scss @@ -0,0 +1,563 @@ +/// +/// This file regroups the frontend general design rules and portal design +/// rules. +/// + +// ====== Variables ========= +$o-theme-navbar-logo-height: $nav-link-height !default; +$o-theme-navbar-fixed-logo-height: $nav-link-height !default; + +// Portal toolbar (filters, search bar) +$o-portal-mobile-toolbar: true; // Enable/Disable custom design +$o-portal-mobile-toolbar-border: $border-color; +$o-portal-mobile-toolbar-bg: $gray-200; + +// Portal Tables +$o-portal-table-th-pt: map-get($spacers, 2) !default; // bts4 pt-2 +$o-portal-table-th-pb: map-get($spacers, 2) !default; // bts4 pb-2 +$o-portal-table-td-pt: map-get($spacers, 1) !default; // bts4 pt-1 +$o-portal-table-td-pb: map-get($spacers, 1) !default; // bts4 pb-1 + +// Portal custom bg color +$o-portal-bg-color: desaturate($gray-200, 100%); + +// Check if portal uses default colors +$o-portal-use-default-colors: $body-bg == $o-portal-default-body-bg; + +// Frontend general +body { + // Set frontend direction that will be flipped with + // rtlcss for right-to-left text direction. + direction: ltr; +} + +header { + .navbar-brand { + flex: 0 0 auto; + max-width: 75%; + + &.logo { + padding-top: 0; + padding-bottom: 0; + + img { + // object-fit does not work on IE but is only used as a fallback + object-fit: contain; + display: block; + width: auto; + height: $o-theme-navbar-logo-height; + + @include media-breakpoint-down(sm) { + max-height: min($o-theme-navbar-logo-height, 5rem); + } + } + } + } + .nav-link { + white-space: nowrap; + } +} +.navbar { + margin-bottom: 0; + .nav.navbar-nav.float-right { + @include media-breakpoint-down(sm) { + float: none!important; + } + } +} +@include media-breakpoint-up(md) { + .navbar-expand-md ul.nav > li.divider { + display: list-item; + } +} +ul.flex-column > li > a { + padding: 2px 15px; +} + +// Link without text but an icon +a, .btn-link { + &.fa:hover { + text-decoration: $o-theme-btn-icon-hover-decoration; + } +} + +// Odoo options classes +.jumbotron { + margin-bottom: 0; +} + +// Typography +ul { + list-style-type: disc; +} +ul ul { + list-style-type: circle; +} +ul ul ul { + list-style-type: square; +} +ul ul ul ul { + list-style-type: disc; +} +ul ul ul ul ul { + list-style-type: circle; +} +ul ul ul ul ul ul { + list-style-type: square; +} +ul ul ul ul ul ul ul { + list-style-type: disc; +} +ol { + list-style-type: decimal; +} +ol ol { + list-style-type: lower-alpha; +} +ol ol ol { + list-style-type: lower-greek; +} +ol ol ol ol { + list-style-type: decimal; +} +ol ol ol ol ol { + list-style-type: lower-alpha; +} +ol ol ol ol ol ol { + list-style-type: lower-greek; +} +ol ol ol ol ol ol ol { + list-style-type: decimal; +} +li > p { + margin: 0; +} + +// Bootstrap hacks +%o-double-container-no-padding { + padding-right: 0; + padding-left: 0; +} +.container { + .container, .container-fluid { + @extend %o-double-container-no-padding; + } +} +.container-fluid .container-fluid { + @extend %o-double-container-no-padding; +} +#wrap { + .container, .container-fluid { + // BS3 used to do this on all containers so that margins and floats are + // cleared inside containers. As lots of current odoo layouts may rely + // on this for some alignments, this is restored (at least for a while) + // here only for main containers of the frontend. + &::before, &::after { + content: ""; + display: table; + clear: both; + } + } +} +[class^="col-lg-"] { + min-height: 24px; +} +.input-group { + flex-flow: row nowrap; +} +.list-group-item:not([class*="list-group-item-"]):not(.active) { + color: color-yiq($list-group-bg); +} + +%o-portal-breadcrumbs { + background-color: inherit; +} + +// Replaces old BS3 page-header class +%o-page-header { + margin-bottom: $headings-margin-bottom * 2; + padding-bottom: $headings-margin-bottom; + border-bottom-width: $border-width; + border-bottom-style: solid; + border-bottom-color: $border-color; + line-height: 2.1rem; +} +.o_page_header { + @extend %o-page-header; +} + +// Images spacing +img, .media_iframe_video, .o_image { + &.float-right { + margin-left: $grid-gutter-width / 2; + } + &.float-left { + margin-right: $grid-gutter-width / 2; + } +} + +// Others +::-moz-selection { + background: rgba(150, 150, 220, 0.3); +} +::selection { + background: rgba(150, 150, 220, 0.3); +} +.oe_search_box { + padding-right: 23px; +} + +// Kept for (up to) saas-12 compatibility +.para_large { + font-size: 120%; +} +.jumbotron .para_large p { + font-size: 150%; +} +.readable { + font-size: 120%; + max-width: 700px; + margin-left: auto; + margin-right: auto; + + .container { + padding-left: 0; + padding-right: 0; + width: auto; + } +} + +// Background (kept for 8.0 compatibility) (! some are still used by website_blog) +.oe_dark { + background-color: rgba(200, 200, 200, 0.14); +} +.oe_black { + background-color: rgba(0, 0, 0, 0.9); + color: white; +} +.oe_green { + background-color: #169C78; + color: white; + .text-muted { + color: #ddd !important; + } +} +.oe_blue_light { + background-color: #41b6ab; + color: white; + .text-muted { + color: #ddd !important; + } +} +.oe_blue { + background-color: #34495e; + color: white; +} +.oe_orange { + background-color: #f05442; + color: white; + .text-muted { + color: #ddd !important; + } +} +.oe_purple { + background-color: #b163a3; + color: white; + .text-muted { + color: #ddd !important; + } +} +.oe_red { + background-color: #9C1b31; + color: white; + .text-muted { + color: #ddd !important; + } +} +.oe_none { + background-color: #FFFFFF; +} +.oe_yellow { + background-color: #A2A51B; +} +.oe_green { + background-color: #149F2C; +} + +// Portal specific +// === Page custom bg === +// To be applied to all portal pages if bg-color is white (default). +@if ($o-portal-use-default-colors) { + #wrapwrap.o_portal { + @include o-bg-color($o-portal-bg-color, $with-extras: false); + } +} + +.o_portal { + .breadcrumb { + @extend %o-portal-breadcrumbs; + } + + > tbody.o_portal_report_tbody { + vertical-align: middle; + } +} + +.o_portal_wrap { + .o_portal_my_home > .o_page_header > a:hover { + text-decoration: none; + } + + .o_portal_navbar { + .breadcrumb { + padding-left: 0; + padding-right: 0; + @extend %o-portal-breadcrumbs; + } + + @if ($o-portal-use-default-colors) { + background-color: white!important; + } + } + + .o_portal_my_doc_table { + th { + padding-top: $o-portal-table-th-pt; + padding-bottom: $o-portal-table-th-pb; + max-width: 500px; + } + + td { + padding-top: $o-portal-table-td-pt; + padding-bottom: $o-portal-table-td-pb; + max-width: 10rem; + + } + + tr:last-child td { + padding-bottom: $o-portal-table-td-pb * 1.5; + } + + td, th { + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + address { + span[itemprop="name"] { + margin-bottom: 0.3em; + } + + div[itemprop="address"] > div { + position: relative; + + span[itemprop="streetAddress"] { + line-height: 1.2; + margin-bottom: 0.3em; + } + + .fa { + @include o-position-absolute(0, $left: 0); + line-height: $line-height-base; + + + span, + div { + display: block; + // FontAwesome '.fa-fw' fixed-with + margin + padding-left: 1.28571429em + 0.5em; + } + } + } + } + + .o_my_sidebar div[itemprop="address"] > div { + margin-top: 0.5em; + } + + @if ($o-portal-mobile-toolbar) { + #o_portal_navbar_content { + @include media-breakpoint-down(md) { + margin: $navbar-padding-y (-$navbar-padding-x) 0; + padding: $navbar-padding-y $navbar-padding-x ; + border-top: $border-width solid $o-portal-mobile-toolbar-border; + background-color: $o-portal-mobile-toolbar-bg; + } + } + } + + table.table tr { + word-wrap: break-word; + } +} + +.oe_attachments .o_image_small { + height: 40px; + width: 50px; + background-repeat: no-repeat; +} + +form label { + font-weight: $font-weight-bold; + &.label-optional { + font-weight: $font-weight-normal; + } +} + +.o_portal_contact_img { + width: 2.3em; + height: 2.3em; + object-fit: cover; +} + +.o_portal_sidebar { + #sidebar_content.card { + border-left: 0; + border-bottom: 0; + + > div.card-body { + border-left: $border-width solid $border-color; + } + + > ul > li { + border-left: $border-width solid $border-color; + margin-bottom: -1px; + } + + > div.card-footer { + border-left: $border-width solid $border-color; + border-bottom: $border-width solid $border-color; + } + } + + .o_portal_html_view { + overflow: hidden; + background: white; + position: relative; + + .o_portal_html_loader { + @include o-position-absolute(45%, 0, auto, 0); + } + + iframe { + position: relative; + } + } +} + +// ------------------------------------------------------------ +// Frontend Discuss widget +// ------------------------------------------------------------ + +// Readonly display +.o_portal_chatter { + padding: 10px; + + .o_portal_chatter_avatar { + width: 45px; + height: 45px; + margin-right: 1rem; + } + + .o_portal_chatter_header { + margin-bottom: 15px; + } + + .o_portal_chatter_composer { + margin-bottom: 15px; + } + + .o_portal_chatter_messages { + margin-bottom: 15px; + + .o_portal_chatter_message { + div.media-body > p:not(.o_portal_chatter_puslished_date):last-of-type { + margin-bottom: 5px; + } + } + + .o_portal_chatter_message_title { + p { + font-size:85%; + color:$o-main-color-muted; + margin: 0px; + } + } + } + + .o_portal_chatter_pager { + text-align: center; + } + + +} + +// Readonly / Composer mix display +.o_portal_chatter, +.o_portal_chatter_composer { + .o_portal_chatter_attachment { + .o_portal_chatter_attachment_name { + word-wrap: break-word; + } + + .o_portal_chatter_attachment_delete { + @include o-position-absolute($top: 0, $right: 0); + opacity: 0; + } + + &:hover .o_portal_chatter_attachment_delete { + opacity: 1; + } + } + + .o_portal_message_internal_off { + .btn-danger { + display: none; + } + } + + .o_portal_message_internal_on { + .btn-success { + display: none; + } + } +} + +.o_portal_security_body { + @extend .mx-auto; + max-width: map-get($container-max-widths, sm); + section { + @extend .mt-4; + form.oe_reset_password_form { + max-width: initial; + margin: initial; + } + .form-group { + // for the absolutely positioned meter on new password + position: relative; + } + label, button { + @extend .text-nowrap; + } + } +} + +// Copyright +.o_footer_copyright { + .o_footer_copyright_name { + vertical-align: middle; + } + .js_language_selector { + display: inline-block; + } + @include media-breakpoint-up(md) { + .row { + display: flex; + > div { + margin: auto 0; + } + } + } +} + diff --git a/addons/portal/static/src/scss/primary_variables.scss b/addons/portal/static/src/scss/primary_variables.scss new file mode 100644 index 00000000..57a12f4c --- /dev/null +++ b/addons/portal/static/src/scss/primary_variables.scss @@ -0,0 +1,5 @@ +$o-portal-default-body-bg: white; + +$o-theme-navbar-logo-height: null; + +$o-theme-btn-icon-hover-decoration: none; diff --git a/addons/portal/static/src/xml/portal_chatter.xml b/addons/portal/static/src/xml/portal_chatter.xml new file mode 100644 index 00000000..ae3a2374 --- /dev/null +++ b/addons/portal/static/src/xml/portal_chatter.xml @@ -0,0 +1,162 @@ +<templates id="template" xml:space="preserve"> + + <t t-name="portal.chatter_message_count"> + <t t-set="count" t-value="widget.get('message_count')"/> + <div class="o_message_counter"> + <t t-if="count"> + <span class="fa fa-comments" /> + <span class="o_message_count"> <t t-esc="count"/></span> + <t t-if="count == 1">comment</t> + <t t-else="">comments</t> + </t> + <t t-else=""> + There are no comments for now. + </t> + </div> + </t> + + <!-- + Widget PortalComposer (standalone) + + required many options: token, res_model, res_id, ... + --> + <t t-name="portal.Composer"> + <div class="o_portal_chatter_composer" t-if="widget.options['allow_composer']"> + <t t-set="discussion_url" t-value="window.encodeURI(window.location.href.split('#')[0] + '#discussion')"/> + <t t-if="!widget.options['display_composer']"> + <h4>Leave a comment</h4> + <p>You must be <a t-attf-href="/web/login?redirect=#{discussion_url}">logged in</a> to post a comment.</p> + </t> + <t t-if="widget.options['display_composer']"> + <div class="media"> + <img alt="Avatar" class="o_portal_chatter_avatar" t-attf-src="/web/image/res.partner/#{widget.options['partner_id']}/image_128/50x50" + t-if="!widget.options['is_user_public'] or !widget.options['token']"/> + <div class="media-body"> + <form class="o_portal_chatter_composer_form" t-attf-action="/mail/chatter_post" method="POST"> + <input type="hidden" name="csrf_token" t-att-value="widget.options['csrf_token']"/> + <div class="mb32"> + <textarea rows="4" name="message" class="form-control" placeholder="Write a message..."></textarea> + <input type="hidden" name="res_model" t-att-value="widget.options['res_model']"/> + <input type="hidden" name="res_id" t-att-value="widget.options['res_id']"/> + <input type="hidden" name="token" t-att-value="widget.options['token']" t-if="widget.options['token']"/> + <input type='hidden' name="pid" t-att-value="widget.options['pid']" t-if="widget.options['pid']"/> + <input type='hidden' name="hash" t-att-value="widget.options['hash']" t-if="widget.options['hash']"/> + <input type="hidden" name="sha_in" t-att-value="widget.options['sha_in']" t-if="widget.options['sha_in']"/> + <input type="hidden" name="sha_time" t-att-value="widget.options['sha_time']" t-if="widget.options['sha_time']"/> + <input type="hidden" name="redirect" t-att-value="discussion_url"/> + <input type="hidden" name="attachment_ids" class="o_portal_chatter_attachment_ids"/> + <input type="hidden" name="attachment_tokens" class="o_portal_chatter_attachment_tokens"/> + <div class="alert alert-danger mt8 mb0 o_portal_chatter_composer_error" style="display:none;" role="alert"> + Oops! Something went wrong. Try to reload the page and log in. + </div> + <div class="o_portal_chatter_attachments mt-3"/> + <div class="mt8"> + <button t-attf-class="o_portal_chatter_composer_btn btn btn-primary" type="submit">Send</button> + <button class="o_portal_chatter_attachment_btn btn btn-secondary" type="button" title="Add attachment"> + <i class="fa fa-paperclip"/> + </button> + </div> + </div> + </form> + <form class="d-none"> + <input type="file" class="o_portal_chatter_file_input" multiple="multiple"/> + </form> + </div> + </div> + </t> + </div> + </t> + + <t t-name="portal.Chatter.Attachments"> + <div t-if="attachments.length" class="row"> + <div t-foreach="attachments" t-as="attachment" class="col-lg-2 col-md-3 col-sm-6"> + <div class="o_portal_chatter_attachment mb-2 position-relative text-center" t-att-data-id="attachment.id"> + <button t-if="showDelete and attachment.state == 'pending'" class="o_portal_chatter_attachment_delete btn btn-sm btn-outline-danger" title="Delete"> + <i class="fa fa-times"/> + </button> + <a t-attf-href="/web/content/#{attachment.id}?download=true&access_token=#{attachment.access_token}" target="_blank"> + <div class='oe_attachment_embedded o_image' t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype"/> + <div class='o_portal_chatter_attachment_name'><t t-esc='attachment.name'/></div> + </a> + </div> + </div> + </div> + </t> + + <!-- + Widget PortalChatter, and subtemplates + --> + + <t t-name="portal.chatter_messages"> + <div class="o_portal_chatter_messages"> + <t t-foreach="widget.get('messages') || []" t-as="message"> + <div class="media o_portal_chatter_message" t-att-id="'message-' + message.id"> + <img class="o_portal_chatter_avatar" t-att-src="message.author_avatar_url" alt="avatar"/> + <div class="media-body"> + <t t-call="portal.chatter_internal_toggle" t-if="widget.options['is_user_employee']"/> + + <div class="o_portal_chatter_message_title"> + <h5 class='mb-1'><t t-esc="message.author_id[1]"/></h5> + <p class="o_portal_chatter_puslished_date"><t t-esc="message.published_date_str"/></p> + </div> + <t t-raw="message.body"/> + + <div class="o_portal_chatter_attachments"> + <t t-call="portal.Chatter.Attachments"> + <t t-set="attachments" t-value="message.attachment_ids"/> + </t> + </div> + </div> + </div> + </t> + </div> + </t> + + <!-- Chatter: internal toggle widget --> + <t t-name="portal.chatter_internal_toggle"> + <div t-attf-class="float-right o_portal_chatter_js_is_internal #{message.is_internal and 'o_portal_message_internal_on' or 'o_portal_message_internal_off'}" + t-att-data-message-id="message.id" + t-att-data-is-internal="message.is_internal"> + <button class="btn btn-danger" + title="Currently restricted to internal employees, click to make it available to everyone viewing this document.">Employees Only</button> + <button class="btn btn-success" + title="Currently available to everyone viewing this document, click to restrict to internal employees.">Visible</button> + </div> + </t> + + <t t-name="portal.pager"> + <div class="o_portal_chatter_pager"> + <t t-if="!_.isEmpty(widget.get('pager'))"> + <ul class="pagination" t-if="widget.get('pager')['pages'].length > 1"> + <li t-if="widget.get('pager')['page'] != widget.get('pager')['page_previous']" t-att-data-page="widget.get('pager')['page_previous']" class="page-item o_portal_chatter_pager_btn"> + <a href="#" class="page-link"><i class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/></a> + </li> + <t t-foreach="widget.get('pager')['pages']" t-as="page"> + <li t-att-data-page="page" t-attf-class="page-item #{page == widget.get('pager')['page'] ? 'o_portal_chatter_pager_btn active' : 'o_portal_chatter_pager_btn'}"> + <a href="#" class="page-link"><t t-esc="page"/></a> + </li> + </t> + <li t-if="widget.get('pager')['page'] != widget.get('pager')['page_next']" t-att-data-page="widget.get('pager')['page_next']" class="page-item o_portal_chatter_pager_btn"> + <a href="#" class="page-link"><i class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/></a> + </li> + </ul> + </t> + </div> + </t> + + <t t-name="portal.Chatter"> + <div class="o_portal_chatter p-0"> + <div class="o_portal_chatter_header"> + <t t-call="portal.chatter_message_count"/> + </div> + <hr t-if="widget.options['allow_composer']"/> + <div class="o_portal_chatter_composer"/> + <hr/> + <t t-call="portal.chatter_messages"/> + <div class="o_portal_chatter_footer"> + <t t-call="portal.pager"/> + </div> + </div> + </t> + +</templates> diff --git a/addons/portal/static/src/xml/portal_security.xml b/addons/portal/static/src/xml/portal_security.xml new file mode 100644 index 00000000..ff5e62e8 --- /dev/null +++ b/addons/portal/static/src/xml/portal_security.xml @@ -0,0 +1,13 @@ +<templates xml:space="preserve"> + <t t-name="portal.identitycheck"> + <form string="Security Control"> + <h3><strong>Please confirm your password to continue</strong></h3> + <p>This is necessary for security-related changes. The authorization will last for a few minutes.</p> + <div> + <label for="password" class="col-4 col-md-12 px-0">Password</label> + <input class="form-control col-10 col-md-6" autocomplete="current-password" + name="password" type="password" required="required"/> + </div> + </form> + </t> +</templates> diff --git a/addons/portal/static/src/xml/portal_signature.xml b/addons/portal/static/src/xml/portal_signature.xml new file mode 100644 index 00000000..43456d9c --- /dev/null +++ b/addons/portal/static/src/xml/portal_signature.xml @@ -0,0 +1,35 @@ +<templates id="template" xml:space="preserve"> + + <!-- Template for the widget SignatureForm. --> + <t t-name="portal.portal_signature"> + <form method="POST"> + <input type="hidden" name="csrf_token" t-att-value="widget.csrf_token"/> + <div class="o_web_sign_name_and_signature"/> + <div class="o_portal_sign_controls my-3"> + <div class="text-right my-3"> + <button type="submit" class="o_portal_sign_submit btn btn-primary" disabled="disabled"> + <i class="fa fa-check"/> + <t t-esc="widget.sendLabel"/> + </button> + </div> + </div> + </form> + </t> + <!-- Template when the sign rpc is successful. --> + <t t-name="portal.portal_signature_success"> + <div class="alert alert-success" role="status"> + <span t-if="widget.message" t-esc="widget.message"/> + <span t-else="">Thank You!</span> + <a t-if="widget.redirect_url" t-att-href="widget.redirect_url"> + <t t-if="widget.redirect_message" t-esc="widget.redirect_message"/> + <t t-else="">Click here to see your document.</t> + </a> + </div> + </t> + <!-- Template when the sign rpc returns an error. --> + <t t-name="portal.portal_signature_error"> + <div class="o_portal_sign_error_msg alert alert-danger" role="status"> + <t t-esc="widget.error"/> + </div> + </t> +</templates> |
