From 75a0e97c53984bb404044bec401a530f1df0e3f4 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 8 Nov 2024 16:55:00 +0100 Subject: [PATCH 1/2] disable dynamic carousel & blog snippets --- .../src/snippets/s_dynamic_snippet_carousel/{000.js => 000.jsoff} | 0 .../static/src/snippets/s_blog_posts/{000.js => 000.jsoff} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename addons/website/static/src/snippets/s_dynamic_snippet_carousel/{000.js => 000.jsoff} (100%) rename addons/website_blog/static/src/snippets/s_blog_posts/{000.js => 000.jsoff} (100%) diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.jsoff similarity index 100% rename from addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.js rename to addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.jsoff diff --git a/addons/website_blog/static/src/snippets/s_blog_posts/000.js b/addons/website_blog/static/src/snippets/s_blog_posts/000.jsoff similarity index 100% rename from addons/website_blog/static/src/snippets/s_blog_posts/000.js rename to addons/website_blog/static/src/snippets/s_blog_posts/000.jsoff From 92d6b91abb350f290a3e8391c7e76b8a1293ecb5 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 8 Nov 2024 17:26:03 +0100 Subject: [PATCH 2/2] dynamic snippet --- addons/website/__manifest__.py | 2 + .../src/snippets/s_dynamic_snippet/000.js | 247 ++++++++---------- .../snippets/dynamic_snippet.test.js | 74 ++++++ 3 files changed, 182 insertions(+), 141 deletions(-) create mode 100644 addons/website/static/tests/interactions/snippets/dynamic_snippet.test.js diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 2c8245841d282..255fcbcdc8d30 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -289,6 +289,8 @@ 'web.assets_unit_tests_setup': [ 'website/static/src/core/**/*', 'website/static/src/interactions/**/*', + 'website/static/src/snippets/s_dynamic_snippet/000.js', + 'website/static/src/snippets/s_dynamic_snippet/000.xml', 'website/static/src/snippets/s_table_of_content/000.js', 'website/static/src/snippets/s_table_of_content/000.scss', diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/000.js b/addons/website/static/src/snippets/s_dynamic_snippet/000.js index 1a4cc0b84f3d7..aaa19f668a644 100644 --- a/addons/website/static/src/snippets/s_dynamic_snippet/000.js +++ b/addons/website/static/src/snippets/s_dynamic_snippet/000.js @@ -1,7 +1,8 @@ -import publicWidget from "@web/legacy/js/public/public_widget"; +import { registry } from "@web/core/registry"; +import { Interaction } from "@website/core/interaction"; import { rpc } from "@web/core/network/rpc"; import { uniqueId } from "@web/core/utils/functions"; -import { renderToString } from "@web/core/utils/render"; +import { renderToElement } from "@web/core/utils/render"; import { listenSizeChange, utils as uiUtils } from "@web/core/ui/ui_service"; import { markup } from "@odoo/owl"; @@ -9,19 +10,17 @@ import { markup } from "@odoo/owl"; const DEFAULT_NUMBER_OF_ELEMENTS = 4; const DEFAULT_NUMBER_OF_ELEMENTS_SM = 1; -const DynamicSnippet = publicWidget.Widget.extend({ - selector: '.s_dynamic_snippet', - read_events: { - 'click [data-url]': '_onCallToAction', - }, - disabledInEditableMode: false, +export class DynamicSnippet extends Interaction { + static selector = ".s_dynamic_snippet"; + static dynamicContent = { + "[data-url]": { + "t-on-click": "callToAction", // TODO Disable in edit mode. + }, + }; + // TODO Support edit-mode enabled. + static disabledInEditableMode = false; - /** - * - * @override - */ - init: function () { - this._super.apply(this, arguments); + setup() { /** * The dynamic filter data source data formatted with the chosen template. * Can be accessed when overriding the _render_content() function in order to generate @@ -30,91 +29,66 @@ const DynamicSnippet = publicWidget.Widget.extend({ * @type {*|jQuery.fn.init|jQuery|HTMLElement} */ this.data = []; - this.renderedContent = ''; + this.renderedContentEl = document.createTextNode(""); this.isDesplayedAsMobile = uiUtils.isSmall(); - this.unique_id = uniqueId("s_dynamic_snippet_"); - this.template_key = 'website.s_dynamic_snippet.grid'; - }, - /** - * - * @override - */ - willStart: function () { - return this._super.apply(this, arguments).then( - () => Promise.all([ - this._fetchData(), - ]) - ); - }, - /** - * - * @override - */ - start: function () { - return this._super.apply(this, arguments) - .then(() => { - this._setupSizeChangedManagement(true); - this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerUnactive(); - this._render(); - this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerActive(); - }); - }, - /** - * - * @override - */ - destroy: function () { - this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerUnactive(); - this._toggleVisibility(false); - this._setupSizeChangedManagement(false); - this._clearContent(); - this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerActive(); - this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _clearContent: function () { - const $templateArea = this.$el.find('.dynamic_snippet_template'); + this.uniqueId = uniqueId("s_dynamic_snippet_"); + this.templateKey = "website.s_dynamic_snippet.grid"; + } + async willStart() { + return this.fetchData(); + } + start() { + this.setupSizeChangedManagement(true); + // TODO Editor behavior. + // this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerUnactive(); + this.render(); + // TODO Editor behavior. + // this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerActive(); + } + destroy() { + // TODO Editor behavior. + // this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerUnactive(); + this.toggleVisibility(false); + this.setupSizeChangedManagement(false); + this.clearContent(); + // TODO Editor behavior. + // this.options.wysiwyg && this.options.wysiwyg.odooEditor.observerActive(); + } + clearContent() { + const templateAreaEl = this.el.querySelector(".dynamic_snippet_template"); + /* + this.stopInteraction(templateAreaEl); // TODO Support something like that. this.trigger_up('widgets_stop_request', { - $target: $templateArea, + target: templateAreaEl, }); - $templateArea.html(''); - }, + */ + templateAreaEl.replaceChildren(); + } /** * Method to be overridden in child components if additional configuration elements * are required in order to fetch data. - * @private */ - _isConfigComplete: function () { - return this.$el.get(0).dataset.filterId !== undefined && this.$el.get(0).dataset.templateKey !== undefined; - }, + isConfigComplete() { + return this.el.dataset.filterId !== undefined && this.el.dataset.templateKey !== undefined; + } /** * Method to be overridden in child components in order to provide a search * domain if needed. - * @private */ - _getSearchDomain: function () { + getSearchDomain() { return []; - }, + } /** * Method to be overridden in child components in order to add custom parameters if needed. - * @private */ - _getRpcParameters: function () { + getRpcParameters() { return {}; - }, + } /** * Fetches the data. - * @private */ - async _fetchData() { - if (this._isConfigComplete()) { + async fetchData() { + if (this.isConfigComplete()) { const nodeData = this.el.dataset; const filterFragments = await rpc( '/website/snippet/filters', @@ -122,10 +96,10 @@ const DynamicSnippet = publicWidget.Widget.extend({ 'filter_id': parseInt(nodeData.filterId), 'template_key': nodeData.templateKey, 'limit': parseInt(nodeData.numberOfRecords), - 'search_domain': this._getSearchDomain(), + 'search_domain': this.getSearchDomain(), 'with_sample': this.editableMode, }, - this._getRpcParameters(), + this.getRpcParameters(), JSON.parse(this.el.dataset?.customTemplateData || "{}") ) ); @@ -133,24 +107,22 @@ const DynamicSnippet = publicWidget.Widget.extend({ } else { this.data = []; } - }, + } /** * Method to be overridden in child components in order to prepare content * before rendering. - * @private */ - _prepareContent: function () { - this.renderedContent = renderToString( - this.template_key, - this._getQWebRenderOptions() + prepareContent() { + this.renderedContentEl = renderToElement( + this.templateKey, + this.getQWebRenderOptions() ); - }, + } /** * Method to be overridden in child components in order to prepare QWeb * options. - * @private */ - _getQWebRenderOptions: function () { + getQWebRenderOptions() { const dataset = this.el.dataset; const numberOfRecords = parseInt(dataset.numberOfRecords); let numberOfElements; @@ -163,52 +135,54 @@ const DynamicSnippet = publicWidget.Widget.extend({ return { chunkSize: chunkSize, data: this.data, - unique_id: this.unique_id, + unique_id: this.uniqueId, extraClasses: dataset.extraClasses || '', columnClasses: dataset.columnClasses || '', }; - }, - /** - * - * @private - */ - _render: function () { + } + render() { if (this.data.length > 0 || this.editableMode) { - this.$el.removeClass('o_dynamic_snippet_empty'); - this._prepareContent(); + this.el.classList.remove("o_dynamic_snippet_empty"); + this.prepareContent(); } else { - this.$el.addClass('o_dynamic_snippet_empty'); - this.renderedContent = ''; + this.el.classList.add("o_dynamic_snippet_empty"); + this.renderedContentEl = document.createTextNode(""); } - this._renderContent(); + this.renderContent(); + // TODO Support something like that + /* this.trigger_up('widgets_start_request', { - $target: this.$el.children(), + target: this.el.children(), options: {parent: this}, editableMode: this.editableMode, }); - }, - /** - * @private - */ - _renderContent: function () { - const $templateArea = this.$el.find('.dynamic_snippet_template'); + */ + } + renderContent() { + const templateAreaEl = this.el.querySelector(".dynamic_snippet_template"); + // TODO Support something like that + /* this.trigger_up('widgets_stop_request', { - $target: $templateArea, + target: templateAreaEl, }); - const mainPageUrl = this._getMainPageUrl(); + */ + const mainPageUrl = this.getMainPageUrl(); const allContentLink = this.el.querySelector(".s_dynamic_snippet_main_page_url"); if (allContentLink && mainPageUrl) { allContentLink.href = mainPageUrl; allContentLink.classList.remove("d-none"); } - $templateArea.html(this.renderedContent); + templateAreaEl.replaceChildren(this.renderedContentEl); // TODO this is probably not the only public widget which creates DOM // which should be attached to another public widget. Maybe a generic // method could be added to properly do this operation of DOM addition. + // TODO Support something like that + /* this.trigger_up('widgets_start_request', { - $target: $templateArea, + target: templateAreaEl, editableMode: this.editableMode, }); + */ // Same as above and probably should be done automatically for any // bootstrap behavior (apparently needed since BS 5.3): start potential // carousel in new content (according to their data-bs-ride and other @@ -216,7 +190,7 @@ const DynamicSnippet = publicWidget.Widget.extend({ // extension, because: why not? // (TODO review + See interaction with "slider" public widget). setTimeout(() => { - $templateArea[0].querySelectorAll('.carousel').forEach(carouselEl => { + templateAreaEl.querySelectorAll('.carousel').forEach(carouselEl => { if (carouselEl.dataset.bsInterval === "0") { delete carouselEl.dataset.bsRide; delete carouselEl.dataset.bsInterval; @@ -227,36 +201,32 @@ const DynamicSnippet = publicWidget.Widget.extend({ } }); }, 0); - }, + } /** * * @param {Boolean} enable - * @private */ - _setupSizeChangedManagement: function (enable) { + setupSizeChangedManagement(enable) { if (enable === true) { - this.removeSizeListener = listenSizeChange(this._onSizeChanged.bind(this)); + this.removeSizeListener = listenSizeChange(this.sizeChanged.bind(this)); } else if (this.removeSizeListener) { this.removeSizeListener(); delete this.removeSizeListener; } - }, + } /** * * @param visible - * @private */ - _toggleVisibility: function (visible) { - this.$el.toggleClass('o_dynamic_snippet_empty', !visible); - }, + toggleVisibility(visible) { + this.el.classList.toggle("o_dynamic_snippet_empty", !visible); + } /** * Returns the main URL of the module related to the active filter. - * - * @private */ - _getMainPageUrl() { + getMainPageUrl() { return ''; - }, + } //------------------------------------- ------------------------------------- // Handlers @@ -264,24 +234,19 @@ const DynamicSnippet = publicWidget.Widget.extend({ /** * Navigates to the call to action url. - * @private */ - _onCallToAction: function (ev) { - window.location = $(ev.currentTarget).attr('data-url'); - }, + callToAction(ev) { + window.location = ev.currentTarget.dataset.url; + } /** * Called when the size has reached a new bootstrap breakpoint. - * - * @private */ - _onSizeChanged: function () { + sizeChanged() { if (this.isDesplayedAsMobile !== uiUtils.isSmall()) { this.isDesplayedAsMobile = uiUtils.isSmall(); - this._render(); + this.render(); } - }, -}); - -publicWidget.registry.dynamic_snippet = DynamicSnippet; + } +} -export default DynamicSnippet; +registry.category("website.active_elements").add("website.dynamic_snippet", DynamicSnippet); diff --git a/addons/website/static/tests/interactions/snippets/dynamic_snippet.test.js b/addons/website/static/tests/interactions/snippets/dynamic_snippet.test.js new file mode 100644 index 0000000000000..49ac0df084138 --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/dynamic_snippet.test.js @@ -0,0 +1,74 @@ +import { expect, test } from "@odoo/hoot"; +import { animationFrame, click, scroll } from "@odoo/hoot-dom"; +import { advanceTime } from "@odoo/hoot-mock"; +import { + onRpc, +} from "@web/../tests/web_test_helpers"; +import { registry } from "@web/core/registry"; +import { Interaction } from "@website/core/interaction"; +import { startInteractions, setupInteractionWhiteList } from "../../core/helpers"; + +class TestItem extends Interaction { + static selector = ".s_test_item"; + + setup() { + this.el.dataset.started = `*${this.el.dataset.testParam}*`; + } +} +registry.category("website.active_elements").add("website.test_item", TestItem); + +setupInteractionWhiteList("website.dynamic_snippet", "website.test_item"); + +test("dynamic snippet loads items and displays them through template", async () => { + onRpc("/website/snippet/filters", async (args) => { + for await (const chunk of args.body) { + const json = JSON.parse(new TextDecoder().decode(chunk)); + expect(json.params.filter_id).toBe(1); + expect(json.params.template_key).toBe("website.dynamic_filter_template_test_item"); + expect(json.params.limit).toBe(16); + expect(json.params.search_domain).toEqual([]); + } + return [` +
+ Some test record +
+ `, ` +
+ Another test record +
+ `]; + }); + const { core, el } = await startInteractions(` +
+
+
+
+
+
+
+ Your Dynamic Snippet will be displayed here... This message is displayed because you did not provide both a filter and a template to use. +
+
+
+
+
+
+
+
+
+ `); + expect(core.interactions.length).toBe(1); + const contentEl = el.querySelector(".dynamic_snippet_template"); + const itemEls = contentEl.querySelectorAll(".s_test_item"); + expect(itemEls[0].dataset.testParam).toBe("test"); + expect(itemEls[1].dataset.testParam).toBe("test2"); + // TODO Make sure element interactions are started. + // expect(itemEls[0].dataset.started).toBe("*test*"); + // expect(itemEls[1].dataset.started).toBe("*test2*"); +});