Initial commit: Mail packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit 4e53507711
1948 changed files with 751201 additions and 0 deletions

View file

@ -0,0 +1,185 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
import { Dropdown } from "@web/core/dropdown/dropdown";
import { useService } from "@web/core/utils/hooks";
import { Many2OneField } from '@web/views/fields/many2one/many2one_field';
import Domain from 'web.Domain';
const { useState, useEffect } = owl;
export class MailingFilterDropdown extends Dropdown {
setup() {
super.setup();
useEffect((inputFilterEl) => {
if (inputFilterEl) {
inputFilterEl.focus();
}
}, () => [document.querySelector('.o_mass_mailing_filter_name')]);
}
}
/**
* Widget to create / remove favorite filters on mass mailing and/or marketing automation, extended
* from Many2OneField. This widget is designed specifically for 'mailing_filter_id'
* field on 'mailing.mailing' and 'marketing.campaign' form view.
*
* In edit mode, it will allow to save the latest configured domain
* in form of the favorite filter, or to remove the store filters.
*
*/
export class FieldMany2OneMailingFilter extends Many2OneField {
setup() {
super.setup();
this.notification = useService("notification");
this.filter = useState({
canSaveFilter: false,
});
useEffect(() => this._updateFilterIcons());
}
/**
* Updates the 'Add to favorite' / 'Remove' icons' visibility based on the
* current state, and shows the custom message when no filter is available
* for the selected model.
*
* The filter can be saved if one of those conditions is matched:
* - No favorite filter is currently set
* - User emptied the input
* - User changed the domain when favorite filter is set
* - The input is currently being edited, known by the "this.state.isFloating" variable
*
* @private
*/
_updateFilterIcons() {
const el = document.querySelector('.o_mass_mailing_filter_container');
if (!el || this.props.readonly) {
return;
}
const filterCount = this.props.record.data.mailing_filter_count;
const dropdown = document.querySelector('.o_field_mailing_filter > .o_field_many2one_selection > .o_input_dropdown')
if (dropdown) {
dropdown.classList.toggle('d-none', !filterCount);
}
// By default, domains in recordData are in string format, but adding / removing a leaf from domain widget converts
// value into object, so we use 'Domain' class to convert them in same (string) format, allowing proper comparison.
let recordDomain;
let filterDomain;
try {
recordDomain = new Domain(this.props.record.data[this.props.domain_field] || []).toString();
filterDomain = new Domain(this.props.record.data.mailing_filter_domain || []).toString();
} catch {
// Don't raise a traceback if a domain set manually doesn't match the format expected.
// This can happen when we unfocus the domain editor
this.filter.canSaveFilter = false;
this.filter.canRemoveFilter = false;
return;
}
const modelFieldElement = this.props.model_field && document.querySelector(
`input#${this.props.model_field},div [name="${this.props.model_field}"]`);
let value = "";
if (modelFieldElement && modelFieldElement.tagName === "span") {
value = modelFieldElement.textContent;
} else if (modelFieldElement && modelFieldElement.tagName === "input") {
value = modelFieldElement.value;
}
el.classList.toggle('d-none', recordDomain === '[]');
this.filter.canSaveFilter = !this.props.record.data.mailing_filter_id
|| value.length
|| this.state.isFloating
|| filterDomain !== recordDomain;
this.filter.canRemoveFilter = !this.filter.canSaveFilter
}
// HANDLERS
/**
* Focus the 'Save' button on 'Tab' key, or directly save the filter on 'Enter'
*
* @param {event} ev
*/
onFilterNameInputKeydown(ev) {
const btnSave = document.querySelector('.o_mass_mailing_btn_save_filter');
if (ev.key === 'Tab') {
ev.preventDefault();
btnSave.focus();
} else if (ev.key === 'Enter') {
btnSave.click();
}
}
/**
* Deletes the saved filter, but we do not reset the applied domain
* in this case.
*
* @param {event} ev
*/
async onRemoveFilter(ev) {
const filterId = this.props.record.data.mailing_filter_id[0];
const mailingDomain = this.props.record.data[this.props.domain_field];
// Prevent multiple clicks to avoid trying to deleting same record multiple times.
ev.target.disabled = true;
await this.orm.unlink('mailing.filter', [filterId]);
this.update([{ id: false, name: false }]);
this.props.record.update({[this.props.domain_field]: mailingDomain});
}
/**
* Creates a new favorite filter, with the name provided from drop-down and
* with the 'up to date' domain. If the input is blank, displays the warning
* and keeps the popup open by preventing event propagation.
*
* Note: We do not disable the save button here to avoid multiple clicks as for the delete,
* because as soon as the 'Save' button is clicked, the popup will be closed immediately.
*
* @param {event} ev
*/
async onSaveFilter(ev) {
const filterInput = document.querySelector('input.o_mass_mailing_filter_name');
const filterName = filterInput.value.trim();
if (filterName.length === 0) {
this.notification.add(
this.env._t("Please provide a name for the filter"),
{type: 'danger'}
);
// Keep the drop-down open, and re-focus the input
ev.stopPropagation();
filterInput.focus();
} else {
const newFilterId = await this.env.model.orm.create("mailing.filter", [{
name: filterName,
mailing_domain: this.props.record.data[this.props.domain_field],
mailing_model_id: this.props.record.data[this.props.model_field][0],
}]);
this.update([{ id: newFilterId, name: filterName }]);
}
}
}
FieldMany2OneMailingFilter.template = 'mass_mailing.MailingFilter';
FieldMany2OneMailingFilter.components = {
...Many2OneField.components,
MailingFilterDropdown,
};
FieldMany2OneMailingFilter.props = {
...Many2OneField.props,
domain_field: { type: String, optional: true },
model_field: { type: String, optional: true },
};
FieldMany2OneMailingFilter.defaultProps = {
...Many2OneField.defaultProps,
domain_field: "mailing_domain",
model_field: "mailing_model_id",
};
FieldMany2OneMailingFilter.extractProps = ({ field, attrs }) => {
return {
...Many2OneField.extractProps({ field, attrs }),
domain_field: attrs.options.domain_field,
model_field: attrs.options.model_field,
}
};
registry.category('fields').add('mailing_filter', FieldMany2OneMailingFilter);

View file

@ -0,0 +1,165 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { formView } from "@web/views/form/form_view";
import { throttleForAnimation } from "@web/core/utils/timing";
const {
useSubEnv,
onMounted,
onWillUnmount,
} = owl;
export class MassMailingFullWidthViewController extends formView.Controller {
setup() {
super.setup();
useSubEnv({
onIframeUpdated: () => this._updateIframe(),
mailingFilterTemplates: true,
});
this._resizeObserver = new ResizeObserver(throttleForAnimation(() => {
this._resizeMailingEditorIframe();
this._repositionMailingEditorSidebar();
}));
onMounted(() => {
$('.o_content').on('scroll.repositionMailingEditorSidebar', throttleForAnimation(this._repositionMailingEditorSidebar.bind(this)));
});
onWillUnmount(() => {
$('.o_content').off('.repositionMailingEditorSidebar');
this._resizeObserver.disconnect();
});
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Resize the given iframe so its height fits its contents and initialize a
* resize observer to resize on each size change in its contents.
* This also ensures the contents of the sidebar remain visible no matter
* how much we resize the iframe and scroll down.
*
* @private
* @param {JQuery} ev.data.$iframe
*/
_updateIframe() {
const $iframe = $('iframe.wysiwyg_iframe:visible, iframe.o_readonly');
if (!$iframe.length || !$iframe.contents().length) {
return;
}
const hasIframeChanged = !this.$iframe || !this.$iframe.length || $iframe[0] !== this.$iframe[0];
this.$iframe = $iframe;
this._resizeMailingEditorIframe();
const $iframeDoc = $iframe.contents();
$iframeDoc.get(0).querySelector('html').classList.add('o_mass_mailing_iframe_full_width');
const iframeTarget = $iframeDoc.find('#iframe_target');
if (hasIframeChanged) {
$iframeDoc.find('body').on('click', '.o_fullscreen_btn', this._onToggleFullscreen.bind(this));
if (iframeTarget[0]) {
this._resizeObserver.disconnect();
this._resizeObserver.observe(iframeTarget[0]);
}
}
if (iframeTarget[0]) {
const isFullscreen = this._isFullScreen();
iframeTarget.css({
display: isFullscreen ? '' : 'flex',
'flex-direction': isFullscreen ? '' : 'column',
});
}
}
/**
* Reposition the sidebar so it always occupies the full available visible
* height, no matter the scroll position. This way, the sidebar is always
* visible and as big as possible.
*
* @private
*/
_repositionMailingEditorSidebar() {
const windowHeight = $(window).height();
const $iframeDocument = this.$iframe.contents();
const $sidebar = $iframeDocument.find('#oe_snippets');
const isFullscreen = this._isFullScreen();
if (isFullscreen) {
$sidebar.height(windowHeight);
this.$iframe.height(windowHeight);
$sidebar.css({
top: '',
bottom: '',
});
} else {
const iframeTop = this.$iframe.offset().top;
$sidebar.css({
height: '',
top: Math.max(0, $('.o_content').offset().top - iframeTop),
bottom: this.$iframe.height() - windowHeight + iframeTop,
});
}
}
/**
* Switch "scrolling modes" on toggle fullscreen mode: in fullscreen mode,
* the scroll happens within the iframe whereas in regular mode we pretend
* there is no iframe and scroll in the top document. Also reposition the
* sidebar since toggling the fullscreen mode visibly changes the
* positioning of elements in the document.
*
* @private
*/
_onToggleFullscreen() {
const isFullscreen = this._isFullScreen();
const $iframeDoc = this.$iframe.contents();
const html = $iframeDoc.find('html').get(0);
html.scrollTop = 0;
html.classList.toggle('o_fullscreen', isFullscreen);
const wysiwyg = $iframeDoc.find('.note-editable').data('wysiwyg');
if (wysiwyg && wysiwyg.snippetsMenu) {
// Restore the appropriate scrollable depending on the mode.
this._$scrollable = this._$scrollable || wysiwyg.snippetsMenu.$scrollable;
wysiwyg.snippetsMenu.$scrollable = isFullscreen ? $iframeDoc.find('.note-editable') : this._$scrollable;
}
this._repositionMailingEditorSidebar();
this._resizeMailingEditorIframe();
}
/**
* Return true if the mailing editor is in full screen mode, false
* otherwise.
*
* @private
* @returns {boolean}
*/
_isFullScreen() {
return window.top.document.body.classList.contains('o_field_widgetTextHtml_fullscreen');
}
/**
* Resize the mailing editor's iframe container so its height fits its
* contents. This needs to be called whenever the iframe's contents might
* have changed, eg. when adding/removing content to/from it or when a
* template is picked.
*
* @private
*/
_resizeMailingEditorIframe() {
const minHeight = $(window).height() - Math.abs(this.$iframe.offset().top);
const $iframeDoc = this.$iframe.contents();
const $themeSelectorNew = $iframeDoc.find('.o_mail_theme_selector_new');
if ($themeSelectorNew.length) {
this.$iframe.height(Math.max($themeSelectorNew[0].scrollHeight, minHeight));
} else {
const ref = $iframeDoc.find('#iframe_target')[0];
if (ref) {
this.$iframe.css({
height: this._isFullScreen()
? $(window).height()
: Math.max(ref.scrollHeight, minHeight),
});
}
}
}
}
export const massMailingFormView = {
...formView,
Controller: MassMailingFullWidthViewController,
};
registry.category("views").add("mailing_mailing_view_form_full_width", massMailingFormView);

View file

@ -0,0 +1,198 @@
odoo.define('mass_mailing.unsubscribe', function (require) {
'use strict';
var session = require('web.session');
var ajax = require('web.ajax');
var core = require('web.core');
require('web.dom_ready');
var _t = core._t;
var email = $("input[name='email']").val();
var mailing_id = parseInt($("input[name='mailing_id']").val());
var res_id = parseInt($("input[name='res_id']").val());
var token = (location.search.split('token' + '=')[1] || '').split('&')[0];
if (!$('.o_unsubscribe_form').length) {
return;
}
session.load_translations().then(function () {
if (email != '' && email != undefined){
ajax.jsonRpc('/mailing/blacklist/check', 'call', {'email': email, 'mailing_id': mailing_id, 'res_id': res_id, 'token': token})
.then(function (result) {
if (result == 'unauthorized'){
$('#button_add_blacklist').hide();
$('#button_remove_blacklist').hide();
}
else if (result == true) {
$('#button_remove_blacklist').show();
toggle_opt_out_section(false);
}
else if (result == false) {
$('#button_add_blacklist').show();
toggle_opt_out_section(true);
}
else {
$('#subscription_info').text(_t('An error occurred. Please try again later or contact us.'));
$('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-warning').addClass('alert-error');
}
})
.guardedCatch(function () {
$('#subscription_info').text(_t('An error occurred. Please try again later or contact us.'));
$('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-warning').addClass('alert-error');
});
}
else {
$('#div_blacklist').hide();
}
var unsubscribed_list = $("input[name='unsubscribed_list']").val();
if (unsubscribed_list){
$('#subscription_info').html(_.str.sprintf(
_t("You have been <strong>successfully unsubscribed from %s</strong>."),
_.escape(unsubscribed_list)
));
}
else{
$('#subscription_info').html(_t('You have been <strong>successfully unsubscribed</strong>.'));
}
});
$('#unsubscribe_form').on('submit', function (e) {
e.preventDefault();
var checked_ids = [];
$("input[type='checkbox']:checked").each(function (i){
checked_ids[i] = parseInt($(this).val());
});
var unchecked_ids = [];
$("input[type='checkbox']:not(:checked)").each(function (i){
unchecked_ids[i] = parseInt($(this).val());
});
ajax.jsonRpc('/mail/mailing/unsubscribe', 'call', {'opt_in_ids': checked_ids, 'opt_out_ids': unchecked_ids, 'email': email, 'mailing_id': mailing_id, 'res_id': res_id, 'token': token})
.then(function (result) {
if (result == 'unauthorized'){
$('#subscription_info').text(_t('You are not authorized to do this!'));
$('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-error').addClass('alert-warning');
}
else if (result == true) {
$('#subscription_info').text(_t('Your changes have been saved.'));
$('#info_state').removeClass('alert-info').addClass('alert-success');
}
else {
$('#subscription_info').text(_t('An error occurred. Your changes have not been saved, try again later.'));
$('#info_state').removeClass('alert-info').addClass('alert-warning');
}
})
.guardedCatch(function () {
$('#subscription_info').text(_t('An error occurred. Your changes have not been saved, try again later.'));
$('#info_state').removeClass('alert-info').addClass('alert-warning');
});
});
// ==================
// Blacklist
// ==================
$('#button_add_blacklist').click(function (e) {
e.preventDefault();
ajax.jsonRpc('/mailing/blacklist/add', 'call', {'email': email, 'mailing_id': mailing_id, 'res_id': res_id, 'token': token})
.then(function (result) {
if (result == 'unauthorized'){
$('#subscription_info').text(_t('You are not authorized to do this!'));
$('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-error').addClass('alert-warning');
}
else
{
if (result) {
$('#subscription_info').html(_t('You have been successfully <strong>added to our blacklist</strong>. '
+ 'You will not be contacted anymore by our services.'));
$('#info_state').removeClass('alert-warning').removeClass('alert-info').removeClass('alert-error').addClass('alert-success');
toggle_opt_out_section(false);
}
else {
$('#subscription_info').text(_t('An error occurred. Please try again later or contact us.'));
$('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-warning').addClass('alert-error');
}
$('#button_add_blacklist').hide();
$('#button_remove_blacklist').show();
$('#unsubscribed_info').hide();
}
})
.guardedCatch(function () {
$('#subscription_info').text(_t('An error occurred. Please try again later or contact us.'));
$('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-warning').addClass('alert-error');
});
});
$('#button_remove_blacklist').click(function (e) {
e.preventDefault();
ajax.jsonRpc('/mailing/blacklist/remove', 'call', {'email': email, 'mailing_id': mailing_id, 'res_id': res_id, 'token': token})
.then(function (result) {
if (result == 'unauthorized'){
$('#subscription_info').text(_t('You are not authorized to do this!'));
$('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-error').addClass('alert-warning');
}
else
{
if (result) {
$('#subscription_info').html(_t("You have been successfully <strong>removed from our blacklist</strong>. "
+ "You are now able to be contacted by our services."));
$('#info_state').removeClass('alert-warning').removeClass('alert-info').removeClass('alert-error').addClass('alert-success');
toggle_opt_out_section(true);
}
else {
$('#subscription_info').text(_t('An error occurred. Please try again later or contact us.'));
$('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-warning').addClass('alert-error');
}
$('#button_add_blacklist').show();
$('#button_remove_blacklist').hide();
$('#unsubscribed_info').hide();
}
})
.guardedCatch(function () {
$('#subscription_info').text(_t('An error occurred. Please try again later or contact us.'));
$('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-warning').addClass('alert-error');
});
});
// ==================
// Feedback
// ==================
$('#button_feedback').click(function (e) {
var feedback = $("textarea[name='opt_out_feedback']").val();
e.preventDefault();
ajax.jsonRpc('/mailing/feedback', 'call', {'mailing_id': mailing_id, 'res_id': res_id, 'email': email, 'feedback': feedback, 'token': token})
.then(function (result) {
if (result == 'unauthorized'){
$('#subscription_info').text(_t('You are not authorized to do this!'));
$('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-error').addClass('alert-warning');
}
else if (result == true){
$('#subscription_info').text(_t('Thank you! Your feedback has been sent successfully!'));
$('#info_state').removeClass('alert-warning').removeClass('alert-info').removeClass('alert-error').addClass('alert-success');
$("#div_feedback").hide();
}
else {
$('#subscription_info').text(_t('An error occurred. Please try again later or contact us.'));
$('#info_state').removeClass('alert-success').removeClass('alert-info').removeClass('alert-error').addClass('alert-warning');
}
})
.guardedCatch(function () {
$('#subscription_info').text(_t('An error occurred. Please try again later or contact us.'));
$('#info_state').removeClass('alert-info').removeClass('alert-success').removeClass('alert-error').addClass('alert-warning');
});
});
});
function toggle_opt_out_section(value) {
var result = !value;
$("#div_opt_out").find('*').attr('disabled',result);
$("#button_add_blacklist").attr('disabled', false);
$("#button_remove_blacklist").attr('disabled', false);
if (value) { $('[name="button_subscription"]').addClass('clickable'); }
else { $('[name="button_subscription"]').removeClass('clickable'); }
}

View file

@ -0,0 +1,15 @@
odoo.define('mass_mailing.mass_mailing', function (require) {
"use strict";
var KanbanColumn = require('web.KanbanColumn');
KanbanColumn.include({
init: function () {
this._super.apply(this, arguments);
if (this.modelName === 'mailing.mailing') {
this.draggable = false;
}
},
});
});

View file

@ -0,0 +1,120 @@
/** @odoo-module alias=mass_mailing.design_constants**/
export const CSS_PREFIX = '.o_mail_wrapper';
export const BTN_SIZE_STYLES = {
'btn-sm': {
'padding': '3px 7.5px',
'font-size': '0.875rem',
'line-height': '1.5rem',
},
'btn-lg': {
'padding': '7px 14px',
'font-size': '1.25rem',
'line-height': '1.5rem',
},
'btn-md': {
'padding': false, // Property must be removed.
'font-size': '14px',
'line-height': false, // Property must be removed.
},
};
export const DEFAULT_BUTTON_SIZE = 'btn-md';
export const PRIORITY_STYLES = {
'h1': ['font-family'],
'h2': ['font-family'],
'h3': ['font-family'],
'p': ['font-family'],
'a:not(.btn)': [],
'a.btn.btn-primary': [],
'a.btn.btn-secondary': [],
'hr': ['border-top-width','border-top-style','border-top-color'],
};
export const RE_CSS_TEXT_MATCH = /([^{]+)([^}]+)/;
export const RE_SELECTOR_ENDS_WITH_GT_STAR = />\s*\*\s*$/;
export const transformFontFamilySelector = selector => {
if (selector.trim().endsWith(':not(.fa)')) {
return [selector];
}
if (!selector.endsWith('*')) {
return [`${selector.trim()}:not(.fa)`, `${selector.trim()} :not(.fa)`];
} else if (RE_SELECTOR_ENDS_WITH_GT_STAR.test(selector)) {
return [`${selector.replace(RE_SELECTOR_ENDS_WITH_GT_STAR, '').trim()} :not(.fa)`];
}
}
/**
* Take a css text and splits each comma-separated selector into separate
* styles, applying the css prefix to each. Return the modified css text.
*
* @param {string} [css]
* @returns {string}
*/
export const splitCss = css => {
const styleElement = document.createElement('style');
styleElement.textContent = css;
// Temporarily insert the style element in the dom to have a stylesheet.
document.head.appendChild(styleElement);
const rules = [...styleElement.sheet.cssRules];
styleElement.remove();
const stylesToWrite = {};
for (const rule of rules) {
const styles = rule.style;
for (let selector of rule.selectorText.split(',')) {
if (!selector.trim().startsWith(CSS_PREFIX)) {
selector = `${CSS_PREFIX} ${selector.trim()}`;
}
for (const style of rule.style) {
let selectors = [selector];
if (style === 'font-family') {
// Ensure font-family gets passed to all descendants and never
// overwrite font awesome.
selectors = transformFontFamilySelector(selector);
}
for (const selectorToWriteTo of selectors) {
if (!stylesToWrite[selectorToWriteTo]) {
stylesToWrite[selectorToWriteTo] = [];
}
stylesToWrite[selectorToWriteTo].push([style, styles[style] + (styles.getPropertyPriority(style) === 'important' ? ' !important' : '')]);
}
}
}
}
return Object.entries(stylesToWrite).map(([selector, styles]) => (
`${selector.trim()} {\n${styles.map(([styleName, style]) => ` ${styleName}: ${style};`).join('\n')}\n}`
)).join('\n');
};
export const getFontName = fontFamily => fontFamily.split(',')[0].replace(/"/g, '').replace(/([a-z])([A-Z])/g, (v, a, b) => `${a} ${b}`).trim();
export const normalizeFontFamily = fontFamily => fontFamily.replace(/"/g, '').replace(/, /g, ',');
export const initializeDesignTabCss = $editable => {
let styleElement = $editable.get(0).ownerDocument.querySelector('#design-element');
if (styleElement) {
styleElement.textContent = splitCss(styleElement.textContent);
} else {
// If a style element can't be found, create one and initialize it.
styleElement = document.createElement('style');
styleElement.setAttribute('id', 'design-element');
}
// The style element needs to be within the layout of the email in
// order to be saved along with it.
$editable.find('.o_layout').prepend(styleElement);
};
export const FONT_FAMILIES = [
'Arial, "Helvetica Neue", Helvetica, sans-serif', // name: "Arial"
'"Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace', // name: "Courier New"
'Georgia, Times, "Times New Roman", serif', // name: "Georgia"
'"Helvetica Neue", Helvetica, Arial, sans-serif', // name: "Helvetica Neue"
'"Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Geneva, Verdana, sans-serif', // name: "Lucida Grande"
'Tahoma, Verdana, Segoe, sans-serif', // name: "Tahoma"
'TimesNewRoman, "Times New Roman", Times, Baskerville, Georgia, serif', // name: "Times New Roman"
'"Trebuchet MS", "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Tahoma, sans-serif', // name: "Trebuchet MS"
'Verdana, Geneva, sans-serif', // name: "Verdana"
].map(fontFamily => normalizeFontFamily(fontFamily));
export default {
CSS_PREFIX, BTN_SIZE_STYLES, DEFAULT_BUTTON_SIZE, PRIORITY_STYLES,
RE_CSS_TEXT_MATCH, FONT_FAMILIES, RE_SELECTOR_ENDS_WITH_GT_STAR,
splitCss, getFontName, normalizeFontFamily, initializeDesignTabCss,
transformFontFamilySelector,
}

View file

@ -0,0 +1,671 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { initializeDesignTabCss } from "mass_mailing.design_constants";
import { toInline } from "web_editor.convertInline";
import { loadBundle } from "@web/core/assets";
import { qweb } from 'web.core';
import { useService } from "@web/core/utils/hooks";
import { buildQuery } from "web.rpc";
import { HtmlField } from "@web_editor/js/backend/html_field";
import { getWysiwygClass } from 'web_editor.loader';
import { device } from 'web.config';
import { MassMailingMobilePreviewDialog } from "./mass_mailing_mobile_preview";
import { getRangePosition } from '@web_editor/js/editor/odoo-editor/src/utils/utils';
const {
useSubEnv,
onWillUpdateProps,
status,
} = owl;
export class MassMailingHtmlField extends HtmlField {
setup() {
super.setup();
useSubEnv({
onWysiwygReset: this._resetIframe.bind(this),
});
this.action = useService('action');
this.rpc = useService('rpc');
this.dialog = useService('dialog');
onWillUpdateProps(() => {
if (this.props.record.data.mailing_model_id && this.wysiwyg) {
this._hideIrrelevantTemplates();
}
});
}
get wysiwygOptions() {
return {
...super.wysiwygOptions,
onIframeUpdated: () => this.onIframeUpdated(),
snippets: 'mass_mailing.email_designer_snippets',
resizable: false,
defaultDataForLinkTools: { isNewWindow: true },
toolbarTemplate: 'mass_mailing.web_editor_toolbar',
onWysiwygBlur: () => {
this.commitChanges();
this.wysiwyg.odooEditor.toolbarHide();
},
...this.props.wysiwygOptions,
};
}
/**
* @param {HTMLElement} popover
* @param {Object} position
* @override
*/
positionDynamicPlaceholder(popover, position) {
const editable = this.wysiwyg.$iframe ? this.wysiwyg.$iframe[0] : this.wysiwyg.$editable[0];
const relativeParentPosition = editable.getBoundingClientRect();
let topPosition = relativeParentPosition.top;
let leftPosition = relativeParentPosition.left;
const rangePosition = getRangePosition(popover, this.wysiwyg.options.document);
topPosition += rangePosition.top;
// Offset the popover to ensure the arrow is pointing at
// the precise range location.
leftPosition += rangePosition.left - 14;
// Apply the position back to the element.
popover.style.top = topPosition + 'px';
popover.style.left = leftPosition + 'px';
}
async commitChanges(...args) {
if (this.props.readonly || !this.isRendered) {
return super.commitChanges(...args);
}
if (!this._isDirty() || this._pendingCommitChanges) {
// In case there is still a pending change while committing the
// changes from the save button, we need to wait for the previous
// operation to finish, otherwise the "inline field" of the mass
// mailing might not be saved.
return this._pendingCommitChanges;
}
this._pendingCommitChanges = (async () => {
const codeViewEl = this._getCodeViewEl();
if (codeViewEl) {
this.wysiwyg.setValue(this._getCodeViewValue(codeViewEl));
}
if (this.wysiwyg.$iframeBody.find('.o_basic_theme').length) {
this.wysiwyg.$iframeBody.find('*').css('font-family', '');
}
const $editable = this.wysiwyg.getEditable();
this.wysiwyg.odooEditor.historyPauseSteps();
await this.wysiwyg.cleanForSave();
if (args.length) {
await super.commitChanges({ ...args[0], urgent: true });
} else {
await super.commitChanges({ urgent: true });
}
const $editorEnable = $editable.closest('.editor_enable');
$editorEnable.removeClass('editor_enable');
// Prevent history reverts.
this.wysiwyg.odooEditor.observerUnactive('toInline');
const iframe = document.createElement('iframe');
iframe.style.height = '0px';
iframe.style.visibility = 'hidden';
iframe.setAttribute('sandbox', 'allow-same-origin'); // Make sure no scripts get executed.
const clonedHtmlNode = $editable[0].closest('html').cloneNode(true);
// Replace the body to only contain the target as we do not care for
// other elements (e.g. sidebar, toolbar, ...)
const clonedBody = clonedHtmlNode.querySelector('body');
const clonedIframeTarget = clonedHtmlNode.querySelector('#iframe_target');
clonedBody.replaceChildren(clonedIframeTarget);
clonedHtmlNode.querySelectorAll('script').forEach(script => script.remove()); // Remove scripts.
iframe.srcdoc = clonedHtmlNode.outerHTML;
const iframePromise = new Promise((resolve) => {
iframe.addEventListener("load", resolve);
});
document.body.append(iframe);
// Wait for the css and images to be loaded.
await iframePromise;
const editableClone = iframe.contentDocument.querySelector('.note-editable');
// The jQuery data are lost because of the cloning operations above.
// The hacky fix for stable is to simply add it back manually.
// TODO in master: Update toInline to use an options parameter.
$(editableClone).data("wysiwyg", this.wysiwyg);
await toInline($(editableClone), undefined, $(iframe));
iframe.remove();
this.wysiwyg.odooEditor.observerActive('toInline');
const inlineHtml = editableClone.innerHTML;
$editorEnable.addClass('editor_enable');
this.wysiwyg.odooEditor.historyUnpauseSteps();
this.wysiwyg.odooEditor.historyRevertCurrentStep();
const fieldName = this.props.inlineField;
await this.props.record.update({[fieldName]: this._unWrap(inlineHtml)});
this._pendingCommitChanges = null;
})();
return this._pendingCommitChanges;
}
async startWysiwyg(...args) {
await super.startWysiwyg(...args);
await loadBundle({
jsLibs: [
'/mass_mailing/static/src/js/mass_mailing_link_dialog_fix.js',
'/mass_mailing/static/src/js/mass_mailing_snippets.js',
'/mass_mailing/static/src/snippets/s_masonry_block/options.js',
'/mass_mailing/static/src/snippets/s_media_list/options.js',
'/mass_mailing/static/src/snippets/s_showcase/options.js',
'/mass_mailing/static/src/snippets/s_rating/options.js',
],
});
if (status(this) === "destroyed") {
return;
}
await this._resetIframe();
}
async _resetIframe() {
if (this._switchingTheme) {
return;
}
this.wysiwyg.$iframeBody.find('.o_mail_theme_selector_new').remove();
await this._onSnippetsLoaded();
// Data is removed on save but we need the mailing and its body to be
// named so they are handled properly by the snippets menu.
this.wysiwyg.$iframeBody.find('.o_layout').addBack().data('name', 'Mailing');
// We don't want to drop snippets directly within the wysiwyg.
this.wysiwyg.$iframeBody.find('.odoo-editor-editable').removeClass('o_editable');
initializeDesignTabCss(this.wysiwyg.getEditable());
this.wysiwyg.getEditable().find('img').attr('loading', '');
this.wysiwyg.odooEditor.observerFlush();
this.wysiwyg.odooEditor.historyReset();
this.wysiwyg.$iframeBody.addClass('o_mass_mailing_iframe');
this.onIframeUpdated();
}
async _onSnippetsLoaded() {
if (this.wysiwyg.snippetsMenu && $(window.top.document).find('.o_mass_mailing_form_full_width')[0]) {
// In full width form mode, ensure the snippets menu's scrollable is
// in the form view, not in the iframe.
this.wysiwyg.snippetsMenu.$scrollable = this.wysiwyg.$el.closestScrollable();
// Ensure said scrollable keeps its scrollbar at all times to
// prevent the scrollbar from appearing at awkward moments (ie: when
// previewing an option)
this.wysiwyg.snippetsMenu.$scrollable.css('overflow-y', 'scroll');
}
// Remove the web editor menu to avoid flicker (we add it back at the
// end of the method)
this.wysiwyg.$iframeBody.find('.iframe-utils-zone').addClass('d-none');
// Filter the fetched templates based on the current model
const args = this.props.filterTemplates
? [[['mailing_model_id', '=', this.props.record.data.mailing_model_id[0]]]]
: [];
const rpcQuery = buildQuery({
model: 'mailing.mailing',
method: 'action_fetch_favorites',
args: args,
})
// Templates taken from old mailings
const result = await this.rpc(rpcQuery.route, rpcQuery.params);
const templatesParams = result.map(values => {
return {
id: values.id,
modelId: values.mailing_model_id[0],
modelName: values.mailing_model_id[1],
name: `template_${values.id}`,
nowrap: true,
subject: values.subject,
template: values.body_arch,
userId: values.user_id[0],
userName: values.user_id[1],
};
});
const $snippetsSideBar = this.wysiwyg.snippetsMenu.$el;
const $themes = $snippetsSideBar.find("#email_designer_themes").children();
const $snippets = $snippetsSideBar.find(".oe_snippet");
const selectorToKeep = '.o_we_external_history_buttons, .email_designer_top_actions';
// Overide `d-flex` class which style is `!important`
$snippetsSideBar.find(`.o_we_website_top_actions > *:not(${selectorToKeep})`).attr('style', 'display: none!important');
if (!odoo.debug) {
$snippetsSideBar.find('.o_codeview_btn').hide();
}
const $codeview = this.wysiwyg.$iframe.contents().find('textarea.o_codeview');
// Unbind first the event handler as this method can be called multiple time during the component life.
$snippetsSideBar.off('click', '.o_codeview_btn');
$snippetsSideBar.on('click', '.o_codeview_btn', () => {
this.wysiwyg.odooEditor.observerUnactive();
$codeview.toggleClass('d-none');
this.wysiwyg.getEditable().toggleClass('d-none');
this.wysiwyg.odooEditor.observerActive();
if ($codeview.hasClass('d-none')) {
this.wysiwyg.setValue(this._getCodeViewValue($codeview[0]));
} else {
$codeview.val(this.wysiwyg.getValue());
}
this.wysiwyg.snippetsMenu.activateSnippet(false);
this.onIframeUpdated();
});
const $previewBtn = $snippetsSideBar.find('.o_mobile_preview_btn');
$previewBtn.off('click');
$previewBtn.on('click', () => {
$previewBtn.prop('disabled', true); // Prevent double execution when double-clicking on the button
let mailingHtml = new DOMParser().parseFromString(this.wysiwyg.getValue(), 'text/html');
[...mailingHtml.querySelectorAll('a')].forEach(el => {
el.style.setProperty('pointer-events', 'none');
});
this.mobilePreview = this.dialog.add(MassMailingMobilePreviewDialog, {
title: this.env._t("Mobile Preview"),
preview: mailingHtml.body.innerHTML,
}, {
onClose: () => $previewBtn.prop('disabled', false),
});
});
if (!this._themeParams) {
// Initialize theme parameters.
this._themeClassNames = "";
const displayableThemes =
device.isMobile ?
_.filter($themes, theme => !$(theme).data("hideFromMobile")) :
$themes;
this._themeParams = _.map(displayableThemes, (theme) => {
const $theme = $(theme);
const name = $theme.data("name");
const classname = "o_" + name + "_theme";
this._themeClassNames += " " + classname;
const imagesInfo = _.defaults($theme.data("imagesInfo") || {}, {
all: {}
});
for (const info of Object.values(imagesInfo)) {
_.defaults(info, imagesInfo.all, {
module: "mass_mailing",
format: "jpg"
});
}
return {
name: name,
title: $theme.attr("title") || "",
className: classname || "",
img: $theme.data("img") || "",
template: $theme.html().trim(),
nowrap: !!$theme.data('nowrap'),
get_image_info: function (filename) {
if (imagesInfo[filename]) {
return imagesInfo[filename];
}
return imagesInfo.all;
},
layoutStyles: $theme.data('layout-styles'),
};
});
}
$themes.parent().remove();
if (!this._themeParams.length) {
return;
}
const themesParams = [...this._themeParams];
// Create theme selection screen and check if it must be forced opened.
// Reforce it opened if the last snippet is removed.
const $themeSelectorNew = $(qweb.render("mass_mailing.theme_selector_new", {
themes: themesParams,
templates: templatesParams,
modelName: this.props.record.data.mailing_model_id[1] || '',
}));
// Check if editable area is empty.
const $layout = this.wysiwyg.$iframeBody.find(".o_layout");
let $mailWrapper = $layout.children(".o_mail_wrapper");
let $mailWrapperContent = $mailWrapper.find('.o_mail_wrapper_td');
if (!$mailWrapperContent.length) {
$mailWrapperContent = $mailWrapper;
}
let value;
if ($mailWrapperContent.length > 0) {
value = $mailWrapperContent.html();
} else if ($layout.length) {
value = $layout.html();
} else {
value = this.wysiwyg.getValue();
}
let blankEditable = "<p><br></p>";
const editableAreaIsEmpty = value === "" || value === blankEditable;
if (editableAreaIsEmpty) {
// unfold to prevent toolbar from going over the menu
this.wysiwyg.setSnippetsMenuFolded(false);
$themeSelectorNew.appendTo(this.wysiwyg.$iframeBody);
}
$themeSelectorNew.on('click', '.dropdown-item', async (e) => {
e.preventDefault();
e.stopImmediatePropagation();
const themeName = $(e.currentTarget).attr('id');
const themeParams = [...themesParams, ...templatesParams].find(theme => theme.name === themeName);
await this._switchThemes(themeParams);
this.wysiwyg.$iframeBody.closest('body').removeClass("o_force_mail_theme_choice");
$themeSelectorNew.remove();
this.wysiwyg.setSnippetsMenuFolded(device.isMobile || themeName === 'basic');
this._switchImages(themeParams, $snippets);
const $editable = this.wysiwyg.$editable.find('.o_editable');
this.$editorMessageElements = $editable
.not('[data-editor-message]')
.attr('data-editor-message', this.env._t('DRAG BUILDING BLOCKS HERE'));
$editable.filter(':empty').attr('contenteditable', false);
// Wait the next tick because some mutation have to be processed by
// the Odoo editor before resetting the history.
setTimeout(() => {
this.wysiwyg.historyReset();
// Update undo/redo buttons
this.wysiwyg.odooEditor.dispatchEvent(new Event('historyStep'));
// The selection has been lost when switching theme.
const document = this.wysiwyg.odooEditor.document;
const selection = document.getSelection();
const p = this.wysiwyg.odooEditor.editable.querySelector('p');
if (p) {
const range = document.createRange();
range.setStart(p, 0);
range.setEnd(p, 0);
selection.removeAllRanges();
selection.addRange(range);
}
// mark selection done for tour testing
$editable.addClass('theme_selection_done');
this.onIframeUpdated();
}, 0);
});
// Remove the mailing from the favorites list
$themeSelectorNew.on('click', '.o_mail_template_preview i.o_mail_template_remove_favorite', async (ev) => {
ev.stopPropagation();
ev.preventDefault();
const $target = $(ev.currentTarget);
const mailingId = $target.data('id');
const rpcQuery = buildQuery({
model: 'mailing.mailing',
method: 'action_remove_favorite',
args: [mailingId],
})
const action = await this.rpc(rpcQuery.route, rpcQuery.params);
this.action.doAction(action);
$target.parents('.o_mail_template_preview').remove();
});
// Clear any previous theme class before adding new one.
this.wysiwyg.$iframeBody.closest('body').removeClass(this._themeClassNames);
let selectedTheme = this._getSelectedTheme(themesParams);
if (selectedTheme) {
this.wysiwyg.$iframeBody.closest('body').addClass(selectedTheme.className);
this._switchImages(selectedTheme, $snippets);
} else if (this.wysiwyg.$iframeBody.find('.o_layout').length) {
themesParams.push({
name: 'o_mass_mailing_no_theme',
className: 'o_mass_mailing_no_theme',
img: "",
template: this.wysiwyg.$iframeBody.find('.o_layout').addClass('o_mass_mailing_no_theme').clone().find('oe_structure').empty().end().html().trim(),
nowrap: true,
get_image_info: function () {}
});
selectedTheme = this._getSelectedTheme(themesParams);
}
this.wysiwyg.setSnippetsMenuFolded(device.isMobile || (selectedTheme && selectedTheme.name === 'basic'));
this.wysiwyg.$iframeBody.find('.iframe-utils-zone').removeClass('d-none');
if (this.env.mailingFilterTemplates && this.wysiwyg) {
this._hideIrrelevantTemplates();
}
this.wysiwyg.odooEditor.activateContenteditable();
}
_getCodeViewEl() {
const codeView = this.wysiwyg &&
this.wysiwyg.$iframe &&
this.wysiwyg.$iframe.contents().find('textarea.o_codeview')[0];
return codeView && !codeView.classList.contains('d-none') && codeView;
}
/**
* The .o_mail_wrapper_td element is where snippets can be dropped into.
* This getter wraps the codeview value in such element in case it got
* removed during edition in the codeview, in order to preserve the snippets
* dropping functionality.
*/
_getCodeViewValue(codeViewEl) {
const editable = this.wysiwyg.$editable[0];
const initialDropZone = editable.querySelector('.o_mail_wrapper_td');
if (initialDropZone) {
const parsedHtml = new DOMParser().parseFromString(codeViewEl.value, "text/html");
if (!parsedHtml.querySelector('.o_mail_wrapper_td')) {
initialDropZone.replaceChildren(...parsedHtml.body.childNodes);
return editable.innerHTML;
}
}
return codeViewEl.value;
}
/**
* This method will take the model in argument and will hide all mailing template
* in the mass mailing widget that do not belong to this model.
*
* This will also update the help message in the same widget to include the
* new model name.
*
* @param {Number} modelId
* @param {String} modelName
*
* @private
*/
_hideIrrelevantTemplates() {
const iframeContent = this.wysiwyg.$iframe.contents();
const mailing_model_id = this.props.record.data.mailing_model_id[0];
iframeContent
.find(`.o_mail_template_preview[model-id!="${mailing_model_id}"]`)
.addClass('d-none')
.removeClass('d-inline-block');
const sameModelTemplates = iframeContent
.find(`.o_mail_template_preview[model-id="${mailing_model_id}"]`);
sameModelTemplates
.removeClass('d-none')
.addClass('d-inline-block');
// Hide or show the help message and preview wrapper based on whether there are any relevant templates
if (sameModelTemplates.length) {
iframeContent.find('.o_mailing_template_message').addClass('d-none');
iframeContent.find('.o_mailing_template_preview_wrapper').removeClass('d-none');
} else {
iframeContent.find('.o_mailing_template_message').removeClass('d-none');
iframeContent.find('.o_mailing_template_message span').text(this.props.record.data.mailing_model_id[1]);
iframeContent.find('.o_mailing_template_preview_wrapper').addClass('d-none');
}
}
/**
* Returns the selected theme, if any.
*
* @private
* @param {Object} themesParams
* @returns {false|Object}
*/
_getSelectedTheme(themesParams) {
const $layout = this.wysiwyg.$iframeBody.find(".o_layout");
let selectedTheme = false;
if ($layout.length !== 0) {
_.each(themesParams, function (themeParams) {
if ($layout.hasClass(themeParams.className)) {
selectedTheme = themeParams;
}
});
}
return selectedTheme;
}
/**
* Swap the previous theme's default images with the new ones.
* (Redefine the `src` attribute of all images in a $container, depending on the theme parameters.)
*
* @private
* @param {Object} themeParams
* @param {JQuery} $container
*/
_switchImages(themeParams, $container) {
if (!themeParams) {
return;
}
for (const img of $container.find("img")) {
const $img = $(img);
const src = $img.attr("src");
let m = src.match(/^\/web\/image\/\w+\.s_default_image_(?:theme_[a-z]+_)?(.+)$/);
if (!m) {
m = src.match(/^\/\w+\/static\/src\/img\/(?:theme_[a-z]+\/)?s_default_image_(.+)\.[a-z]+$/);
}
if (!m) {
return;
}
if (themeParams.get_image_info) {
const file = m[1];
const imgInfo = themeParams.get_image_info(file);
const src = imgInfo.format
? `/${imgInfo.module}/static/src/img/theme_${themeParams.name}/s_default_image_${file}.${imgInfo.format}`
: `/web/image/${imgInfo.module}.s_default_image_theme_${themeParams.name}_${file}`;
$img.attr('src', src);
}
}
}
/**
* Switch themes or import first theme.
*
* @private
* @param {Object} themeParams
*/
async _switchThemes(themeParams) {
if (!themeParams || this.switchThemeLast === themeParams) {
return;
}
this.switchThemeLast = themeParams;
this.wysiwyg.$iframeBody.closest('body').removeClass(this._themeClassNames).addClass(themeParams.className);
const old_layout = this.wysiwyg.$editable.find('.o_layout')[0];
let $newWrapper;
let $newWrapperContent;
if (themeParams.nowrap) {
$newWrapper = $('<div/>', {
class: 'oe_structure'
});
$newWrapperContent = $newWrapper;
} else {
// This wrapper structure is the only way to have a responsive
// and centered fixed-width content column on all mail clients
$newWrapper = $('<div/>', {
class: 'container o_mail_wrapper o_mail_regular oe_unremovable',
});
$newWrapperContent = $('<div/>', {
class: 'col o_mail_no_options o_mail_wrapper_td bg-white oe_structure o_editable'
});
$newWrapper.append($('<div class="row"/>').append($newWrapperContent));
}
const $newLayout = $('<div/>', {
class: 'o_layout oe_unremovable oe_unmovable bg-200 ' + themeParams.className,
style: themeParams.layoutStyles,
'data-name': 'Mailing',
}).append($newWrapper);
const $contents = themeParams.template;
$newWrapperContent.append($contents);
this._switchImages(themeParams, $newWrapperContent);
old_layout && old_layout.remove();
this.wysiwyg.odooEditor.resetContent($newLayout[0].outerHTML);
$newWrapperContent.find('*').addBack()
.contents()
.filter(function () {
return this.nodeType === 3 && this.textContent.match(/\S/);
}).parent().addClass('o_default_snippet_text');
if (themeParams.name === 'basic') {
this.wysiwyg.$editable[0].focus();
}
initializeDesignTabCss(this.wysiwyg.$editable);
this.wysiwyg.trigger('reload_snippet_dropzones');
this.onIframeUpdated();
this.wysiwyg.odooEditor.historyStep(true);
// The value of the field gets updated upon editor blur. If for any
// reason, the selection was not in the editable before modifying
// another field, ensure that the value is properly set.
this._switchingTheme = true;
await this.commitChanges();
this._switchingTheme = false;
}
async _getWysiwygClass() {
return getWysiwygClass({moduleName: 'mass_mailing.wysiwyg'});
}
/**
* @override
*/
async _setupReadonlyIframe() {
if (!this.props.value.length) {
this.props.value = this.props.record.data.body_html;
}
await super._setupReadonlyIframe();
}
}
MassMailingHtmlField.props = {
...standardFieldProps,
...HtmlField.props,
filterTemplates: { type: Boolean, optional: true },
inlineField: { type: String, optional: true },
iframeHtmlClass: { type: String, optional: true },
};
MassMailingHtmlField.displayName = _lt("Email");
MassMailingHtmlField.extractProps = (...args) => {
const [{ attrs }] = args;
const htmlProps = HtmlField.extractProps(...args);
return {
...htmlProps,
filterTemplates: attrs.options.filterTemplates,
inlineField: attrs.options['inline-field'],
iframeHtmlClass: attrs['iframeHtmlClass'],
};
};
MassMailingHtmlField.fieldDependencies = {
body_html: { type: 'html' },
};
registry.category("fields").add("mass_mailing_html", MassMailingHtmlField);

View file

@ -0,0 +1,75 @@
odoo.define('mass_mailing.fix.LinkDialog', function (require) {
'use strict';
const LinkDialog = require('wysiwyg.widgets.LinkDialog');
/**
* Primary and link buttons are "hacked" by mailing themes scss. We thus
* have to fix their preview if possible.
*/
LinkDialog.include({
/**
* @override
*/
start() {
const ret = this._super(...arguments);
if (!$(this.editable).find('.o_mail_wrapper').length) {
return ret;
}
this.opened().then(() => {
// Ugly hack to show the real color for link and primary which
// depend on the mailing themes. Note: the hack is not enough as
// the mailing theme changes those colors in some environment,
// sometimes (for example 'btn-primary in this snippet looks like
// that')... we'll consider this a limitation until a master
// refactoring of those mailing themes.
this.__realMMColors = {};
const $previewArea = $('<div/>').addClass('o_mail_snippet_general');
$(this.editable).find('.o_layout').append($previewArea);
_.each(['link', 'primary', 'secondary'], type => {
const $el = $('<a href="#" class="btn btn-' + type + '"/>');
$el.appendTo($previewArea);
this.__realMMColors[type] = {
'border-color': $el.css('border-top-color'),
'background-color': $el.css('background-color'),
'color': $el.css('color'),
};
$el.remove();
this.$('label > .o_btn_preview.btn-' + type)
.css(_.pick(this.__realMMColors[type], 'background-color', 'color'));
});
$previewArea.remove();
this._adaptPreview();
});
return ret;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_adaptPreview() {
this._super(...arguments);
if (this.__realMMColors) {
var $preview = this.$("#link-preview");
$preview.css('border-color', '');
$preview.css('background-color', '');
$preview.css('color', '');
_.each(['link', 'primary', 'secondary'], type => {
if ($preview.hasClass('btn-' + type) || type === 'link' && !$preview.hasClass('btn')) {
$preview.css(this.__realMMColors[type]);
}
});
}
},
});
});

View file

@ -0,0 +1,43 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { Dialog } from "@web/core/dialog/dialog";
const { useEffect, onWillStart } = owl;
export class MassMailingMobilePreviewDialog extends Dialog {
setup() {
super.setup();
this.rpc = useService("rpc");
onWillStart(async () => {
this.styleSheets = await this.rpc("/mailing/get_preview_assets");
});
useEffect((modalEl) => {
if (modalEl) {
const modalBody = modalEl.querySelector('.modal-body');
const invertIcon = document.createElement("span");
invertIcon.className = "fa fa-refresh";
const iframe = document.createElement("iframe");
iframe.srcdoc = this._getSourceDocument();
modalEl.classList.add('o_mailing_mobile_preview');
modalEl.querySelector('.modal-title').append(invertIcon);
modalEl.querySelector('.modal-header').addEventListener('click', () => modalBody.classList.toggle('o_invert_orientation'));
modalBody.append(iframe);
}
}, () => [document.querySelector(':not(.o_inactive_modal).o_dialog')]);
}
_getSourceDocument() {
return '<!DOCTYPE html><html>' +
'<head>' + this.styleSheets + '</head>' +
'<body>' + this.props.preview + '</body>' +
'</html>';
}
}
MassMailingMobilePreviewDialog.props = {
...Dialog.props,
preview: { type: String },
close: Function,
};
delete MassMailingMobilePreviewDialog.props.slots;

View file

@ -0,0 +1,406 @@
odoo.define('mass_mailing.snippets.options', function (require) {
"use strict";
const options = require('web_editor.snippets.options');
const {loadImage} = require('web_editor.image_processing');
const {ColorpickerWidget} = require('web.Colorpicker');
const SelectUserValueWidget = options.userValueWidgetsRegistry['we-select'];
const weUtils = require('web_editor.utils');
const {
CSS_PREFIX, BTN_SIZE_STYLES,
DEFAULT_BUTTON_SIZE, PRIORITY_STYLES, FONT_FAMILIES,
getFontName, normalizeFontFamily, initializeDesignTabCss,
transformFontFamilySelector,
} = require('mass_mailing.design_constants');
//--------------------------------------------------------------------------
// Options
//--------------------------------------------------------------------------
// Snippet option for resizing image and column width inline like excel
options.registry.mass_mailing_sizing_x = options.Class.extend({
/**
* @override
*/
start: function () {
var def = this._super.apply(this, arguments);
this.containerWidth = this.$target.parent().closest("td, table, div").width();
var self = this;
var offset, sib_offset, target_width, sib_width;
this.$overlay.find(".o_handle.e, .o_handle.w").removeClass("readonly");
this.isIMG = this.$target.is("img");
if (this.isIMG) {
this.$overlay.find(".o_handle.w").addClass("readonly");
}
var $body = $(this.ownerDocument.body);
this.$overlay.find(".o_handle").on('mousedown', function (event) {
event.preventDefault();
var $handle = $(this);
var compass = false;
_.each(['n', 's', 'e', 'w'], function (handler) {
if ($handle.hasClass(handler)) { compass = handler; }
});
if (self.isIMG) { compass = "image"; }
$body.on("mousemove.mass_mailing_width_x", function (event) {
event.preventDefault();
offset = self.$target.offset().left;
target_width = self.get_max_width(self.$target);
if (compass === 'e' && self.$target.next().offset()) {
sib_width = self.get_max_width(self.$target.next());
sib_offset = self.$target.next().offset().left;
self.change_width(event, self.$target, target_width, offset, true);
self.change_width(event, self.$target.next(), sib_width, sib_offset, false);
}
if (compass === 'w' && self.$target.prev().offset()) {
sib_width = self.get_max_width(self.$target.prev());
sib_offset = self.$target.prev().offset().left;
self.change_width(event, self.$target, target_width, offset, false);
self.change_width(event, self.$target.prev(), sib_width, sib_offset, true);
}
if (compass === 'image') {
const maxWidth = self.$target.closest("div").width();
// Equivalent to `self.change_width` but ensuring `maxWidth` is the maximum:
self.$target.css("width", Math.min(maxWidth, Math.round(event.pageX - offset)));
self.trigger_up('cover_update');
}
});
$body.one("mouseup", function () {
$body.off('.mass_mailing_width_x');
});
});
return def;
},
change_width: function (event, target, target_width, offset, grow) {
target.css("width", Math.round(grow ? (event.pageX - offset) : (offset + target_width - event.pageX)));
this.trigger_up('cover_update');
},
get_int_width: function (el) {
return parseInt($(el).css("width"), 10);
},
get_max_width: function ($el) {
return this.containerWidth - _.reduce(_.map($el.siblings(), this.get_int_width), function (memo, w) { return memo + w; });
},
onFocus: function () {
this._super.apply(this, arguments);
if (this.$target.is("td, th")) {
this.$overlay.find(".o_handle.e, .o_handle.w").toggleClass("readonly", this.$target.siblings().length === 0);
}
},
});
// Adding compatibility for the outlook compliance of mailings.
// Commit of such compatibility : a14f89c8663c9cafecb1cc26918055e023ecbe42
options.registry.MassMailingBackgroundImage = options.registry.BackgroundImage.extend({
start: function () {
this._super();
const $table_target = this.$target.find('table:first');
if ($table_target.length) {
this.$target = $table_target;
}
}
});
options.registry.MassMailingImageTools = options.registry.ImageTools.extend({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_getCSSColorValue(color) {
if (!color || ColorpickerWidget.isCSSColor(color)) {
return color;
}
const doc = this.options.document;
const tempEl = doc.body.appendChild(doc.createElement('div'));
tempEl.className = `bg-${color}`;
const colorValue = window.getComputedStyle(tempEl).getPropertyValue("background-color").trim();
tempEl.parentNode.removeChild(tempEl);
return ColorpickerWidget.normalizeCSSColor(colorValue).replace(/"/g, "'");
},
/**
* @override
*/
async computeShape(svgText, img) {
const dataURL = await this._super(...arguments);
const image = await loadImage(dataURL);
const canvas = document.createElement("canvas");
const imgFilename = (img.dataset.originalSrc.split("/").pop()).split(".")[0];
img.dataset.fileName = `${imgFilename}.png`;
img.dataset.mimetype = "image/png";
canvas.width = image.width;
canvas.height = image.height;
canvas.getContext("2d").drawImage(image, 0, 0, image.width, image.height);
return canvas.toDataURL(`image/png`, 1.0);
}
});
options.userValueWidgetsRegistry['we-fontfamilypicker'] = SelectUserValueWidget.extend({
/**
* @override
* @see FONT_FAMILIES
*/
start: async function () {
const res = await this._super(...arguments);
// Populate the `we-select` with the font family buttons
for (const fontFamily of FONT_FAMILIES) {
const button = document.createElement('we-button');
button.style.setProperty('font-family', fontFamily);
button.dataset.customizeCssProperty = fontFamily;
button.dataset.cssProperty = 'font-family';
button.dataset.selectorText = this.el.dataset.selectorText;
button.textContent = getFontName(fontFamily);
this.menuEl.appendChild(button);
};
return res;
},
});
options.registry.DesignTab = options.Class.extend({
/**
* @override
*/
init() {
this._super(...arguments);
// Set the target on the whole editable so apply-to looks within it.
this.setTarget(this.options.wysiwyg.getEditable());
},
/**
* @override
*/
async start() {
const res = await this._super(...arguments);
const $editable = this.options.wysiwyg.getEditable();
this.document = $editable[0].ownerDocument;
this.$layout = $editable.find('.o_layout');
initializeDesignTabCss($editable);
this.styleElement = this.document.querySelector('#design-element');
// When editing a stylesheet, its content is not updated so it won't be
// saved along with the mailing. Therefore we need to write its cssText
// into it. However, when doing that we lose its reference. So we need
// two separate style elements: one that will be saved and one to hold
// the stylesheet. Both need to be synchronized, which will be done via
// `_commitCss`.
let sheetOwner = this.document.querySelector('#sheet-owner');
if (!sheetOwner) {
sheetOwner = document.createElement('style');
sheetOwner.setAttribute('id', 'sheet-owner');
this.document.head.appendChild(sheetOwner);
}
sheetOwner.disabled = true;
sheetOwner.textContent = this.styleElement.textContent;
this.styleSheet = sheetOwner.sheet;
return res;
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Option method to set a css property in the mailing's custom stylesheet.
* Note: marks all the styles as important to make sure they take precedence
* on other stylesheets.
*
* @param {boolean|string} previewMode
* @param {string} widgetValue
* @param {Object} params
* @param {string} params.selectorText the css selector for which to apply
* the css
* @param {string} params.cssProperty the name of the property to edit
* (camel cased)
* @param {string} [params.toggle] if 'true', will remove the property if
* its value is already the one it's being
* set to
* @param {string} [params.activeValue] the value to set, if `widgetValue`
* is not defined.
* @returns {Promise|undefined}
*/
customizeCssProperty(previewMode, widgetValue, params) {
if (!params.selectorText || !params.cssProperty) {
return;
}
let value = widgetValue || params.activeValue;
if (params.cssProperty.includes('color')) {
value = weUtils.normalizeColor(value);
}
let selectors = this._getSelectors(params.selectorText);
const firstSelector = selectors[0].replace(CSS_PREFIX, '').trim();
if (params.cssProperty === 'font-family') {
// Ensure font-family gets passed to all descendants and never
// overwrite font awesome.
const newSelectors = [];
for (const selector of selectors) {
newSelectors.push(...transformFontFamilySelector(selector));
}
selectors = [...new Set(newSelectors)];
}
for (const selector of selectors) {
const priority = PRIORITY_STYLES[firstSelector].includes(params.cssProperty) ? ' !important' : '';
const rule = this._getRule(selector);
if (rule) {
// The rule exists: update it.
if (params.toggle === 'true' && rule.style.getPropertyValue(params.cssProperty) === value) {
rule.style.removeProperty(params.cssProperty);
} else {
// Convert the style to css text and add the new style (the
// `style` property is readonly, we can only edit
// `cssText`).
const cssTexts = [];
for (const style of rule.style) {
const ownPriority = rule.style.getPropertyPriority(style) ? ' !important' : '';
if (style !== params.cssProperty) {
cssTexts.push(`${style}: ${rule.style[style]}${ownPriority};`);
}
}
cssTexts.push(`${params.cssProperty}: ${value}${priority};`);
rule.style.cssText = cssTexts.join('\n'); // Apply the new css text.
}
} else {
// The rule doesn't exist: create it.
this.styleSheet.insertRule(`${selector} {
${params.cssProperty}: ${value}${priority};
}`);
}
}
this._commitCss();
},
/**
* Option method to change the size of buttons.
*
* @see BTN_SIZE_STYLES
* @param {boolean|string} previewMode
* @param {string} widgetValue ('btn-sm'|'btn-md'|'btn-lg'|''|undefined)
* @param {Object} params
* @returns {Promise|undefined}
*/
applyButtonSize(previewMode, widgetValue, params) {
for (const [styleName, styleValue] of Object.entries(BTN_SIZE_STYLES[widgetValue || params.activeValue || DEFAULT_BUTTON_SIZE])) {
if (styleValue) {
this.customizeCssProperty(previewMode, styleValue, Object.assign({}, params, { cssProperty: styleName }));
} else {
// If the value is falsy, remove the property.
for (const selector of this._getSelectors(params.selectorText)) {
const rule = this._getRule(selector);
if (rule) {
rule.style.removeProperty(styleName);
}
}
}
}
this._commitCss();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Apply the stylesheet's css text to the style element that will be saved.
*/
_commitCss() {
const cssTexts = [];
for (const rule of this.styleSheet.cssRules || this.styleSheet.rules) {
cssTexts.push(rule.cssText);
}
this.styleElement.textContent = cssTexts.join('\n');
// Flush the rules cache for convert_inline, to make sure they are
// recomputed to account for the change.
this.options.wysiwyg._rulesCache = undefined;
},
/**
* @override
*/
async _computeWidgetState(methodName, params) {
const res = await this._super(...arguments);
if (res === undefined) {
switch (methodName) {
case 'applyButtonSize':
case 'customizeCssProperty': {
if (!params.selectorText) {
return;
}
// Here we parse the selector in order to create a matching
// element that we inject into the DOM so we can retrieve
// its computed style. We then remove the element from the
// DOM, no harm, no foul.
const firstSelector = params.selectorText.split(',')[0].replace(CSS_PREFIX, '').trim();
const classes = firstSelector.replace(/:not\([^\)]*\)/g, '').match(/\.([\w\d-_]+)/g) || [];
const fakeElement = document.createElement(firstSelector.split(/[\.:, ]/)[0]);
for (const className of classes) {
fakeElement.classList.toggle(className.replace('.', ''), true);
}
this.$layout.find(CSS_PREFIX).prepend(fakeElement);
let res;
if (methodName === 'applyButtonSize') {
// Match a button size by its padding value.
const padding = getComputedStyle(fakeElement).padding;
const classIndex = Object.values(BTN_SIZE_STYLES).findIndex(style => style.padding === padding);
res = classIndex >= 0 ? Object.keys(BTN_SIZE_STYLES)[classIndex] : DEFAULT_BUTTON_SIZE;
} else {
fakeElement.style.display = 'none'; // Needed to get width in %.
res = getComputedStyle(fakeElement)[params.cssProperty || 'font-family'];
if (params.possibleValues && params.possibleValues[1] === FONT_FAMILIES[0]) {
// For font-family, we need to normalize it so it
// matches an option value.
res = normalizeFontFamily(res);
}
if (params.cssProperty === 'font-weight') {
res = parseInt(res) >= 600 ? 'bolder' : '';
} else if (res === 'auto') {
res = '100%';
}
}
fakeElement.remove();
return res;
}
case 'applyButtonSize':
// Match a button size by its padding value.
const rule = this._getRule(this._getSelectors(params.selectorText)[0]);
if (rule) {
const classIndex = Object.values(BTN_SIZE_STYLES).findIndex(style => style.padding === rule.style.padding);
return classIndex >= 0 ? Object.keys(BTN_SIZE_STYLES)[classIndex] : DEFAULT_BUTTON_SIZE;
} else {
return DEFAULT_BUTTON_SIZE;
}
}
} else {
return res;
}
},
/**
* Take a CSS selector and split it into separate selectors, all prefixed
* with the `CSS_PREFIX`. Return them as an array.
*
* @see CSS_PREFIX
* @param {string} selectorText
* @returns {string[]}
*/
_getSelectors(selectorText) {
return selectorText.split(',').map(t => `${t.startsWith(CSS_PREFIX) ? '' : CSS_PREFIX + ' '}${t.trim()}`.trim());;
},
/**
* Take a CSS selector and find its matching rule in the mailing's custom
* stylesheet, if it exists.
*
* @param {string} selectorText
* @returns {CSSStyleRule|undefined}
*/
_getRule(selectorText) {
return [...(this.styleSheet.cssRules || this.styleSheet.rules)].find(rule => rule.selectorText === selectorText);
},
});
});

View file

@ -0,0 +1,206 @@
odoo.define('mass_mailing.snippets.editor', function (require) {
'use strict';
const {_lt} = require('web.core');
const snippetsEditor = require('web_editor.snippet.editor');
const MassMailingSnippetsMenu = snippetsEditor.SnippetsMenu.extend({
events: _.extend({}, snippetsEditor.SnippetsMenu.prototype.events, {
'click .o_we_customize_design_btn': '_onDesignTabClick',
}),
custom_events: _.extend({}, snippetsEditor.SnippetsMenu.prototype.custom_events, {
drop_zone_over: '_onDropZoneOver',
drop_zone_out: '_onDropZoneOut',
drop_zone_start: '_onDropZoneStart',
drop_zone_stop: '_onDropZoneStop',
}),
tabs: _.extend({}, snippetsEditor.SnippetsMenu.prototype.tabs, {
DESIGN: 'design',
}),
optionsTabStructure: [
['design-options', _lt("Design Options")],
],
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
start: function () {
return this._super(...arguments).then(() => {
this.$editable = this.options.wysiwyg.getEditable();
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_onClick: function (ev) {
this._super(...arguments);
var srcElement = ev.target || (ev.originalEvent && (ev.originalEvent.target || ev.originalEvent.originalTarget)) || ev.srcElement;
// When we select something and move our cursor too far from the editable area, we get the
// entire editable area as the target, which causes the tab to shift from OPTIONS to BLOCK.
// To prevent unnecessary tab shifting, we provide a selection for this specific case.
if (srcElement.classList.contains('o_mail_wrapper') || srcElement.querySelector('.o_mail_wrapper')) {
const selection = this.options.wysiwyg.odooEditor.document.getSelection();
if (selection.anchorNode) {
const parent = selection.anchorNode.parentElement;
if (parent) {
srcElement = parent;
}
this._activateSnippet($(srcElement));
}
}
},
/**
* @override
*/
_insertDropzone: function ($hook) {
const $hookParent = $hook.parent();
const $dropzone = this._super(...arguments);
$dropzone.attr('data-editor-message', $hookParent.attr('data-editor-message'));
$dropzone.attr('data-editor-sub-message', $hookParent.attr('data-editor-sub-message'));
return $dropzone;
},
/**
* @override
*/
_updateRightPanelContent: function ({content, tab}) {
this._super(...arguments);
this.$('.o_we_customize_design_btn').toggleClass('active', tab === this.tabs.DESIGN);
},
/**
* @override
*/
_computeSnippetTemplates: function (html) {
const $html = $(html);
const btnSelector = '.note-editable .oe_structure > div.o_mail_snippet_general .btn:not(.btn-link)';
const $colorpickers = $html.find('[data-selector] > we-colorpicker[data-css-property="background-color"]');
for (const colorpicker of $colorpickers) {
const $option = $(colorpicker).parent();
const selectors = $option.data('selector').split(',');
const filteredSelectors = selectors.filter(selector => !selector.includes(btnSelector)).join(',');
$option.attr('data-selector', filteredSelectors);
}
html = $html.toArray().map(node => node.outerHTML).join('');
return this._super(html);
},
//--------------------------------------------------------------------------
// Handler
//--------------------------------------------------------------------------
/**
* @override
*/
_onDropZoneOver: function () {
this.$editable.find('.o_editable').css('background-color', '');
},
/**
* @override
*/
_onDropZoneOut: function () {
const $oEditable = this.$editable.find('.o_editable');
if ($oEditable.find('.oe_drop_zone.oe_insert:not(.oe_vertical):only-child').length) {
$oEditable[0].style.setProperty('background-color', 'transparent', 'important');
}
},
/**
* @override
*/
_onDropZoneStart: function () {
const $oEditable = this.$editable.find('.o_editable');
if ($oEditable.find('.oe_drop_zone.oe_insert:not(.oe_vertical):only-child').length) {
$oEditable[0].style.setProperty('background-color', 'transparent', 'important');
}
},
/**
* @override
*/
_onDropZoneStop: function () {
const $oEditable = this.$editable.find('.o_editable');
$oEditable.css('background-color', '');
if (!$oEditable.find('.oe_drop_zone.oe_insert:not(.oe_vertical):only-child').length) {
$oEditable.attr('contenteditable', true);
}
// Refocus again to save updates when calling `_onWysiwygBlur`
this.$editable.focus();
},
/**
* @override
*/
_onSnippetRemoved: function () {
this._super(...arguments);
const $oEditable = this.$editable.find('.o_editable');
if (!$oEditable.children().length) {
$oEditable.empty(); // remove any superfluous whitespace
$oEditable.attr('contenteditable', false);
}
},
/**
* @private
*/
async _onDesignTabClick() {
// Note: nothing async here but start the loading effect asap
let releaseLoader;
try {
const promise = new Promise(resolve => releaseLoader = resolve);
this._execWithLoadingEffect(() => promise, false, 0);
// loader is added to the DOM synchronously
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
// ensure loader is rendered: first call asks for the (already done) DOM update,
// second call happens only after rendering the first "updates"
if (!this.topFakeOptionEl) {
let el;
for (const [elementName, title] of this.optionsTabStructure) {
const newEl = document.createElement(elementName);
newEl.dataset.name = title;
if (el) {
el.appendChild(newEl);
} else {
this.topFakeOptionEl = newEl;
}
el = newEl;
}
this.bottomFakeOptionEl = el;
this.el.appendChild(this.topFakeOptionEl);
}
// Need all of this in that order so that:
// - the element is visible and can be enabled and the onFocus method is
// called each time.
// - the element is hidden afterwards so it does not take space in the
// DOM, same as the overlay which may make a scrollbar appear.
this.topFakeOptionEl.classList.remove('d-none');
const editorPromise = this._activateSnippet($(this.bottomFakeOptionEl));
releaseLoader(); // because _activateSnippet uses the same mutex as the loader
releaseLoader = undefined;
const editor = await editorPromise;
this.topFakeOptionEl.classList.add('d-none');
editor.toggleOverlay(false);
this._updateRightPanelContent({
tab: this.tabs.DESIGN,
});
} catch (e) {
// Normally the loading effect is removed in case of error during the action but here
// the actual activity is happening outside of the action, the effect must therefore
// be cleared in case of error as well
if (releaseLoader) {
releaseLoader();
}
throw e;
}
},
});
return MassMailingSnippetsMenu;
});

View file

@ -0,0 +1,67 @@
odoo.define('mass_mailing.mass_mailing_code_view_tour', function (require) {
"use strict";
var tour = require('web_tour.tour');
tour.register('mass_mailing_code_view_tour', {
url: '/web',
test: true,
}, [tour.stepUtils.showAppsMenuItem(), {
trigger: '.o_app[data-menu-xmlid="mass_mailing.mass_mailing_menu_root"]',
}, {
trigger: 'button.o_list_button_add',
}, {
trigger: 'input#subject',
content: ('Pick the <b>email subject</b>.'),
position: 'bottom',
run: 'text Test'
}, {
trigger: 'div[name="contact_list_ids"] .o_input_dropdown input[type="text"]',
content: 'Click on the dropdown to open it and then start typing to search.',
}, {
trigger: 'div[name="contact_list_ids"] .ui-state-active',
content: 'Select item from dropdown',
run: 'click',
}, {
trigger: 'div[name="body_arch"] iframe #default',
content: 'Choose this <b>theme</b>.',
run: 'click',
}, {
trigger: 'iframe .o_codeview_btn',
content: ('Click here to switch to <b>code view</b>'),
run: 'click'
}, {
trigger: 'iframe .o_codeview',
content: ('Remove all content from codeview'),
run: function () {
const iframe = document.querySelector('.wysiwyg_iframe');
const iframeDocument = iframe.contentWindow.document;
let element = iframeDocument.querySelector(".o_codeview");
element.value = '';
}
}, {
trigger: 'iframe .o_codeview_btn',
content: ('Click here to switch back from <b>code view</b>'),
run: 'click'
}, {
trigger: '[name="body_arch"] iframe .o_mail_wrapper_td',
content: 'Verify that the dropable zone was not removed',
run: () => {},
}, {
trigger: '[name="body_arch"] iframe #email_designer_default_body [name="Title"] .ui-draggable-handle',
content: 'Drag the "Title" snippet from the design panel and drop it in the editor',
extra_trigger: '[name="body_arch"] iframe body.editor_enable',
run: function (actions) {
actions.drag_and_drop('[name="body_arch"] iframe .o_editable', this.$anchor);
}
}, {
trigger: '[name="body_arch"] iframe .o_editable h1',
content: 'Verify that the title was inserted properly in the editor',
run: () => {},
}, {
trigger: 'button.o_form_button_save',
content: 'Click on the "Save" button to save the changes.',
run: 'click',
},
...tour.stepUtils.saveForm(),]);
});

View file

@ -0,0 +1,257 @@
odoo.define('mass_mailing.mass_mailing_editor_tour', function (require) {
"use strict";
var tour = require('web_tour.tour');
const { boundariesIn, setSelection } = require('@web_editor/js/editor/odoo-editor/src/utils/utils');
tour.register('mass_mailing_editor_tour', {
url: '/web',
test: true,
}, [tour.stepUtils.showAppsMenuItem(), {
trigger: '.o_app[data-menu-xmlid="mass_mailing.mass_mailing_menu_root"]',
}, {
trigger: 'button.o_list_button_add',
}, {
trigger: 'div[name="contact_list_ids"] .o_input_dropdown input[type="text"]',
}, {
trigger: 'div[name="contact_list_ids"] .ui-state-active'
}, {
content: 'choose the theme "empty" to edit the mailing with snippets',
trigger: '[name="body_arch"] iframe #empty',
}, {
content: 'wait for the editor to be rendered',
trigger: '[name="body_arch"] iframe .o_editable[data-editor-message="DRAG BUILDING BLOCKS HERE"]',
run: () => {},
}, {
content: 'drag the "Title" snippet from the design panel and drop it in the editor',
trigger: '[name="body_arch"] iframe #email_designer_default_body [name="Title"] .ui-draggable-handle',
run: function (actions) {
actions.drag_and_drop('[name="body_arch"] iframe .o_editable', this.$anchor);
}
}, {
content: 'wait for the snippet menu to finish the drop process',
trigger: '[name="body_arch"] iframe #email_designer_header_elements:not(:has(.o_we_already_dragging))',
run: () => {}
}, {
content: 'verify that the title was inserted properly in the editor',
trigger: '[name="body_arch"] iframe .o_editable h1',
run: () => {},
}, {
trigger: 'button.o_form_button_save',
}, {
content: 'verify that the save failed (since the field "subject" was not set and it is required)',
trigger: 'label.o_field_invalid',
run: () => {},
}, {
content: 'verify that the edited mailing body was not lost during the failed save',
trigger: '[name="body_arch"] iframe .o_editable h1',
run: () => {},
}, {
trigger: 'input#subject',
run: 'text Test',
}, {
trigger: '.o_form_view', // blur previous input
},
...tour.stepUtils.saveForm(),
{
trigger: 'iframe .o_editable',
run: () => {},
}]);
tour.register('mass_mailing_basic_theme_toolbar', {
test: true,
url: '/web',
}, [
tour.stepUtils.showAppsMenuItem(),
{
content: "Select the 'Email Marketing' app.",
trigger: '.o_app[data-menu-xmlid="mass_mailing.mass_mailing_menu_root"]',
},
{
content: "Click on the create button to create a new mailing.",
trigger: 'button.o_list_button_add',
},
{
content: "Fill in Subject",
trigger: '#subject',
run: 'text Test Basic Theme',
},
{
content: "Fill in Mailing list",
trigger: '#contact_list_ids',
run: 'text Newsletter',
},
{
content: "Pick 'Newsletter' option",
trigger: '.o_input_dropdown a:contains(Newsletter)',
},
{
content: "Pick the basic theme",
trigger: 'iframe #basic',
extra_trigger: 'iframe .o_mail_theme_selector_new',
},
{
content: "Make sure the snippets menu is hidden",
trigger: 'iframe html:has(#oe_snippets.d-none)',
run: () => null, // no click, just check
},
{
content: "Click on the New button to create another mailing",
trigger: 'button.o_form_button_create',
},
{
content: "Fill in Subject",
trigger: '#subject',
extra_trigger: 'iframe .o_mail_theme_selector_new',
run: 'text Test Newsletter Theme',
},
{
content: "Fill in Mailing list",
trigger: '#contact_list_ids',
run: 'text Newsletter',
},
{
content: "Pick 'Newsletter' option",
trigger: '.o_input_dropdown a:contains(Newsletter)',
},
{
content: "Pick the newsletter theme",
trigger: 'iframe #newsletter',
// extra_trigger: 'iframe .o_mail_theme_selector_new',
},
{
content: "Make sure the snippets menu is displayed",
trigger: 'iframe #oe_snippets',
run: () => null, // no click, just check
},
{
content: 'Save form',
trigger: '.o_form_button_save',
},
{
content: 'Go back to previous mailing',
trigger: 'button.o_pager_previous',
},
{
content: "Make sure the snippets menu is hidden",
trigger: 'iframe html:has(#oe_snippets.d-none)',
run: () => null,
},
{
content: "Add some content to be selected afterwards",
trigger: 'iframe p',
run: 'text content',
},
{
content: "Select text",
trigger: 'iframe p:contains(content)',
run() {
setSelection(...boundariesIn(this.$anchor[0]), false);
}
},
{
content: "Make sure the floating toolbar is visible",
trigger: '#toolbar.oe-floating[style*="visible"]',
run: () => null,
},
{
content: "Open the color picker",
trigger: '#toolbar #oe-text-color',
},
{
content: "Pick a color",
trigger: '#toolbar button[data-color="o-color-1"]',
},
{
content: "Check that color was applied",
trigger: 'iframe p font.text-o-color-1',
run: () => null,
},
{
content: 'Save changes',
trigger: '.o_form_button_save',
},
{
content: "Go to 'Mailings' list view",
trigger: '.breadcrumb a:contains(Mailings)'
},
{
content: "Open newly created mailing",
trigger: 'td:contains("Test Basic Theme")',
},
{
content: "Make sure the snippets menu is hidden",
trigger: 'iframe html:has(#oe_snippets.d-none)',
run: () => null,
},
{
content: "Select content",
trigger: 'iframe p:contains(content)',
run() {
setSelection(...boundariesIn(this.$anchor[0]), false);
}
},
{
content: "Make sure the floating toolbar is visible",
trigger: '#toolbar.oe-floating[style*="visible"]',
run: () => null,
},
...tour.stepUtils.discardForm(),
]);
tour.register('mass_mailing_campaing_new_mailing', {
url: '/web',
test: true,
}, [
tour.stepUtils.showAppsMenuItem(),
{
content: 'Select the "Email Marketing" app',
trigger: '.o_app[data-menu-xmlid="mass_mailing.mass_mailing_menu_root"]',
},
{
content: 'Select "Campaings" Navbar item',
trigger: '.o_nav_entry[data-menu-xmlid="mass_mailing.menu_email_campaigns"]',
},
{
content: 'Select "Newsletter" campaign',
trigger: '.oe_kanban_card:contains("Test Newsletter")',
},
{
content: 'Add a line (create new mailing)',
trigger: '.o_field_x2many_list_row_add a',
},
{
content: 'Pick the basic theme',
trigger: 'iframe',
run(actions) {
// For some reason the selectors inside the iframe cannot be triggered.
const link = this.$anchor[0].contentDocument.querySelector('#basic');
actions.click(link);
}
},
{
content: 'Fill in Subject',
trigger: '#subject',
run: 'text Test',
},
{
content: 'Fill in Mailing list',
trigger: '#contact_list_ids',
run: 'text Test Newsletter',
},
{
content: 'Pick "Newsletter" option',
trigger: '.o_input_dropdown a:contains(Test Newsletter)',
},
{
content: 'Save form',
trigger: '.o_form_button_save',
},
{
content: 'Check that newly created record is on the list',
trigger: '[name="mailing_mail_ids"] td[name="subject"]:contains("Test")',
run: () => null,
},
...tour.stepUtils.saveForm(),
]);
});

View file

@ -0,0 +1,189 @@
/** @odoo-module **/
import tour from 'web_tour.tour';
tour.register('mass_mailing_snippets_menu_tabs', {
test: true,
url: '/web',
}, [
tour.stepUtils.showAppsMenuItem(), {
content: "Select the 'Email Marketing' app.",
trigger: '.o_app[data-menu-xmlid="mass_mailing.mass_mailing_menu_root"]',
},
{
content: "Click on the create button to create a new mailing.",
trigger: 'button.o_list_button_add',
},
{
content: "Click on the 'Start From Scratch' template.",
trigger: 'iframe #empty',
},
{
content: "Click on the 'Design' tab.",
trigger: 'iframe .o_we_customize_design_btn',
},
{
content: "Click on the empty 'DRAG BUILDING BLOCKS HERE' area.",
trigger: 'iframe .oe_structure.o_mail_no_options',
},
{
content: "Click on the 'Design' tab.",
trigger: 'iframe .o_we_customize_design_btn',
},
{
content: "Verify that the customize panel is not empty.",
trigger: 'iframe .o_we_customize_panel .snippet-option-DesignTab',
run: () => null, // it's a check
},
{
content: "Click on the style tab.",
trigger: 'iframe .o_we_customize_snippet_btn',
},
{
content: "Click on the 'Design' tab.",
trigger: 'iframe .o_we_customize_design_btn',
},
{
content: "Verify that the customize panel is not empty.",
trigger: 'iframe .o_we_customize_panel .snippet-option-DesignTab',
run: () => null, // it's a check
},
...tour.stepUtils.discardForm(),
]);
tour.register('mass_mailing_snippets_menu_toolbar_new_mailing_mobile', {
test: true,
url: '/web',
}, [
tour.stepUtils.showAppsMenuItem(), {
content: "Select the 'Email Marketing' app.",
trigger: '.o_app[data-menu-xmlid="mass_mailing.mass_mailing_menu_root"]',
},
{
content: "Click on the create button to create a new mailing.",
trigger: 'button.o_list_button_add',
mobile: true,
},
{
content: "Check templates available in theme selector",
trigger: 'iframe .o_mail_theme_selector_new',
run: function () {
if (this.$anchor[0].querySelector('#empty')) {
console.error('The empty template should not be visible on mobile.');
}
},
mobile: true,
},
{
content: "Make sure the toolbar isn't floating",
trigger: 'iframe',
run: function () {
const iframeDocument = this.$anchor[0].contentDocument;
if (iframeDocument.querySelector('#toolbar.oe-floating')) {
console.error('There should not be a floating toolbar in the iframe');
}
},
mobile: true,
},
{
content: "Click on the 'Start From Scratch' template.",
trigger: 'iframe #default',
mobile: true,
},
{
content: "Select an editable element",
trigger: 'iframe .s_text_block',
mobile: true,
},
{
content: "Make sure the snippets menu is hidden",
trigger: 'iframe',
run: function () {
const iframeDocument = this.$anchor[0].contentDocument;
if (!iframeDocument.querySelector('#oe_snippets.d-none')) {
console.error('The snippet menu should be hidden');
}
},
mobile: true,
},
{
content: "Make sure the toolbar is there, with the tables formating tool",
trigger: 'iframe #toolbar.oe-floating #table:not(.d-none)',
run: () => null, // it's a check
mobile: true,
},
]);
tour.register('mass_mailing_snippets_menu_toolbar', {
test: true,
url: '/web',
}, [
tour.stepUtils.showAppsMenuItem(), {
content: "Select the 'Email Marketing' app.",
trigger: '.o_app[data-menu-xmlid="mass_mailing.mass_mailing_menu_root"]',
},
{
content: "Click on the create button to create a new mailing.",
trigger: 'button.o_list_button_add',
},
{
content: "Wait for the theme selector to load.",
trigger: 'iframe .o_mail_theme_selector_new',
},
{
content: "Make sure there does not exist a floating toolbar",
trigger: 'iframe',
run: function () {
const iframeDocument = this.$anchor[0].contentDocument;
if (iframeDocument.querySelector('#toolbar.oe-floating')) {
console.error('There should not be a floating toolbar in the iframe');
}
},
},
{
content: "Make sure the empty template is an option on non-mobile devices.",
trigger: 'iframe #empty',
run: () => null,
},
{
content: "Click on the default 'welcome' template.",
trigger: 'iframe #default',
},
{ // necessary to wait for the cursor to be placed in the first p
// and to avoid leaving the page before the selection is added
content: "Wait for template selection event to be over.",
trigger: 'iframe .o_editable.theme_selection_done',
},
{
content: "Make sure the snippets menu is not hidden",
trigger: 'iframe #oe_snippets:not(.d-none)',
run: () => null,
},
{
content: "Wait for .s_text_block to be populated",
trigger: 'iframe .s_text_block p',
run: () => null,
},
{
content: "Click and select p block inside the editor",
trigger: 'iframe',
run: function () {
const iframeWindow = this.$anchor[0].contentWindow;
const iframeDocument = iframeWindow.document;
const p = iframeDocument.querySelector('.s_text_block p');
p.click();
const selection = iframeWindow.getSelection();
const range = iframeDocument.createRange();
range.selectNodeContents(p);
selection.removeAllRanges();
selection.addRange(range);
},
},
{
content: "Make sure the toolbar is there",
trigger: 'iframe #oe_snippets .o_we_customize_panel #toolbar',
run: () => null,
},
...tour.stepUtils.discardForm(),
]);

View file

@ -0,0 +1,93 @@
odoo.define('mass_mailing.mass_mailing_tour', function (require) {
"use strict";
const {_t} = require('web.core');
const {Markup} = require('web.utils');
var tour = require('web_tour.tour');
tour.register('mass_mailing_tour', {
url: '/web',
rainbowManMessage: _t('Congratulations, I love your first mailing. :)'),
sequence: 200,
}, [tour.stepUtils.showAppsMenuItem(), {
trigger: '.o_app[data-menu-xmlid="mass_mailing.mass_mailing_menu_root"]',
content: _t("Let's try the Email Marketing app."),
width: 225,
position: 'bottom',
edition: 'enterprise',
}, {
trigger: '.o_app[data-menu-xmlid="mass_mailing.mass_mailing_menu_root"]',
content: _t("Let's try the Email Marketing app."),
edition: 'community',
}, {
trigger: '.o_list_button_add',
extra_trigger: '.o_mass_mailing_mailing_tree',
content: Markup(_t("Start by creating your first <b>Mailing</b>.")),
position: 'bottom',
}, {
trigger: 'div[name="subject"]',
content: Markup(_t('Pick the <b>email subject</b>.')),
position: 'bottom',
run: 'click',
}, {
trigger: 'div[name="contact_list_ids"] > .o_input_dropdown > input[type="text"]',
run: 'click',
auto: true,
}, {
trigger: 'li.ui-menu-item',
run: 'click',
auto: true,
}, {
trigger: 'div[name="body_arch"] iframe #newsletter',
content: Markup(_t('Choose this <b>theme</b>.')),
position: 'left',
edition: 'enterprise',
run: 'click',
}, {
trigger: 'div[name="body_arch"] iframe #default',
content: Markup(_t('Choose this <b>theme</b>.')),
position: 'right',
edition: 'community',
run: 'click',
}, {
trigger: 'div[name="body_arch"] iframe div.theme_selection_done div.s_text_block',
content: _t('Click on this paragraph to edit it.'),
position: 'top',
edition: 'enterprise',
run: 'click',
}, {
trigger: 'div[name="body_arch"] iframe div.o_mail_block_title_text',
content: _t('Click on this paragraph to edit it.'),
position: 'top',
edition: 'community',
run: 'click',
}, {
trigger: 'button[name="action_set_favorite"]',
content: _t('Click on this button to add this mailing to your templates.'),
position: 'bottom',
run: 'click',
}, {
trigger: 'button[name="action_test"]',
content: _t("Test this mailing by sending a copy to yourself."),
position: 'bottom',
}, {
trigger: 'button[name="send_mail_test"]',
content: _t("Check the email address and click send."),
position: 'bottom',
}, {
trigger: 'button[name="action_launch"]',
content: _t("Ready for take-off!"),
position: 'bottom',
}, {
trigger: '.btn-primary:contains("Ok")',
content: _t("Don't worry, the mailing contact we created is an internal user."),
position: 'bottom',
run: "click",
}, {
trigger: '.o_back_button',
content: Markup(_t("By using the <b>Breadcrumb</b>, you can navigate back to the overview.")),
position: 'bottom',
run: 'click',
}]
);
});

View file

@ -0,0 +1,148 @@
odoo.define('mass_mailing.wysiwyg', function (require) {
'use strict';
var Wysiwyg = require('web_editor.wysiwyg');
var MassMailingSnippetsMenu = require('mass_mailing.snippets.editor');
const {closestElement} = require('@web_editor/js/editor/odoo-editor/src/OdooEditor');
const Toolbar = require('web_editor.toolbar');
const MassMailingWysiwyg = Wysiwyg.extend({
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
startEdition: async function () {
const res = await this._super(...arguments);
// Prevent selection change outside of snippets.
this.$editable.on('mousedown', e => {
if ($(e.target).is('.o_editable:empty') || e.target.querySelector('.o_editable')) {
e.preventDefault();
}
});
this.snippetsMenuToolbar = this.toolbar;
return res;
},
toggleLinkTools(options = {}) {
this._super({
...options,
// Always open the dialog when the sidebar is folded.
forceDialog: options.forceDialog || this.snippetsMenu.folded
});
if (this.snippetsMenu.folded) {
// Hide toolbar and avoid it being re-displayed after getDeepRange.
this.odooEditor.document.getSelection().collapseToEnd();
}
},
/**
* Sets SnippetsMenu fold state and switches toolbar.
* Instantiates a new floating Toolbar if needed.
*
* @param {Boolean} fold
*/
setSnippetsMenuFolded: async function (fold = true) {
if (fold) {
this.snippetsMenu.setFolded(true);
if (!this.floatingToolbar) {
// Instantiate and configure new toolbar.
this.floatingToolbar = new Toolbar(this, 'web_editor.toolbar');
this.toolbar = this.floatingToolbar;
await this.toolbar.appendTo(document.createElement('void'));
this._configureToolbar({ snippets: false });
this._updateEditorUI();
this.setCSSVariables(this.toolbar.el);
this.odooEditor.setupToolbar(this.toolbar.el);
if (this.odooEditor.isMobile) {
document.body.querySelector('.o_mail_body').prepend(this.toolbar.el);
} else {
document.body.append(this.toolbar.el);
}
} else {
this.toolbar = this.floatingToolbar;
}
this.toolbar.el.classList.remove('d-none');
this.odooEditor.autohideToolbar = true;
this.odooEditor.toolbarHide();
} else {
this.snippetsMenu.setFolded(false);
this.toolbar = this.snippetsMenuToolbar;
this.odooEditor.autohideToolbar = false;
if (this.floatingToolbar) {
this.floatingToolbar.el.classList.add('d-none');
}
}
this.odooEditor.toolbar = this.toolbar.el;
},
/**
* @override
*/
openMediaDialog: function() {
this._super(...arguments);
// Opening the dialog in the outer document does not trigger the selectionChange
// (that would normally hide the toolbar) in the iframe.
if (this.snippetsMenu.folded) {
this.odooEditor.toolbarHide();
}
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_createSnippetsMenuInstance: function (options={}) {
return new MassMailingSnippetsMenu(this, Object.assign({
wysiwyg: this,
selectorEditableArea: '.o_editable',
}, options));
},
/**
* @override
*/
_getPowerboxOptions: function () {
const options = this._super();
const {commands} = options;
const linkCommands = commands.filter(command => command.name === 'Link' || command.name === 'Button');
for (const linkCommand of linkCommands) {
// Remove the command if the selection is within a background-image.
const superIsDisabled = linkCommand.isDisabled;
linkCommand.isDisabled = () => {
if (superIsDisabled && superIsDisabled()) {
return true;
} else {
const selection = this.odooEditor.document.getSelection();
const range = selection.rangeCount && selection.getRangeAt(0);
return !!range && !!closestElement(range.startContainer, '[style*=background-image]');
}
}
}
return {...options, commands};
},
/**
* @override
*/
_updateEditorUI: function (e) {
this._super(...arguments);
// Hide the create-link button if the selection is within a
// background-image.
const selection = this.odooEditor.document.getSelection();
const range = selection.rangeCount && selection.getRangeAt(0);
const isWithinBackgroundImage = !!range && !!closestElement(range.startContainer, '[style*=background-image]');
if (isWithinBackgroundImage) {
this.toolbar.$el.find('#create-link').toggleClass('d-none', true);
}
},
_getEditorOptions: function () {
const options = this._super(...arguments);
const finalOptions = { autoActivateContentEditable: false, ...options };
return finalOptions;
},
});
return MassMailingWysiwyg;
});