diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index e5e730ee5480a..4cb7d1a0f07f3 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -236,6 +236,7 @@ 'web/static/src/legacy/js/public/public_root_instance.js', 'web/static/src/legacy/js/public/public_widget.js', 'web/static/src/legacy/js/public/signin.js', + 'web/static/src/legacy/js/public/interaction_util.js', ], 'web.assets_frontend_lazy': [ diff --git a/addons/web/static/src/legacy/js/public/interaction_util.js b/addons/web/static/src/legacy/js/public/interaction_util.js new file mode 100644 index 0000000000000..d7c4d1e7be167 --- /dev/null +++ b/addons/web/static/src/legacy/js/public/interaction_util.js @@ -0,0 +1,40 @@ + +export function buildEditableInteractions(builders) { + const result = []; + + const mixinPerInteraction = new Map(); + for (const makeEditable of builders) { + mixinPerInteraction.set(makeEditable.Interaction, makeEditable.mixin || ((C) => C)); + } + for (const makeEditable of builders) { + if (makeEditable.isAbstract) { + continue; + } + let I = makeEditable.Interaction; + // Collect mixins to up to Interaction class in reverse order. + const mixins = []; + while (I.name !== "Interaction") { + const mixin = mixinPerInteraction.get(I); + if (mixin === null) { + console.log(`No mixin defined for: ${I.name}`); + } else { + mixins.push(mixin); + } + I = I.__proto__; + } + // Apply mixins from top-most class. + let EI = makeEditable.Interaction; + while (mixins.length) { + EI = mixins.pop()(EI); + } + if (!EI.name) { + // if we get here, this is most likely because we have an anonymous + // class. To make it easier to work with, we can add the name property + // by doing a little hack + const name = makeEditable.Interaction.name + "__mixin"; + EI = {[name]: class extends EI {}} [name]; + } + result.push(EI); + } + return result; +} diff --git a/addons/web/static/src/legacy/js/public/public_root.js b/addons/web/static/src/legacy/js/public/public_root.js index 541015f717a4d..49c1b4dfd95f3 100644 --- a/addons/web/static/src/legacy/js/public/public_root.js +++ b/addons/web/static/src/legacy/js/public/public_root.js @@ -12,6 +12,7 @@ import { jsToPyLocale, pyToJsLocale } from "@web/core/l10n/utils"; import { App, Component, whenReady } from "@odoo/owl"; import { RPCError } from '@web/core/network/rpc'; import { registry } from "@web/core/registry"; +import { buildEditableInteractions } from "@web/legacy/js/public/interaction_util"; const { Settings } = luxon; @@ -24,46 +25,6 @@ function getLang() { const lang = cookie.get('frontend_lang') || getLang(); // FIXME the cookie value should maybe be in the ctx? -export function buildEditableInteractions(builders) { - const result = []; - - const mixinPerInteraction = new Map(); - for (const makeEditable of builders) { - mixinPerInteraction.set(makeEditable.Interaction, makeEditable.mixin || ((C) => C)); - } - for (const makeEditable of builders) { - if (makeEditable.isAbstract) { - continue; - } - let I = makeEditable.Interaction; - // Collect mixins to up to Interaction class in reverse order. - const mixins = []; - while (I.name !== "Interaction") { - const mixin = mixinPerInteraction.get(I); - if (mixin === null) { - console.log(`No mixin defined for: ${I.name}`); - } else { - mixins.push(mixin); - } - I = I.__proto__; - } - // Apply mixins from top-most class. - let EI = makeEditable.Interaction; - while (mixins.length) { - EI = mixins.pop()(EI); - } - if (!EI.name) { - // if we get here, this is most likely because we have an anonymous - // class. To make it easier to work with, we can add the name property - // by doing a little hack - const name = makeEditable.Interaction.name + "__mixin"; - EI = {[name]: class extends EI {}} [name]; - } - result.push(EI); - } - return result; -} - /** * Element which is designed to be unique and that will be the top-most element * in the widget hierarchy. So, all other widgets will be indirectly linked to diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 2ef7e336c418e..e575b82329dda 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -290,6 +290,7 @@ 'website/static/tests/redirect_field_tests.js', ], 'web.assets_unit_tests': [ + 'web/static/src/legacy/js/public/interaction_util.js', 'website/static/tests/core/**/*', 'website/static/tests/interactions/**/*', ], diff --git a/addons/website/static/src/interactions/animation.js b/addons/website/static/src/interactions/animation.js index b846f25f645aa..d5f946435960c 100644 --- a/addons/website/static/src/interactions/animation.js +++ b/addons/website/static/src/interactions/animation.js @@ -192,6 +192,8 @@ export class Animation extends Interaction { } } +registry.category("active_elements").add("website.animation", Animation); + registry .category("website.editable_active_elements_builders") .add("website.animation", { diff --git a/addons/website/static/src/snippets/s_website_form/form.edit.js b/addons/website/static/src/snippets/s_website_form/form.edit.js new file mode 100644 index 0000000000000..df97d5a24dc89 --- /dev/null +++ b/addons/website/static/src/snippets/s_website_form/form.edit.js @@ -0,0 +1,45 @@ +import { registry } from "@web/core/registry"; +import { + formatDate, + formatDateTime, +} from "@web/core/l10n/dates"; +const { DateTime } = luxon; +import { Form } from "./form"; + +const FormEdit = I => class extends I { + setup() { + super.setup(); + // TODO Translation behavior. + this.editTranslations = false; + // this.editTranslations = !!this.getContext(true).edit_translations; + } + + prepareDateFields() { + // We do not initialize the datetime picker in edit mode but want the dates to be formated + this.el.querySelectorAll(".s_website_form_input.datetimepicker-input").forEach(el => { + const value = el.getAttribute("value"); + if (value) { + const format = + el.closest(".s_website_form_field").dataset.type === "date" + ? formatDate + : formatDateTime; + el.value = format(DateTime.fromSeconds(parseInt(value))); + } + }); + // Do not call super ! + } + + prefillValues() { + if (this.editTranslations) { + return; + } + super.prefillValues(); + } +}; + +registry + .category("website.editable_active_elements_builders") + .add("website.form", { + Interaction: Form, + mixin: FormEdit, + }); diff --git a/addons/website/static/src/snippets/s_website_form/form.js b/addons/website/static/src/snippets/s_website_form/form.js index 6a803fa13ba53..ed29e36f818b3 100644 --- a/addons/website/static/src/snippets/s_website_form/form.js +++ b/addons/website/static/src/snippets/s_website_form/form.js @@ -1,4 +1,4 @@ -import {ReCaptcha} from "@google_recaptcha/js/recaptcha"; +import { ReCaptcha } from "@google_recaptcha/js/recaptcha"; import { session } from "@web/session"; import { registry } from "@web/core/registry"; import { Interaction } from "@website/core/interaction"; @@ -23,28 +23,6 @@ const DEBOUNCE = 400; const { DateTime } = luxon; import wUtils from "@website/js/utils"; -// TODO Editor behavior. -/* -publicWidget.registry.EditModeWebsiteForm = publicWidget.Widget.extend({ - selector: ".s_website_form form, form.s_website_form", // !compatibility - disabledInEditableMode: false, - start: function () { - if (this.editableMode) { - // We do not initialize the datetime picker in edit mode but want the dates to be formated - this.el.querySelectorAll(".s_website_form_input.datetimepicker-input").forEach(el => { - const value = el.getAttribute("value"); - if (value) { - const format = - el.closest(".s_website_form_field").dataset.type === "date" - ? formatDate - : formatDateTime; - el.value = format(DateTime.fromSeconds(parseInt(value))); - } - }); - } - }, -}); -*/ export class Form extends Interaction { static selector = ".s_website_form form, form.s_website_form"; // !compatibility @@ -79,12 +57,9 @@ export class Form extends Interaction { this.visibilityFunctionByFieldName = new Map(); this.visibilityFunctionByFieldEl = new Map(); this.disabledStates = new Map(); - this.inputEls = undefined; - this.dateFieldEls = undefined; + this.inputEls = this.el.querySelectorAll(".s_website_form_field.s_website_form_field_hidden_if .s_website_form_input"); + this.dateFieldEls = this.el.querySelectorAll(".s_website_form_datetime, .o_website_form_datetime, .s_website_form_date, .o_website_form_date"); this.disableDateTimePickers = []; - // TODO Translation behavior. - this.editTranslations = false; - // this.editTranslations = !!this.getContext(true).edit_translations; this.preFillValues = {}; } async willStart() { @@ -120,82 +95,9 @@ export class Form extends Interaction { } } start() { - this.dateFieldEls = this.el.querySelectorAll(".s_website_form_datetime, .o_website_form_datetime, .s_website_form_date, .o_website_form_date"); - if (!this.editableMode) { - for (const fieldEl of this.dateFieldEls) { - const inputEl = fieldEl.querySelector("input"); - const defaultValue = inputEl.getAttribute("value"); - this.disableDateTimePickers.push(this.services.datetime_picker.create({ - target: inputEl, - onChange: () => inputEl.dispatchEvent(new Event("input", { bubbles: true })), - pickerProps: { - type: fieldEl.matches(".s_website_form_date, .o_website_form_date") ? "date" : "datetime", - value: defaultValue && DateTime.fromSeconds(parseInt(defaultValue)), - }, - }).enable()); - } - for (const fieldEl of this.dateFieldEls) { - fieldEl.classList.add("s_website_form_datepicker_initialized"); - } - } + this.prepareDateFields(); + this.prefillValues(); - // Display form values from tag having data-for attribute - // It's necessary to handle field values generated on server-side - // Because, using t-att- inside form make it non-editable - // Data-fill-with attribute is given during registry and is used by - // to know which user data should be used to prfill fields. - let dataForValues = wUtils.getParsedDataFor(this.el.id, document); - // TODO Translation behavior. - // On the "edit_translations" mode, a with a translated term - // will replace the attribute value, leading to some inconsistencies - // (setting again the on the attributes after the editor's - // cleanup, setting wrong values on the attributes after translating - // default values...) - if (!this.editTranslations - && (dataForValues || Object.keys(this.preFillValues).length)) { - dataForValues = dataForValues || {}; - const fieldNames = [...this.el.querySelectorAll("[name]")].map( - (el) => el.name - ); - // All types of inputs do not have a value property (eg:hidden), - // for these inputs any function that is supposed to put a value - // property actually puts a HTML value attribute. Because of - // this, we have to clean up these values at destroy or else the - // data loaded here could become default values. We could set - // the values to submit() for these fields but this could break - // customizations that use the current behavior as a feature. - for (const name of fieldNames) { - const fieldEl = this.el.querySelector(`[name="${CSS.escape(name)}"]`); - - // In general, we want the data-for and prefill values to - // take priority over set default values. The 'email_to' - // field is however treated as an exception at the moment - // so that values set by users are always used. - if (name === "email_to" && fieldEl.value - // The following value is the default value that - // is set if the form is edited in any way. (see the - // @website/js/form_editor_registry module in editor - // assets bundle). - // TODO that value should probably never be forced - // unless explicitely manipulated by the user or on - // custom form addition but that seems risky to - // change as a stable fix. - && fieldEl.value !== "info@yourcompany.example.com") { - continue; - } - - let newValue; - if (dataForValues && dataForValues[name]) { - newValue = dataForValues[name]; - } else if (this.preFillValues[fieldEl.dataset.fillWith]) { - newValue = this.preFillValues[fieldEl.dataset.fillWith]; - } - if (newValue) { - this.initialValues.set(fieldEl, fieldEl.getAttribute("value")); - fieldEl.value = newValue; - } - } - } // Visibility might need to be adapted according to pre-filled values. this.updateContent(); @@ -207,7 +109,6 @@ export class Form extends Interaction { }); } // Check disabled states - this.inputEls = this.el.querySelectorAll(".s_website_form_field.s_website_form_field_hidden_if .s_website_form_input"); for (const inputEl of this.inputEls) { this.disabledStates[inputEl] = inputEl.disabled; } @@ -285,6 +186,82 @@ export class Form extends Interaction { } } + prepareDateFields() { + for (const fieldEl of this.dateFieldEls) { + const inputEl = fieldEl.querySelector("input"); + const defaultValue = inputEl.getAttribute("value"); + this.disableDateTimePickers.push(this.services.datetime_picker.create({ + target: inputEl, + onChange: () => inputEl.dispatchEvent(new Event("input", { bubbles: true })), + pickerProps: { + type: fieldEl.matches(".s_website_form_date, .o_website_form_date") ? "date" : "datetime", + value: defaultValue && DateTime.fromSeconds(parseInt(defaultValue)), + }, + }).enable()); + } + for (const fieldEl of this.dateFieldEls) { + fieldEl.classList.add("s_website_form_datepicker_initialized"); + } + } + + prefillValues() { + // Display form values from tag having data-for attribute + // It's necessary to handle field values generated on server-side + // Because, using t-att- inside form make it non-editable + // Data-fill-with attribute is given during registry and is used by + // to know which user data should be used to prfill fields. + let dataForValues = wUtils.getParsedDataFor(this.el.id, document); + // On the "edit_translations" mode, a with a translated term + // will replace the attribute value, leading to some inconsistencies + // (setting again the on the attributes after the editor's + // cleanup, setting wrong values on the attributes after translating + // default values...) + if (dataForValues || Object.keys(this.preFillValues).length) { + dataForValues = dataForValues || {}; + const fieldNames = [...this.el.querySelectorAll("[name]")].map( + (el) => el.name + ); + // All types of inputs do not have a value property (eg:hidden), + // for these inputs any function that is supposed to put a value + // property actually puts a HTML value attribute. Because of + // this, we have to clean up these values at destroy or else the + // data loaded here could become default values. We could set + // the values to submit() for these fields but this could break + // customizations that use the current behavior as a feature. + for (const name of fieldNames) { + const fieldEl = this.el.querySelector(`[name="${CSS.escape(name)}"]`); + + // In general, we want the data-for and prefill values to + // take priority over set default values. The 'email_to' + // field is however treated as an exception at the moment + // so that values set by users are always used. + if (name === "email_to" && fieldEl.value + // The following value is the default value that + // is set if the form is edited in any way. (see the + // @website/js/form_editor_registry module in editor + // assets bundle). + // TODO that value should probably never be forced + // unless explicitely manipulated by the user or on + // custom form addition but that seems risky to + // change as a stable fix. + && fieldEl.value !== "info@yourcompany.example.com") { + continue; + } + + let newValue; + if (dataForValues && dataForValues[name]) { + newValue = dataForValues[name]; + } else if (this.preFillValues[fieldEl.dataset.fillWith]) { + newValue = this.preFillValues[fieldEl.dataset.fillWith]; + } + if (newValue) { + this.initialValues.set(fieldEl, fieldEl.getAttribute("value")); + fieldEl.value = newValue; + } + } + } + } + async send(e) { e.preventDefault(); // Prevent the default submit behavior // Prevent users from crazy clicking diff --git a/addons/website/static/tests/core/helpers.js b/addons/website/static/tests/core/helpers.js index 33929429755ef..a413622d89de0 100644 --- a/addons/website/static/tests/core/helpers.js +++ b/addons/website/static/tests/core/helpers.js @@ -6,6 +6,7 @@ import { } from "@web/../tests/web_test_helpers"; import { defineMailModels } from "@mail/../tests/mail_test_helpers"; import { registry } from "@web/core/registry"; +import { buildEditableInteractions } from "@web/legacy/js/public/interaction_util"; let activeInteractions = null; let elementRegistry = registry.category("website.active_elements"); @@ -29,7 +30,7 @@ export async function startInteraction(I, html, options) { export async function startInteractions( html, - options = { waitForStart: true }, + options = { waitForStart: true, editMode: false }, ) { defineMailModels(); const fixture = getFixture(); @@ -49,6 +50,17 @@ export async function startInteractions( } const env = await makeMockEnv(); const core = env.services.website_core; + if (options.editMode) { + core.stopInteractions(); + const builders = registry.category("website.editable_active_elements_builders").getEntries(); + for (const [key, builder] of builders) { + if (activeInteractions && !activeInteractions.includes(key)) { + builder.isAbstract = true; + } + } + const editableInteractions = buildEditableInteractions(builders.map((builder) => builder[1])); + core.activate(editableInteractions); + } if (options.waitForStart) { await core.isReady; } diff --git a/addons/website/static/tests/interactions/snippets/form.edit.test.js b/addons/website/static/tests/interactions/snippets/form.edit.test.js new file mode 100644 index 0000000000000..33af96094bd6e --- /dev/null +++ b/addons/website/static/tests/interactions/snippets/form.edit.test.js @@ -0,0 +1,52 @@ +import { expect, test } from "@odoo/hoot"; +import { animationFrame } from "@odoo/hoot-dom"; +import { advanceTime, Deferred } from "@odoo/hoot-mock"; +import { MockServer, onRpc, patchWithCleanup, webModels } from "@web/../tests/web_test_helpers"; +import { + startInteractions, + setupInteractionWhiteList, +} from "../../core/helpers"; + +setupInteractionWhiteList("website.form"); + +test("form formats date in edit mode", async () => { + const { core, el } = await startInteractions(` +
+
+
+
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+ + Submit +
+
+ +
+
+
+ `, { editMode: true }); + expect(core.interactions.length).toBe(1); + const formEl = el.querySelector("form"); + const dateEl = el.querySelector("input[name=When]"); + expect(dateEl.value).toBe("01/01/2025 10:00:00"); + // Verify that non-edit code did not run. + const dateField = dateEl.closest(".s_website_form_datetime"); + expect(dateField).not.toHaveClass("s_website_form_datepicker_initialized"); +});