diff options
Diffstat (limited to 'addons/sms/static')
15 files changed, 1220 insertions, 0 deletions
diff --git a/addons/sms/static/img/sms_failure.svg b/addons/sms/static/img/sms_failure.svg new file mode 100644 index 00000000..d03ee04b --- /dev/null +++ b/addons/sms/static/img/sms_failure.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512"><defs><path id="a" d="M91.974 123.535v-17.07c0-.839-.283-1.542-.851-2.111-.568-.57-1.24-.854-2.017-.854H71.894c-.777 0-1.449.285-2.017.854-.568.569-.851 1.272-.851 2.11v17.071c0 .839.283 1.542.851 2.111.568.57 1.24.854 2.017.854h17.212c.777 0 1.449-.285 2.017-.854.568-.569.851-1.272.851-2.11zm-.179-33.601l1.614-41.239c0-.718-.3-1.287-.897-1.707-.777-.659-1.494-.988-2.151-.988H70.639c-.657 0-1.374.33-2.151.988-.598.42-.897 1.048-.897 1.887l1.524 41.059c0 .599.3 1.093.897 1.482.597.39 1.314.584 2.151.584h16.584c.837 0 1.54-.195 2.107-.584.568-.39.881-.883.941-1.482zM90.54 6.02l68.846 126.5c2.092 3.773 2.032 7.546-.179 11.32a11.276 11.276 0 0 1-4.168 4.133 11.192 11.192 0 0 1-5.693 1.527H11.654c-2.032 0-3.93-.51-5.693-1.527a11.276 11.276 0 0 1-4.168-4.133c-2.211-3.774-2.271-7.547-.18-11.32L70.46 6.02a11.462 11.462 0 0 1 4.213-4.403A11.105 11.105 0 0 1 80.5 0c2.092 0 4.034.54 5.827 1.617A11.462 11.462 0 0 1 90.54 6.02z"/><path id="c" d="M91.974 123.535v-17.07c0-.839-.283-1.542-.851-2.111-.568-.57-1.24-.854-2.017-.854H71.894c-.777 0-1.449.285-2.017.854-.568.569-.851 1.272-.851 2.11v17.071c0 .839.283 1.542.851 2.111.568.57 1.24.854 2.017.854h17.212c.777 0 1.449-.285 2.017-.854.568-.569.851-1.272.851-2.11zm-.179-33.601l1.614-41.239c0-.718-.3-1.287-.897-1.707-.777-.659-1.494-.988-2.151-.988H70.639c-.657 0-1.374.33-2.151.988-.598.42-.897 1.048-.897 1.887l1.524 41.059c0 .599.3 1.093.897 1.482.597.39 1.314.584 2.151.584h16.584c.837 0 1.54-.195 2.107-.584.568-.39.881-.883.941-1.482zM90.54 6.02l68.846 126.5c2.092 3.773 2.032 7.546-.179 11.32a11.276 11.276 0 0 1-4.168 4.133 11.192 11.192 0 0 1-5.693 1.527H11.654c-2.032 0-3.93-.51-5.693-1.527a11.276 11.276 0 0 1-4.168-4.133c-2.211-3.774-2.271-7.547-.18-11.32L70.46 6.02a11.462 11.462 0 0 1 4.213-4.403A11.105 11.105 0 0 1 80.5 0c2.092 0 4.034.54 5.827 1.617A11.462 11.462 0 0 1 90.54 6.02z"/></defs><g fill="none" fill-rule="evenodd"><circle cx="256" cy="253" r="256" fill="#FDA20C"/><path fill="#000" fill-opacity=".3" fill-rule="nonzero" d="M361 98.292C361 90.982 353.978 85 345.396 85H163.604C155.022 85 148 90.981 148 98.292v296.416c0 7.31 7.022 13.292 15.604 13.292h181.792c8.582 0 15.604-5.981 15.604-13.292v-64.636c-5.462 3.323-11.703 6.646-17.945 9.305v33.399c0 1.329-.78 1.994-2.34 1.994h-172.43c-1.56 0-2.34-.665-2.34-1.994V126.87c0-1.329.78-1.993 2.34-1.993h172.43c1.56 0 2.34.664 2.34 1.993v63.113c3.901 0 8.582-.664 12.483-.664H361V98.292zM254.5 380c5.067 0 9.5 4.433 9.5 9.5s-4.433 9.5-9.5 9.5-9.5-4.433-9.5-9.5 4.433-9.5 9.5-9.5zm24.577-266.907h-47.593c-2.341 0-3.902-1.56-3.902-3.9s1.56-3.899 3.902-3.899h47.593c2.34 0 3.901 1.56 3.901 3.9 0 2.339-2.34 3.899-3.901 3.899z"/><path fill="#FFF" fill-rule="nonzero" d="M355.538 321.966c-3.9 0-8.582 0-12.483-.665v45.438c0 1.33-.78 1.996-2.34 1.996h-172.43c-1.56 0-2.34-.665-2.34-1.996V120.58c0-1.33.78-1.996 2.34-1.996h172.43c1.56 0 2.34.665 2.34 1.996v63.81c3.901 0 8.582-.666 12.483-.666H361V91.306C361 83.988 353.978 78 345.396 78H163.604C155.022 78 148 83.988 148 91.306v297.388c0 7.318 7.022 13.306 15.604 13.306h181.792c8.582 0 15.604-5.988 15.604-13.306v-66.728h-5.462zM230.703 98.871h47.594c2.34 0 3.9 1.56 3.9 3.901 0 2.34-1.56 3.902-3.12 3.902h-47.593c-2.341 0-3.902-1.561-3.902-3.902 0-2.34.78-3.901 3.121-3.901zM254.5 394c-5.067 0-9.5-4.433-9.5-9.5s4.433-9.5 9.5-9.5 9.5 4.433 9.5 9.5c0 5.7-4.433 9.5-9.5 9.5z"/><g opacity=".437" transform="translate(217 160)"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g fill="#2F3136" mask="url(#b)"><path d="M0 0H161V161H0z"/></g></g><g transform="translate(217 149)"><mask id="d" fill="#fff"><use xlink:href="#c"/></mask><g fill="#FFF" mask="url(#d)"><path d="M0 0H161V161H0z"/></g></g></g></svg>
\ No newline at end of file diff --git a/addons/sms/static/src/bugfix/bugfix.js b/addons/sms/static/src/bugfix/bugfix.js new file mode 100644 index 00000000..ee604b8b --- /dev/null +++ b/addons/sms/static/src/bugfix/bugfix.js @@ -0,0 +1,10 @@ +/** + * This file allows introducing new JS modules without contaminating other files. + * This is useful when bug fixing requires adding such JS modules in stable + * versions of Odoo. Any module that is defined in this file should be isolated + * in its own file in master. + */ +odoo.define('sms/static/src/bugfix/bugfix.js', function (require) { +'use strict'; + +}); diff --git a/addons/sms/static/src/bugfix/bugfix.scss b/addons/sms/static/src/bugfix/bugfix.scss new file mode 100644 index 00000000..c4272e52 --- /dev/null +++ b/addons/sms/static/src/bugfix/bugfix.scss @@ -0,0 +1,6 @@ +/** +* This file allows introducing new styles without contaminating other files. +* This is useful when bug fixing requires adding new components for instance in +* stable versions of Odoo. Any style that is defined in this file should be isolated +* in its own file in master. +*/ diff --git a/addons/sms/static/src/bugfix/bugfix.xml b/addons/sms/static/src/bugfix/bugfix.xml new file mode 100644 index 00000000..c17906f7 --- /dev/null +++ b/addons/sms/static/src/bugfix/bugfix.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + +<!-- + This file allows introducing new static templates without contaminating other files. + This is useful when bug fixing requires adding new components for instance in stable + versions of Odoo. Any template that is defined in this file should be isolated + in its own file in master. +--> + +</templates> diff --git a/addons/sms/static/src/bugfix/bugfix_tests.js b/addons/sms/static/src/bugfix/bugfix_tests.js new file mode 100644 index 00000000..938426c6 --- /dev/null +++ b/addons/sms/static/src/bugfix/bugfix_tests.js @@ -0,0 +1,18 @@ +odoo.define('sms/static/src/bugfix/bugfix_tests.js', function (require) { +'use strict'; + +/** + * This file allows introducing new QUnit test modules without contaminating + * other test files. This is useful when bug fixing requires adding new + * components for instance in stable versions of Odoo. Any test that is defined + * in this file should be isolated in its own file in master. + */ +QUnit.module('sms', {}, function () { +QUnit.module('bugfix', {}, function () { +QUnit.module('bugfix_tests.js', { + +}); +}); +}); + +}); diff --git a/addons/sms/static/src/components/message/message.xml b/addons/sms/static/src/components/message/message.xml new file mode 100644 index 00000000..b8b6a18d --- /dev/null +++ b/addons/sms/static/src/components/message/message.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-inherit="mail.Message" t-inherit-mode="extension"> + <xpath expr="//*[@name='failureIcon']" position="replace"> + <t t-if="message.message_type === 'sms'"> + <i class="o_Message_notificationIcon fa fa-mobile"/> SMS + </t> + <t t-else="">$0</t> + </xpath> + + <xpath expr="//*[@name='notificationIcon']" position="replace"> + <t t-if="message.message_type === 'sms'"> + <i class="o_Message_notificationIcon fa fa-mobile"/> SMS + </t> + <t t-else="">$0</t> + </xpath> + </t> + +</templates> diff --git a/addons/sms/static/src/components/message/message_tests.js b/addons/sms/static/src/components/message/message_tests.js new file mode 100644 index 00000000..57bc21ab --- /dev/null +++ b/addons/sms/static/src/components/message/message_tests.js @@ -0,0 +1,197 @@ +odoo.define('sms/static/src/components/message/message_tests.js', function (require) { +'use strict'; + +const components = { + Message: require('mail/static/src/components/message/message.js'), +}; +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('sms', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('message', {}, function () { +QUnit.module('message_tests.js', { + beforeEach() { + beforeEach(this); + + this.createMessageComponent = async (message, otherProps) => { + const props = Object.assign({ messageLocalId: message.localId }, otherProps); + await createRootComponent(this, components.Message, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('Notification Sent', async function (assert) { + assert.expect(9); + + await this.start(); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['create', { + id: 11, + model: 'mail.channel', + }]], + }); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'sms', + notifications: [['insert', { + id: 11, + notification_status: 'sent', + notification_type: 'sms', + partner: [['insert', { id: 12, name: "Someone" }]], + }]], + originThread: [['link', threadViewer.thread]] + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIconClickable', + "should display the notification icon container" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIcon', + "should display the notification icon" + ); + assert.hasClass( + document.querySelector('.o_Message_notificationIcon'), + 'fa-mobile', + "icon should represent sms" + ); + + await afterNextRender(() => { + document.querySelector('.o_Message_notificationIconClickable').click(); + }); + assert.containsOnce( + document.body, + '.o_NotificationPopover', + "notification popover should be open" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationIcon', + "popover should have one icon" + ); + assert.hasClass( + document.querySelector('.o_NotificationPopover_notificationIcon'), + 'fa-check', + "popover should have the sent icon" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationPartnerName', + "popover should have the partner name" + ); + assert.strictEqual( + document.querySelector('.o_NotificationPopover_notificationPartnerName').textContent.trim(), + "Someone", + "partner name should be correct" + ); +}); + +QUnit.test('Notification Error', async function (assert) { + assert.expect(8); + + const openResendActionDef = makeDeferred(); + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action, + 'sms.sms_resend_action', + "action should be the one to resend sms" + ); + assert.strictEqual( + payload.options.additional_context.default_mail_message_id, + 10, + "action should have correct message id" + ); + openResendActionDef.resolve(); + }); + + await this.start({ env: { bus } }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['create', { + id: 11, + model: 'mail.channel', + }]], + }); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'sms', + notifications: [['insert', { + id: 11, + notification_status: 'exception', + notification_type: 'sms', + }]], + originThread: [['link', threadViewer.thread]] + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIconClickable', + "should display the notification icon container" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIcon', + "should display the notification icon" + ); + assert.hasClass( + document.querySelector('.o_Message_notificationIcon'), + 'fa-mobile', + "icon should represent sms" + ); + document.querySelector('.o_Message_notificationIconClickable').click(); + await openResendActionDef; + assert.verifySteps( + ['do_action'], + "should do an action to display the resend sms dialog" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/sms/static/src/components/notification_group/notification_group.js b/addons/sms/static/src/components/notification_group/notification_group.js new file mode 100644 index 00000000..053fedc5 --- /dev/null +++ b/addons/sms/static/src/components/notification_group/notification_group.js @@ -0,0 +1,27 @@ +odoo.define('sms/static/src/components/notification_group/notification_group.js', function (require) { +'use strict'; + +const components = { + NotificationGroup: require('mail/static/src/components/notification_group/notification_group.js'), +}; + +const { patch } = require('web.utils'); + +patch(components.NotificationGroup, 'sms/static/src/components/notification_group/notification_group.js', { + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + image() { + if (this.group.notification_type === 'sms') { + return '/sms/static/img/sms_failure.svg'; + } + return this._super(...arguments); + }, +}); + +}); diff --git a/addons/sms/static/src/components/notification_group/notification_group.xml b/addons/sms/static/src/components/notification_group/notification_group.xml new file mode 100644 index 00000000..c5f5a8db --- /dev/null +++ b/addons/sms/static/src/components/notification_group/notification_group.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-inherit="mail.NotificationGroup" t-inherit-mode="extension"> + <xpath expr="//*[hasclass('o_NotificationGroup_inlineText')]" position="inside"> + <t t-if="group.notification_type === 'sms'"> + An error occurred when sending an SMS. + </t> + </xpath> + </t> + +</templates> diff --git a/addons/sms/static/src/components/notification_list/notification_list_notification_group_tests.js b/addons/sms/static/src/components/notification_list/notification_list_notification_group_tests.js new file mode 100644 index 00000000..bd5d8402 --- /dev/null +++ b/addons/sms/static/src/components/notification_list/notification_list_notification_group_tests.js @@ -0,0 +1,309 @@ +odoo.define('sms/static/src/components/notification_list/notification_list_notification_group_tests.js', function (require) { +'use strict'; + +const components = { + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), +}; + +const { + afterEach, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('sms', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('notification_list', {}, function () { +QUnit.module('notification_list_notification_group_tests.js', { + beforeEach() { + beforeEach(this); + + /** + * @param {Object} param0 + * @param {string} [param0.filter='all'] + */ + this.createNotificationListComponent = async ({ filter = 'all' } = {}) => { + await createRootComponent(this, components.NotificationList, { + props: { filter }, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('mark as read', async function (assert) { + assert.expect(6); + + this.data['mail.message'].records.push( + // message that is expected to have a failure + { + id: 11, // random unique id, will be used to link failure to message + message_type: 'sms', // message must be sms (goal of the test) + model: 'mail.channel', // expected value to link message to channel + res_id: 31, // id of a random channel + } + ); + this.data['mail.notification'].records.push( + // failure that is expected to be used in the test + { + mail_message_id: 11, // id of the related message + notification_status: 'exception', // necessary value to have a failure + notification_type: 'sms', // expected failure type for sms message + } + ); + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action, + 'sms.sms_cancel_action', + "action should be the one to cancel sms" + ); + assert.strictEqual( + payload.options.additional_context.default_model, + 'mail.channel', + "action should have the group model as default_model" + ); + assert.strictEqual( + payload.options.additional_context.unread_counter, + 1, + "action should have the group notification length as unread_counter" + ); + }); + + await this.start({ env: { bus } }); + await this.createNotificationListComponent(); + + assert.containsOnce( + document.body, + '.o_NotificationGroup_markAsRead', + "should have 1 mark as read button" + ); + + document.querySelector('.o_NotificationGroup_markAsRead').click(); + assert.verifySteps( + ['do_action'], + "should do an action to display the cancel sms dialog" + ); +}); + +QUnit.test('notifications grouped by notification_type', async function (assert) { + assert.expect(11); + + this.data['mail.message'].records.push( + // first message that is expected to have a failure + { + id: 11, // random unique id, will be used to link failure to message + message_type: 'email', // different type from second message + model: 'res.partner', // same model as second message (and not `mail.channel`) + res_id: 31, // same res_id as second message + res_model_name: "Partner", // random related model name + }, + // second message that is expected to have a failure + { + id: 12, // random unique id, will be used to link failure to message + message_type: 'sms', // different type from first message + model: 'res.partner', // same model as first message (and not `mail.channel`) + res_id: 31, // same res_id as first message + res_model_name: "Partner", // same related model name for consistency + } + ); + this.data['mail.notification'].records.push( + // first failure that is expected to be used in the test + { + mail_message_id: 11, // id of the related first message + notification_status: 'exception', // necessary value to have a failure + notification_type: 'email', // different type from second failure + }, + // second failure that is expected to be used in the test + { + mail_message_id: 12, // id of the related second message + notification_status: 'exception', // necessary value to have a failure + notification_type: 'sms', // different type from first failure + } + ); + await this.start(); + await this.createNotificationListComponent(); + + assert.containsN( + document.body, + '.o_NotificationGroup', + 2, + "should have 2 notifications group" + ); + const groups = document.querySelectorAll('.o_NotificationGroup'); + assert.containsOnce( + groups[0], + '.o_NotificationGroup_name', + "should have 1 group name in first group" + ); + assert.strictEqual( + groups[0].querySelector('.o_NotificationGroup_name').textContent, + "Partner", + "should have model name as group name" + ); + assert.containsOnce( + groups[0], + '.o_NotificationGroup_counter', + "should have 1 group counter in first group" + ); + assert.strictEqual( + groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in first group" + ); + assert.strictEqual( + groups[0].querySelector('.o_NotificationGroup_inlineText').textContent.trim(), + "An error occurred when sending an email.", + "should have the group text corresponding to email" + ); + assert.containsOnce( + groups[1], + '.o_NotificationGroup_name', + "should have 1 group name in second group" + ); + assert.strictEqual( + groups[1].querySelector('.o_NotificationGroup_name').textContent, + "Partner", + "should have second model name as group name" + ); + assert.containsOnce( + groups[1], + '.o_NotificationGroup_counter', + "should have 1 group counter in second group" + ); + assert.strictEqual( + groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in second group" + ); + assert.strictEqual( + groups[1].querySelector('.o_NotificationGroup_inlineText').textContent.trim(), + "An error occurred when sending an SMS.", + "should have the group text corresponding to sms" + ); +}); + +QUnit.test('grouped notifications by document model', async function (assert) { + // If all failures linked to a document model refers to different documents, + // a single notification should group all failures that are linked to this + // document model. + assert.expect(12); + + this.data['mail.message'].records.push( + // first message that is expected to have a failure + { + id: 11, // random unique id, will be used to link failure to message + message_type: 'sms', // message must be sms (goal of the test) + model: 'res.partner', // same model as second message (and not `mail.channel`) + res_id: 31, // different res_id from second message + res_model_name: "Partner", // random related model name + }, + // second message that is expected to have a failure + { + id: 12, // random unique id, will be used to link failure to message + message_type: 'sms', // message must be sms (goal of the test) + model: 'res.partner', // same model as first message (and not `mail.channel`) + res_id: 32, // different res_id from first message + res_model_name: "Partner", // same related model name for consistency + } + ); + this.data['mail.notification'].records.push( + // first failure that is expected to be used in the test + { + mail_message_id: 11, // id of the related first message + notification_status: 'exception', // necessary value to have a failure + notification_type: 'sms', // expected failure type for sms message + }, + // second failure that is expected to be used in the test + { + mail_message_id: 12, // id of the related second message + notification_status: 'exception', // necessary value to have a failure + notification_type: 'sms', // expected failure type for sms message + } + ); + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.name, + "SMS Failures", + "action should have 'SMS Failures' as name", + ); + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + "action should have the type act_window" + ); + assert.strictEqual( + payload.action.view_mode, + 'kanban,list,form', + "action should have 'kanban,list,form' as view_mode" + ); + assert.strictEqual( + JSON.stringify(payload.action.views), + JSON.stringify([[false, 'kanban'], [false, 'list'], [false, 'form']]), + "action should have correct views" + ); + assert.strictEqual( + payload.action.target, + 'current', + "action should have 'current' as target" + ); + assert.strictEqual( + payload.action.res_model, + 'res.partner', + "action should have the group model as res_model" + ); + assert.strictEqual( + JSON.stringify(payload.action.domain), + JSON.stringify([['message_has_sms_error', '=', true]]), + "action should have 'message_has_sms_error' as domain" + ); + }); + + await this.start({ env: { bus } }); + await this.createNotificationListComponent(); + + assert.containsOnce( + document.body, + '.o_NotificationGroup', + "should have 1 notification group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_counter', + "should have 1 group counter" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(2)", + "should have 2 notifications in the group" + ); + + document.querySelector('.o_NotificationGroup').click(); + assert.verifySteps( + ['do_action'], + "should do an action to display the related records" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/sms/static/src/js/fields_phone_widget.js b/addons/sms/static/src/js/fields_phone_widget.js new file mode 100644 index 00000000..82b8e5b3 --- /dev/null +++ b/addons/sms/static/src/js/fields_phone_widget.js @@ -0,0 +1,100 @@ +odoo.define('sms.fields', function (require) { +"use strict"; + +var basic_fields = require('web.basic_fields'); +var core = require('web.core'); +var session = require('web.session'); + +var _t = core._t; + +/** + * Override of FieldPhone to add a button calling SMS composer if option activated (default) + */ + +var Phone = basic_fields.FieldPhone; +Phone.include({ + /** + * By default, enable_sms is activated + * + * @override + */ + init() { + this._super.apply(this, arguments); + this.enableSMS = 'enable_sms' in this.attrs.options ? this.attrs.options.enable_sms : true; + // reinject in nodeOptions (and thus in this.attrs) to signal the property + this.attrs.options.enable_sms = this.enableSMS; + }, + /** + * When the send SMS button is displayed, $el becomes a div wrapping + * the original links. + * This method makes sure we always focus the phone number + * + * @override + */ + getFocusableElement() { + if (this.enableSMS && this.mode === 'readonly') { + return this.$el.filter('.' + this.className); + } + return this._super.apply(this, arguments); + }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Open SMS composer wizard + * + * @private + */ + _onClickSMS: function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + + var context = session.user_context; + context = _.extend({}, context, { + default_res_model: this.model, + default_res_id: parseInt(this.res_id), + default_number_field_name: this.name, + default_composition_mode: 'comment', + }); + var self = this; + return this.do_action({ + title: _t('Send SMS Text Message'), + type: 'ir.actions.act_window', + res_model: 'sms.composer', + target: 'new', + views: [[false, 'form']], + context: context, + }, { + on_close: function () { + self.trigger_up('reload'); + }}); + }, + + /** + * Add a button to call the composer wizard + * + * @override + * @private + */ + _renderReadonly: function () { + var def = this._super.apply(this, arguments); + if (this.enableSMS && this.value) { + var $composerButton = $('<a>', { + title: _t('Send SMS Text Message'), + href: '', + class: 'ml-3 d-inline-flex align-items-center o_field_phone_sms', + html: $('<small>', {class: 'font-weight-bold ml-1', html: 'SMS'}), + }); + $composerButton.prepend($('<i>', {class: 'fa fa-mobile'})); + $composerButton.on('click', this._onClickSMS.bind(this)); + this.$el = this.$el.add($composerButton); + } + + return def; + }, +}); + +return Phone; + +}); diff --git a/addons/sms/static/src/js/fields_sms_widget.js b/addons/sms/static/src/js/fields_sms_widget.js new file mode 100644 index 00000000..5a52d68e --- /dev/null +++ b/addons/sms/static/src/js/fields_sms_widget.js @@ -0,0 +1,185 @@ +odoo.define('sms.sms_widget', function (require) { +"use strict"; + +var core = require('web.core'); +var fieldRegistry = require('web.field_registry'); +var FieldTextEmojis = require('mail.field_text_emojis'); + +var _t = core._t; +/** + * SmsWidget is a widget to display a textarea (the body) and a text representing + * the number of SMS and the number of characters. This text is computed every + * time the user changes the body. + */ +var SmsWidget = FieldTextEmojis.extend({ + className: 'o_field_text', + enableEmojis: false, + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + this.nbrChar = 0; + this.nbrSMS = 0; + this.encoding = 'GSM7'; + this.enableEmojis = !!this.nodeOptions.enable_emojis; + }, + + /** + * @override + *"This will add the emoji dropdown to a target field (controlled by the "enableEmojis" attribute) + */ + on_attach_callback: function () { + if (this.enableEmojis) { + this._super.apply(this, arguments); + } + }, + + //-------------------------------------------------------------------------- + // Private: override widget + //-------------------------------------------------------------------------- + + /** + * @private + * @override + */ + _renderEdit: function () { + var def = this._super.apply(this, arguments); + + this._compute(); + $('.o_sms_container').remove(); + var $sms_container = $('<div class="o_sms_container"/>'); + $sms_container.append(this._renderSMSInfo()); + $sms_container.append(this._renderIAPButton()); + this.$el = this.$el.add($sms_container); + + return def; + }, + + //-------------------------------------------------------------------------- + // Private: SMS + //-------------------------------------------------------------------------- + + /** + * Compute the number of characters and sms + * @private + */ + _compute: function () { + var content = this._getValue(); + this.encoding = this._extractEncoding(content); + this.nbrChar = content.length; + this.nbrChar += (content.match(/\n/g) || []).length; + this.nbrSMS = this._countSMS(this.nbrChar, this.encoding); + }, + + /** + * Count the number of SMS of the content + * @private + * @returns {integer} Number of SMS + */ + _countSMS: function () { + if (this.nbrChar === 0) { + return 0; + } + if (this.encoding === 'UNICODE') { + if (this.nbrChar <= 70) { + return 1; + } + return Math.ceil(this.nbrChar / 67); + } + if (this.nbrChar <= 160) { + return 1; + } + return Math.ceil(this.nbrChar / 153); + }, + + /** + * Extract the encoding depending on the characters in the content + * @private + * @param {String} content Content of the SMS + * @returns {String} Encoding of the content (GSM7 or UNICODE) + */ + _extractEncoding: function (content) { + if (String(content).match(RegExp("^[@£$¥èéùìòÇ\\nØø\\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\\\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà]*$"))) { + return 'GSM7'; + } + return 'UNICODE'; + }, + + /** + * Render the IAP button to redirect to IAP pricing + * @private + */ + _renderIAPButton: function () { + return $('<a>', { + 'href': 'https://iap-services.odoo.com/iap/sms/pricing', + 'target': '_blank', + 'title': _t('SMS Pricing'), + 'aria-label': _t('SMS Pricing'), + 'class': 'fa fa-lg fa-info-circle', + }); + }, + + /** + * Render the number of characters, sms and the encoding. + * @private + */ + _renderSMSInfo: function () { + var string = _.str.sprintf(_t('%s characters, fits in %s SMS (%s) '), this.nbrChar, this.nbrSMS, this.encoding); + var $span = $('<span>', { + 'class': 'text-muted o_sms_count', + }); + $span.text(string); + return $span; + }, + + /** + * Update widget SMS information with re-computed info about length, ... + * @private + */ + _updateSMSInfo: function () { + this._compute(); + var string = _.str.sprintf(_t('%s characters, fits in %s SMS (%s) '), this.nbrChar, this.nbrSMS, this.encoding); + this.$('.o_sms_count').text(string); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _onBlur: function () { + var content = this._getValue(); + if( !content.trim().length && content.length > 0) { + this.do_warn(_t("Your SMS Text Message must include at least one non-whitespace character")); + this.$input.val(content.trim()); + this._updateSMSInfo(); + } + }, + + /** + * @override + * @private + */ + _onChange: function () { + this._super.apply(this, arguments); + this._updateSMSInfo(); + }, + + /** + * @override + * @private + */ + _onInput: function () { + this._super.apply(this, arguments); + this._updateSMSInfo(); + }, +}); + +fieldRegistry.add('sms_widget', SmsWidget); + +return SmsWidget; +}); diff --git a/addons/sms/static/src/models/message/message.js b/addons/sms/static/src/models/message/message.js new file mode 100644 index 00000000..2f468cc0 --- /dev/null +++ b/addons/sms/static/src/models/message/message.js @@ -0,0 +1,33 @@ +odoo.define('sms/static/src/models/message/message.js', function (require) { +'use strict'; + +const { + registerInstancePatchModel, +} = require('mail/static/src/model/model_core.js'); + +registerInstancePatchModel('mail.message', 'sms/static/src/models/message/message.js', { + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + openResendAction() { + if (this.message_type === 'sms') { + this.env.bus.trigger('do-action', { + action: 'sms.sms_resend_action', + options: { + additional_context: { + default_mail_message_id: this.id, + }, + }, + }); + } else { + this._super(...arguments); + } + }, +}); + +}); diff --git a/addons/sms/static/src/models/notification_group/notification_group.js b/addons/sms/static/src/models/notification_group/notification_group.js new file mode 100644 index 00000000..887d9a7d --- /dev/null +++ b/addons/sms/static/src/models/notification_group/notification_group.js @@ -0,0 +1,62 @@ +odoo.define('sms/static/src/models/notification_group/notification_group.js', function (require) { +'use strict'; + +const { + registerInstancePatchModel, +} = require('mail/static/src/model/model_core.js'); + +registerInstancePatchModel('mail.notification_group', 'sms/static/src/models/notification_group/notification_group.js', { + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + openCancelAction() { + if (this.notification_type !== 'sms') { + return this._super(...arguments); + } + this.env.bus.trigger('do-action', { + action: 'sms.sms_cancel_action', + options: { + additional_context: { + default_model: this.res_model, + unread_counter: this.notifications.length, + }, + }, + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _openDocuments() { + if (this.notification_type !== 'sms') { + return this._super(...arguments); + } + this.env.bus.trigger('do-action', { + action: { + name: this.env._t("SMS Failures"), + type: 'ir.actions.act_window', + view_mode: 'kanban,list,form', + views: [[false, 'kanban'], [false, 'list'], [false, 'form']], + target: 'current', + res_model: this.res_model, + domain: [['message_has_sms_error', '=', true]], + }, + }); + if (this.env.messaging.device.isMobile) { + // messaging menu has a higher z-index than views so it must + // be closed to ensure the visibility of the view + this.env.messaging.messagingMenu.close(); + } + }, +}); + +}); diff --git a/addons/sms/static/tests/sms_widget_test.js b/addons/sms/static/tests/sms_widget_test.js new file mode 100644 index 00000000..3da34dcd --- /dev/null +++ b/addons/sms/static/tests/sms_widget_test.js @@ -0,0 +1,229 @@ +odoo.define('sms.sms_widget_tests', function (require) { +"use strict"; + +var config = require('web.config'); +var FormView = require('web.FormView'); +var ListView = require('web.ListView'); +var testUtils = require('web.test_utils'); + +var createView = testUtils.createView; + +QUnit.module('fields', { + beforeEach: function () { + this.data = { + partner: { + fields: { + message: {string: "message", type: "text"}, + foo: {string: "Foo", type: "char", default: "My little Foo Value"}, + mobile: {string: "mobile", type: "text"}, + }, + records: [{ + id: 1, + message: "", + foo: 'yop', + mobile: "+32494444444", + }, { + id: 2, + message: "", + foo: 'bayou', + }] + }, + visitor: { + fields: { + mobile: {string: "mobile", type: "text"}, + }, + records: [{ + id: 1, + mobile: "+32494444444", + }] + }, + }; + } +}, function () { + + QUnit.module('SmsWidget'); + + QUnit.test('Sms widgets are correctly rendered', async function (assert) { + assert.expect(9); + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><sheet><field name="message" widget="sms_widget"/></sheet></form>', + }); + + assert.containsOnce(form, '.o_sms_count', "Should have a sms counter"); + assert.strictEqual(form.$('.o_sms_count').text(), '0 characters, fits in 0 SMS (GSM7) ', + 'Should be "0 characters, fits in 0 SMS (GSM7) " by default'); + // GSM-7 + await testUtils.fields.editAndTrigger(form.$('.o_input'), "Hello from Odoo", 'input'); + assert.strictEqual(form.$('.o_sms_count').text(), '15 characters, fits in 1 SMS (GSM7) ', + 'Should be "15 characters, fits in 1 SMS (GSM7) " for "Hello from Odoo"'); + // GSM-7 with \n => this one count as 2 characters + form.$('.o_input').val("Hello from Odoo\n").trigger('input'); + assert.strictEqual(form.$('.o_sms_count').text(), '17 characters, fits in 1 SMS (GSM7) ', + 'Should be "17 characters, fits in 1 SMS (GSM7) " for "Hello from Odoo\\n"'); + // Unicode => ê + form.$('.o_input').val("Hêllo from Odoo").trigger('input'); + assert.strictEqual(form.$('.o_sms_count').text(), '15 characters, fits in 1 SMS (UNICODE) ', + 'Should be "15 characters, fits in 1 SMS (UNICODE) " for "Hêllo from Odoo"'); + // GSM-7 with 160c + var text = Array(161).join('a'); + await testUtils.fields.editAndTrigger(form.$('.o_input'), text, 'input'); + assert.strictEqual(form.$('.o_sms_count').text(), '160 characters, fits in 1 SMS (GSM7) ', + 'Should be "160 characters, fits in 1 SMS (GSM7) " for 160 x "a"'); + // GSM-7 with 161c + text = Array(162).join('a'); + await testUtils.fields.editAndTrigger(form.$('.o_input'), text, 'input'); + assert.strictEqual(form.$('.o_sms_count').text(), '161 characters, fits in 2 SMS (GSM7) ', + 'Should be "161 characters, fits in 2 SMS (GSM7) " for 161 x "a"'); + // Unicode with 70c + text = Array(71).join('ê'); + await testUtils.fields.editAndTrigger(form.$('.o_input'), text, 'input'); + assert.strictEqual(form.$('.o_sms_count').text(), '70 characters, fits in 1 SMS (UNICODE) ', + 'Should be "70 characters, fits in 1 SMS (UNICODE) " for 70 x "ê"'); + // Unicode with 71c + text = Array(72).join('ê'); + await testUtils.fields.editAndTrigger(form.$('.o_input'), text, 'input'); + assert.strictEqual(form.$('.o_sms_count').text(), '71 characters, fits in 2 SMS (UNICODE) ', + 'Should be "71 characters, fits in 2 SMS (UNICODE) " for 71 x "ê"'); + + form.destroy(); + }); + + QUnit.test('Sms widgets with non-empty initial value', async function (assert) { + assert.expect(1); + var form = await createView({ + View: FormView, + model: 'visitor', + data: this.data, + arch: `<form><sheet><field name="mobile" widget="sms_widget"/></sheet></form>`, + res_id: 1, + res_ids: [1], + }); + + assert.strictEqual(form.$('.o_field_text').text(), '+32494444444', + 'Should have the initial value'); + + form.destroy(); + }); + + QUnit.test('Sms widgets with empty initial value', async function (assert) { + assert.expect(1); + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: `<form><sheet><field name="message" widget="sms_widget"/></sheet></form>`, + res_id: 1, + res_ids: [1], + }); + + assert.strictEqual(form.$('.o_field_text').text(), '', + 'Should have the empty initial value'); + + form.destroy(); + }); + + QUnit.module('PhoneWidget'); + + QUnit.test('phone field in editable list view on normal screens', async function (assert) { + assert.expect(11); + var doActionCount = 0; + + var list = await createView({ + View: ListView, + model: 'partner', + data: this.data, + debug:true, + arch: '<tree editable="bottom"><field name="foo" widget="phone"/></tree>', + intercepts: { + do_action(ev) { + assert.equal(ev.data.action.res_model, 'sms.composer', + 'The action to send an SMS should have been executed'); + doActionCount += 1; + } + } + }); + + assert.containsN(list, 'tbody td:not(.o_list_record_selector)', 4); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'yopSMS', + "value should be displayed properly with a link to send SMS"); + + assert.containsN(list, 'a.o_field_widget.o_form_uri', 2, + "should have the correct classnames"); + + // Edit a line and check the result + var $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + await testUtils.dom.click($cell); + assert.hasClass($cell.parent(),'o_selected_row', 'should be set as edit mode'); + assert.strictEqual($cell.find('input').val(), 'yop', + 'should have the corect value in internal input'); + await testUtils.fields.editInput($cell.find('input'), 'new'); + + // save + await testUtils.dom.click(list.$buttons.find('.o_list_button_save')); + $cell = list.$('tbody td:not(.o_list_record_selector)').first(); + assert.doesNotHaveClass($cell.parent(), 'o_selected_row', 'should not be in edit mode anymore'); + assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').first().text(), 'newSMS', + "value should be properly updated"); + assert.containsN(list, 'a.o_field_widget.o_form_uri', 2, + "should still have links with correct classes"); + + await testUtils.dom.click(list.$('tbody td:not(.o_list_record_selector) .o_field_phone_sms').first()); + assert.equal(doActionCount, 1, 'Only one action should have been executed'); + assert.containsNone(list, '.o_selected_row', + 'None of the list element should have been activated'); + + list.destroy(); + }); + + QUnit.test('readonly sms phone field is properly rerendered after been changed by onchange', async function (assert) { + assert.expect(4); + + const NEW_PHONE = '+32595555555'; + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="foo" on_change="1"/>' + // onchange to update mobile in readonly mode directly + '<field name="mobile" widget="phone" readonly="1"/>' + // readonly only, we don't want to go through write mode + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + viewOptions: {mode: 'edit'}, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + return Promise.resolve({ + value: { + mobile: NEW_PHONE, // onchange to update mobile in readonly mode directly + }, + }); + } + return this._super.apply(this, arguments); + }, + }); + // check initial rendering + assert.strictEqual(form.$('.o_field_phone').text(), "+32494444444", + 'Initial Phone text should be set'); + assert.strictEqual(form.$('.o_field_phone_sms').text(), 'SMS', + 'SMS button label should be rendered'); + + // trigger the onchange to update phone field, but still in readonly mode + await testUtils.fields.editInput($('input[name="foo"]'), 'someOtherFoo'); + + // check rendering after changes + assert.strictEqual(form.$('.o_field_phone').text(), NEW_PHONE, + 'Phone text should be updated'); + assert.strictEqual(form.$('.o_field_phone_sms').text(), 'SMS', + 'SMS button label should not be changed'); + + form.destroy(); + }); +}); +}); |
