odoo18/addons/website/static/src/js/tours/tour_utils.js

596 lines
21 KiB
JavaScript

/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { cookie } from "@web/core/browser/cookie";
import { markup } from "@odoo/owl";
import { omit } from "@web/core/utils/objects";
export function addMedia(position = "right") {
return {
trigger: `.modal-content footer .btn-primary`,
content: markup(_t("<b>Add</b> the selected image.")),
tooltipPosition: position,
run: "click",
};
}
export function assertCssVariable(variableName, variableValue, trigger = ':iframe body') {
return {
isActive: ["auto"],
content: `Check CSS variable ${variableName}=${variableValue}`,
trigger: trigger,
run() {
const styleValue = getComputedStyle(this.anchor).getPropertyValue(variableName);
if ((styleValue && styleValue.trim().replace(/["']/g, '')) !== variableValue.trim().replace(/["']/g, '')) {
throw new Error(`Failed precondition: ${variableName}=${styleValue} (should be ${variableValue})`);
}
},
};
}
export function assertPathName(pathname, trigger) {
return {
content: `Check if we have been redirected to ${pathname}`,
trigger: trigger,
async run() {
await new Promise((resolve) => {
let elapsedTime = 0;
const intervalTime = 100;
const interval = setInterval(() => {
if (window.location.pathname.startsWith(pathname)) {
clearInterval(interval);
resolve();
}
elapsedTime += intervalTime;
if (elapsedTime >= 5000) {
clearInterval(interval);
console.error(`The pathname ${pathname} has not been found`);
}
}, intervalTime);
});
},
};
}
export function changeBackground(snippet, position = "bottom") {
return [
{
trigger: ".o_we_customize_panel .o_we_bg_success",
content: markup(_t("<b>Customize</b> any block through this menu. Try to change the background image of this block.")),
tooltipPosition: position,
run: "click",
},
];
}
export function changeBackgroundColor(position = "bottom") {
return {
trigger: ".o_we_customize_panel .o_we_color_preview",
content: markup(_t("<b>Customize</b> any block through this menu. Try to change the background color of this block.")),
tooltipPosition: position,
run: "click",
};
}
export function selectColorPalette(position = "left") {
return {
trigger:
".o_we_customize_panel .o_we_so_color_palette we-selection-items, .o_we_customize_panel .o_we_color_preview",
content: markup(_t(`<b>Select</b> a Color Palette.`)),
tooltipPosition: position,
run: 'click',
};
}
export function changeColumnSize(position = "right") {
return {
trigger: `:iframe .oe_overlay.o_draggable.o_we_overlay_sticky.oe_active .o_handle.e`,
content: markup(_t("<b>Slide</b> this button to change the column size.")),
tooltipPosition: position,
run: "click",
};
}
export function changeImage(snippet, position = "bottom") {
return [
{
trigger: "body.editor_enable",
},
{
trigger: snippet.id ? `#wrapwrap .${snippet.id} img` : snippet,
content: markup(_t("<b>Double click on an image</b> to change it with one of your choice.")),
tooltipPosition: position,
run: "dblclick",
},
];
}
/**
wTourUtils.changeOption('HeaderTemplate', '[data-name="header_alignment_opt"]', _t('alignment')),
By default, prevents the step from being active if a palette is opened.
Set allowPalette to true to select options within a palette.
*/
export function changeOption(optionName, weName = '', optionTooltipLabel = '', position = "bottom", allowPalette = false) {
const noPalette = allowPalette ? '' : '.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened))';
const option_block = `${noPalette} we-customizeblock-option[class='snippet-option-${optionName}']`;
return {
trigger: `${option_block} ${weName}, ${option_block} [title='${weName}']`,
content: markup(_t("<b>Click</b> on this option to change the %s of the block.", optionTooltipLabel)),
tooltipPosition: position,
run: "click",
};
}
export function selectNested(trigger, optionName, altTrigger = null, optionTooltipLabel = '', position = "top", allowPalette = false) {
const noPalette = allowPalette ? '' : '.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened))';
const option_block = `${noPalette} we-customizeblock-option[class='snippet-option-${optionName}']`;
return {
trigger: trigger + (altTrigger ? `, ${option_block} ${altTrigger}` : ""),
content: markup(_t("<b>Select</b> a %s.", optionTooltipLabel)),
tooltipPosition: position,
run: 'click',
};
}
export function changePaddingSize(direction) {
let paddingDirection = "n";
let position = "top";
if (direction === "bottom") {
paddingDirection = "s";
position = "bottom";
}
return {
trigger: `:iframe .oe_overlay.o_draggable.o_we_overlay_sticky.oe_active .o_handle.${paddingDirection}`,
content: markup(_t("<b>Slide</b> this button to change the %s padding", direction)),
tooltipPosition: position,
run: "click",
};
}
/**
* Checks if an element is visible on the screen, i.e., not masked by another
* element.
*
* @param {String} elementSelector The selector of the element to be checked.
* @returns {Object} The steps required to check if the element is visible.
*/
export function checkIfVisibleOnScreen(elementSelector) {
return {
content: "Check if the element is visible on screen",
trigger: `${elementSelector}`,
run() {
const boundingRect = this.anchor.getBoundingClientRect();
const centerX = boundingRect.left + boundingRect.width / 2;
const centerY = boundingRect.top + boundingRect.height / 2;
const iframeDocument = document.querySelector(".o_iframe").contentDocument;
const el = iframeDocument.elementFromPoint(centerX, centerY);
if (!this.anchor.contains(el)) {
console.error("The element is not visible on screen");
}
},
};
}
/**
* Simple click on an element in the page.
* @param {*} elementName
* @param {*} selector
*/
export function clickOnElement(elementName, selector) {
return {
content: `Clicking on the ${elementName}`,
trigger: selector,
run: 'click'
};
}
/**
* Click on the top right edit button and wait for the edit mode
*
* @param {string} position Where the purple arrow will show up
*/
export function clickOnEditAndWaitEditMode(position = "bottom") {
return [{
content: markup(_t("<b>Click Edit</b> to start designing your homepage.")),
trigger: ".o_menu_systray .o_edit_website_container a",
tooltipPosition: position,
run: "click",
}, {
isActive: ["auto"], // Checking step only for automated tests
content: "Check that we are in edit mode",
trigger: ".o_website_preview.editor_enable.editor_has_snippets",
}];
}
/**
* Click on the top right edit dropdown, then click on the edit dropdown item
* and wait for the edit mode
*
* @param {string} position Where the purple arrow will show up
*/
export function clickOnEditAndWaitEditModeInTranslatedPage(position = "bottom") {
return [{
content: markup(_t("<b>Click Edit</b> dropdown")),
trigger: ".o_edit_website_container button",
tooltipPosition: position,
run: "click",
}, {
content: markup(_t("<b>Click Edit</b> to start designing your homepage.")),
trigger: ".o_edit_website_dropdown_item",
tooltipPosition: position,
run: "click",
}, {
isActive: ["auto"], // Checking step only for automated tests
content: "Check that we are in edit mode",
trigger: ".o_website_preview.editor_enable.editor_has_snippets",
}];
}
/**
* Simple click on a snippet in the edition area
* @param {*} snippet
* @param {*} position
*/
export function clickOnSnippet(snippet, position = "bottom") {
const trigger = snippet.id ? `#wrapwrap .${snippet.id}` : snippet;
return [
{
trigger: "body.editor_has_snippets",
noPrepend: true,
},
{
trigger: `:iframe ${trigger}`,
content: markup(_t("<b>Click on a snippet</b> to access its options menu.")),
tooltipPosition: position,
run: "click",
},
];
}
export function clickOnSave(position = "bottom", timeout) {
return [
{
trigger: "#oe_snippets:not(:has(.o_we_ongoing_insertion))",
},
{
trigger: "body:not(:has(.o_dialog))",
noPrepend: true,
},
{
trigger:
'div:not(.o_loading_dummy) > #oe_snippets button[data-action="save"]:not([disabled])',
// TODO this should not be needed but for now it better simulates what
// an human does. By the time this was added, it's technically possible
// to drag and drop a snippet then immediately click on save and have
// some problem. Worst case probably is a traceback during the redirect
// after save though so it's not that big of an issue. The problem will
// of course be solved (or at least prevented in stable). More details
// in related commit message.
content: markup(_t("Good job! It's time to <b>Save</b> your work.")),
tooltipPosition: position,
timeout: timeout,
run: "click",
},
{
isActive: ["auto"], // Just making sure save is finished in automatic tests
trigger: ":iframe body:not(.editor_enable)",
noPrepend: true,
timeout: timeout,
},
];
}
/**
* Click on a snippet's text to modify its content
* @param {*} snippet
* @param {*} element Target the element which should be rewrite
* @param {*} position
*/
export function clickOnText(snippet, element, position = "bottom") {
return [
{
trigger: ":iframe body.editor_enable",
},
{
trigger: snippet.id ? `:iframe #wrapwrap .${snippet.id} ${element}` : snippet,
content: markup(_t("<b>Click on a text</b> to start editing it.")),
tooltipPosition: position,
run: "click",
},
];
}
/**
* Selects a category or an inner snippet from the snippets menu and insert it
* in the page.
* @param {*} snippet contain the id and the name of the targeted snippet. If it
* contains a group it means that the snippet is shown in the "add snippets"
* dialog.
* @param {*} position Where the purple arrow will show up
*/
export function insertSnippet(snippet, position = "bottom") {
const blockEl = snippet.groupName || snippet.name;
const insertSnippetSteps = [{
trigger: ".o_website_preview.editor_enable.editor_has_snippets",
noPrepend: true,
}];
if (snippet.groupName) {
insertSnippetSteps.push({
content: markup(_t("Click on the <b>%s</b> category.", blockEl)),
trigger: `#oe_snippets .oe_snippet[name="${blockEl}"].o_we_draggable .oe_snippet_thumbnail:not(.o_we_ongoing_insertion)`,
tooltipPosition: position,
run: "click",
},
{
content: markup(_t("Click on the <b>%s</b> building block.", snippet.name)),
// FIXME `:not(.d-none)` should obviously not be needed but it seems
// currently needed when using a tour in user/interactive mode.
trigger: `:iframe .o_snippet_preview_wrap[data-snippet-id="${snippet.id}"]:not(.d-none)`,
noPrepend: true,
tooltipPosition: "top",
run: "click",
},
{
trigger: `#oe_snippets .oe_snippet[name="${blockEl}"].o_we_draggable .oe_snippet_thumbnail:not(.o_we_ongoing_insertion)`,
});
} else {
insertSnippetSteps.push({
content: markup(_t("Drag the <b>%s</b> block and drop it at the bottom of the page.", blockEl)),
trigger: `#oe_snippets .oe_snippet[name="${blockEl}"].o_we_draggable .oe_snippet_thumbnail:not(.o_we_ongoing_insertion)`,
tooltipPosition: position,
run: "drag_and_drop :iframe #wrapwrap > footer",
});
}
return insertSnippetSteps;
}
export function goBackToBlocks(position = "bottom") {
return {
trigger: '.o_we_add_snippet_btn',
content: _t("Click here to go back to block tab."),
tooltipPosition: position,
run: "click",
};
}
export function goToTheme(position = "bottom") {
return [
{
trigger: "#oe_snippets.o_loaded",
},
{
trigger: ".o_we_customize_theme_btn",
content: _t("Go to the Theme tab"),
tooltipPosition: position,
run: "click",
},
];
}
export function selectHeader(position = "bottom") {
return {
trigger: `:iframe header#top`,
content: markup(_t(`<b>Click</b> on this header to configure it.`)),
tooltipPosition: position,
run: "click",
};
}
export function selectSnippetColumn(snippet, index = 0, position = "bottom") {
return {
trigger: `:iframe #wrapwrap .${snippet.id} .row div[class*="col-lg-"]:eq(${index})`,
content: markup(_t("<b>Click</b> on this column to access its options.")),
tooltipPosition: position,
run: "click",
};
}
export function prepend_trigger(steps, prepend_text='') {
for (const step of steps) {
if (!step.noPrepend && prepend_text) {
step.trigger = prepend_text + step.trigger;
}
}
return steps;
}
export function getClientActionUrl(path, edition) {
let url = `/odoo/action-website.website_preview`;
if (path) {
url += `?path=${encodeURIComponent(path)}`;
}
if (edition) {
url += `${path ? '&' : '?'}enable_editor=1`;
}
return url;
}
export function clickOnExtraMenuItem(stepOptions, backend = false) {
return Object.assign({
content: "Click on the extra menu dropdown toggle if it is there",
trigger: `${backend ? ":iframe" : ""} .top_menu`,
async run(actions) {
const extraMenuButton = this.anchor.querySelector(".o_extra_menu_items a.nav-link");
// Don't click on the extra menu button if it's already visible.
if (extraMenuButton && !extraMenuButton.classList.contains("show")) {
await actions.click(extraMenuButton);
}
},
}, stepOptions);
}
/**
* Registers a tour that will go in the website client action.
*
* @param {string} name The tour's name
* @param {object} options The tour options
* @param {string} options.url The page to edit
* @param {boolean} [options.edition] If the tour starts in edit mode
* @param {() => TourStep[]} steps The steps of the tour. Has to be a function to avoid direct interpolation of steps.
*/
export function registerWebsitePreviewTour(name, options, steps) {
if (typeof steps !== "function") {
throw new Error(`tour.steps has to be a function that returns TourStep[]`);
}
return registry.category("web_tour.tours").add(name, {
...omit(options, "edition"),
url: getClientActionUrl(options.url, !!options.edition),
steps: () => {
const tourSteps = [...steps()];
// Note: for both non edit mode and edit mode, we set a high timeout for the
// first step. Indeed loading both the backend and the frontend (in the
// iframe) and potentially starting the edit mode can take a long time in
// automatic tests. We'll try and decrease the need for this high timeout
// of course.
if (options.edition) {
tourSteps.unshift({
isActive: ["auto"],
content: "Wait for the edit mode to be started",
trigger: ".o_website_preview.editor_enable.editor_has_snippets",
timeout: 30000,
});
} else {
tourSteps[0].timeout = 20000;
}
return tourSteps.map((step) => {
delete step.noPrepend;
return step;
});
},
});
}
export function registerThemeHomepageTour(name, steps) {
if (typeof steps !== "function") {
throw new Error(`tour.steps has to be a function that returns TourStep[]`);
}
return registerWebsitePreviewTour(name, {
url: '/',
saveAs: "homepage", // disable manual mode for theme homepage tours - FIXME
},
() => [
...clickOnEditAndWaitEditMode(),
...prepend_trigger(
steps().concat(clickOnSave()),
".o_website_preview[data-view-xmlid='website.homepage'] "
),
]);
}
export function registerBackendAndFrontendTour(name, options, steps) {
if (typeof steps !== "function") {
throw new Error(`tour.steps has to be a function that returns TourStep[]`);
}
if (window.location.pathname === '/odoo') {
return registerWebsitePreviewTour(name, options, () => {
const newSteps = [];
for (const step of steps()) {
const newStep = Object.assign({}, step);
newStep.trigger = `:iframe ${step.trigger}`;
newSteps.push(newStep);
}
return newSteps;
});
}
return registry.category("web_tour.tours").add(name, {
url: options.url,
steps: () => {
return steps();
},
});
}
/**
* Selects an element inside a we-select, if the we-select is from a m2o widget, searches for it.
*
* @param widgetName {string} The widget's data-name
* @param elementName {string} the element to search
* @param searchNeeded {Boolean} if the widget is a m2o widget and a search is needed
*/
export function selectElementInWeSelectWidget(widgetName, elementName, searchNeeded = false) {
const steps = [clickOnElement(`${widgetName} toggler`, `we-select[data-name=${widgetName}] we-toggler`)];
if (searchNeeded) {
steps.push({
content: `Inputing ${elementName} in m2o widget search`,
trigger: `we-select[data-name=${widgetName}] div.o_we_m2o_search input`,
run: `edit ${elementName}`,
});
}
steps.push(clickOnElement(`${elementName} in the ${widgetName} widget`,
`we-select[data-name="${widgetName}"] we-button:contains("${elementName}"), ` +
`we-select[data-name="${widgetName}"] we-button[data-select-label="${elementName}"]`));
return steps;
}
/**
* Switches to a different website by clicking on the website switcher.
*
* @param {number} websiteId - The ID of the website to switch to.
* @param {string} websiteName - The name of the website to switch to.
* @returns {Array} - The steps required to perform the website switch.
*/
export function switchWebsite(websiteId, websiteName) {
return [{
content: `Click on the website switch to switch to website '${websiteName}'`,
trigger: '.o_website_switcher_container button',
run: "click",
},
{
trigger: `:iframe html:not([data-website-id="${websiteId}"])`,
},
{
content: `Switch to website '${websiteName}'`,
trigger: `.o-dropdown--menu .dropdown-item[data-website-id="${websiteId}"]:contains("${websiteName}")`,
run: "click",
}, {
content: "Wait for the iframe to be loaded",
// The page reload generates assets for the new website, it may take
// some time
timeout: 20000,
trigger: `:iframe html[data-website-id="${websiteId}"]`,
}];
}
/**
* Switches to a different website by clicking on the website switcher.
* This function can only be used during test tours as it requires
* specific cookies to properly function.
*
* @param {string} websiteName - The name of the website to switch to.
* @returns {Array} - The steps required to perform the website switch.
*/
export function testSwitchWebsite(websiteName) {
const websiteIdMapping = JSON.parse(cookie.get('websiteIdMapping') || '{}');
const websiteId = websiteIdMapping[websiteName];
return switchWebsite(websiteId, websiteName)
}
/**
* Toggles the mobile preview on or off.
*
* @param {Boolean} toggleOn true to toggle the mobile preview on, false to
* toggle it off.
* @returns {Array}
*/
export function toggleMobilePreview(toggleOn) {
const onOrOff = toggleOn ? "on" : "off";
const mobileOnSelector = ".o_is_mobile";
const mobileOffSelector = ":not(.o_is_mobile)";
return [
{
trigger: `:iframe html${toggleOn ? mobileOffSelector : mobileOnSelector}`,
},
{
content: `Toggle the mobile preview ${onOrOff}`,
trigger: ".o_we_website_top_actions [data-action='mobile']",
run: "click",
},
{
content: `Check that the mobile preview is ${onOrOff}`,
trigger: `:iframe html${toggleOn ? mobileOnSelector : mobileOffSelector}`,
},
];
}