[IMP] *: rewrite module loader

This commit rewrite the module loader to simplify it and to improve error handling.
before this commit, you could have silent errors or "lucky" imports (import a module based on the fact that his asset will surely be load and if not, the error would be silent anyway).
e.g.: The tours cannot import modules extern to tours' bundle anymore. Or load module before your libs in assets.

TASK ID: 3266021

closes odoo/odoo#128502

Related: odoo/enterprise#44100
Signed-off-by: Bastien Pierre (ipb) <ipb@odoo.com>
This commit is contained in:
Géry Debongnie
2023-09-08 16:47:56 +00:00
committed by Bastien PIERRE
parent 1d33b7a9b8
commit e0fdb6a0b4
46 changed files with 715 additions and 892 deletions
+1 -1
View File
@@ -33,7 +33,7 @@
],
'bus.websocket_worker_assets': [
'web/static/src/legacy/js/promise_extension.js',
'web/static/src/boot.js',
'web/static/src/module_loader.js',
'bus/static/src/workers/*',
],
},
@@ -13,7 +13,7 @@ class TestImLivechatSupportPage(HttpCase):
# interrupt errors then ensures all the assets are loaded.
check_js_modules = """
setTimeout(() => {
const { missing, failed, unloaded } = odoo.__DEBUG__.jsModules;
const { missing, failed, unloaded } = odoo.loader.findErrors();
if ([missing, failed, unloaded].some(arr => arr.length)) {
console.error("Couldn't load all JS modules.", JSON.stringify({ missing, failed, unloaded }));
} else {
@@ -27,4 +27,4 @@ class TestImLivechatSupportPage(HttpCase):
}, 1000);
"""
self.browser_js("/im_livechat/support/1", code=check_js_modules, ready="odoo.__DEBUG__.didLogInfo")
self.browser_js("/im_livechat/support/1", code=check_js_modules)
@@ -1,7 +1,6 @@
/* @odoo-module */
import { DiscussPublic } from "@mail/discuss/core/public/discuss_public";
import { data } from "mail.discuss_public_template";
import { mount, whenReady } from "@odoo/owl";
@@ -16,7 +15,7 @@ import { makeEnv, startServices } from "@web/env";
const mainComponentsRegistry = registry.category("main_components");
mainComponentsRegistry.add("DiscussPublic", {
Component: DiscussPublic,
props: { data },
props: { data: odoo.discuss_data },
});
const env = makeEnv();
@@ -18,17 +18,14 @@ registry.category("web_tour.tours").add("mail/static/tests/tours/discuss_public_
if (!window.location.pathname.startsWith("/discuss/channel")) {
console.error("Channel secret token is still present in URL.");
}
// Wait for modules to be loaded or failed for the next step
odoo.__DEBUG__.didLogInfo.then(() => {
const { missing, failed, unloaded } = odoo.__DEBUG__.jsModules;
if ([missing, failed, unloaded].some((arr) => arr.length)) {
console.error(
"Couldn't load all JS modules.",
JSON.stringify({ missing, failed, unloaded })
);
}
document.body.classList.add("o_discuss_channel_public_modules_loaded");
});
const { missing, failed, unloaded } = odoo.loader.findErrors();
if ([missing, failed, unloaded].some((arr) => arr.length)) {
console.error(
"Couldn't load all JS modules.",
JSON.stringify({ missing, failed, unloaded })
);
}
document.body.classList.add("o_discuss_channel_public_modules_loaded");
},
extraTrigger: ".o_discuss_channel_public_modules_loaded",
},
@@ -12,6 +12,7 @@
__session_info__: <t t-out="json.dumps(session_info)"/>,
csrf_token: "<t t-out="request.csrf_token(None)"/>",
debug: "<t t-out="debug"/>",
discuss_data: <t t-out="json.dumps(data)"/>
};
{
const { user_context, cache_hashes } = odoo.__session_info__;
@@ -25,13 +26,6 @@
direction: <t t-out="env['res.lang']._lang_get(lang or env.user.lang).direction or 'ltr'" />;
}
</style>
<script>
odoo.define('mail.discuss_public_template', [], function() {
return {
data: <t t-out="json.dumps(data)"/>,
};
});
</script>
</head>
<body class="d-flex flex-column h-100">
<t t-out="body or ''"/>
+1 -1
View File
@@ -60,7 +60,7 @@
'web/static/src/scss/fontawesome_overridden.scss',
'web/static/src/legacy/js/promise_extension.js',
'web/static/src/boot.js',
'web/static/src/module_loader.js',
'web/static/src/session.js',
'web/static/src/legacy/js/core/cookie_utils.js',
+12 -10
View File
@@ -101,8 +101,18 @@
'web/static/src/scss/bootstrap_overridden.scss',
'web/static/src/scss/fontawesome_overridden.scss',
'web/static/fonts/fonts.scss',
# JS boot
'web/static/src/module_loader.js',
# libs (should be loaded before framework)
'point_of_sale/static/lib/**/*',
'web/static/lib/luxon/luxon.js',
'web/static/lib/owl/owl.js',
'web/static/lib/owl/odoo_module.js',
'web_editor/static/lib/html2canvas.js',
'web/static/lib/zxing-library/zxing-library.js',
# FIXME POSREF legacy dependencies to remove
'web/static/lib/moment/moment.js',
'web/static/src/legacy/js/promise_extension.js',
# JS framework
('include', 'web._assets_core'),
('remove', 'web/static/src/core/errors/error_handlers.js'), # error handling in PoS is different from the webclient
@@ -125,10 +135,6 @@
'bus/static/src/workers/*',
# report download utils
'web/static/src/webclient/actions/reports/utils.js',
# libs
'point_of_sale/static/lib/**/*',
'web_editor/static/lib/html2canvas.js',
'web/static/lib/zxing-library/zxing-library.js',
# PoS files
'point_of_sale/static/src/**/*',
('remove', 'point_of_sale/static/src/backend/**/*'),
@@ -138,10 +144,6 @@
'web/static/lib/jquery/jquery.js',
'web_tour/static/src/tour_pointer/**/*',
'web_tour/static/src/tour_service/**/*',
# FIXME POSREF legacy dependencies to remove
'web/static/lib/moment/moment.js',
'web/static/src/legacy/js/promise_extension.js',
],
# Bundle that starts the pos, loaded on /pos/ui
'point_of_sale.assets_prod': [
@@ -26,8 +26,6 @@ whenReady(() => {
isEnterprise: session.server_version_info.slice(-1)[0] === "e",
};
// Wait for all templates
await odoo.ready(/\.bundle\.xml/);
// Make a temporary app to be able to use renderToString method before the main app is available.
const renderToStringApp = new App(Component, {
name: "renderToString app",
@@ -38,7 +38,9 @@
odoo.loadMenusPromise = Promise.resolve();
</script>
<t t-call="web.conditional_assets_tests"/>
<t t-call="web.conditional_assets_tests">
<t t-set="ignore_missing_deps" t-value="True"/>
</t>
<t t-call-assets="point_of_sale.assets_prod" />
</head>
<body class="pos">
+1 -2
View File
@@ -85,7 +85,6 @@
],
'web.assets_tests': [
'project/static/tests/tours/**/*',
'project/static/tests/views/**/*',
],
'project.webclient': [
('include', 'web._assets_helpers'),
@@ -111,7 +110,7 @@
'web/static/src/scss/fontawesome_overridden.scss',
'web/static/src/legacy/js/promise_extension.js',
'web/static/src/boot.js',
'web/static/src/module_loader.js',
'web/static/src/session.js',
'web/static/src/legacy/js/core/cookie_utils.js',
@@ -1,15 +1,17 @@
/** @odoo-module **/
import SurveyFormWidget from "@survey/js/survey_form";
/**
* Speed up fade-in fade-out to avoid useless delay in tests.
*/
SurveyFormWidget.include({
_submitForm: function () {
this.fadeInOutDelay = 0;
return this._super.apply(this, arguments);
}
});
*/
function patchSurveyWidget() {
const SurveyFormWidget = odoo.loader.modules.get('@survey/js/survey_form')[Symbol.for('default')]
SurveyFormWidget.include({
_submitForm: function () {
this.fadeInOutDelay = 0;
return this._super.apply(this, arguments);
}
});
}
/**
* This tour will test that, for the demo certification allowing 2 attempts, a user can
@@ -18,6 +20,14 @@ SurveyFormWidget.include({
import { registry } from "@web/core/registry";
var patch = [{
content: "Patching Survey Widget",
trigger: 'body',
run: function(){
patchSurveyWidget();
}
}]
var failSteps = [{ // Page-1
content: "Clicking on Start Certification",
trigger: 'button.btn.btn-primary.btn-lg:contains("Start Certification")',
@@ -104,4 +114,4 @@ var lastSteps = [{
registry.category("web_tour.tours").add('test_certification_failure', {
test: true,
url: '/survey/start/4ead4bc8-b8f2-4760-a682-1fde8daaaaac',
steps: () => [].concat(failSteps, retrySteps, failSteps, lastSteps) });
steps: () => [].concat(patch, failSteps, retrySteps, failSteps, lastSteps) });
@@ -1,22 +1,30 @@
/** @odoo-module **/
import SurveyFormWidget from "@survey/js/survey_form";
/**
* Speed up fade-in fade-out to avoid useless delay in tests.
*/
SurveyFormWidget.include({
_submitForm: function () {
this.fadeInOutDelay = 0;
return this._super.apply(this, arguments);
}
});
function patchSurveyWidget() {
const SurveyFormWidget = odoo.loader.modules.get('@survey/js/survey_form')[Symbol.for('default')]
SurveyFormWidget.include({
_submitForm: function () {
this.fadeInOutDelay = 0;
return this._super.apply(this, arguments);
}
});
}
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add('test_certification_success', {
test: true,
url: '/survey/start/4ead4bc8-b8f2-4760-a682-1fde8daaaaac',
steps: () => [{ // Page-1
steps: () => [{
content: "Patching Survey Widget",
trigger: 'body',
run: function(){
patchSurveyWidget();
}
}, { // Page-1
content: "Clicking on Start Certification",
trigger: 'button.btn.btn-primary.btn-lg:contains("Start Certification")',
}, { // Question: Do we sell Acoustic Bloc Screens?
@@ -1,35 +1,39 @@
/** @odoo-module **/
import SessionManager from "@survey/js/survey_session_manage";
const { DateTime } = luxon;
/**
* Small override for test/tour purposes.
*/
SessionManager.include({
function patchSessionManager() {
const { DateTime } = luxon;
const SessionManager = odoo.loader.modules.get('@survey/js/survey_session_manage')[Symbol.for('default')]
/**
* - Trigger the fetch of answer results immediately at the start.
* (Instead of wasting 2 seconds waiting after the start).
* - Set the fade in/out time to 1 ms to avoid unnecessary delays.
* - Avoid refreshing the results every 2 seconds
* Small override for test/tour purposes.
*/
start: function () {
var self = this;
return this._super.apply(this, arguments)
.then(this._refreshResults.bind(this))
.then(function () {
self.fadeInOutTime = 1;
clearInterval(self.resultsRefreshInterval);
});
},
SessionManager.include({
/**
* - Trigger the fetch of answer results immediately at the start.
* (Instead of wasting 2 seconds waiting after the start).
* - Set the fade in/out time to 1 ms to avoid unnecessary delays.
* - Avoid refreshing the results every 2 seconds
*/
start: function () {
var self = this;
return this._super.apply(this, arguments)
.then(this._refreshResults.bind(this))
.then(function () {
self.fadeInOutTime = 1;
clearInterval(self.resultsRefreshInterval);
});
},
/**
* Force the timer to "now" to avoid introducing potential test breaking
* timely variables (rpc/small server delay/...) if the start_question_time flickers.
*/
_startTimer: function () {
this.$el.data('timer', DateTime.utc());
return this._super.apply(this, arguments);
}
});
/**
* Force the timer to "now" to avoid introducing potential test breaking
* timely variables (rpc/small server delay/...) if the start_question_time flickers.
*/
_startTimer: function () {
this.$el.data('timer', DateTime.utc());
return this._super.apply(this, arguments);
}
})
};
export default SessionManager;
export default patchSessionManager;
@@ -5,6 +5,8 @@ import { zip } from "@web/core/utils/arrays";
import { TourError } from "@web_tour/tour_service/tour_utils";
import { accessSurveysteps } from "./survey_tour_session_tools";
let rootWidget = null;
/**
* Since the chart is rendered using SVG, we can't use jQuery triggers to check if everything
* is correctly rendered.
@@ -13,7 +15,6 @@ import { accessSurveysteps } from "./survey_tour_session_tools";
*/
const getChartData = () => {
const chartData = [];
const rootWidget = odoo.__DEBUG__.services['root.widget'];
const surveyManagePublicWidget = rootWidget.publicWidgets.find((widget) => {
return widget.$el.hasClass('o_survey_session_manage');
});
@@ -144,6 +145,9 @@ registry.category("web_tour.tours").add('test_survey_session_manage_tour', {
}, {
trigger: 'h1:contains("Nickname")',
isCheck: true // check nickname question is displayed
}, {
trigger: 'body',
run: async () => { rootWidget = await odoo.loader.modules.get('root.widget'); }
}, {
trigger: 'h1',
run: nextScreen
@@ -242,8 +246,8 @@ registry.category("web_tour.tours").add('test_survey_session_manage_tour', {
}
}, {
trigger: 'h1:contains("Scored Simple Choice")',
// Wait for Button to be updated ("late" enough DOM change after onNext() is triggered).
extra_trigger: '.o_survey_session_navigation_next_label:contains("Show Correct Answer(s)")',
// Wait for progressbar to be updated ("late" enough DOM change after onNext() is triggered).
extra_trigger: '.o_survey_session_progress_small[style*="width: 100%"]',
run: () => {
checkAnswers(getChartData(), [
{value: 1, type: "regular"},
@@ -255,7 +259,7 @@ registry.category("web_tour.tours").add('test_survey_session_manage_tour', {
}
}, {
trigger: 'h1:contains("Scored Simple Choice")',
// Same as above
// Wait for Button to be updated ("late" enough DOM change after onNext() is triggered).
extra_trigger: '.o_survey_session_navigation_next_label:contains("Show Leaderboard")',
run: () => {
checkAnswers(getChartData(), [
+1 -1
View File
@@ -28,7 +28,7 @@ models which only purpose is to run tests.""",
'http://test.external.link/javascript1.js',
'/web/static/lib/jquery.ui/jquery-ui.css',
'http://test.external.link/style1.css',
'/web/static/src/boot.js',
'/web/static/src/module_loader.js',
'http://test.external.link/javascript2.js',
'http://test.external.link/style2.css',
],
+5 -3
View File
@@ -156,7 +156,7 @@ This module provides the core of the Odoo Web Client.
],
'web.assets_frontend_minimal': [
'web/static/src/legacy/js/promise_extension.js',
'web/static/src/boot.js',
'web/static/src/module_loader.js',
'web/static/src/session.js',
'web/static/src/legacy/js/core/cookie_utils.js',
'web/static/src/legacy/js/public/lazyloader.js',
@@ -256,6 +256,8 @@ This module provides the core of the Odoo Web Client.
('remove', 'web/static/src/core/debug/debug_menu.js'),
('remove', 'web/static/src/core/file_viewer/file_viewer.dark.scss'),
('remove', 'web/static/src/core/emoji_picker/emoji_data.js'),
'web/static/src/core/commands/default_providers.js',
'web/static/src/core/commands/command_palette.js',
'web/static/src/public/error_notifications.js',
'web/static/src/public/datetime_picker_widget.js',
'web/static/src/libs/pdfjs.js',
@@ -274,7 +276,7 @@ This module provides the core of the Odoo Web Client.
('include', 'web.assets_frontend'),
# Remove assets_frontend_minimal
('remove', 'web/static/src/legacy/js/promise_extension.js'),
('remove', 'web/static/src/boot.js'),
('remove', 'web/static/src/module_loader.js'),
('remove', 'web/static/src/session.js'),
('remove', 'web/static/src/legacy/js/core/cookie_utils.js'),
('remove', 'web/static/src/legacy/js/public/lazyloader.js'),
@@ -368,7 +370,7 @@ This module provides the core of the Odoo Web Client.
# Bare javascript essentials: module loader, core folder and core libs
'web._assets_core': [
# module loader
'web/static/src/boot.js',
'web/static/src/module_loader.js',
# libs
'web/static/lib/luxon/luxon.js',
'web/static/lib/owl/owl.js',
-455
View File
@@ -1,455 +0,0 @@
/**
*------------------------------------------------------------------------------
* Odoo Web Boostrap Code
*------------------------------------------------------------------------------
*
* Each module can return a promise. In that case, the module is marked as loaded
* only when the promise is resolved, and its value is equal to the resolved value.
* The module can be rejected (unloaded). This will be logged in the console as info.
*
* logs:
* Missing dependencies:
* These modules do not appear in the page. It is possible that the
* JavaScript file is not in the page or that the module name is wrong
* Failed modules:
* A javascript error is detected
* Rejected modules:
* The module returns a rejected promise. It (and its dependent modules)
* is not loaded.
* Rejected linked modules:
* Modules who depend on a rejected module
* Non loaded modules:
* Modules who depend on a missing or a failed module
* Debug:
* Non loaded or failed module informations for debugging
*/
(function () {
"use strict";
var jobs = [];
var factories = Object.create(null);
var jobDeps = [];
var jobPromises = [];
const failed = [];
var services = Object.create({});
if (!globalThis.odoo) {
globalThis.odoo = {};
}
var odoo = globalThis.odoo;
if (odoo.debug && !new URLSearchParams(location.search).has("debug")) {
// remove debug mode if not explicitely set in url
odoo.debug = "";
}
var debug = odoo.debug;
var didLogInfoResolve;
var didLogInfoPromise = new Promise(function (resolve) {
didLogInfoResolve = resolve;
});
odoo.remainingJobs = jobs;
odoo.__DEBUG__ = {
didLogInfo: didLogInfoPromise,
getDependencies: function (name, transitive) {
const deps = new Set();
let queue = [name];
do {
queue = queue.flatMap((job) =>
jobDeps.filter((dep) => dep.to === job).map((dep) => dep.from)
);
for (const dep of queue) {
deps.add(dep);
}
} while (queue.length && transitive);
return [...deps];
},
getDependents: function (name) {
return jobDeps
.filter(function (dep) {
return dep.from === name;
})
.map(function (dep) {
return dep.to;
});
},
getMissingJobs() {
const waited = new Set(
jobs.filter((job) => !job.ignoreMissingDeps).map((job) => job.name)
);
const missing = new Set();
for (const job of waited) {
for (const dep of this.getDependencies(job)) {
if (
!(dep in this.services) &&
!waited.has(dep) &&
!failed.find((job) => job.name === dep)
) {
missing.add(dep);
}
}
}
return [...missing];
},
processJobs: function () {
var job;
function processJob(job) {
var require = makeRequire(job);
var jobExec;
function onError(e) {
job.error = e;
failed.push(job);
console.error(`Error while loading ${job.name}: ${e.message}`, e);
Promise.reject(e);
}
var def = new Promise(function (resolve) {
try {
jobExec = job.factory.call(null, require);
jobs.splice(jobs.indexOf(job), 1);
} catch (e) {
onError(e);
}
if (!job.error) {
Promise.resolve(jobExec)
.then(function (data) {
services[job.name] = data;
resolve();
odoo.__DEBUG__.processJobs();
})
.catch(function (e) {
if (e instanceof Error) {
onError(e);
}
resolve();
});
} else {
resolve();
}
});
jobPromises.push(def);
def.then(job.resolve);
}
function isReady(job) {
return (
!job.error &&
job.factory.deps.every(function (name) {
return name in services;
})
);
}
function makeRequire(job) {
var deps = {};
Object.keys(services)
.filter(function (item) {
return job.deps.indexOf(item) >= 0;
})
.forEach(function (key) {
deps[key] = services[key];
});
return function require(name) {
if (!(name in deps)) {
console.error("Undefined dependency: ", name);
}
return deps[name];
};
}
while (jobs.length) {
job = undefined;
for (var i = 0; i < jobs.length; i++) {
if (isReady(jobs[i])) {
job = jobs[i];
break;
}
}
if (!job) {
break;
}
processJob(job);
}
return services;
},
factories: factories,
services: services,
};
odoo.define = function (name, deps, factory) {
if (!Array.isArray(deps)) {
throw new Error("Dependencies should be defined by an array", deps);
}
if (typeof factory !== "function") {
throw new Error("Factory should be defined by a function", factory);
}
if (typeof name !== "string") {
throw new Error("Invalid name definition (should be a string", name);
}
if (name in factories) {
throw new Error("Service " + name + " already defined");
}
factory.deps = deps;
factories[name] = factory;
let promiseResolve;
const promise = new Promise((resolve) => {
promiseResolve = resolve;
});
jobs.push({
name: name,
factory: factory,
deps: deps,
resolve: promiseResolve,
promise: promise,
ignoreMissingDeps: globalThis.__odooIgnoreMissingDependencies,
});
deps.forEach(function (dep) {
jobDeps.push({ from: dep, to: name });
});
odoo.__DEBUG__.processJobs();
};
odoo.log = function () {
var missing = [];
var cycle = null;
if (jobs.length) {
var debugJobs = {};
var job;
var jobdep;
for (var k = 0; k < jobs.length; k++) {
if (jobs[k].ignoreMissingDeps) {
continue;
}
debugJobs[jobs[k].name] = job = {
dependencies: jobs[k].deps,
dependents: odoo.__DEBUG__.getDependents(jobs[k].name),
name: jobs[k].name,
};
if (jobs[k].error) {
job.error = jobs[k].error;
}
var deps = odoo.__DEBUG__.getDependencies(job.name);
for (var i = 0; i < deps.length; i++) {
if (job.name !== deps[i] && !(deps[i] in services)) {
jobdep = debugJobs[deps[i]];
if (!jobdep && deps[i] in factories) {
for (var j = 0; j < jobs.length; j++) {
if (jobs[j].name === deps[i]) {
jobdep = jobs[j];
break;
}
}
}
if (!job.missing) {
job.missing = [];
}
job.missing.push(deps[i]);
}
}
}
missing = odoo.__DEBUG__.getMissingJobs();
var unloaded = Object.keys(debugJobs) // Object.values is not supported
.map(function (key) {
return debugJobs[key];
})
.filter(function (job) {
return job.missing;
});
if (debug || failed.length || unloaded.length) {
var log = globalThis.console[
!failed.length || !unloaded.length ? "info" : "error"
].bind(globalThis.console);
log(
(failed.length ? "error" : unloaded.length ? "warning" : "info") +
": Some modules could not be started"
);
if (missing.length) {
log("Missing dependencies: ", missing);
}
if (failed.length) {
log(
"Failed modules: ",
failed.map(function (fail) {
return fail.name;
})
);
}
if (unloaded.length) {
cycle = findCycle(unloaded);
if (cycle) {
console.error("Cyclic dependencies: " + cycle);
}
log(
"Non loaded modules: ",
unloaded.map(function (unload) {
return unload.name;
})
);
}
if (debug && Object.keys(debugJobs).length) {
log("Debug: ", debugJobs);
}
}
}
const moduleInfo = {
missing: missing,
failed: failed.map((mod) => mod.name),
unloaded: unloaded ? unloaded.map((mod) => mod.name) : [],
cycle,
};
odoo.__DEBUG__.jsModules = moduleInfo;
displayModuleErrors(moduleInfo);
didLogInfoResolve(true);
};
/**
* Returns a resolved promise when the targeted services are loaded.
* If no service is found the promise is used directly.
*
* @param {string|RegExp} serviceName name of the service to expect
* or regular expression matching the service.
* @returns {Promise<number>} resolved when the services ares
* loaded. The value is equal to the number of services found.
*/
odoo.ready = async function (serviceName) {
function match(name) {
return typeof serviceName === "string" ? name === serviceName : serviceName.test(name);
}
await Promise.all(jobs.filter((job) => match(job.name)).map((job) => job.promise));
return Object.keys(factories).filter(match).length;
};
odoo.runtimeImport = function (moduleName) {
if (!(moduleName in services)) {
throw new Error(`Service "${moduleName} is not defined or isn't finished loading."`);
}
return services[moduleName];
};
// Automatically log errors detected when loading modules
globalThis.addEventListener("load", function logWhenLoaded() {
const len = jobPromises.length;
Promise.all(jobPromises).then(function () {
if (len === jobPromises.length) {
odoo.log();
} else {
logWhenLoaded();
}
});
});
/**
* Visit the list of jobs, and return the first found cycle, if any
*
* @param {any[]} jobs
* @returns {null | string} either a string describing a cycle, or null
*/
function findCycle(jobs) {
// build dependency graph
const dependencyGraph = new Map();
for (const job of jobs) {
dependencyGraph.set(job.name, job.dependencies);
}
// helpers
function visitJobs(jobs, visited = new Set()) {
for (const job of jobs) {
const result = visitJob(job, visited);
if (result) {
return result;
}
}
return null;
}
function visitJob(job, visited) {
if (visited.has(job)) {
const jobs = Array.from(visited).concat([job]);
const index = jobs.indexOf(job);
return jobs
.slice(index)
.map((j) => `"${j}"`)
.join(" => ");
}
const deps = dependencyGraph.get(job);
return deps ? visitJobs(deps, new Set(visited).add(job)) : null;
}
// visit each root to find cycles
return visitJobs(jobs.map((j) => j.name));
}
function displayModuleErrors({ failed, missing, unloaded, cycle }) {
if (window.__odooAssetError) {
// this flag is set when we have an error loading the script for an
// asset. We sometimes see a 404, probably due to the fact that the
// attachment has been deleted. It is unclear in which circumstances
// it can happen, but we sometimes observe it. We could here force a
// reload, but it seems dangerous, since in case of errors, this
// could easily lead to an infinite loop.
return;
}
const list = (heading, arr) => {
const frag = document.createDocumentFragment();
if (!arr || !arr.length) {
return frag;
}
frag.textContent = heading;
const ul = document.createElement("ul");
for (const el of arr) {
const li = document.createElement("li");
li.textContent = el;
ul.append(li);
}
frag.appendChild(ul);
return frag;
};
if ([failed, missing, unloaded].some((arr) => arr.length) || cycle) {
// Empty body
while (document.body.childNodes.length) {
document.body.childNodes[0].remove();
}
const container = document.createElement("div");
container.className =
"position-fixed w-100 h-100 d-flex align-items-center flex-column bg-white overflow-auto modal";
container.style.zIndex = "10000";
const alert = document.createElement("div");
alert.className = "alert alert-danger o_error_detail fw-bold m-auto";
container.appendChild(alert);
alert.appendChild(
list(
"The following modules failed to load because of an error, you may find more information in the devtools console:",
failed
)
);
alert.appendChild(
list(
"The following modules could not be loaded because they form a dependency cycle:",
cycle && [cycle]
)
);
alert.appendChild(
list(
"The following modules are needed by other modules but have not been defined, they may not be present in the correct asset bundle:",
missing
)
);
alert.appendChild(
list(
"The following modules could not be loaded because they have unmet dependencies, this is a secondary error which is likely caused by one of the above problems:",
unloaded
)
);
document.body.appendChild(container);
}
}
})();
+1 -5
View File
@@ -169,11 +169,6 @@ assets.loadBundle = async function loadBundle(desc) {
if (typeof urlData === "string") {
// serial loading
await assets.loadJS(urlData);
// Wait template if the JavaScript come from bundle.
const bundle = urlData.match(/\/web\/assets\/.*\/([^/]+?)(\.min)?\.js/);
if (bundle) {
await odoo.ready(bundle[1] + ".bundle.xml");
}
} else {
// parallel loading
await Promise.all(urlData.map(loadJS));
@@ -202,6 +197,7 @@ assets.loadBundle = async function loadBundle(desc) {
);
}
}
odoo.loader.checkAndReportErrors();
};
export const loadJS = function (url) {
@@ -115,7 +115,7 @@ export const loader = {
export async function loadEmoji() {
try {
await loader.loadEmoji();
return odoo.runtimeImport("@web/core/emoji_picker/emoji_data");
return odoo.loader.modules.get("@web/core/emoji_picker/emoji_data");
} catch {
// Could be intentional (tour ended successfully while emoji still loading)
return { emojis: [], categories: [] };
+226
View File
@@ -0,0 +1,226 @@
/**
*------------------------------------------------------------------------------
* Odoo Web Boostrap Code
*------------------------------------------------------------------------------
*/
(function () {
"use strict";
class ModuleLoader {
/** @type {Map<string,{fn: Function, deps: string[]}>} mapping name => deps/fn */
factories = new Map();
/** @type {Set<string>} names of modules waiting to be started */
jobs = new Set();
/** @type {Set<string>} names of failed modules */
failed = new Set();
/** @type {Map<string,any>} mapping name => value */
modules = new Map();
bus = new EventTarget();
checkErrorProm = null;
/**
* @param {string} name
* @param {string[]} deps
* @param {Function} factory
*/
define(name, deps, factory) {
if (typeof name !== "string") {
throw new Error(`Invalid name definition: ${name} (should be a string)"`);
}
if (!(deps instanceof Array)) {
throw new Error(`Dependencies should be defined by an array: ${deps}`);
}
if (typeof factory !== "function") {
throw new Error(`Factory should be defined by a function ${factory}`);
}
if (!this.factories.has(name)) {
this.factories.set(name, {
deps,
fn: factory,
ignoreMissingDeps: globalThis.__odooIgnoreMissingDependencies,
});
this.addJob(name);
this.checkErrorProm ||= Promise.resolve().then(() => {
this.checkAndReportErrors();
this.checkErrorProm = null;
});
}
}
addJob(name) {
this.jobs.add(name);
this.startModules();
}
findJob() {
for (const job of this.jobs) {
if (this.factories.get(job).deps.every((dep) => this.modules.has(dep))) {
return job;
}
}
return null;
}
startModules() {
let job;
while ((job = this.findJob())) {
this.startModule(job);
}
}
startModule(name) {
const require = (name) => this.modules.get(name);
this.jobs.delete(name);
const factory = this.factories.get(name);
let value = null;
try {
value = factory.fn(require);
} catch (error) {
this.failed.add(name);
throw new Error(`Error while loading "${name}":\n${error}`);
}
this.modules.set(name, value);
this.bus.dispatchEvent(
new CustomEvent("module-started", { detail: { moduleName: name, module: value } })
);
}
findErrors() {
// cycle detection
const dependencyGraph = new Map();
for (const job of this.jobs) {
dependencyGraph.set(job, this.factories.get(job).deps);
}
function visitJobs(jobs, visited = new Set()) {
for (const job of jobs) {
const result = visitJob(job, visited);
if (result) {
return result;
}
}
return null;
}
function visitJob(job, visited) {
if (visited.has(job)) {
const jobs = Array.from(visited).concat([job]);
const index = jobs.indexOf(job);
return jobs
.slice(index)
.map((j) => `"${j}"`)
.join(" => ");
}
const deps = dependencyGraph.get(job);
return deps ? visitJobs(deps, new Set(visited).add(job)) : null;
}
// missing dependencies
const missing = new Set();
for (const job of this.jobs) {
const factory = this.factories.get(job);
if (factory.ignoreMissingDeps) {
continue;
}
for (const dep of factory.deps) {
if (!this.factories.has(dep)) {
missing.add(dep);
}
}
}
return {
failed: [...this.failed],
cycle: visitJobs(this.jobs),
missing: [...missing],
unloaded: [...this.jobs].filter((j) => !this.factories.get(j).ignoreMissingDeps),
};
}
async checkAndReportErrors() {
const { failed, cycle, missing, unloaded } = this.findErrors();
if (!failed.length && !unloaded.length) {
return;
}
function domReady(cb) {
if (document.readyState === "complete") {
cb();
} else {
document.addEventListener("DOMContentLoaded", cb);
}
}
function list(heading, names) {
const frag = document.createDocumentFragment();
if (!names || !names.length) {
return frag;
}
frag.textContent = heading;
const ul = document.createElement("ul");
for (const el of names) {
const li = document.createElement("li");
li.textContent = el;
ul.append(li);
}
frag.appendChild(ul);
return frag;
}
domReady(() => {
// Empty body
while (document.body.childNodes.length) {
document.body.childNodes[0].remove();
}
const container = document.createElement("div");
container.className =
"position-fixed w-100 h-100 d-flex align-items-center flex-column bg-white overflow-auto modal";
container.style.zIndex = "10000";
const alert = document.createElement("div");
alert.className = "alert alert-danger o_error_detail fw-bold m-auto";
container.appendChild(alert);
alert.appendChild(
list(
"The following modules failed to load because of an error, you may find more information in the devtools console:",
failed
)
);
alert.appendChild(
list(
"The following modules could not be loaded because they form a dependency cycle:",
cycle && [cycle]
)
);
alert.appendChild(
list(
"The following modules are needed by other modules but have not been defined, they may not be present in the correct asset bundle:",
missing
)
);
alert.appendChild(
list(
"The following modules could not be loaded because they have unmet dependencies, this is a secondary error which is likely caused by one of the above problems:",
unloaded
)
);
document.body.appendChild(container);
});
}
}
if (!globalThis.odoo) {
globalThis.odoo = {};
}
const odoo = globalThis.odoo;
if (odoo.debug && !new URLSearchParams(location.search).has("debug")) {
// remove debug mode if not explicitely set in url
odoo.debug = "";
}
const loader = new ModuleLoader();
odoo.define = loader.define.bind(loader);
odoo.loader = loader;
})();
@@ -23,7 +23,7 @@
const { isEnterprise } = odoo.info;
const { onWillStart } = owl;
let appsMenusOnly = false;
const isStudioInstalled = "@web_studio/studio_service" in odoo.__DEBUG__.services;
const isStudioInstalled = odoo.loader.modules.has("@web_studio/studio_service");
let actionCount = 0;
let viewUpdateCount = 0;
let studioCount = 0;
@@ -49,8 +49,8 @@
actionCount++;
});
const { patch } = odoo.__DEBUG__.services["@web/core/utils/patch"];
const { WithSearch } = odoo.__DEBUG__.services["@web/search/with_search/with_search"];
const { patch } = odoo.loader.modules.get("@web/core/utils/patch");
const { WithSearch } = odoo.loader.modules.get("@web/search/with_search/with_search");
patch(WithSearch.prototype, {
setup() {
@@ -67,7 +67,7 @@
// This test file is not respecting Odoo module dependencies.
// The following module might not be loaded (eg. if mail is not installed).
const DiscussWidgetModule = odoo.__DEBUG__.services["@mail/widgets/discuss/discuss"];
const DiscussWidgetModule = odoo.loader.modules.get("@mail/widgets/discuss/discuss");
const DiscussWidget = DiscussWidgetModule && DiscussWidgetModule[Symbol.for("default")];
if (DiscussWidget) {
DiscussWidget.include({
@@ -9,12 +9,12 @@
function invalidImportsFrom(folder, allowedFolders) {
// modules within a folder can always depend on one another
allowedFolders.push(folder);
const modulesToCheck = Object.keys(odoo.__DEBUG__.services).filter((module) =>
const modulesToCheck = Array.from(odoo.loader.modules.keys()).filter((module) =>
module.startsWith(`@web/${folder}/`)
);
const invalidDeps = {};
for (const module of modulesToCheck) {
const invalid = odoo.__DEBUG__.getDependencies(module).filter((dep) => {
const invalid = odoo.loader.factories.get(module).deps.filter((dep) => {
// owl and @web/session are allowed everywhere
if (dep === "@odoo/owl" || dep === "@web/session") {
return false;
+1 -2
View File
@@ -257,7 +257,7 @@ export function setupQUnit() {
modulesAlert.classList.add("alert-info");
modulesAlert.textContent = "Waiting for modules check...";
document.getElementById("qunit").appendChild(modulesAlert);
const info = odoo.__DEBUG__.jsModules;
const info = odoo.loader.findErrors();
if (info.missing.length || info.failed.length || info.unloaded.length) {
document.querySelector("#qunit-banner").classList.add("qunit-fail");
modulesAlert.classList.toggle("alert-danger");
@@ -295,7 +295,6 @@ export function setupQUnit() {
}
}
QUnit.begin(() => odoo.__DEBUG__.didLogInfo);
/**
* If we want to log several errors, we have to log all of them at once, as
* browser_js is closed as soon as an error is logged.
+4 -4
View File
@@ -14,7 +14,7 @@ class TestMenusAdmin(odoo.tests.HttpCase):
for app_id in menus['root']['children']:
with self.subTest(app=menus[app_id]['name']):
_logger.runbot('Testing %s', menus[app_id]['name'])
self.browser_js("/web", "odoo.__DEBUG__.services['@web/webclient/clickbot/clickbot_loader'].startClickEverywhere('%s');" % menus[app_id]['xmlid'], "odoo.isReady === true", login="admin", timeout=600)
self.browser_js("/web", "odoo.loader.modules.get('@web/webclient/clickbot/clickbot_loader').startClickEverywhere('%s');" % menus[app_id]['xmlid'], "odoo.isReady === true", login="admin", timeout=600)
@odoo.tests.tagged('click_all', 'post_install', '-at_install', '-standard')
@@ -26,16 +26,16 @@ class TestMenusDemo(odoo.tests.HttpCase):
for app_id in menus['root']['children']:
with self.subTest(app=menus[app_id]['name']):
_logger.runbot('Testing %s', menus[app_id]['name'])
self.browser_js("/web", "odoo.__DEBUG__.services['@web/webclient/clickbot/clickbot_loader'].startClickEverywhere('%s');" % menus[app_id]['xmlid'], "odoo.isReady === true", login="demo", timeout=600)
self.browser_js("/web", "odoo.loader.modules.get('@web/webclient/clickbot/clickbot_loader').startClickEverywhere('%s');" % menus[app_id]['xmlid'], "odoo.isReady === true", login="demo", timeout=600)
@odoo.tests.tagged('post_install', '-at_install')
class TestMenusAdminLight(odoo.tests.HttpCase):
allow_end_on_form = True
def test_01_click_apps_menus_as_admin(self):
self.browser_js("/web", "odoo.__DEBUG__.services['@web/webclient/clickbot/clickbot_loader'].startClickEverywhere(undefined, true);", "odoo.isReady === true", login="admin", timeout=120)
self.browser_js("/web", "odoo.loader.modules.get('@web/webclient/clickbot/clickbot_loader').startClickEverywhere(undefined, true);", "odoo.isReady === true", login="admin", timeout=120)
@odoo.tests.tagged('post_install', '-at_install',)
class TestMenusDemoLight(odoo.tests.HttpCase):
allow_end_on_form = True
def test_01_click_apps_menus_as_demo(self):
self.browser_js("/web", "odoo.__DEBUG__.services['@web/webclient/clickbot/clickbot_loader'].startClickEverywhere(undefined, true);", "odoo.isReady === true", login="demo", timeout=120)
self.browser_js("/web", "odoo.loader.modules.get('@web/webclient/clickbot/clickbot_loader').startClickEverywhere(undefined, true);", "odoo.isReady === true", login="demo", timeout=120)
+10 -3
View File
@@ -4,8 +4,13 @@
<!-- Call this template instead of "web.assets_tests" to have the proper conditional check -->
<template id="conditional_assets_tests" name="Tests Assets Bundle">
<t t-if="'tests' in debug or test_mode_enabled">
<!-- To allow all tests assets and tours to be in the same bundle, the assets_tests bundle ignores missing dependencies -->
<t t-call-assets="web.__assets_tests_call__" defer_load="True" />
<t t-if="ignore_missing_deps">
<!-- FIXME: This is only to ignore the errors for the lazy loading. To allow all tests assets and tours to be in the same bundle, the assets_tests bundle ignores missing dependencies -->
<t t-call-assets="web.__assets_tests_call__" defer_load="True" />
</t>
<t t-else="">
<t t-call-assets="web.assets_tests" defer_load="True" />
</t>
</t>
</template>
@@ -47,7 +52,9 @@
}
</script>
<t t-call-assets="web.assets_frontend_minimal" t-css="false" defer_load="True"/>
<t t-call="web.conditional_assets_tests"/>
<t t-call="web.conditional_assets_tests">
<t t-set="ignore_missing_deps" t-value="True"/>
</t>
<t t-call-assets="web.assets_frontend_lazy" t-css="false" lazy_load="True"/>
</xpath>
<xpath expr="//t[@t-out='0']" position="replace">
+2 -2
View File
@@ -49,7 +49,7 @@ Odoo Web Editor widget.
'web/static/src/scss/fontawesome_overridden.scss',
'web/static/src/legacy/js/promise_extension.js',
'web/static/src/boot.js',
'web/static/src/module_loader.js',
'web/static/src/session.js',
'web/static/src/legacy/js/core/cookie_utils.js',
@@ -260,7 +260,7 @@ Odoo Web Editor widget.
],
'web_editor.mocha_tests': [
'web/static/src/legacy/js/promise_extension.js',
'web/static/src/boot.js',
'web/static/src/module_loader.js',
# insert module dependencies here
'web/static/src/core/utils/concurrency.js',
@@ -801,7 +801,7 @@ function flattenBackgroundImages(editable) {
*/
function fontToImg($editable) {
const editable = $editable.get(0);
const { fonts } = odoo.__DEBUG__.services["@web_editor/js/wysiwyg/fonts"];
const { fonts } = odoo.loader.modules.get("@web_editor/js/wysiwyg/fonts");
for (const font of editable.querySelectorAll('.fa')) {
let icon, content;
@@ -8,12 +8,8 @@ export async function loadLegacyWysiwygAssets(additionnalAssets = []) {
}
export async function requireLegacyModule(moduleName, loadCallback = () => {}) {
if (!(await odoo.ready(moduleName))) {
await loadCallback();
await odoo.ready(moduleName);
}
const mod = odoo.__DEBUG__.services[moduleName]
return mod[Symbol.for('default')] || mod;
await loadCallback();
return odoo.loader.modules.get(moduleName)[Symbol.for('default')] || odoo.loader.modules.get(moduleName);
}
export async function requireWysiwygLegacyModule(moduleName) {
+5 -2
View File
@@ -164,7 +164,6 @@
'web.assets_tests': [
'website/static/tests/tour_utils/**/*',
'website/static/tests/tours/**/*',
'website/static/src/client_actions/website_preview/website_preview_test_mode.js',
],
'web.assets_backend': [
('include', 'website.assets_editor'),
@@ -175,7 +174,6 @@
'website/static/src/js/backend/**/*',
'website/static/src/js/tours/tour_utils.js',
'website/static/src/client_actions/*/*',
('remove', 'website/static/src/client_actions/website_preview/website_preview_test_mode.js'),
'website/static/src/components/fields/*',
'website/static/src/components/fullscreen_indication/fullscreen_indication.js',
'website/static/src/components/fullscreen_indication/fullscreen_indication.scss',
@@ -254,6 +252,11 @@
'website/static/src/js/widgets/link_popover_widget.js',
'website/static/src/xml/website.cookies_bar.xml',
],
'website.assets_all_wysiwyg': [
('include', 'web_editor.assets_wysiwyg'),
('include', 'web_editor.assets_legacy_wysiwyg'),
('include', 'website.assets_wysiwyg'),
],
'web_editor.assets_media_dialog': [
'website/static/src/components/media_dialog/image_selector.js',
],
@@ -1,12 +0,0 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { WebsitePreview } from '@website/client_actions/website_preview/website_preview';
patch(WebsitePreview.prototype, {
/**
* @override
*/
get testMode() {
return true;
}
});
@@ -2,15 +2,15 @@
import { createPublicRoot } from "@web/legacy/js/public/public_root";
import { WebsiteRoot } from "./website_root";
import { loadLegacyWysiwygAssets } from "@web_editor/js/frontend/loader";
import { getBundle, loadBundle } from "@web/core/assets";
export default createPublicRoot(WebsiteRoot).then(rootInstance => {
export default createPublicRoot(WebsiteRoot).then(async (rootInstance) => {
// This data attribute is set by the WebsitePreview client action for a
// restricted editor user.
if (window.frameElement && window.frameElement.dataset.loadWysiwyg === 'true') {
loadLegacyWysiwygAssets(['web_editor.assets_wysiwyg', 'website.assets_wysiwyg']).then(() => {
window.dispatchEvent(new CustomEvent('PUBLIC-ROOT-READY', {detail: {rootInstance}}));
});
const assets = await getBundle("website.assets_all_wysiwyg");
await loadBundle(assets);
window.dispatchEvent(new CustomEvent('PUBLIC-ROOT-READY', {detail: {rootInstance}}));
}
return rootInstance;
});
@@ -2,7 +2,7 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from '@web/core/registry';
import { loadLegacyWysiwygAssets } from '@web_editor/js/frontend/loader';
import { getBundle, loadBundle } from "@web/core/assets";
import { FullscreenIndication } from '../components/fullscreen_indication/fullscreen_indication';
import { WebsiteLoader } from '../components/website_loader/website_loader';
@@ -221,7 +221,8 @@ export const websiteService = {
websites = [...(await orm.searchRead('website', [], ['domain', 'id', 'name']))];
},
async loadWysiwyg() {
await loadLegacyWysiwygAssets(['website.assets_wysiwyg']);
const assets = await getBundle("website.assets_all_wysiwyg");
await loadBundle(assets);
},
blockPreview(showLoader, processId) {
if (!blockingProcesses.length) {
@@ -1,16 +1,18 @@
/** @odoo-module **/
import options from "@web_editor/js/editor/snippets.options";
const FocusBlur = options.Class.extend({
onFocus() {
window.focusBlurSnippetsResult.push(`focus ${this.focusBlurName}`);
},
onBlur() {
window.focusBlurSnippetsResult.push(`blur ${this.focusBlurName}`);
},
odoo.loader.bus.addEventListener("module-started", (e) => {
if (e.detail.moduleName === "@web_editor/js/editor/snippets.options"){
const options = e.detail.module[Symbol.for("default")];
const FocusBlur = options.Class.extend({
onFocus() {
window.focusBlurSnippetsResult.push(`focus ${this.focusBlurName}`);
},
onBlur() {
window.focusBlurSnippetsResult.push(`blur ${this.focusBlurName}`);
},
});
options.registry.FocusBlurParent = FocusBlur.extend({focusBlurName: 'parent'});
options.registry.FocusBlurChild1 = FocusBlur.extend({focusBlurName: 'child1'});
options.registry.FocusBlurChild2 = FocusBlur.extend({focusBlurName: 'child2'});
}
});
options.registry.FocusBlurParent = FocusBlur.extend({focusBlurName: 'parent'});
options.registry.FocusBlurChild1 = FocusBlur.extend({focusBlurName: 'child1'});
options.registry.FocusBlurChild2 = FocusBlur.extend({focusBlurName: 'child2'});
@@ -0,0 +1,17 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
// It's an optionnal import, to patch only when the WebsitePreview is loaded.
const WebsitePreviewLoader = odoo.loader.modules.get("@website/client_actions/website_preview/website_preview");
if (WebsitePreviewLoader) {
patch(WebsitePreviewLoader.WebsitePreview.prototype, {
/**
* @override
*/
get testMode() {
return true;
}
});
}
@@ -1,16 +1,18 @@
/** @odoo-module **/
odoo.loader.bus.addEventListener("module-started", (e) => {
if (e.detail.moduleName === "@web_editor/js/frontend/loadWysiwygFromTextarea") {
const publicWidget = odoo.loader.modules.get("@web/legacy/js/public/public_widget")[Symbol.for('default')];
const { loadWysiwygFromTextarea } = e.detail.module;
import publicWidget from "@web/legacy/js/public/public_widget";
import { loadWysiwygFromTextarea } from "@web_editor/js/frontend/loadWysiwygFromTextarea";
publicWidget.registry['public_user_editor_test'] = publicWidget.Widget.extend({
selector: 'textarea.o_public_user_editor_test_textarea',
publicWidget.registry['public_user_editor_test'] = publicWidget.Widget.extend({
selector: 'textarea.o_public_user_editor_test_textarea',
/**
* @override
*/
start: async function () {
await this._super(...arguments);
await loadWysiwygFromTextarea(this, this.el, {});
},
});
/**
* @override
*/
start: async function () {
await this._super(...arguments);
await loadWysiwygFromTextarea(this, this.el, {});
},
});
}
})
+3 -1
View File
@@ -1,7 +1,6 @@
/** @odoo-module **/
import wTourUtils from "@website/js/tours/tour_utils";
import { Wysiwyg } from "@web_editor/js/wysiwyg/wysiwyg";
import { whenReady } from "@odoo/owl";
wTourUtils.registerWebsitePreviewTour('rte_translator', {
@@ -113,6 +112,7 @@ wTourUtils.dragNDrop({
trigger: 'iframe #wrap p font:first',
run: function (actionHelper) {
actionHelper.text('translated Parseltongue text');
const { Wysiwyg } = odoo.loader.modules.get('@web_editor/js/wysiwyg/wysiwyg');
Wysiwyg.setRange(this.$anchor.contents()[0], 22);
this.$anchor.trigger($.Event("keyup", {key: '_', keyCode: 95}));
this.$anchor.trigger('input');
@@ -123,6 +123,7 @@ wTourUtils.dragNDrop({
run: function (actionHelper) {
actionHelper.click();
this.$anchor.prepend('&lt;{translated}&gt;');
const { Wysiwyg } = odoo.loader.modules.get('@web_editor/js/wysiwyg/wysiwyg');
Wysiwyg.setRange(this.$anchor.contents()[0], 0);
this.$anchor.trigger($.Event("keyup", {key: '_', keyCode: 95}));
this.$anchor.trigger('input');
@@ -190,6 +191,7 @@ wTourUtils.dragNDrop({
mousedown.initMouseEvent('mousedown', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, el);
el.dispatchEvent(mousedown);
var mouseup = document.createEvent('MouseEvents');
const { Wysiwyg } = odoo.loader.modules.get('@web_editor/js/wysiwyg/wysiwyg');
Wysiwyg.setRange(el.childNodes[2], 6, el.childNodes[2], 13);
mouseup.initMouseEvent('mouseup', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, el);
el.dispatchEvent(mouseup);
@@ -1,17 +1,19 @@
/** @odoo-module **/
import { WysiwygAdapterComponent } from "@website/components/wysiwyg_adapter/wysiwyg_adapter";
import websiteTourUtils from "@website/js/tours/tour_utils";
import { patch } from "@web/core/utils/patch";
const patchWysiwygAdapter = () => patch(WysiwygAdapterComponent.prototype, {
_trigger_up(ev) {
super._trigger_up(...arguments);
if (ev.name === 'snippet_removed') {
$('body').attr('test-dd-snippet-removed', true);
const patchWysiwygAdapter = () => {
const { WysiwygAdapterComponent } = odoo.loader.modules.get("@website/components/wysiwyg_adapter/wysiwyg_adapter");
return patch(WysiwygAdapterComponent.prototype, {
_trigger_up(ev) {
super._trigger_up(...arguments);
if (ev.name === 'snippet_removed') {
$('body').attr('test-dd-snippet-removed', true);
}
}
}
});
});
};
let unpatchWysiwygAdapter = null;
+1 -1
View File
@@ -32,10 +32,10 @@
'assets': {
'website.assets_wysiwyg': [
'website_blog/static/src/js/options.js',
'website_blog/static/src/js/wysiwyg.js',
'website_blog/static/src/snippets/s_blog_posts/options.js',
],
'website.assets_editor': [
'website_blog/static/src/js/wysiwyg_adapter.js',
'website_blog/static/src/js/tours/website_blog.js',
'website_blog/static/src/js/systray_items/*.js',
],
@@ -1,7 +1,6 @@
/** @odoo-module **/
import { WysiwygAdapterComponent } from '@website/components/wysiwyg_adapter/wysiwyg_adapter';
import "@website/js/editor/snippets.options";
import { patch } from "@web/core/utils/patch";
patch(WysiwygAdapterComponent.prototype, {
@@ -1,7 +1,5 @@
/** @odoo-module **/
import { RATING } from "@im_livechat/embed/core/livechat_service";
/*******************************
* Common Steps
*******************************/
@@ -55,6 +53,13 @@ export const endDiscussion = [
];
export const feedback = [
{
content: "Patching Livechat",
trigger: "textarea[placeholder='Explain your note']",
run: function() {
document.body.classList.add("feedback_sent");
}
},
{
content: "Type a feedback",
trigger: "textarea[placeholder='Explain your note']",
@@ -114,20 +119,20 @@ export const close = [
export const goodRating = [
{
content: "Choose Good Rating",
trigger: `img[src*=rating][alt=${RATING.GOOD}]`,
trigger: `img[src*=rating][alt=5]`,
},
];
export const okRating = [
{
content: "Choose ok Rating",
trigger: `img[src*=rating][alt=${RATING.OK}]`,
trigger: `img[src*=rating][alt=3]`,
},
];
export const sadRating = [
{
content: "Choose bad Rating",
trigger: `img[src*=rating][alt=${RATING.BAD}]`,
trigger: `img[src*=rating][alt=1]`,
},
];
@@ -3,30 +3,34 @@
import { patch } from "@web/core/utils/patch";
import { endDiscussion, okRating, feedback, transcript, close } from "./website_livechat_common";
import { registry } from "@web/core/registry";
import { LivechatService, ODOO_VERSION_KEY } from "@im_livechat/embed/core/livechat_service";
// Livechat sessions are deleted if they come from a different odoo version than
// the current one. Since each test is run with a different browser the version
// is never set. This patch sets the version to the current one.
patch(LivechatService.prototype, {
async initialize() {
try {
const response = await fetch("/web/webclient/version_info", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
const { server_version: serverVersion } = (await response.json()).result;
localStorage.setItem(ODOO_VERSION_KEY, serverVersion);
} catch {
// Could be intentional (tour ended successfully while version still loading)
}
return super.initialize(...arguments);
},
});
odoo.loader.bus.addEventListener("module-started", (e) => {
if (e.detail.moduleName === "@im_livechat/embed/core/livechat_service") {
const { LivechatService, ODOO_VERSION_KEY } = e.detail.module;
// Livechat sessions are deleted if they come from a different odoo version than
// the current one. Since each test is run with a different browser the version
// is never set. This patch sets the version to the current one.
patch(LivechatService.prototype, {
async initialize() {
try {
const response = await fetch("/web/webclient/version_info", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
const { server_version: serverVersion } = (await response.json()).result;
localStorage.setItem(ODOO_VERSION_KEY, serverVersion);
} catch {
// Could be intentional (tour ended successfully while version still loading)
}
return super.initialize(...arguments);
},
});
}
})
const chatRequest = [
{
@@ -2,10 +2,205 @@
import { WysiwygAdapterComponent } from '@website/components/wysiwyg_adapter/wysiwyg_adapter';
import { patch } from "@web/core/utils/patch";
import { Markup } from "@web/legacy/js/core/utils";
// TODO this whole patch actually seems unnecessary. The bug it solved seems
// to stay solved if this is removed. To investigate.
patch(WysiwygAdapterComponent.prototype, {
/**
* @override
*/
async init() {
await super.init(...arguments);
let ribbons = [];
if (this._isProductListPage()) {
ribbons = await this.orm.searchRead(
'product.ribbon',
[],
['id', 'html', 'bg_color', 'text_color', 'html_class'],
);
}
this.ribbons = Object.fromEntries(ribbons.map(ribbon => {
ribbon.html = Markup(ribbon.html);
return [ribbon.id, ribbon];
}));
this.originalRibbons = Object.assign({}, this.ribbons);
this.productTemplatesRibbons = [];
this.deletedRibbonClasses = '';
},
/**
* @override
*/
async _saveViewBlocks() {
await this._saveRibbons();
return super._saveViewBlocks(...arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Saves the ribbons in the database.
*
* @private
*/
async _saveRibbons() {
if (!this._isProductListPage()) {
return;
}
const originalIds = Object.keys(this.originalRibbons).map(id => parseInt(id));
const currentIds = Object.keys(this.ribbons).map(id => parseInt(id));
const ribbons = Object.values(this.ribbons);
const created = ribbons.filter(ribbon => !originalIds.includes(ribbon.id));
const deletedIds = originalIds.filter(id => !currentIds.includes(id));
const modified = ribbons.filter(ribbon => {
if (created.includes(ribbon)) {
return false;
}
const original = this.originalRibbons[ribbon.id];
return Object.entries(ribbon).some(([key, value]) => value !== original[key]);
});
const proms = [];
let createdRibbonIds;
if (created.length > 0) {
proms.push(this.orm.create(
'product.ribbon',
created.map(ribbon => {
ribbon = Object.assign({}, ribbon);
delete ribbon.id;
return ribbon;
}),
).then(ids => createdRibbonIds = ids));
}
modified.forEach(ribbon => proms.push(this.orm.write(
'product.ribbon',
[ribbon.id],
ribbon,
)));
if (deletedIds.length > 0) {
proms.push(this.orm.unlink(
'product.ribbon',
deletedIds,
));
}
await Promise.all(proms);
const localToServer = Object.assign(
this.ribbons,
Object.fromEntries(created.map((ribbon, index) => [ribbon.id, {id: createdRibbonIds[index]}])),
{'false': {id: false}},
);
// Building the final template to ribbon-id map
const finalTemplateRibbons = this.productTemplatesRibbons.reduce((acc, {templateId, ribbonId}) => {
acc[templateId] = ribbonId;
return acc;
}, {});
// Inverting the relationship so that we have all templates that have the same ribbon to reduce RPCs
const ribbonTemplates = Object.entries(finalTemplateRibbons).reduce((acc, [templateId, ribbonId]) => {
if (!acc[ribbonId]) {
acc[ribbonId] = [];
}
acc[ribbonId].push(parseInt(templateId));
return acc;
}, {});
const setProductTemplateRibbons = Object.entries(ribbonTemplates)
// If the ribbonId that the template had no longer exists, remove the ribbon (id = false)
.map(([ribbonId, templateIds]) => {
const id = currentIds.includes(parseInt(ribbonId)) ? ribbonId : false;
return [id, templateIds];
}).map(([ribbonId, templateIds]) => this.orm.write(
'product.template',
templateIds,
{'website_ribbon_id': localToServer[ribbonId].id},
));
return Promise.all(setProductTemplateRibbons);
},
/**
* Checks whether the current page is the product list.
*
* @private
*/
_isProductListPage() {
return this.options.editable && this.options.editable.find('#products_grid').length !== 0;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Returns a copy of this.ribbons through a callback.
*
* @private
*/
_onGetRibbons(ev) {
ev.data.callback(Object.assign({}, this.ribbons));
},
/**
* Returns all ribbon classes, current and deleted, so they can be removed.
*
* @private
*/
_onGetRibbonClasses(ev) {
const classes = Object.values(this.ribbons).reduce((classes, ribbon) => {
return classes + ` ${ribbon.html_class}`;
}, '') + this.deletedRibbonClasses;
ev.data.callback(classes);
},
/**
* Deletes a ribbon.
*
* @private
*/
_onDeleteRibbon(ev) {
this.deletedRibbonClasses += ` ${this.ribbons[ev.data.id].html_class}`;
delete this.ribbons[ev.data.id];
},
/**
* Sets a ribbon;
*
* @private
*/
_onSetRibbon(ev) {
const {ribbon} = ev.data;
const previousRibbon = this.ribbons[ribbon.id];
if (previousRibbon) {
this.deletedRibbonClasses += ` ${previousRibbon.html_class}`;
}
this.ribbons[ribbon.id] = ribbon;
},
/**
* Sets which ribbon is used by a product template.
*
* @private
*/
_onSetProductRibbon(ev) {
const {templateId, ribbonId} = ev.data;
this.productTemplatesRibbons.push({templateId, ribbonId});
},
/**
* @override
*/
_trigger_up(ev) {
const methods = {
get_ribbons: this._onGetRibbons.bind(this),
get_ribbon_classes: this._onGetRibbonClasses.bind(this),
delete_ribbon: this._onDeleteRibbon.bind(this),
set_ribbon: this._onSetRibbon.bind(this),
set_product_ribbon: this._onSetProductRibbon.bind(this),
}
if (methods[ev.name]) {
return methods[ev.name](ev);
} else {
return super._trigger_up(...arguments);
}
},
// TODO this whole patch actually seems unnecessary. The bug it solved seems
// to stay solved if this is removed. To investigate.
/**
* @override
*/
@@ -1,212 +1,12 @@
/** @odoo-module **/
import options from "@web_editor/js/editor/snippets.options";
import { WysiwygAdapterComponent } from '@website/components/wysiwyg_adapter/wysiwyg_adapter';
import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog";
import { _t } from "@web/core/l10n/translation";
import { Markup } from "@web/legacy/js/core/utils";
import Dialog from "@web/legacy/js/core/dialog";
import "@website/js/editor/snippets.options";
import { patch } from "@web/core/utils/patch";
import { renderToElement } from "@web/core/utils/render";
patch(WysiwygAdapterComponent.prototype, {
/**
* @override
*/
async init() {
await super.init(...arguments);
let ribbons = [];
if (this._isProductListPage()) {
ribbons = await this.orm.searchRead(
'product.ribbon',
[],
['id', 'html', 'bg_color', 'text_color', 'html_class'],
);
}
this.ribbons = Object.fromEntries(ribbons.map(ribbon => {
ribbon.html = Markup(ribbon.html);
return [ribbon.id, ribbon];
}));
this.originalRibbons = Object.assign({}, this.ribbons);
this.productTemplatesRibbons = [];
this.deletedRibbonClasses = '';
},
/**
* @override
*/
async _saveViewBlocks() {
await this._saveRibbons();
return super._saveViewBlocks(...arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Saves the ribbons in the database.
*
* @private
*/
async _saveRibbons() {
if (!this._isProductListPage()) {
return;
}
const originalIds = Object.keys(this.originalRibbons).map(id => parseInt(id));
const currentIds = Object.keys(this.ribbons).map(id => parseInt(id));
const ribbons = Object.values(this.ribbons);
const created = ribbons.filter(ribbon => !originalIds.includes(ribbon.id));
const deletedIds = originalIds.filter(id => !currentIds.includes(id));
const modified = ribbons.filter(ribbon => {
if (created.includes(ribbon)) {
return false;
}
const original = this.originalRibbons[ribbon.id];
return Object.entries(ribbon).some(([key, value]) => value !== original[key]);
});
const proms = [];
let createdRibbonIds;
if (created.length > 0) {
proms.push(this.orm.create(
'product.ribbon',
created.map(ribbon => {
ribbon = Object.assign({}, ribbon);
delete ribbon.id;
return ribbon;
}),
).then(ids => createdRibbonIds = ids));
}
modified.forEach(ribbon => proms.push(this.orm.write(
'product.ribbon',
[ribbon.id],
ribbon,
)));
if (deletedIds.length > 0) {
proms.push(this.orm.unlink(
'product.ribbon',
deletedIds,
));
}
await Promise.all(proms);
const localToServer = Object.assign(
this.ribbons,
Object.fromEntries(created.map((ribbon, index) => [ribbon.id, {id: createdRibbonIds[index]}])),
{'false': {id: false}},
);
// Building the final template to ribbon-id map
const finalTemplateRibbons = this.productTemplatesRibbons.reduce((acc, {templateId, ribbonId}) => {
acc[templateId] = ribbonId;
return acc;
}, {});
// Inverting the relationship so that we have all templates that have the same ribbon to reduce RPCs
const ribbonTemplates = Object.entries(finalTemplateRibbons).reduce((acc, [templateId, ribbonId]) => {
if (!acc[ribbonId]) {
acc[ribbonId] = [];
}
acc[ribbonId].push(parseInt(templateId));
return acc;
}, {});
const setProductTemplateRibbons = Object.entries(ribbonTemplates)
// If the ribbonId that the template had no longer exists, remove the ribbon (id = false)
.map(([ribbonId, templateIds]) => {
const id = currentIds.includes(parseInt(ribbonId)) ? ribbonId : false;
return [id, templateIds];
}).map(([ribbonId, templateIds]) => this.orm.write(
'product.template',
templateIds,
{'website_ribbon_id': localToServer[ribbonId].id},
));
return Promise.all(setProductTemplateRibbons);
},
/**
* Checks whether the current page is the product list.
*
* @private
*/
_isProductListPage() {
return this.options.editable && this.options.editable.find('#products_grid').length !== 0;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Returns a copy of this.ribbons through a callback.
*
* @private
*/
_onGetRibbons(ev) {
ev.data.callback(Object.assign({}, this.ribbons));
},
/**
* Returns all ribbon classes, current and deleted, so they can be removed.
*
* @private
*/
_onGetRibbonClasses(ev) {
const classes = Object.values(this.ribbons).reduce((classes, ribbon) => {
return classes + ` ${ribbon.html_class}`;
}, '') + this.deletedRibbonClasses;
ev.data.callback(classes);
},
/**
* Deletes a ribbon.
*
* @private
*/
_onDeleteRibbon(ev) {
this.deletedRibbonClasses += ` ${this.ribbons[ev.data.id].html_class}`;
delete this.ribbons[ev.data.id];
},
/**
* Sets a ribbon;
*
* @private
*/
_onSetRibbon(ev) {
const {ribbon} = ev.data;
const previousRibbon = this.ribbons[ribbon.id];
if (previousRibbon) {
this.deletedRibbonClasses += ` ${previousRibbon.html_class}`;
}
this.ribbons[ribbon.id] = ribbon;
},
/**
* Sets which ribbon is used by a product template.
*
* @private
*/
_onSetProductRibbon(ev) {
const {templateId, ribbonId} = ev.data;
this.productTemplatesRibbons.push({templateId, ribbonId});
},
/**
* @override
*/
_trigger_up(ev) {
const methods = {
get_ribbons: this._onGetRibbons.bind(this),
get_ribbon_classes: this._onGetRibbonClasses.bind(this),
delete_ribbon: this._onDeleteRibbon.bind(this),
set_ribbon: this._onSetRibbon.bind(this),
set_product_ribbon: this._onSetProductRibbon.bind(this),
}
if (methods[ev.name]) {
return methods[ev.name](ev);
} else {
return super._trigger_up(...arguments);
}
}
});
options.registry.WebsiteSaleGridLayout = options.Class.extend({
/**
@@ -1,20 +1,25 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import websiteSaleTracking from "@website_sale/js/website_sale_tracking";
import tourUtils from "@website_sale/js/tours/tour_utils";
odoo.loader.bus.addEventListener("module-started", (e) => {
if (e.detail.moduleName === "@website_sale/js/website_sale_tracking") {
//import websiteSaleTracking from "@website_sale/js/website_sale_tracking";
e.detail.module[Symbol.for("default")].include({
// Purposely don't call super to avoid call to third party (GA) during tests
_onViewItem(event, data) {
$('body').attr('view-event-id', data.item_id);
},
_onAddToCart(event, data) {
$('body').attr('cart-event-id', data.item_id);
},
});
}
});
let itemId;
websiteSaleTracking.include({
// Purposely don't call super to avoid call to third party (GA) during tests
_onViewItem(event, data) {
$('body').attr('view-event-id', data.item_id);
},
_onAddToCart(event, data) {
$('body').attr('cart-event-id', data.item_id);
},
});
registry.category("web_tour.tours").add('google_analytics_view_item', {
test: true,
+1 -1
View File
@@ -149,7 +149,7 @@ Featuring
'website/static/src/libs/zoomodoo/zoomodoo.scss',
'web/static/src/legacy/js/promise_extension.js',
'web/static/src/boot.js',
'web/static/src/module_loader.js',
'web/static/src/session.js',
'web/static/src/legacy/js/core/cookie_utils.js',
@@ -1,23 +1,26 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import FullScreen from '@website_slides/js/slides_course_fullscreen_player';
/**
* Alter this method for test purposes.
* This will make the video start at 10 minutes.
* As it lasts 10min24s, it will mark it as completed immediately.
*/
FullScreen.include({
_renderSlide: function () {
function patchFullScreen(){
/**
* Alter this method for test purposes.
* This will make the video start at 10 minutes.
* As it lasts 10min24s, it will mark it as completed immediately.
*/
const FullScreen = odoo.loader.modules.get('@website_slides/js/slides_course_fullscreen_player')[Symbol.for("default")];
FullScreen.include({
_renderSlide: function () {
var slide = this.get('slide');
slide.embedUrl += '&start=260';
this.set('slide', slide);
var slide = this.get('slide');
slide.embedUrl += '&start=260';
this.set('slide', slide);
return this._super.call(this, arguments);
}
});
}
return this._super.call(this, arguments);
}
});
/**
* Global use case:
@@ -32,6 +35,13 @@ registry.category("web_tour.tours").add('course_member_youtube', {
url: '/slides',
test: true,
steps: () => [
{
content: "Patching FullScreen",
trigger: 'body',
run: function() {
patchFullScreen()
}
},
// eLearning: go on /all, find free course and join it
{
trigger: 'a.o_wslides_home_all_slides'