mirror of
https://github.com/bringout/oca-ocb-web.git
synced 2026-04-23 15:32:07 +02:00
19.0 vanilla
This commit is contained in:
parent
20e6dadd87
commit
4b94f0abc5
205 changed files with 24700 additions and 14614 deletions
|
|
@ -1,242 +1,294 @@
|
|||
odoo.define('web_tour.tour', function (require) {
|
||||
"use strict";
|
||||
import { Component, markup, whenReady, validate } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { session } from "@web/session";
|
||||
import { loadBundle } from "@web/core/assets";
|
||||
import { createPointerState } from "@web_tour/js/tour_pointer/tour_pointer_state";
|
||||
import { tourState } from "@web_tour/js/tour_state";
|
||||
import { callWithUnloadCheck } from "@web_tour/js/utils/tour_utils";
|
||||
import {
|
||||
tourRecorderState,
|
||||
TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY,
|
||||
} from "@web_tour/js/tour_recorder/tour_recorder_state";
|
||||
import { redirect } from "@web/core/utils/urls";
|
||||
|
||||
var rootWidget = require('root.widget');
|
||||
var rpc = require('web.rpc');
|
||||
var session = require('web.session');
|
||||
var TourManager = require('web_tour.TourManager');
|
||||
const { device } = require('web.config');
|
||||
class OnboardingItem extends Component {
|
||||
static components = { DropdownItem };
|
||||
static template = "web_tour.OnboardingItem";
|
||||
static props = {
|
||||
toursEnabled: { type: Boolean },
|
||||
toggleItem: { type: Function },
|
||||
};
|
||||
setup() {}
|
||||
}
|
||||
|
||||
const untrackedClassnames = ["o_tooltip", "o_tooltip_content", "o_tooltip_overlay"];
|
||||
const StepSchema = {
|
||||
id: { type: [String], optional: true },
|
||||
content: { type: [String, Object], optional: true }, //allow object(_t && markup)
|
||||
debugHelp: { type: String, optional: true },
|
||||
isActive: { type: Array, element: String, optional: true },
|
||||
run: { type: [String, Function, Boolean], optional: true },
|
||||
timeout: {
|
||||
optional: true,
|
||||
validate(value) {
|
||||
return value >= 0 && value <= 60000;
|
||||
},
|
||||
},
|
||||
tooltipPosition: {
|
||||
optional: true,
|
||||
validate(value) {
|
||||
return ["top", "bottom", "left", "right"].includes(value);
|
||||
},
|
||||
},
|
||||
trigger: { type: String },
|
||||
expectUnloadPage: { type: Boolean, optional: true },
|
||||
//ONLY IN DEBUG MODE
|
||||
pause: { type: Boolean, optional: true },
|
||||
break: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* @namespace
|
||||
* @property {Object} active_tooltips
|
||||
* @property {Object} tours
|
||||
* @property {Array} consumed_tours
|
||||
* @property {String} running_tour
|
||||
* @property {Number} running_step_delay
|
||||
* @property {'community' | 'enterprise'} edition
|
||||
* @property {Array} _log
|
||||
*/
|
||||
return session.is_bound.then(function () {
|
||||
var defs = [];
|
||||
// Load the list of consumed tours and the tip template only if we are admin, in the frontend,
|
||||
// tours being only available for the admin. For the backend, the list of consumed is directly
|
||||
// in the page source.
|
||||
if (session.is_frontend && session.is_admin) {
|
||||
var def = rpc.query({
|
||||
model: 'web_tour.tour',
|
||||
method: 'get_consumed_tours',
|
||||
});
|
||||
defs.push(def);
|
||||
}
|
||||
return Promise.all(defs).then(function (results) {
|
||||
var consumed_tours = session.is_frontend ? results[0] : session.web_tours;
|
||||
const disabled = session.tour_disable || device.isMobile;
|
||||
var tour_manager = new TourManager(rootWidget, consumed_tours, disabled);
|
||||
const TourSchema = {
|
||||
name: { type: String, optional: true },
|
||||
steps: Function,
|
||||
url: { type: String, optional: true },
|
||||
wait_for: { type: [Function, Object], optional: true },
|
||||
};
|
||||
|
||||
// The tests can be loaded inside an iframe. The tour manager should
|
||||
// not run in that context, as it will already run in its parent
|
||||
// window.
|
||||
const isInIframe = window.frameElement && window.frameElement.classList.contains('o_iframe');
|
||||
if (isInIframe) {
|
||||
return tour_manager;
|
||||
}
|
||||
registry.category("web_tour.tours").addValidation(TourSchema);
|
||||
const debugMenuRegistry = registry.category("debug").category("default");
|
||||
|
||||
function _isTrackedNode(node) {
|
||||
if (node.classList) {
|
||||
return !untrackedClassnames
|
||||
.some(className => node.classList.contains(className));
|
||||
export const tourService = {
|
||||
// localization dependency to make sure translations used by tours are loaded
|
||||
dependencies: ["orm", "effect", "overlay", "localization"],
|
||||
start: async (env, { orm, effect, overlay }) => {
|
||||
await whenReady();
|
||||
let toursEnabled = session?.tour_enabled;
|
||||
const tourRegistry = registry.category("web_tour.tours");
|
||||
const pointer = createPointerState();
|
||||
pointer.stop = () => {};
|
||||
|
||||
debugMenuRegistry.add("onboardingItem", () => ({
|
||||
type: "component",
|
||||
Component: OnboardingItem,
|
||||
props: {
|
||||
toursEnabled: toursEnabled || false,
|
||||
toggleItem: async () => {
|
||||
tourState.clear();
|
||||
toursEnabled = await orm.call("res.users", "switch_tour_enabled", [
|
||||
!toursEnabled,
|
||||
]);
|
||||
browser.location.reload();
|
||||
},
|
||||
},
|
||||
sequence: 500,
|
||||
section: "testing",
|
||||
}));
|
||||
|
||||
function getTourFromRegistry(tourName) {
|
||||
if (!tourRegistry.contains(tourName)) {
|
||||
return;
|
||||
}
|
||||
return true;
|
||||
const tour = tourRegistry.get(tourName);
|
||||
return {
|
||||
...tour,
|
||||
steps: tour.steps(),
|
||||
name: tourName,
|
||||
wait_for: tour.wait_for || Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
const classSplitRegex = /\s+/g;
|
||||
const tooltipParentRegex = /\bo_tooltip_parent\b/;
|
||||
let currentMutations = [];
|
||||
function _processMutations() {
|
||||
const hasTrackedMutation = currentMutations.some(mutation => {
|
||||
// First check if the mutation applied on an element we do not
|
||||
// track (like the tour tips themself).
|
||||
if (!_isTrackedNode(mutation.target)) {
|
||||
return false;
|
||||
}
|
||||
async function getTourFromDB(tourName) {
|
||||
const tour = await orm.call("web_tour.tour", "get_tour_json_by_name", [tourName]);
|
||||
if (!tour) {
|
||||
throw new Error(`Tour '${tourName}' is not found in the database.`);
|
||||
}
|
||||
|
||||
if (mutation.type === 'characterData') {
|
||||
return true;
|
||||
}
|
||||
if (!tour.steps.length && tourRegistry.contains(tour.name)) {
|
||||
tour.steps = tourRegistry.get(tour.name).steps();
|
||||
}
|
||||
|
||||
if (mutation.type === 'childList') {
|
||||
// If it is a modification to the DOM hierarchy, only
|
||||
// consider the addition/removal of tracked nodes.
|
||||
for (const nodes of [mutation.addedNodes, mutation.removedNodes]) {
|
||||
for (const node of nodes) {
|
||||
if (_isTrackedNode(node)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else if (mutation.type === 'attributes') {
|
||||
// Get old and new value of the attribute. Note: as we
|
||||
// compute the new value after a setTimeout, this might not
|
||||
// actually be the new value for that particular mutation
|
||||
// record but this is the one after all mutations. This is
|
||||
// normally not an issue: e.g. "a" -> "a b" -> "a" will be
|
||||
// seen as "a" -> "a" (not "a b") + "a b" -> "a" but we
|
||||
// only need to detect *one* tracked mutation to know we
|
||||
// have to update tips anyway.
|
||||
const oldV = mutation.oldValue ? mutation.oldValue.trim() : '';
|
||||
const newV = (mutation.target.getAttribute(mutation.attributeName) || '').trim();
|
||||
return tour;
|
||||
}
|
||||
|
||||
// Not sure why but this occurs, especially on ID change
|
||||
// (probably some strange jQuery behavior, see below).
|
||||
// Also sometimes, a class is just considered changed while
|
||||
// it just loses the spaces around the class names.
|
||||
if (oldV === newV) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mutation.attributeName === 'id') {
|
||||
// Check if this is not an ID change done by jQuery for
|
||||
// performance reasons.
|
||||
return !(oldV.includes('sizzle') || newV.includes('sizzle'));
|
||||
} else if (mutation.attributeName === 'class') {
|
||||
// Check if the change is *only* about receiving or
|
||||
// losing the 'o_tooltip_parent' class, which is linked
|
||||
// to the tour service system. We have to check the
|
||||
// potential addition of another class as we compute
|
||||
// the new value after a setTimeout. So this case:
|
||||
// 'a' -> 'a b' -> 'a b o_tooltip_parent' produces 2
|
||||
// mutation records but will be seen here as
|
||||
// 1) 'a' -> 'a b o_tooltip_parent'
|
||||
// 2) 'a b' -> 'a b o_tooltip_parent'
|
||||
const hadClass = tooltipParentRegex.test(oldV);
|
||||
const newClasses = mutation.target.classList;
|
||||
const hasClass = newClasses.contains('o_tooltip_parent');
|
||||
return !(hadClass !== hasClass
|
||||
&& Math.abs(oldV.split(classSplitRegex).length - newClasses.length) === 1);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Either all the mutations have been ignored or one was detected as
|
||||
// tracked and will trigger a tour manager update.
|
||||
currentMutations = [];
|
||||
|
||||
// Update the tour manager if required.
|
||||
if (hasTrackedMutation) {
|
||||
tour_manager.update();
|
||||
function validateStep(step) {
|
||||
try {
|
||||
validate(step, StepSchema);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error in schema for TourStep ${JSON.stringify(step, null, 4)}\n${
|
||||
error.message
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Use a MutationObserver to detect DOM changes. When a mutation occurs,
|
||||
// only add it to the list of mutations to process and delay the
|
||||
// mutation processing. We have to record them all and not in a
|
||||
// debounced way otherwise we may ignore tracked ones in a serie of
|
||||
// 10 tracked mutations followed by an untracked one. Most of them
|
||||
// will trigger a tip check anyway so, most of the time, processing the
|
||||
// first ones will be enough to ensure that a tip update has to be done.
|
||||
let mutationTimer;
|
||||
const observer = new MutationObserver(mutations => {
|
||||
clearTimeout(mutationTimer);
|
||||
currentMutations = currentMutations.concat(mutations);
|
||||
mutationTimer = setTimeout(() => _processMutations(), 750);
|
||||
});
|
||||
async function startTour(tourName, options = {}) {
|
||||
pointer.stop();
|
||||
const tourFromRegistry = getTourFromRegistry(tourName);
|
||||
|
||||
// Now that the observer is configured, we have to start it when needed.
|
||||
const observerOptions = {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributeOldValue: true,
|
||||
characterData: true,
|
||||
if (!tourFromRegistry && !options.fromDB) {
|
||||
// Sometime tours are not loaded depending on the modules.
|
||||
// For example, point_of_sale do not load all tours assets.
|
||||
return;
|
||||
}
|
||||
|
||||
const tour = options.fromDB ? { name: tourName, url: options.url } : tourFromRegistry;
|
||||
if (!session.is_public && !toursEnabled && options.mode === "manual") {
|
||||
toursEnabled = await orm.call("res.users", "switch_tour_enabled", [!toursEnabled]);
|
||||
}
|
||||
|
||||
let tourConfig = {
|
||||
delayToCheckUndeterminisms: 0,
|
||||
stepDelay: 0,
|
||||
keepWatchBrowser: false,
|
||||
mode: "auto",
|
||||
showPointerDuration: 0,
|
||||
debug: false,
|
||||
redirect: true,
|
||||
};
|
||||
|
||||
tourConfig = Object.assign(tourConfig, options);
|
||||
tourState.setCurrentConfig(tourConfig);
|
||||
tourState.setCurrentTour(tour.name);
|
||||
tourState.setCurrentIndex(0);
|
||||
|
||||
const willUnload = callWithUnloadCheck(() => {
|
||||
if (tour.url && tourConfig.startUrl != tour.url && tourConfig.redirect) {
|
||||
redirect(tour.url);
|
||||
}
|
||||
});
|
||||
if (!willUnload) {
|
||||
await resumeTour();
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeTour() {
|
||||
const tourName = tourState.getCurrentTour();
|
||||
const tourConfig = tourState.getCurrentConfig();
|
||||
|
||||
let tour = getTourFromRegistry(tourName);
|
||||
if (tourConfig.fromDB) {
|
||||
tour = await getTourFromDB(tourName);
|
||||
}
|
||||
if (!tour) {
|
||||
return;
|
||||
}
|
||||
|
||||
tour.steps.forEach((step) => validateStep(step));
|
||||
|
||||
if (tourConfig.mode === "auto") {
|
||||
if (!odoo.loader.modules.get("@web_tour/js/tour_automatic/tour_automatic")) {
|
||||
await loadBundle("web_tour.automatic", { css: false });
|
||||
}
|
||||
const { TourAutomatic } = odoo.loader.modules.get(
|
||||
"@web_tour/js/tour_automatic/tour_automatic"
|
||||
);
|
||||
new TourAutomatic(tour).start();
|
||||
} else {
|
||||
await loadBundle("web_tour.interactive");
|
||||
const { TourPointer } = odoo.loader.modules.get(
|
||||
"@web_tour/js/tour_pointer/tour_pointer"
|
||||
);
|
||||
pointer.stop = overlay.add(
|
||||
TourPointer,
|
||||
{
|
||||
pointerState: pointer.state,
|
||||
bounce: !(tourConfig.mode === "auto" && tourConfig.keepWatchBrowser),
|
||||
},
|
||||
{
|
||||
sequence: 1100, // sequence based on bootstrap z-index values.
|
||||
}
|
||||
);
|
||||
const { TourInteractive } = odoo.loader.modules.get(
|
||||
"@web_tour/js/tour_interactive/tour_interactive"
|
||||
);
|
||||
new TourInteractive(tour).start(env, pointer, async () => {
|
||||
pointer.stop();
|
||||
tourState.clear();
|
||||
browser.console.log("tour succeeded");
|
||||
let message = tourConfig.rainbowManMessage || tour.rainbowManMessage;
|
||||
if (message) {
|
||||
message = window.DOMPurify.sanitize(tourConfig.rainbowManMessage);
|
||||
effect.add({
|
||||
type: "rainbow_man",
|
||||
message: markup(message),
|
||||
});
|
||||
}
|
||||
|
||||
const nextTour = await orm.call("web_tour.tour", "consume", [tour.name]);
|
||||
if (nextTour) {
|
||||
startTour(nextTour.name, {
|
||||
mode: "manual",
|
||||
redirect: false,
|
||||
rainbowManMessage: nextTour.rainbowManMessage,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function tourRecorder() {
|
||||
await loadBundle("web_tour.recorder");
|
||||
const { TourRecorder } = odoo.loader.modules.get(
|
||||
"@web_tour/js/tour_recorder/tour_recorder"
|
||||
);
|
||||
const remove = overlay.add(
|
||||
TourRecorder,
|
||||
{
|
||||
onClose: () => {
|
||||
remove();
|
||||
browser.localStorage.removeItem(TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY);
|
||||
tourRecorderState.clear();
|
||||
},
|
||||
},
|
||||
{ sequence: 99999 }
|
||||
);
|
||||
}
|
||||
|
||||
async function startTourRecorder() {
|
||||
if (!browser.localStorage.getItem(TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY)) {
|
||||
await tourRecorder();
|
||||
}
|
||||
browser.localStorage.setItem(TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY, "1");
|
||||
}
|
||||
|
||||
if (!window.frameElement) {
|
||||
const paramsTourName = new URLSearchParams(browser.location.search).get("tour");
|
||||
if (paramsTourName) {
|
||||
startTour(paramsTourName, { mode: "manual", fromDB: true });
|
||||
}
|
||||
|
||||
if (tourState.getCurrentTour()) {
|
||||
if (tourState.getCurrentConfig().mode === "auto" || toursEnabled) {
|
||||
resumeTour();
|
||||
} else {
|
||||
tourState.clear();
|
||||
}
|
||||
} else if (session.current_tour) {
|
||||
startTour(session.current_tour.name, {
|
||||
mode: "manual",
|
||||
redirect: false,
|
||||
rainbowManMessage: session.current_tour.rainbowManMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
browser.localStorage.getItem(TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY) &&
|
||||
!session.is_public
|
||||
) {
|
||||
await tourRecorder();
|
||||
}
|
||||
}
|
||||
|
||||
odoo.startTour = startTour;
|
||||
odoo.isTourReady = (tourName) => getTourFromRegistry(tourName).wait_for.then(() => true);
|
||||
|
||||
return {
|
||||
startTour,
|
||||
startTourRecorder,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
var start_service = (function () {
|
||||
return function (observe) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
tour_manager._register_all(observe).then(function () {
|
||||
if (observe) {
|
||||
observer.observe(document.body, observerOptions);
|
||||
|
||||
// If an iframe is added during the tour, its DOM
|
||||
// mutations should also be observed to update the
|
||||
// tour manager.
|
||||
const findIframe = mutations => {
|
||||
for (const mutation of mutations) {
|
||||
for (const addedNode of Array.from(mutation.addedNodes)) {
|
||||
if (addedNode.nodeType === Node.ELEMENT_NODE) {
|
||||
if (addedNode.classList.contains('o_iframe')) {
|
||||
return addedNode;
|
||||
}
|
||||
const iframeChildEl = addedNode.querySelector('.o_iframe');
|
||||
if (iframeChildEl) {
|
||||
return iframeChildEl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const iframeObserver = new MutationObserver(mutations => {
|
||||
const iframeEl = findIframe(mutations);
|
||||
if (iframeEl) {
|
||||
iframeEl.addEventListener('load', () => {
|
||||
observer.observe(iframeEl.contentDocument, observerOptions);
|
||||
});
|
||||
// If the iframe was added without a src,
|
||||
// its load event was immediately fired and
|
||||
// will not fire again unless another src is
|
||||
// set. Unfortunately, the case of this
|
||||
// happening and the iframe content being
|
||||
// altered programmaticaly may happen.
|
||||
// (E.g. at the moment this was written,
|
||||
// the mass mailing editor iframe is added
|
||||
// without src and its content rewritten
|
||||
// immediately afterwards).
|
||||
if (!iframeEl.src) {
|
||||
observer.observe(iframeEl.contentDocument, observerOptions);
|
||||
}
|
||||
}
|
||||
});
|
||||
iframeObserver.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
||||
// Enable the MutationObserver for the admin or if a tour is running, when the DOM is ready
|
||||
start_service(session.is_admin || tour_manager.running_tour);
|
||||
|
||||
// Override the TourManager so that it enables/disables the observer when necessary
|
||||
if (!session.is_admin) {
|
||||
var run = tour_manager.run;
|
||||
tour_manager.run = function () {
|
||||
var self = this;
|
||||
var args = arguments;
|
||||
|
||||
start_service(true).then(function () {
|
||||
run.apply(self, args);
|
||||
if (!self.running_tour) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
};
|
||||
var _consume_tour = tour_manager._consume_tour;
|
||||
tour_manager._consume_tour = function () {
|
||||
_consume_tour.apply(this, arguments);
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
// helper to start a tour manually (or from a python test with its counterpart start_tour function)
|
||||
odoo.startTour = tour_manager.run.bind(tour_manager);
|
||||
return tour_manager;
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
registry.category("services").add("tour_service", tourService);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue