diff options
Diffstat (limited to 'addons/web')
| -rw-r--r-- | addons/web/static/src/js/core/patch_mixin.js | 80 | ||||
| -rw-r--r-- | addons/web/static/src/js/core/utils.js | 7 | ||||
| -rw-r--r-- | addons/web/static/tests/core/patch_mixin_tests.js | 994 | ||||
| -rw-r--r-- | addons/web/views/webclient_templates.xml | 2 |
4 files changed, 1082 insertions, 1 deletions
diff --git a/addons/web/static/src/js/core/patch_mixin.js b/addons/web/static/src/js/core/patch_mixin.js new file mode 100644 index 00000000000..99f06f56588 --- /dev/null +++ b/addons/web/static/src/js/core/patch_mixin.js @@ -0,0 +1,80 @@ +odoo.define("web.patchMixin", function () { + "use strict"; + + /** + * This module defines and exports the 'patchMixin' function. This function + * returns a 'monkey-patchable' version of the ES6 Class given in arguments. + * + * const patchMixin = require('web.patchMixin'); + * class MyClass { + * print() { + * console.log('MyClass'); + * } + * } + * const MyPatchedClass = patchMixin(MyClass); + * + * + * A patchable class has a 'patch' function, allowing to define a patch: + * + * MyPatchedClass.patch("module_name.key", T => + * class extends T { + * print() { + * console.log('MyPatchedClass'); + * super.print(); + * } + * } + * ); + * + * const myPatchedClass = new MyPatchedClass(); + * myPatchedClass.print(); // displays "MyPatchedClass" and "MyClass" + * + * + * The 'unpatch' function can be used to remove a patch, given its key: + * + * MyPatchedClass.unpatch("module_name.key"); + */ + function patchMixin(OriginalClass) { + let unpatchList = []; + class PatchableClass extends OriginalClass {} + + PatchableClass.patch = function (name, patch) { + if (unpatchList.find(x => x.name === name)) { + throw new Error(`Class ${OriginalClass.name} already has a patch ${name}`); + } + if (!Object.prototype.hasOwnProperty.call(this, 'patch')) { + throw new Error(`Class ${this.name} is not patchable`); + } + const SubClass = patch(Object.getPrototypeOf(this)); + unpatchList.push({ + name: name, + elem: this, + prototype: this.prototype, + origProto: Object.getPrototypeOf(this), + origPrototype: Object.getPrototypeOf(this.prototype), + patch: patch, + }); + Object.setPrototypeOf(this, SubClass); + Object.setPrototypeOf(this.prototype, SubClass.prototype); + }; + + PatchableClass.unpatch = function (name) { + if (!unpatchList.find(x => x.name === name)) { + throw new Error(`Class ${OriginalClass.name} does not have any patch ${name}`); + } + const toUnpatch = unpatchList.reverse(); + unpatchList = []; + for (let unpatch of toUnpatch) { + Object.setPrototypeOf(unpatch.elem, unpatch.origProto); + Object.setPrototypeOf(unpatch.prototype, unpatch.origPrototype); + } + for (let u of toUnpatch.reverse()) { + if (u.name !== name) { + PatchableClass.patch(u.name, u.patch); + } + } + }; + return PatchableClass; + } + + return patchMixin; +}); diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js index f1d4b00c6a9..e8924747616 100644 --- a/addons/web/static/src/js/core/utils.js +++ b/addons/web/static/src/js/core/utils.js @@ -527,7 +527,12 @@ var utils = { /** * Patch a class and return a function that remove the patch * when called. - * + * + * This function is the last resort solution for monkey-patching an + * ES6 Class, for people that do not control the code defining the Class + * to patch (e.g. partners), and when that Class isn't patchable already + * (i.e. when it doesn't have a 'patch' function, defined by the 'web.PatchMixin'). + * * @param {Class} C Class to patch * @param {string} patchName * @param {Object} patch diff --git a/addons/web/static/tests/core/patch_mixin_tests.js b/addons/web/static/tests/core/patch_mixin_tests.js new file mode 100644 index 00000000000..7329a9607d4 --- /dev/null +++ b/addons/web/static/tests/core/patch_mixin_tests.js @@ -0,0 +1,994 @@ +odoo.define("web.patchMixin_tests", function (require) { +"use strict"; + +const patchMixin = require('web.patchMixin'); + +QUnit.module('core', {}, function () { + + QUnit.module('patchMixin', {}, function () { + + QUnit.test('basic use', function (assert) { + assert.expect(4); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + const a = new A(); + a.f(); + + assert.ok(a instanceof A); + assert.verifySteps([ + 'A.constructor', + 'A.f', + ]); + }); + + QUnit.test('simple patch', function (assert) { + assert.expect(5); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + A.patch('patch', T => + class extends T { + constructor() { + super(); + assert.step('patch.constructor'); + } + f() { + super.f(); + assert.step('patch.f'); + } + } + ); + + (new A()).f(); + + assert.verifySteps([ + 'A.constructor', + 'patch.constructor', + 'A.f', + 'patch.f', + ]); + }); + + QUnit.test('two patches on same base class', function (assert) { + assert.expect(7); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + A.patch('patch1', T => + class extends T { + constructor() { + super(); + assert.step('patch1.constructor'); + } + f() { + super.f(); + assert.step('patch1.f'); + } + } + ); + + A.patch('patch2', T => + class extends T { + constructor() { + super(); + assert.step('patch2.constructor'); + } + f() { + super.f(); + assert.step('patch2.f'); + } + } + ); + + (new A()).f(); + + assert.verifySteps([ + 'A.constructor', + 'patch1.constructor', + 'patch2.constructor', + 'A.f', + 'patch1.f', + 'patch2.f', + ]); + }); + + QUnit.test('two patches with same name on same base class', function (assert) { + assert.expect(1); + + const A = patchMixin(class {}); + + A.patch('patch', T => class extends T {}); + + // keys should be unique + assert.throws(() => { + A.patch('patch', T => class extends T {}); + }); + }); + + QUnit.test('unpatch', function (assert) { + assert.expect(8); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + A.patch('patch', T => + class extends T { + constructor() { + super(); + assert.step('patch.constructor'); + } + f() { + super.f(); + assert.step('patch.f'); + } + } + ); + + (new A()).f(); + + assert.verifySteps([ + 'A.constructor', + 'patch.constructor', + 'A.f', + 'patch.f', + ]); + + A.unpatch('patch'); + + (new A()).f(); + + assert.verifySteps([ + 'A.constructor', + 'A.f', + ]); + }); + + QUnit.test('unpatch 2', function (assert) { + assert.expect(12); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + A.patch('patch1', T => + class extends T { + constructor() { + super(); + assert.step('patch1.constructor'); + } + f() { + super.f(); + assert.step('patch1.f'); + } + } + ); + + A.patch('patch2', T => + class extends T { + constructor() { + super(); + assert.step('patch2.constructor'); + } + f() { + super.f(); + assert.step('patch2.f'); + } + } + ); + + (new A()).f(); + + assert.verifySteps([ + 'A.constructor', + 'patch1.constructor', + 'patch2.constructor', + 'A.f', + 'patch1.f', + 'patch2.f', + ]); + + A.unpatch('patch1'); + + (new A()).f(); + + assert.verifySteps([ + 'A.constructor', + 'patch2.constructor', + 'A.f', + 'patch2.f', + ]); + }); + + QUnit.test('unpatch inexistent', function (assert) { + assert.expect(1); + + const A = patchMixin(class {}); + A.patch('patch', T => class extends T {}); + + A.unpatch('patch'); + assert.throws(() => { + A.unpatch('inexistent-patch'); + }); + }); + + QUnit.test('patch for specialization', function (assert) { + assert.expect(1); + + let args = []; + + const A = patchMixin( + class { + constructor() { + args = ['A', ...arguments]; + } + } + ); + + A.patch('patch', T => + class extends T { + constructor() { + super('patch', ...arguments); + } + } + ); + + new A('instantiation'); + + assert.deepEqual(args, ['A', 'patch', 'instantiation']); + }); + + QUnit.test('instance fields', function (assert) { + assert.expect(1); + + const A = patchMixin( + class { + constructor() { + this.x = ['A']; + } + } + ); + + A.patch('patch', T => + class extends T { + constructor() { + super(); + this.x.push('patch'); + } + } + ); + + const a = new A(); + assert.deepEqual(a.x, ['A', 'patch']); + }); + + QUnit.test('call instance method defined in patch', function (assert) { + assert.expect(3); + + const A = patchMixin( + class {} + ); + + assert.notOk((new A()).f); + + A.patch('patch', T => + class extends T { + f() { + assert.step('patch.f'); + } + } + ); + + (new A()).f(); + assert.verifySteps(['patch.f']); + }); + + QUnit.test('class methods', function (assert) { + assert.expect(7); + + const A = patchMixin( + class { + static f() { + assert.step('A'); + } + } + ); + + A.f(); + assert.verifySteps(['A']); + + A.patch('patch', T => + class extends T { + static f() { + super.f(); + assert.step('patch'); + } + } + ); + + A.f(); + assert.verifySteps(['A', 'patch']); + + A.unpatch('patch'); + + A.f(); + assert.verifySteps(['A']); + }); + + QUnit.test('class fields', function (assert) { + assert.expect(4); + + class A {} + A.foo = ['A']; + A.bar = 'A'; + + const PatchableA = patchMixin(A); + + PatchableA.patch('patch', T => { + class Patch extends T {} + + Patch.foo = [...T.foo, 'patched A']; + Patch.bar = 'patched A'; + + return Patch; + }); + + assert.deepEqual(PatchableA.foo, ['A', 'patched A']); + assert.strictEqual(PatchableA.bar, 'patched A'); + + PatchableA.unpatch('patch'); + + assert.deepEqual(PatchableA.foo, ['A']); + assert.strictEqual(PatchableA.bar, 'A'); + }); + + QUnit.test('lazy patch', function (assert) { + assert.expect(4); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + const a = new A(); + + A.patch('patch', T => + class extends T { + constructor() { + super(); + // will not be called + assert.step('patch.constructor'); + } + f() { + super.f(); + assert.step('patch.f'); + } + } + ); + + a.f(); + + assert.verifySteps([ + 'A.constructor', + 'A.f', + 'patch.f', + ]); + }); + + + QUnit.module('inheritance'); + + QUnit.test('inheriting a patchable class', function (assert) { + assert.expect(8); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + class B extends A { + constructor() { + super(); + assert.step('B.constructor'); + } + f() { + super.f(); + assert.step('B.f'); + } + } + + (new A()).f(); + + assert.verifySteps([ + 'A.constructor', + 'A.f', + ]); + + (new B()).f(); + + assert.verifySteps([ + 'A.constructor', + 'B.constructor', + 'A.f', + 'B.f', + ]); + }); + + QUnit.test('inheriting a patchable class that has patch', function (assert) { + assert.expect(12); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + A.patch('patch', T => + class extends T { + constructor() { + super(); + assert.step('patch.constructor'); + } + f() { + super.f(); + assert.step('patch.f'); + } + } + ); + + class B extends A { + constructor() { + super(); + assert.step('B.constructor'); + } + f() { + super.f(); + assert.step('B.f'); + } + } + + (new A()).f(); + + assert.verifySteps([ + 'A.constructor', + 'patch.constructor', + 'A.f', + 'patch.f', + ]); + + (new B()).f(); + + assert.verifySteps([ + 'A.constructor', + 'patch.constructor', + 'B.constructor', + 'A.f', + 'patch.f', + 'B.f', + ]); + }); + + QUnit.test('patch inherited patchable class', function (assert) { + assert.expect(10); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + const B = patchMixin( + class extends A { + constructor() { + super(); + assert.step('B.constructor'); + } + f() { + super.f(); + assert.step('B.f'); + } + } + ); + + B.patch('patch', T => + class extends T { + constructor() { + super(); + assert.step('patch.constructor'); + } + f() { + super.f(); + assert.step('patch.f'); + } + } + ); + + (new A()).f(); + + assert.verifySteps([ + 'A.constructor', + 'A.f', + ]); + + (new B()).f(); + + assert.verifySteps([ + 'A.constructor', + 'B.constructor', + 'patch.constructor', + 'A.f', + 'B.f', + 'patch.f', + ]); + }); + + QUnit.test('patch inherited patched class', function (assert) { + assert.expect(14); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + A.patch('patch', T => + class extends T { + constructor() { + super(); + assert.step('A.patch.constructor'); + } + f() { + super.f(); + assert.step('A.patch.f'); + } + } + ); + + /** + * /!\ WARNING /!\ + * + * If you want to patch class B, make it patchable + * otherwise it will patch class A! + */ + const B = patchMixin( + class extends A { + constructor() { + super(); + assert.step('B.constructor'); + } + f() { + super.f(); + assert.step('B.f'); + } + } + ); + + B.patch('patch', T => + class extends T { + constructor() { + super(); + assert.step('B.patch.constructor'); + } + f() { + super.f(); + assert.step('B.patch.f'); + } + } + ); + + const a = new A(); + a.f(); + + assert.verifySteps([ + 'A.constructor', + 'A.patch.constructor', + 'A.f', + 'A.patch.f', + ]); + + const b = new B(); + b.f(); + + assert.verifySteps([ + 'A.constructor', + 'A.patch.constructor', + 'B.constructor', + 'B.patch.constructor', + 'A.f', + 'A.patch.f', + 'B.f', + 'B.patch.f', + ]); + }); + + QUnit.test('unpatch inherited patched class', function (assert) { + assert.expect(15); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + A.patch('patch', T => + class extends T { + constructor() { + super(); + assert.step('A.patch.constructor'); + } + f() { + super.f(); + assert.step('A.patch.f'); + } + } + ); + + const B = patchMixin( + class extends A { + constructor() { + super(); + assert.step('B.constructor'); + } + f() { + super.f(); + assert.step('B.f'); + } + } + ); + + B.patch('patch', T => + class extends T { + constructor() { + super(); + assert.step('B.patch.constructor'); + } + f() { + super.f(); + assert.step('B.patch.f'); + } + } + ); + + A.unpatch('patch'); + + (new A()).f(); + + assert.verifySteps([ + 'A.constructor', + 'A.f', + ]); + + (new B()).f(); + + assert.verifySteps([ + 'A.constructor', + 'B.constructor', + 'B.patch.constructor', + 'A.f', + 'B.f', + 'B.patch.f', + ]); + + B.unpatch('patch'); + + (new B()).f(); + + assert.verifySteps([ + 'A.constructor', + 'B.constructor', + 'A.f', + 'B.f', + ]); + }); + + QUnit.test('unpatch inherited patched class 2', function (assert) { + assert.expect(12); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + A.patch('patch', T => + class extends T { + constructor() { + super(); + assert.step('A.patch.constructor'); + } + f() { + super.f(); + assert.step('A.patch.f'); + } + } + ); + + const B = patchMixin( + class extends A { + constructor() { + super(); + assert.step('B.constructor'); + } + f() { + super.f(); + assert.step('B.f'); + } + } + ); + + B.patch('patch', T => + class extends T { + constructor() { + super(); + assert.step('B.patch.constructor'); + } + f() { + super.f(); + assert.step('B.patch.f'); + } + } + ); + + B.unpatch('patch'); + + (new B()).f(); + + assert.verifySteps([ + 'A.constructor', + 'A.patch.constructor', + 'B.constructor', + 'A.f', + 'A.patch.f', + 'B.f', + ]); + + A.unpatch('patch'); + + (new B()).f(); + + assert.verifySteps([ + 'A.constructor', + 'B.constructor', + 'A.f', + 'B.f', + ]); + }); + + QUnit.test('class methods', function (assert) { + assert.expect(12); + + const A = patchMixin( + class { + static f() { + assert.step('A'); + } + } + ); + + const B = patchMixin( + class extends A { + static f() { + super.f(); + assert.step('B'); + } + } + ); + + A.patch('patch', T => + class extends T { + static f() { + super.f(); + assert.step('A.patch'); + } + } + ); + + B.patch('patch', T => + class extends T { + static f() { + super.f(); + assert.step('B.patch'); + } + } + ); + + B.f(); + assert.verifySteps(['A', 'A.patch', 'B', 'B.patch']); + + A.unpatch('patch'); + + B.f(); + assert.verifySteps(['A', 'B', 'B.patch']); + + B.unpatch('patch'); + + B.f(); + assert.verifySteps(['A', 'B']); + }); + + QUnit.test('class fields', function (assert) { + assert.expect(3); + + class A {} + A.foo = ['A']; + A.bar = 'A'; + + const PatchableA = patchMixin(A); + + class B extends PatchableA {} + // /!\ This is not dynamic + // so if A.foo is patched after this assignment + // B.foo won't have the patches of A.foo + B.foo = [...PatchableA.foo, 'B']; + B.bar = 'B'; + + const PatchableB = patchMixin(B); + + PatchableA.patch('patch', T => { + class Patch extends T {} + + Patch.foo = [...T.foo, 'patched A']; + Patch.bar = 'patched A'; + + return Patch; + }); + + PatchableB.patch('patch', T => { + class Patch extends T {} + + Patch.foo = [...T.foo, 'patched B']; + Patch.bar = 'patched B'; + + return Patch; + }); + + assert.deepEqual(PatchableB.foo, [ 'A', /* 'patched A', */ 'B', 'patched B' ]); + assert.deepEqual(PatchableA.foo, [ 'A', 'patched A' ]); + assert.strictEqual(PatchableB.bar, 'patched B'); + }); + + QUnit.test('inheritance and lazy patch', function (assert) { + assert.expect(6); + + const A = patchMixin( + class { + constructor() { + assert.step('A.constructor'); + } + f() { + assert.step('A.f'); + } + } + ); + + class B extends A { + constructor() { + super(); + assert.step('B.constructor'); + } + f() { + super.f(); + assert.step('B.f'); + } + } + + const b = new B(); + + A.patch('patch', T => + class extends T { + constructor() { + super(); + // will not be called + assert.step('patch.constructor'); + } + f() { + super.f(); + assert.step('patch.f'); + } + } + ); + + b.f(); + + assert.verifySteps([ + 'A.constructor', + 'B.constructor', + 'A.f', + 'patch.f', + 'B.f', + ]); + }); + + QUnit.test('patch not patchable class that inherits patchable class', function (assert) { + assert.expect(1); + + const A = patchMixin(class {}); + class B extends A {} + + // class B is not patchable + assert.throws(() => { + B.patch('patch', T => class extends T {}); + }); + }); + }); +}); +}); diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 05059f15d55..275a1ccd59f 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -144,6 +144,7 @@ <script type="text/javascript" src="/web/static/src/js/core/dom.js"></script> <script type="text/javascript" src="/web/static/src/js/core/local_storage.js"></script> <script type="text/javascript" src="/web/static/src/js/core/mixins.js"></script> + <script type="text/javascript" src="/web/static/src/js/core/patch_mixin.js"></script> <script type="text/javascript" src="/web/static/src/js/core/qweb.js"></script> <script type="text/javascript" src="/web/static/src/js/core/ram_storage.js"></script> <script type="text/javascript" src="/web/static/src/js/core/registry.js"></script> @@ -721,6 +722,7 @@ <script type="text/javascript" src="/web/static/tests/core/data_comparison_utils_tests.js"></script> <script type="text/javascript" src="/web/static/tests/core/math_utils_tests.js"></script> <script type="text/javascript" src="/web/static/tests/core/mixins_tests.js"></script> + <script type="text/javascript" src="/web/static/tests/core/patch_mixin_tests.js"></script> <script type="text/javascript" src="/web/static/tests/core/time_tests.js"></script> <script type="text/javascript" src="/web/static/tests/core/concurrency_tests.js"></script> <script type="text/javascript" src="/web/static/tests/core/util_tests.js"></script> |
