summaryrefslogtreecommitdiff
path: root/addons/snailmail/static/src
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/snailmail/static/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/snailmail/static/src')
-rw-r--r--addons/snailmail/static/src/bugfix/bugfix.js10
-rw-r--r--addons/snailmail/static/src/bugfix/bugfix.scss6
-rw-r--r--addons/snailmail/static/src/bugfix/bugfix.xml11
-rw-r--r--addons/snailmail/static/src/bugfix/bugfix_tests.js18
-rw-r--r--addons/snailmail/static/src/components/message/message.js87
-rw-r--r--addons/snailmail/static/src/components/message/message.xml37
-rw-r--r--addons/snailmail/static/src/components/message/message_tests.js680
-rw-r--r--addons/snailmail/static/src/components/notification_group/notification_group.js27
-rw-r--r--addons/snailmail/static/src/components/notification_group/notification_group.xml12
-rw-r--r--addons/snailmail/static/src/components/notification_list/notification_list_notification_group_tests.js304
-rw-r--r--addons/snailmail/static/src/components/snailmail_error_dialog/snailmail_error_dialog.js113
-rw-r--r--addons/snailmail/static/src/components/snailmail_error_dialog/snailmail_error_dialog.xml61
-rw-r--r--addons/snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.js86
-rw-r--r--addons/snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.scss7
-rw-r--r--addons/snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.xml13
-rw-r--r--addons/snailmail/static/src/js/snailmail_external_layout.js7
-rw-r--r--addons/snailmail/static/src/models/message/message.js69
-rw-r--r--addons/snailmail/static/src/models/messaging/messaging.js38
-rw-r--r--addons/snailmail/static/src/models/notification_group/notification_group.js62
-rw-r--r--addons/snailmail/static/src/scss/snailmail_external_layout_asset.scss50
20 files changed, 1698 insertions, 0 deletions
diff --git a/addons/snailmail/static/src/bugfix/bugfix.js b/addons/snailmail/static/src/bugfix/bugfix.js
new file mode 100644
index 00000000..a375f40f
--- /dev/null
+++ b/addons/snailmail/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('snailmail/static/src/bugfix/bugfix.js', function (require) {
+'use strict';
+
+});
diff --git a/addons/snailmail/static/src/bugfix/bugfix.scss b/addons/snailmail/static/src/bugfix/bugfix.scss
new file mode 100644
index 00000000..c4272e52
--- /dev/null
+++ b/addons/snailmail/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/snailmail/static/src/bugfix/bugfix.xml b/addons/snailmail/static/src/bugfix/bugfix.xml
new file mode 100644
index 00000000..c17906f7
--- /dev/null
+++ b/addons/snailmail/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/snailmail/static/src/bugfix/bugfix_tests.js b/addons/snailmail/static/src/bugfix/bugfix_tests.js
new file mode 100644
index 00000000..beffb29b
--- /dev/null
+++ b/addons/snailmail/static/src/bugfix/bugfix_tests.js
@@ -0,0 +1,18 @@
+odoo.define('snailmail/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('snailmail', {}, function () {
+QUnit.module('bugfix', {}, function () {
+QUnit.module('bugfix_tests.js', {
+
+});
+});
+});
+
+});
diff --git a/addons/snailmail/static/src/components/message/message.js b/addons/snailmail/static/src/components/message/message.js
new file mode 100644
index 00000000..1033c901
--- /dev/null
+++ b/addons/snailmail/static/src/components/message/message.js
@@ -0,0 +1,87 @@
+odoo.define('snailmail/static/src/components/message/message.js', function (require) {
+'use strict';
+
+const components = {
+ Message: require('mail/static/src/components/message/message.js'),
+ SnailmailErrorDialog: require('snailmail/static/src/components/snailmail_error_dialog/snailmail_error_dialog.js'),
+ SnailmailNotificationPopover: require('snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.js'),
+};
+
+const { patch } = require('web.utils');
+
+const { useState } = owl;
+
+Object.assign(components.Message.components, {
+ SnailmailErrorDialog: components.SnailmailErrorDialog,
+ SnailmailNotificationPopover: components.SnailmailNotificationPopover,
+});
+
+patch(components.Message, 'snailmail/static/src/components/message/message.js', {
+ /**
+ * @override
+ */
+ _constructor() {
+ this._super(...arguments);
+ this.snailmailState = useState({
+ // Determine if the error dialog is displayed.
+ hasDialog: false,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onClickFailure() {
+ if (this.message.message_type === 'snailmail') {
+ /**
+ * Messages from snailmail are considered to have at most one
+ * notification. The failure type of the whole message is considered
+ * to be the same as the one from that first notification, and the
+ * click action will depend on it.
+ */
+ switch (this.message.notifications[0].failure_type) {
+ case 'sn_credit':
+ // URL only used in this component, not received at init
+ this.env.messaging.fetchSnailmailCreditsUrl();
+ this.snailmailState.hasDialog = true;
+ break;
+ case 'sn_error':
+ this.snailmailState.hasDialog = true;
+ break;
+ case 'sn_fields':
+ this.message.openMissingFieldsLetterAction();
+ break;
+ case 'sn_format':
+ this.message.openFormatLetterAction();
+ break;
+ case 'sn_price':
+ this.snailmailState.hasDialog = true;
+ break;
+ case 'sn_trial':
+ // URL only used in this component, not received at init
+ this.env.messaging.fetchSnailmailCreditsUrlTrial();
+ this.snailmailState.hasDialog = true;
+ break;
+ }
+ } else {
+ this._super(...arguments);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onDialogClosedSnailmailError() {
+ this.snailmailState.hasDialog = false;
+ },
+});
+
+});
diff --git a/addons/snailmail/static/src/components/message/message.xml b/addons/snailmail/static/src/components/message/message.xml
new file mode 100644
index 00000000..d563083f
--- /dev/null
+++ b/addons/snailmail/static/src/components/message/message.xml
@@ -0,0 +1,37 @@
+<?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 === 'snailmail'">
+ <i class="o_Message_notificationIcon fa fa-paper-plane"/>
+ </t>
+ <t t-else="">$0</t>
+ </xpath>
+
+ <xpath expr="//*[@name='notificationIcon']" position="replace">
+ <t t-if="message.message_type === 'snailmail'">
+ <i class="o_Message_notificationIcon fa fa-paper-plane"/>
+ </t>
+ <t t-else="">$0</t>
+ </xpath>
+
+ <xpath expr="//*[@name='rootCondition']" position="inside">
+ <t t-if="snailmailState.hasDialog">
+ <SnailmailErrorDialog messageLocalId="message.localId" t-on-dialog-closed="_onDialogClosedSnailmailError"/>
+ </t>
+ </xpath>
+
+ <!--
+ It was decided that the information displayed for snailmail messages
+ has to be different than for standard messages, see task-1907998.
+ -->
+ <xpath expr="//NotificationPopover" position="replace">
+ <t t-if="message.message_type === 'snailmail'">
+ <SnailmailNotificationPopover messageLocalId="message.localId"/>
+ </t>
+ <t t-else="">$0</t>
+ </xpath>
+ </t>
+
+</templates>
diff --git a/addons/snailmail/static/src/components/message/message_tests.js b/addons/snailmail/static/src/components/message/message_tests.js
new file mode 100644
index 00000000..67044509
--- /dev/null
+++ b/addons/snailmail/static/src/components/message/message_tests.js
@@ -0,0 +1,680 @@
+odoo.define('snailmail/static/src/components/message/message_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Message: require('mail/static/src/components/message/message.js'),
+};
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('snailmail', {}, 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('Sent', async function (assert) {
+ assert.expect(8);
+
+ 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: 'snailmail',
+ notifications: [['insert', {
+ id: 11,
+ notification_status: 'sent',
+ notification_type: 'snail',
+ }]],
+ 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-paper-plane',
+ "icon should represent snailmail"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconClickable').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailNotificationPopover',
+ "notification popover should be open"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailNotificationPopover_icon',
+ "popover should have one icon"
+ );
+ assert.hasClass(
+ document.querySelector('.o_SnailmailNotificationPopover_icon'),
+ 'fa-check',
+ "popover should have the sent icon"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_SnailmailNotificationPopover').textContent.trim(),
+ "Sent",
+ "popover should have the sent text"
+ );
+});
+
+QUnit.test('Canceled', async function (assert) {
+ assert.expect(8);
+
+ 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: 'snailmail',
+ notifications: [['insert', {
+ id: 11,
+ notification_status: 'canceled',
+ notification_type: 'snail',
+ }]],
+ 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-paper-plane',
+ "icon should represent snailmail"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconClickable').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailNotificationPopover',
+ "notification popover should be open"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailNotificationPopover_icon',
+ "popover should have one icon"
+ );
+ assert.hasClass(
+ document.querySelector('.o_SnailmailNotificationPopover_icon'),
+ 'fa-trash-o',
+ "popover should have the canceled icon"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_SnailmailNotificationPopover').textContent.trim(),
+ "Canceled",
+ "popover should have the canceled text"
+ );
+});
+
+QUnit.test('Pending', async function (assert) {
+ assert.expect(8);
+
+ 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: 'snailmail',
+ notifications: [['insert', {
+ id: 11,
+ notification_status: 'ready',
+ notification_type: 'snail',
+ }]],
+ 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-paper-plane',
+ "icon should represent snailmail"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconClickable').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailNotificationPopover',
+ "notification popover should be open"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailNotificationPopover_icon',
+ "popover should have one icon"
+ );
+ assert.hasClass(
+ document.querySelector('.o_SnailmailNotificationPopover_icon'),
+ 'fa-clock-o',
+ "popover should have the pending icon"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_SnailmailNotificationPopover').textContent.trim(),
+ "Awaiting Dispatch",
+ "popover should have the pending text"
+ );
+});
+
+QUnit.test('No Price Available', async function (assert) {
+ assert.expect(10);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'cancel_letter' && args.model === 'mail.message' && args.args[0][0] === 10) {
+ assert.step(args.method);
+ }
+ return this._super(...arguments);
+ },
+ });
+ 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: 'snailmail',
+ notifications: [['insert', {
+ failure_type: 'sn_price',
+ id: 11,
+ notification_status: 'exception',
+ notification_type: 'snail',
+ }]],
+ 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-paper-plane',
+ "icon should represent snailmail"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconClickable').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailErrorDialog',
+ "error dialog should be open"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailErrorDialog_contentPrice',
+ "error dialog should have the 'no price' content"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailErrorDialog_cancelLetterButton',
+ "dialog should have a 'Cancel letter' button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_SnailmailErrorDialog_cancelLetterButton').click();
+ });
+ assert.containsNone(
+ document.body,
+ '.o_SnailmailErrorDialog',
+ "dialog should be closed after click on 'Cancel letter'"
+ );
+ assert.verifySteps(
+ ['cancel_letter'],
+ "should have made a RPC call to 'cancel_letter'"
+ );
+});
+
+QUnit.test('Credit Error', async function (assert) {
+ assert.expect(11);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'send_letter' && args.model === 'mail.message' && args.args[0][0] === 10) {
+ assert.step(args.method);
+ }
+ return this._super(...arguments);
+ },
+ });
+ 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: 'snailmail',
+ notifications: [['insert', {
+ failure_type: 'sn_credit',
+ id: 11,
+ notification_status: 'exception',
+ notification_type: 'snail',
+ }]],
+ 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-paper-plane',
+ "icon should represent snailmail"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconClickable').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailErrorDialog',
+ "error dialog should be open"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailErrorDialog_contentCredit',
+ "error dialog should have the 'credit' content"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailErrorDialog_resendLetterButton',
+ "dialog should have a 'Re-send letter' button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailErrorDialog_cancelLetterButton',
+ "dialog should have a 'Cancel letter' button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_SnailmailErrorDialog_resendLetterButton').click();
+ });
+ assert.containsNone(
+ document.body,
+ '.o_SnailmailErrorDialog',
+ "dialog should be closed after click on 'Re-send letter'"
+ );
+ assert.verifySteps(
+ ['send_letter'],
+ "should have made a RPC call to 'send_letter'"
+ );
+});
+
+QUnit.test('Trial Error', async function (assert) {
+ assert.expect(11);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'send_letter' && args.model === 'mail.message' && args.args[0][0] === 10) {
+ assert.step(args.method);
+ }
+ return this._super(...arguments);
+ },
+ });
+ 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: 'snailmail',
+ notifications: [['insert', {
+ failure_type: 'sn_trial',
+ id: 11,
+ notification_status: 'exception',
+ notification_type: 'snail',
+ }]],
+ 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-paper-plane',
+ "icon should represent snailmail"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconClickable').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailErrorDialog',
+ "error dialog should be open"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailErrorDialog_contentTrial',
+ "error dialog should have the 'trial' content"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailErrorDialog_resendLetterButton',
+ "dialog should have a 'Re-send letter' button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_SnailmailErrorDialog_cancelLetterButton',
+ "dialog should have a 'Cancel letter' button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_SnailmailErrorDialog_resendLetterButton').click();
+ });
+ assert.containsNone(
+ document.body,
+ '.o_SnailmailErrorDialog',
+ "dialog should be closed after click on 'Re-send letter'"
+ );
+ assert.verifySteps(
+ ['send_letter'],
+ "should have made a RPC call to 'send_letter'"
+ );
+});
+
+QUnit.test('Format Error', async function (assert) {
+ assert.expect(8);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action,
+ 'snailmail.snailmail_letter_format_error_action',
+ "action should be the one for format error"
+ );
+ assert.strictEqual(
+ payload.options.additional_context.message_id,
+ 10,
+ "action should have correct message id"
+ );
+ });
+
+ 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: 'snailmail',
+ notifications: [['insert', {
+ failure_type: 'sn_format',
+ id: 11,
+ notification_status: 'exception',
+ notification_type: 'snail',
+ }]],
+ 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-paper-plane',
+ "icon should represent snailmail"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconClickable').click();
+ });
+ assert.verifySteps(
+ ['do_action'],
+ "should do an action to display the format error dialog"
+ );
+});
+
+QUnit.test('Missing Required Fields', async function (assert) {
+ assert.expect(8);
+
+ this.data['mail.message'].records.push({
+ id: 10, // random unique id, useful to link letter and notification
+ message_type: 'snailmail',
+ res_id: 20, // non 0 id, necessary to fetch failure at init
+ model: 'res.partner', // not mail.compose.message, necessary to fetch failure at init
+ });
+ this.data['mail.notification'].records.push({
+ failure_type: 'sn_fields',
+ mail_message_id: 10,
+ notification_status: 'exception',
+ notification_type: 'snail',
+ });
+ this.data['snailmail.letter'].records.push({
+ id: 22, // random unique id, will be asserted in the test
+ message_id: 10, // id of related message
+ });
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action,
+ 'snailmail.snailmail_letter_missing_required_fields_action',
+ "action should be the one for missing fields"
+ );
+ assert.strictEqual(
+ payload.options.additional_context.default_letter_id,
+ 22,
+ "action should have correct letter id"
+ );
+ });
+
+ await this.start({
+ env: { bus },
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', { id: 20, model: 'res.partner' }]],
+ });
+ const message = this.env.models['mail.message'].findFromIdentifyingData({ id: 10 });
+ 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-paper-plane',
+ "icon should represent snailmail"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconClickable').click();
+ });
+ assert.verifySteps(
+ ['do_action'],
+ "an action should be done to display the missing fields dialog"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/snailmail/static/src/components/notification_group/notification_group.js b/addons/snailmail/static/src/components/notification_group/notification_group.js
new file mode 100644
index 00000000..00cdebe1
--- /dev/null
+++ b/addons/snailmail/static/src/components/notification_group/notification_group.js
@@ -0,0 +1,27 @@
+odoo.define('snailmail/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, 'snailmail/static/src/components/notification_group/notification_group.js', {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ image() {
+ if (this.group.notification_type === 'snail') {
+ return '/snailmail/static/img/snailmail_failure.png';
+ }
+ return this._super(...arguments);
+ },
+});
+
+});
diff --git a/addons/snailmail/static/src/components/notification_group/notification_group.xml b/addons/snailmail/static/src/components/notification_group/notification_group.xml
new file mode 100644
index 00000000..7f04e716
--- /dev/null
+++ b/addons/snailmail/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 === 'snail'">
+ An error occurred when sending a letter with Snailmail.
+ </t>
+ </xpath>
+ </t>
+
+</templates>
diff --git a/addons/snailmail/static/src/components/notification_list/notification_list_notification_group_tests.js b/addons/snailmail/static/src/components/notification_list/notification_list_notification_group_tests.js
new file mode 100644
index 00000000..d80b4b26
--- /dev/null
+++ b/addons/snailmail/static/src/components/notification_list/notification_list_notification_group_tests.js
@@ -0,0 +1,304 @@
+odoo.define('snailmail/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('snailmail', {}, 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);
+
+ // message that is expected to have a failure
+ this.data['mail.message'].records.push({
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'snailmail', // message must be snailmail (goal of the test)
+ model: 'mail.channel', // expected value to link message to channel
+ res_id: 31, // id of a random channel
+ });
+ // failure that is expected to be used in the test
+ this.data['mail.notification'].records.push({
+ mail_message_id: 11, // id of the related message
+ notification_status: 'exception', // necessary value to have a failure
+ notification_type: 'snail', // expected failure type for snailmail message
+ });
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action,
+ 'snailmail.snailmail_letter_cancel_action',
+ "action should be the one to cancel letter"
+ );
+ 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 letter 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: 'snailmail', // 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: 'snail', // 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 a letter with Snailmail.",
+ "should have the group text corresponding to snailmail"
+ );
+});
+
+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: 'snailmail', // message must be snailmail (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: 'snailmail', // message must be snailmail (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: 'snail', // expected failure type for snailmail 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: 'snail', // expected failure type for snailmail message
+ }
+ );
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.name,
+ "Snailmail Failures",
+ "action should have 'Snailmail 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_ids.snailmail_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/snailmail/static/src/components/snailmail_error_dialog/snailmail_error_dialog.js b/addons/snailmail/static/src/components/snailmail_error_dialog/snailmail_error_dialog.js
new file mode 100644
index 00000000..a7daf7c1
--- /dev/null
+++ b/addons/snailmail/static/src/components/snailmail_error_dialog/snailmail_error_dialog.js
@@ -0,0 +1,113 @@
+odoo.define('snailmail/static/src/components/snailmail_error_dialog/snailmail_error_dialog.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const Dialog = require('web.OwlDialog');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class SnailmailErrorDialog extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const message = this.env.models['mail.message'].get(props.messageLocalId);
+ const notifications = message ? message.notifications : [];
+ return {
+ message: message ? message.__state : undefined,
+ notifications: notifications.map(notification =>
+ notification ? notification.__state : undefined
+ ),
+ snailmail_credits_url: this.env.messaging.snailmail_credits_url,
+ snailmail_credits_url_trial: this.env.messaging.snailmail_credits_url_trial,
+ };
+ }, {
+ compareDepth: {
+ notifications: 1,
+ },
+ });
+ // to manually trigger the dialog close event
+ this._dialogRef = useRef('dialog');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {boolean}
+ */
+ get hasCreditsError() {
+ return (
+ this.notification.failure_type === 'sn_credit' ||
+ this.notification.failure_type === 'sn_trial'
+ );
+ }
+
+ /**
+ * @returns {mail.message}
+ */
+ get message() {
+ return this.env.models['mail.message'].get(this.props.messageLocalId);
+ }
+
+ /**
+ * @returns {mail.notification}
+ */
+ get notification() {
+ // Messages from snailmail are considered to have at most one notification.
+ return this.message.notifications[0];
+ }
+
+ /**
+ * @returns {string}
+ */
+ get title() {
+ return this.env._t("Failed letter");
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickCancelLetter() {
+ this._dialogRef.comp._close();
+ this.message.cancelLetter();
+ }
+
+ /**
+ * @private
+ */
+ _onClickClose() {
+ this._dialogRef.comp._close();
+ }
+
+ /**
+ * @private
+ */
+ _onClickResendLetter() {
+ this._dialogRef.comp._close();
+ this.message.resendLetter();
+ }
+
+}
+
+Object.assign(SnailmailErrorDialog, {
+ components: { Dialog },
+ props: {
+ messageLocalId: String,
+ },
+ template: 'snailmail.SnailmailErrorDialog',
+});
+
+return SnailmailErrorDialog;
+
+});
diff --git a/addons/snailmail/static/src/components/snailmail_error_dialog/snailmail_error_dialog.xml b/addons/snailmail/static/src/components/snailmail_error_dialog/snailmail_error_dialog.xml
new file mode 100644
index 00000000..bf4f026e
--- /dev/null
+++ b/addons/snailmail/static/src/components/snailmail_error_dialog/snailmail_error_dialog.xml
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="snailmail.SnailmailErrorDialog" owl="1">
+ <Dialog contentClass="'o_SnailmailErrorDialog'" title="title" size="'medium'" t-ref="dialog">
+ <t t-if="message and notification">
+ <t t-if="notification.failure_type === 'sn_credit'">
+ <p class="o_SnailmailErrorDialog_contentCredit">
+ The letter could not be sent due to insufficient credits on your IAP account.
+ </p>
+ <t t-if="env.messaging.snailmail_credits_url">
+ <div class="text-right">
+ <a class="btn btn-link" t-att-href="env.messaging.snailmail_credits_url" target="_blank">
+ <i class="fa fa-arrow-right"/> Buy credits
+ </a>
+ </div>
+ </t>
+ </t>
+ <t t-elif="notification.failure_type === 'sn_trial'">
+ <p class="o_SnailmailErrorDialog_contentTrial">
+ You need credits on your IAP account to send a letter.
+ </p>
+ <t t-if="env.messaging.snailmail_credits_url_trial">
+ <div class="text-right">
+ <a class="btn btn-link" t-att-href="env.messaging.snailmail_credits_url_trial">
+ <i class="fa fa-arrow-right"/> Buy credits
+ </a>
+ </div>
+ </t>
+ </t>
+ <t t-elif="notification.failure_type === 'sn_price'">
+ <p class="o_SnailmailErrorDialog_contentPrice">
+ The country to which you want to send the letter is not supported by our service.
+ </p>
+ </t>
+ <t t-elif="notification.failure_type === 'sn_error'">
+ <p class="o_SnailmailErrorDialog_contentError">
+ An unknown error occurred. Please contact our <a href="https://www.odoo.com/help" target="new">support</a> for further assistance.
+ </p>
+ </t>
+
+ <t t-set-slot="buttons">
+ <t t-if="hasCreditsError">
+ <button class="o_SnailmailErrorDialog_resendLetterButton btn btn-primary" t-on-click="_onClickResendLetter">Re-send letter</button>
+ </t>
+ <button class="o_SnailmailErrorDialog_cancelLetterButton btn"
+ t-att-class="{
+ 'btn-primary': !hasCreditsError,
+ 'btn-secondary': hasCreditsError,
+ }"
+ t-on-click="_onClickCancelLetter"
+ >
+ Cancel letter
+ </button>
+ <button class="o_SnailmailErrorDialog_closeButton btn btn-secondary" t-on-click="_onClickClose">Close</button>
+ </t>
+ </t>
+ </Dialog>
+ </t>
+
+</templates>
diff --git a/addons/snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.js b/addons/snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.js
new file mode 100644
index 00000000..0845ce07
--- /dev/null
+++ b/addons/snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.js
@@ -0,0 +1,86 @@
+odoo.define('snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+class SnailmailNotificationPopover extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const message = this.env.models['mail.message'].get(props.messageLocalId);
+ const notifications = message ? message.notifications : [];
+ return {
+ message: message ? message.__state : undefined,
+ notifications: notifications.map(notification => notification ? notification.__state : undefined),
+ };
+ }, {
+ compareDepth: {
+ notifications: 1,
+ },
+ });
+ }
+
+ /**
+ * @returns {string}
+ */
+ get iconClass() {
+ switch (this.notification.notification_status) {
+ case 'sent':
+ return 'fa fa-check';
+ case 'ready':
+ return 'fa fa-clock-o';
+ case 'canceled':
+ return 'fa fa-trash-o';
+ default:
+ return 'fa fa-exclamation text-danger';
+ }
+ }
+
+ /**
+ * @returns {string}
+ */
+ get iconTitle() {
+ switch (this.notification.notification_status) {
+ case 'sent':
+ return this.env._t("Sent");
+ case 'ready':
+ return this.env._t("Awaiting Dispatch");
+ case 'canceled':
+ return this.env._t("Canceled");
+ default:
+ return this.env._t("Error");
+ }
+ }
+
+ /**
+ * @returns {mail.message}
+ */
+ get message() {
+ return this.env.models['mail.message'].get(this.props.messageLocalId);
+ }
+
+ /**
+ * @returns {mail.notification}
+ */
+ get notification() {
+ // Messages from snailmail are considered to have at most one notification.
+ return this.message.notifications[0];
+ }
+
+}
+
+Object.assign(SnailmailNotificationPopover, {
+ props: {
+ messageLocalId: String,
+ },
+ template: 'snailmail.SnailmailNotificationPopover',
+});
+
+return SnailmailNotificationPopover;
+
+});
diff --git a/addons/snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.scss b/addons/snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.scss
new file mode 100644
index 00000000..970bc79d
--- /dev/null
+++ b/addons/snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.scss
@@ -0,0 +1,7 @@
+// -----------------------------------------------------------------------------
+// Layout
+// -----------------------------------------------------------------------------
+
+.o_SnailmailNotificationPopover_icon {
+ margin-inline-end: map-get($spacers, 2);
+}
diff --git a/addons/snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.xml b/addons/snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.xml
new file mode 100644
index 00000000..873b08f4
--- /dev/null
+++ b/addons/snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="snailmail.SnailmailNotificationPopover" owl="1">
+ <div class="o_SnailmailNotificationPopover">
+ <t t-if="message and notification">
+ <i class="o_SnailmailNotificationPopover_icon" t-att-class="iconClass" role="img"/>
+ <span t-esc="iconTitle"/>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/snailmail/static/src/js/snailmail_external_layout.js b/addons/snailmail/static/src/js/snailmail_external_layout.js
new file mode 100644
index 00000000..be691d26
--- /dev/null
+++ b/addons/snailmail/static/src/js/snailmail_external_layout.js
@@ -0,0 +1,7 @@
+// Change address font-size if needed
+document.addEventListener('DOMContentLoaded', function (evt) {
+ var recipientAddress = document.getElementsByClassName('address row')[0].getElementsByTagName('address')[0];
+ var height = parseFloat(window.getComputedStyle(recipientAddress, null).getPropertyValue('height'));
+ var fontSize = parseFloat(window.getComputedStyle(recipientAddress, null).getPropertyValue('font-size'));
+ recipientAddress.style.fontSize = (85/height) * fontSize + 'px';
+});
diff --git a/addons/snailmail/static/src/models/message/message.js b/addons/snailmail/static/src/models/message/message.js
new file mode 100644
index 00000000..f685e48a
--- /dev/null
+++ b/addons/snailmail/static/src/models/message/message.js
@@ -0,0 +1,69 @@
+odoo.define('snailmail/static/src/models/message.message.js', function (require) {
+'use strict';
+
+const { registerInstancePatchModel } = require('mail/static/src/model/model_core.js');
+
+registerInstancePatchModel('mail.message', 'snailmail/static/src/models/message.message.js', {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Cancels the 'snailmail.letter' corresponding to this message.
+ *
+ * @returns {Deferred}
+ */
+ async cancelLetter() {
+ // the result will come from longpolling: message_notification_update
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'cancel_letter',
+ args: [[this.id]],
+ }));
+ },
+ /**
+ * Opens the action about 'snailmail.letter' format error.
+ */
+ openFormatLetterAction() {
+ this.env.bus.trigger('do-action', {
+ action: 'snailmail.snailmail_letter_format_error_action',
+ options: {
+ additional_context: {
+ message_id: this.id,
+ },
+ },
+ });
+ },
+ /**
+ * Opens the action about 'snailmail.letter' missing fields.
+ */
+ async openMissingFieldsLetterAction() {
+ const letterIds = await this.async(() => this.env.services.rpc({
+ model: 'snailmail.letter',
+ method: 'search',
+ args: [[['message_id', '=', this.id]]],
+ }));
+ this.env.bus.trigger('do-action', {
+ action: 'snailmail.snailmail_letter_missing_required_fields_action',
+ options: {
+ additional_context: {
+ default_letter_id: letterIds[0],
+ },
+ },
+ });
+ },
+ /**
+ * Retries to send the 'snailmail.letter' corresponding to this message.
+ */
+ async resendLetter() {
+ // the result will come from longpolling: message_notification_update
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'send_letter',
+ args: [[this.id]],
+ }));
+ },
+});
+
+});
diff --git a/addons/snailmail/static/src/models/messaging/messaging.js b/addons/snailmail/static/src/models/messaging/messaging.js
new file mode 100644
index 00000000..f00d0544
--- /dev/null
+++ b/addons/snailmail/static/src/models/messaging/messaging.js
@@ -0,0 +1,38 @@
+odoo.define('snailmail/static/src/models/messaging/messaging.js', function (require) {
+'use strict';
+
+const {
+ registerInstancePatchModel,
+ registerFieldPatchModel,
+} = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+registerInstancePatchModel('mail.messaging', 'snailmail/static/src/models/messaging/messaging.js', {
+ async fetchSnailmailCreditsUrl() {
+ const snailmail_credits_url = await this.async(() => this.env.services.rpc({
+ model: 'iap.account',
+ method: 'get_credits_url',
+ args: ['snailmail'],
+ }));
+ this.update({
+ snailmail_credits_url,
+ });
+ },
+ async fetchSnailmailCreditsUrlTrial() {
+ const snailmail_credits_url_trial = await this.async(() => this.env.services.rpc({
+ model: 'iap.account',
+ method: 'get_credits_url',
+ args: ['snailmail', '', 0, true],
+ }));
+ this.update({
+ snailmail_credits_url_trial,
+ });
+ },
+});
+
+registerFieldPatchModel('mail.messaging', 'snailmail/static/src/models/messaging/messaging.js', {
+ snailmail_credits_url: attr(),
+ snailmail_credits_url_trial: attr(),
+});
+
+});
diff --git a/addons/snailmail/static/src/models/notification_group/notification_group.js b/addons/snailmail/static/src/models/notification_group/notification_group.js
new file mode 100644
index 00000000..3fff33cb
--- /dev/null
+++ b/addons/snailmail/static/src/models/notification_group/notification_group.js
@@ -0,0 +1,62 @@
+odoo.define('snailmail/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', 'snailmail/static/src/models/notification_group/notification_group.js', {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ openCancelAction() {
+ if (this.notification_type !== 'snail') {
+ return this._super(...arguments);
+ }
+ this.env.bus.trigger('do-action', {
+ action: 'snailmail.snailmail_letter_cancel_action',
+ options: {
+ additional_context: {
+ default_model: this.res_model,
+ unread_counter: this.notifications.length,
+ },
+ },
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _openDocuments() {
+ if (this.notification_type !== 'snail') {
+ return this._super(...arguments);
+ }
+ this.env.bus.trigger('do-action', {
+ action: {
+ name: this.env._t("Snailmail 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_ids.snailmail_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/snailmail/static/src/scss/snailmail_external_layout_asset.scss b/addons/snailmail/static/src/scss/snailmail_external_layout_asset.scss
new file mode 100644
index 00000000..ae60f3d4
--- /dev/null
+++ b/addons/snailmail/static/src/scss/snailmail_external_layout_asset.scss
@@ -0,0 +1,50 @@
+/* Generic report layouts*/
+.header {
+ max-height: 40mm;
+ overflow: hidden;
+}
+.container {
+ margin-top: 5mm;
+}
+.o_standard_footer, .o_clean_footer, .o_boxed_footer, .o_background_footer {
+ max-height: 90px !important;
+ overflow: hidden;
+ width: 95%;
+ div > div:first-child {
+ padding-left: 10mm;
+ }
+}
+.o_background_footer {
+ padding-left: 8mm;
+}
+.article>.page {
+ > h1, > h2, > h3 {
+ width: 50%;
+ }
+}
+.article > .address.row > div[name="address"] {
+ height: 65mm;
+ background-color: #ffffff;
+ padding-top: 23mm;
+ padding-left: 5mm;
+ padding-bottom: 5mm;
+ left: -20px !important;
+ address {
+ height: 28mm;
+ }
+}
+
+/*l10n_de_din layout*/
+.din {
+ .invoice_address {
+ top: 27.7mm !important;
+ }
+ &.article {
+ .page {
+ top: 18.46mm !important;
+ }
+ }
+ &.o_background_footer {
+ top: -8mm !important;
+ }
+}