From 9e343e409494f0dbffc2fe683144d809417b7cee Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Tue, 11 Mar 2025 10:20:21 +0100 Subject: [PATCH 1/3] form options --- addons/html_builder/__manifest__.py | 3 + .../static/src/core/builder_actions_plugin.js | 2 +- .../static/src/utils/sync_cache.js | 20 + .../form/dev_website_sale_form_editor.js | 45 ++ .../plugins/form/form_action_fields_option.js | 26 + .../plugins/form/form_option.inside.scss | 12 + .../plugins/form/form_option.js | 70 +++ .../plugins/form/form_option.xml | 74 +++ .../plugins/form/form_option_plugin.js | 467 ++++++++++++++++++ .../src/website_builder/plugins/form/utils.js | 150 ++++++ .../static/src/core/history_plugin.js | 7 + addons/website/__manifest__.py | 1 - .../static/src/js/form_editor_registry.js | 3 - .../website/static/src/js/send_mail_form.js | 4 +- .../src/snippets/s_website_form/options.js | 9 +- .../static/src/js/website_crm_editor.js | 4 +- .../src/js/website_hr_recruitment_editor.js | 4 +- .../static/src/js/mass_mailing_form_editor.js | 4 +- .../static/src/js/website_project_editor.js | 4 +- .../static/src/js/website_sale_form_editor.js | 4 +- 20 files changed, 892 insertions(+), 21 deletions(-) create mode 100644 addons/html_builder/static/src/utils/sync_cache.js create mode 100644 addons/html_builder/static/src/website_builder/plugins/form/dev_website_sale_form_editor.js create mode 100644 addons/html_builder/static/src/website_builder/plugins/form/form_action_fields_option.js create mode 100644 addons/html_builder/static/src/website_builder/plugins/form/form_option.inside.scss create mode 100644 addons/html_builder/static/src/website_builder/plugins/form/form_option.js create mode 100644 addons/html_builder/static/src/website_builder/plugins/form/form_option.xml create mode 100644 addons/html_builder/static/src/website_builder/plugins/form/form_option_plugin.js create mode 100644 addons/html_builder/static/src/website_builder/plugins/form/utils.js delete mode 100644 addons/website/static/src/js/form_editor_registry.js diff --git a/addons/html_builder/__manifest__.py b/addons/html_builder/__manifest__.py index f431bbf22d402..5f354c6d6e9e7 100644 --- a/addons/html_builder/__manifest__.py +++ b/addons/html_builder/__manifest__.py @@ -29,6 +29,9 @@ 'assets': { 'web.assets_backend': [ 'html_builder/static/src/website_preview/**/*', + 'website/static/src/xml/website_form_editor.xml', + # TODO Remove the module's form js - this is for testing. + 'website/static/src/js/send_mail_form.js', ], # this bundle is lazy loaded when the editor is ready 'html_builder.assets': [ diff --git a/addons/html_builder/static/src/core/builder_actions_plugin.js b/addons/html_builder/static/src/core/builder_actions_plugin.js index 17c78a8d608cc..b53dde73ed7a7 100644 --- a/addons/html_builder/static/src/core/builder_actions_plugin.js +++ b/addons/html_builder/static/src/core/builder_actions_plugin.js @@ -18,7 +18,7 @@ export class BuilderActionsPlugin extends Plugin { for (const actions of this.getResource("builder_actions")) { for (const [actionId, action] of Object.entries(actions)) { if (actionId in this.actions) { - throw new Error(`Duplicate builder action id: ${action.id}`); + throw new Error(`Duplicate builder action id: ${actionId}`); } this.actions[actionId] = { id: actionId, ...action }; } diff --git a/addons/html_builder/static/src/utils/sync_cache.js b/addons/html_builder/static/src/utils/sync_cache.js new file mode 100644 index 0000000000000..28e8999abf437 --- /dev/null +++ b/addons/html_builder/static/src/utils/sync_cache.js @@ -0,0 +1,20 @@ +import { Cache } from "@web/core/utils/cache"; + +export class SyncCache { + constructor(fn) { + this.asyncCache = new Cache(fn, JSON.stringify); + this.syncCache = new Map(); + } + async preload(params) { + const result = await this.asyncCache.read(params); + this.syncCache.set(params, result); + return result; + } + get(params) { + return this.syncCache.get(params); + } + invalidate() { + this.asyncCache.invalidate(); + this.syncCache.clear(); + } +} diff --git a/addons/html_builder/static/src/website_builder/plugins/form/dev_website_sale_form_editor.js b/addons/html_builder/static/src/website_builder/plugins/form/dev_website_sale_form_editor.js new file mode 100644 index 0000000000000..71c86f61a1466 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/form/dev_website_sale_form_editor.js @@ -0,0 +1,45 @@ +// TODO Delete me when moving to website ! This is about checking that other modules can work too. +// This is a copy of website_sale_form_editor.js which cannot be accessed from this module. +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; + +registry.category("website.form_editor_actions").add("create_customer", { + formFields: [ + { + type: "char", + modelRequired: true, + name: "name", + fillWith: "name", + string: _t("Your Name"), + }, + { + type: "email", + required: true, + fillWith: "email", + name: "email", + string: _t("Your Email"), + }, + { + type: "tel", + fillWith: "phone", + name: "phone", + string: _t("Phone Number"), + }, + { + type: "char", + name: "company_name", + fillWith: "commercial_company_name", + string: _t("Company Name"), + }, + ], + fields: [ + { + name: "author_id", + type: "many2one", + relation: "res.users", + domain: [], + string: _t("Author"), + title: _t("Author."), + }, + ], +}); diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_action_fields_option.js b/addons/html_builder/static/src/website_builder/plugins/form/form_action_fields_option.js new file mode 100644 index 0000000000000..bacfa1c09e1b0 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_action_fields_option.js @@ -0,0 +1,26 @@ +import { onWillStart, onWillUpdateProps, useState } from "@odoo/owl"; +import { BaseOptionComponent } from "@html_builder/core/utils"; + +export class FormActionFieldsOption extends BaseOptionComponent { + static template = "html_builder.website.s_website_form_form_action_fields_option"; + static props = { + activeForm: Object, + prepareFormModel: Function, + }; + + setup() { + super.setup(); + this.state = useState({ + formInfo: { + fields: [], + }, + }); + onWillStart(this.getFormInfo.bind(this)); + onWillUpdateProps(this.getFormInfo.bind(this)); + } + async getFormInfo(props = this.props) { + const el = this.env.getEditingElement(); + const formInfo = await this.props.prepareFormModel(el, props.activeForm); + Object.assign(this.state.formInfo, formInfo); + } +} diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_option.inside.scss b/addons/html_builder/static/src/website_builder/plugins/form/form_option.inside.scss new file mode 100644 index 0000000000000..94ff20c877cdc --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_option.inside.scss @@ -0,0 +1,12 @@ +.o_builder_form_show_message { + &.d-none { + display: block !important; + } + &:not(.d-none) { + display: none !important; + } +} + +.s_website_form input:not(.o_translatable_attribute) { + pointer-events: none; +} \ No newline at end of file diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_option.js b/addons/html_builder/static/src/website_builder/plugins/form/form_option.js new file mode 100644 index 0000000000000..bd28a36205577 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_option.js @@ -0,0 +1,70 @@ +import { BaseOptionComponent, useDomState } from "@html_builder/core/utils"; +import { onWillStart } from "@odoo/owl"; +import { getParsedDataFor } from "./utils"; +import { FormActionFieldsOption } from "./form_action_fields_option"; +import { session } from "@web/session"; + +export class FormOption extends BaseOptionComponent { + static template = "html_builder.website.s_website_form_form_option"; + static props = { + fetchModels: Function, + prepareFormModel: Function, + fetchFieldRecords: Function, + applyFormModel: Function, + }; + static components = { FormActionFieldsOption }; + + setup() { + super.setup(); + this.hasRecaptchaKey = !!session.recaptcha_public_key; + + // Get potential message + const el = this.env.getEditingElement(); + this.messageEl = el.parentElement.querySelector(".s_website_form_end_message"); + this.showEndMessage = false; + onWillStart(async () => { + // Hide change form parameters option for forms + // e.g. User should not be enable to change existing job application form + // to opportunity form in 'Apply job' page. + this.modelCantChange = !!el.getAttribute("hide-change-model"); + + // Get list of website_form compatible models. + this.models = await this.props.fetchModels(); + + const targetModelName = el.dataset.model_name || "mail.mail"; + this.domState.activeForm = this.models.find((m) => m.model === targetModelName); + + // If the form has no model it means a new snippet has been dropped. + // Apply the default model selected in willStart on it. + if (!el.dataset.model_name) { + const formInfo = await this.props.prepareFormModel(el, this.domState.activeForm); + this.props.applyFormModel( + el, + this.domState.activeForm, + this.domState.activeForm.id, + formInfo + ); + } + }); + this.domState = useDomState((el) => { + if (!this.models) { + return { + activeForm: {}, + }; + } + const targetModelName = el.dataset.model_name || "mail.mail"; + const activeForm = this.models.find((m) => m.model === targetModelName); + return { + activeForm, + }; + }); + // Get the email_to value from the data-for attribute if it exists. We + // use it if there is no value on the email_to input. + const formId = el.id; + const dataForValues = getParsedDataFor(formId, el.ownerDocument); + if (dataForValues) { + this.dataForEmailTo = dataForValues["email_to"]; + } + this.defaultEmailToValue = "info@yourcompany.example.com"; + } +} diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_option.xml b/addons/html_builder/static/src/website_builder/plugins/form/form_option.xml new file mode 100644 index 0000000000000..31eef8dbaeb18 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_option.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + None + Required + Optional + + + + + + + + + + + Nothing + Redirect + Show Message + + + + + + + + + + + + + + + + + + None + + + + + + + + + + + + + + + + diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_option_plugin.js b/addons/html_builder/static/src/website_builder/plugins/form/form_option_plugin.js new file mode 100644 index 0000000000000..a723b5ba3bd3b --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_option_plugin.js @@ -0,0 +1,467 @@ +import { registry } from "@web/core/registry"; +import { Cache } from "@web/core/utils/cache"; +import { Plugin } from "@html_editor/plugin"; +import { FormOption } from "./form_option"; +import { getDefaultFormat, getMark, isOptionalMark, isRequiredMark, renderField } from "./utils"; +import { SyncCache } from "@html_builder/utils/sync_cache"; +import { renderToElement } from "@web/core/utils/render"; + +export class FormOptionPlugin extends Plugin { + static id = "websiteFormOption"; + static dependencies = ["builderActions", "builder-options"]; + resources = { + builder_options: [ + { + OptionComponent: FormOption, + props: { + fetchModels: this.fetchModels.bind(this), + prepareFormModel: this.prepareFormModel.bind(this), + fetchFieldRecords: this.fetchFieldRecords.bind(this), + applyFormModel: this.applyFormModel.bind(this), + }, + selector: ".s_website_form", + applyTo: "form", + }, + ], + builder_actions: this.getActions(), + system_classes: ["o_builder_form_show_message"], + normalize_handlers: (el) => { + for (const formEl of el.querySelectorAll(".s_website_form form")) { + // Disable text edition + formEl.contentEditable = "false"; + // Identify editable elements of the form: buttons, description, + // recaptcha and columns which are not fields. + const formEditableSelector = [ + ".s_website_form_send", + ".s_website_form_field_description", + ".s_website_form_recaptcha", + ".row > div:not(.s_website_form_field, .s_website_form_submit, .s_website_form_field *, .s_website_form_submit *)", + ] + .map((selector) => `:scope ${selector}`) + .join(", "); + for (const formEditableEl of formEl.querySelectorAll(formEditableSelector)) { + formEditableEl.contentEditable = "true"; + } + } + }, + clean_for_save_handlers: ({ root: el }) => { + // Maybe useless if all contenteditable are removed + for (const formEl of el.querySelectorAll(".s_website_form form")) { + formEl.removeAttribute("contenteditable"); + } + }, + }; + getActions() { + return { + // Components that use this action MUST await fetchModels before they start. + selectAction: { + load: async ({ editingElement: el, value: modelId }) => { + const modelCantChange = !!el.getAttribute("hide-change-model"); + if (modelCantChange) { + return; + } + const activeForm = this.modelsCache + .get() + .find((model) => model.id === parseInt(modelId)); + return { activeForm, formInfo: await this.prepareFormModel(el, activeForm) }; + }, + apply: ({ editingElement: el, value: modelId, loadResult }) => { + if (!loadResult) { + return; + } + this.applyFormModel( + el, + loadResult.activeForm, + parseInt(modelId), + loadResult.formInfo + ); + }, + isApplied: ({ editingElement: el, value: modelId }) => { + const models = this.modelsCache.get(); + const targetModelName = el.dataset.model_name || "mail.mail"; + const activeForm = models.find((m) => m.model === targetModelName); + return parseInt(modelId) === activeForm.id; + }, + }, + // Select the value of a field (hidden) that will be used on the model as a preset. + // ie: The Job you apply for if the form is on that job's page. + addActionField: { + load: async ({ editingElement: el }) => this.fetchAuthorizedFields(el), + apply: ({ editingElement: el, value, param, loadResult: authorizedFields }) => { + // Remove old property fields. + for (const [fieldName, field] of Object.entries(authorizedFields)) { + if (field._property) { + for (const inputEl of el.querySelectorAll(`[name="${fieldName}"]`)) { + inputEl.closest(".s_website_form_field").remove(); + } + } + } + const fieldName = param.fieldName; + if (param.isSelect === "true") { + value = parseInt(value); + } + this.addHiddenField(el, value, fieldName); + }, + // TODO clear ? if field is a boolean ? + getValue: ({ editingElement: el, param }) => { + // TODO Convert + const value = el.querySelector( + `.s_website_form_dnone input[name="${param.fieldName}"]` + )?.value; + if (param.fieldName === "email_to") { + // For email_to, we try to find a value in this order: + // 1. The current value of the input + // 2. The data-for value if it exists + // 3. The default value (`defaultEmailToValue`) + if (value && value !== this.defaultEmailToValue) { + return value; + } + return this.dataForEmailTo || this.defaultEmailToValue; + } + if (value) { + return value; + } else { + return param.isSelect ? "0" : ""; + } + }, + isApplied: ({ editingElement, param, value }) => { + const getAction = this.dependencies.builderActions.getAction; + const currentValue = getAction("addActionField").getValue({ + editingElement, + param, + }); + return currentValue === value; + }, + }, + promptSaveRedirect: { + apply: ({ editingElement: el }) => { + // TODO Convert after reload-related operations are available + /* + return new Promise((resolve, reject) => { + const message = _t("Would you like to save before being redirected? Unsaved changes will be discarded."); + this.dialog.add(ConfirmationDialog, { + body: message, + confirmLabel: _t("Save"), + confirm: () => { + this.env.requestSave({ + reload: false, + onSuccess: () => { + this._redirectToAction(value); + }, + onFailure: () => { + this.notification.add(_t("Something went wrong."), { + type: 'danger', + sticky: true, + }); + }, + }); + resolve(); + }, + cancel: () => resolve(), + }); + }); + */ + }, + }, + updateLabelsMark: { + apply: ({ editingElement: el }) => { + this.setLabelsMark(el); + }, + isApplied: () => true, + }, + setMark: { + apply: ({ editingElement: el, value }) => { + el.dataset.mark = value.trim(); + this.setLabelsMark(el); + }, + getValue: ({ editingElement: el }) => { + const mark = getMark(el); + return mark; + }, + }, + onSuccess: { + apply: ({ editingElement: el, value }) => { + el.dataset.successMode = value; + let messageEl = el.parentElement.querySelector(".s_website_form_end_message"); + if (value === "message") { + if (!messageEl) { + messageEl = renderToElement("website.s_website_form_end_message"); + el.insertAdjacentElement("afterend", messageEl); + } + } else { + messageEl?.remove(); + messageEl?.classList.remove("o_builder_form_show_message"); + el.classList.remove("o_builder_form_show_message"); + } + }, + isApplied: ({ editingElement: el, value }) => { + const currentValue = el.dataset.successMode; + return currentValue === value; + }, + }, + toggleEndMessage: { + apply: ({ editingElement: el }) => { + const messageEl = el.parentElement.querySelector(".s_website_form_end_message"); + messageEl.classList.add("o_builder_form_show_message"); + el.classList.add("o_builder_form_show_message"); + this.dependencies["builder-options"].updateContainers(messageEl); + }, + clean: ({ editingElement: el }) => { + const messageEl = el.parentElement.querySelector(".s_website_form_end_message"); + messageEl.classList.remove("o_builder_form_show_message"); + el.classList.remove("o_builder_form_show_message"); + this.dependencies["builder-options"].updateContainers(el); + }, + isApplied: ({ editingElement: el, value }) => + el.classList.contains("o_builder_form_show_message"), + }, + formToggleRecaptchaLegal: { + apply: ({ editingElement: el }) => { + const labelWidth = el.querySelector(".s_website_form_label").style.width; + const legalEl = renderToElement("website.s_website_form_recaptcha_legal", { + labelWidth: labelWidth, + }); + legalEl.setAttribute("contentEditable", true); + el.querySelector(".s_website_form_submit").insertAdjacentElement( + "beforebegin", + legalEl + ); + }, + clean: ({ editingElement: el }) => { + const recaptchaLegalEl = el.querySelector(".s_website_form_recaptcha"); + recaptchaLegalEl.remove(); + }, + isApplied: ({ editingElement: el }) => { + const recaptchaLegalEl = el.querySelector(".s_website_form_recaptcha"); + return !!recaptchaLegalEl; + }, + }, + }; + } + setup() { + this.modelsCache = new SyncCache(this._fetchModels.bind(this)); + this.fieldRecordsCache = new SyncCache(this._fetchFieldRecords.bind(this)); + this.authorizedFieldsCache = new Cache( + this._fetchAuthorizedFields.bind(this), + ({ cacheKey }) => cacheKey + ); + } + destroy() { + super.destroy(); + this.modelsCache.invalidate(); + this.fieldRecordsCache.invalidate(); + this.authorizedFieldsCache.invalidate(); + } + async fetchModels() { + return this.modelsCache.preload(); + } + async _fetchModels() { + return await this.services.orm.call("ir.model", "get_compatible_form_models"); + } + async fetchFieldRecords(field) { + return this.fieldRecordsCache.preload(field); + } + /** + * Returns a promise which is resolved once the records of the field + * have been retrieved. + * + * @param {Object} field + * @returns {Promise} + */ + async _fetchFieldRecords(field) { + // Convert the required boolean to a value directly usable + // in qweb js to avoid duplicating this in the templates + field.required = field.required ? 1 : null; + + if (field.records) { + return field.records; + } + if (field._property && field.type === "tags") { + // Convert tags to records to avoid added complexity. + // Tag ids need to escape "," to be able to recover their value on + // the server side if they contain ",". + field.records = field.tags.map((tag) => ({ + id: tag[0].replaceAll("\\", "\\/").replaceAll(",", "\\,"), + display_name: tag[1], + })); + } else if (field._property && field.comodel) { + field.records = await this.services.orm.searchRead(field.comodel, field.domain || [], [ + "display_name", + ]); + } else if (field.type === "selection") { + // Set selection as records to avoid added complexity. + field.records = field.selection.map((el) => ({ + id: el[0], + display_name: el[1], + })); + } else if (field.relation && field.relation !== "ir.attachment") { + const fieldNames = field.fieldName ? [field.fieldName] : ["display_name"]; + field.records = await this.services.orm.searchRead( + field.relation, + field.domain || [], + fieldNames + ); + if (field.fieldName) { + field.records.forEach((r) => (r["display_name"] = r[field.fieldName])); + } + } + return field.records; + } + async prepareFormModel(el, activeForm) { + const formKey = activeForm.website_form_key; + const formInfo = registry.category("website.form_editor_actions").get(formKey, null); + if (formInfo) { + const formatInfo = getDefaultFormat(el); + await formInfo.formFields.forEach(async (field) => { + field.formatInfo = formatInfo; + await this.fetchFieldRecords(field); + }); + await this.fetchFormInfoFields(formInfo); + } + return formInfo; + } + /** + * Add a hidden field to the form + * + * @param {HTMLElement} el + * @param {string} value + * @param {string} fieldName + */ + addHiddenField(el, value, fieldName) { + for (const hiddenEl of el.querySelectorAll( + `.s_website_form_dnone:has(input[name="${fieldName}"])` + )) { + hiddenEl.remove(); + } + // For the email_to field, we keep the field even if it has no value so + // that the email is sent to data-for value or to the default email. + if (fieldName === "email_to" && !value && !this.dataForEmailTo) { + value = this.defaultEmailToValue; + } + if (value || fieldName === "email_to") { + const hiddenField = renderToElement("website.form_field_hidden", { + field: { + name: fieldName, + value: value, + dnone: true, + formatInfo: {}, + }, + }); + el.querySelector(".s_website_form_submit").insertAdjacentElement( + "beforebegin", + hiddenField + ); + } + } + /** + * Apply the model on the form changing its fields + * + * @param {HTMLElement} el + * @param {Object} activeForm + * @param {Integer} modelId + * @param {Object} formInfo obtained from prepareFormModel + */ + applyFormModel(el, activeForm, modelId, formInfo) { + let oldFormInfo; + if (modelId) { + const oldFormKey = activeForm.website_form_key; + if (oldFormKey) { + oldFormInfo = registry + .category("website.form_editor_actions") + .get(oldFormKey, null); + } + for (const fieldEl of el.querySelectorAll(".s_website_form_field")) { + fieldEl.remove(); + } + activeForm = this.modelsCache.get().find((model) => model.id === modelId); + } + // Success page + if (!el.dataset.successMode) { + el.dataset.successMode = "redirect"; + } + if (el.dataset.successMode === "redirect") { + const currentSuccessPage = el.dataset.successPage; + if (formInfo && formInfo.successPage) { + el.dataset.successPage = formInfo.successPage; + } else if ( + !oldFormInfo || + (oldFormInfo !== formInfo && + oldFormInfo.successPage && + currentSuccessPage === oldFormInfo.successPage) + ) { + el.dataset.successPage = "/contactus-thank-you"; + } + } + // Model name + el.dataset.model_name = activeForm.model; + // Load template + if (formInfo) { + const formatInfo = getDefaultFormat(el); + formInfo.formFields.forEach((field) => { + field.formatInfo = formatInfo; + const locationEl = el.querySelector( + ".s_website_form_submit, .s_website_form_recaptcha" + ); + locationEl.insertAdjacentElement("beforebegin", renderField(field)); + }); + } + } + /** + * Ensures formInfo fields are fetched. + */ + async fetchFormInfoFields(formInfo) { + if (formInfo.fields) { + const proms = formInfo.fields.map((field) => this.fetchFieldRecords(field)); + await Promise.all(proms); + } + } + async fetchAuthorizedFields(formEl) { + // Combine model and fields into cache key. + const model = formEl.dataset.model_name; + const propertyOrigins = {}; + const parts = [model]; + for (const hiddenInputEl of [...formEl.querySelectorAll("input[type=hidden]")].sort( + (firstEl, secondEl) => firstEl.name.localeCompare(secondEl.name) + )) { + // Pushing using the name order to avoid being impacted by the + // order of hidden fields within the DOM. + parts.push(hiddenInputEl.name); + parts.push(hiddenInputEl.value); + propertyOrigins[hiddenInputEl.name] = hiddenInputEl.value; + } + const cacheKey = parts.join("/"); + return this.authorizedFieldsCache.read({ cacheKey, model, propertyOrigins }); + } + async _fetchAuthorizedFields({ cacheKey, model, propertyOrigins }) { + return this.services.orm.call("ir.model", "get_authorized_fields", [ + model, + propertyOrigins, + ]); + } + /** + * Set the correct mark on all fields. + */ + setLabelsMark(formEl) { + formEl.querySelectorAll(".s_website_form_mark").forEach((el) => el.remove()); + const mark = getMark(formEl); + if (!mark) { + return; + } + let fieldsToMark = []; + const requiredSelector = ".s_website_form_model_required, .s_website_form_required"; + const fields = Array.from(formEl.querySelectorAll(".s_website_form_field")); + if (isRequiredMark(formEl)) { + fieldsToMark = fields.filter((el) => el.matches(requiredSelector)); + } else if (isOptionalMark(formEl)) { + fieldsToMark = fields.filter((el) => !el.matches(requiredSelector)); + } + fieldsToMark.forEach((field) => { + const span = document.createElement("span"); + span.classList.add("s_website_form_mark"); + span.textContent = ` ${mark}`; + field.querySelector(".s_website_form_label").appendChild(span); + }); + } +} + +registry.category("website-plugins").add(FormOptionPlugin.id, FormOptionPlugin); diff --git a/addons/html_builder/static/src/website_builder/plugins/form/utils.js b/addons/html_builder/static/src/website_builder/plugins/form/utils.js new file mode 100644 index 0000000000000..1dac804143b48 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/form/utils.js @@ -0,0 +1,150 @@ +import { _t } from "@web/core/l10n/translation"; +import { renderToElement } from "@web/core/utils/render"; +import { generateHTMLId } from "@html_builder/utils/utils_css"; + +/** + * Returns the parsed data coming from the data-for element for the given form. + * TODO Note that we should rely on the same util as the website form interaction. + * Maybe this will need to be deleted. + * + * @param {string} formId + * @param {HTMLElement} parentEl + * @returns {Object|undefined} the parsed data + */ +export function getParsedDataFor(formId, parentEl) { + const dataForEl = parentEl.querySelector(`[data-for='${formId}']`); + if (!dataForEl) { + return; + } + return JSON.parse( + dataForEl.dataset.values + // replaces `True` by `true` if they are after `,` or `:` or `[` + .replace(/([,:[]\s*)True/g, "$1true") + // replaces `False` and `None` by `""` if they are after `,` or `:` or `[` + .replace(/([,:[]\s*)(False|None)/g, '$1""') + // replaces the `'` by `"` if they are before `,` or `:` or `]` or `}` + .replace(/'(\s*[,:\]}])/g, '"$1') + // replaces the `'` by `"` if they are after `{` or `[` or `,` or `:` + .replace(/([{[:,]\s*)'/g, '$1"') + ); +} + +/** + * Returns a field object + * + * @param {string} type the type of the field + * @param {string} name The name of the field used also as label + * @returns {Object} + */ +export function getCustomField(type, name) { + return { + name: name, + string: name, + custom: true, + type: type, + // Default values for x2many fields and selection + records: [ + { + id: _t("Option 1"), + display_name: _t("Option 1"), + }, + { + id: _t("Option 2"), + display_name: _t("Option 2"), + }, + { + id: _t("Option 3"), + display_name: _t("Option 3"), + }, + ], + }; +} + +export const getMark = (el) => el.dataset.mark; +export const isOptionalMark = (el) => el.classList.contains("o_mark_optional"); +export const isRequiredMark = (el) => el.classList.contains("o_mark_required"); +/** + * Returns the default formatInfos of a field. + * + * @param {HTMLElement} el + * @returns {Object} + */ +export function getDefaultFormat(el) { + return { + labelWidth: el.querySelector(".s_website_form_label").style.width, + labelPosition: "left", + multiPosition: "horizontal", + requiredMark: isRequiredMark(el), + optionalMark: isOptionalMark(el), + mark: getMark(el), + }; +} + +/** + * Replace all `"` character by `"`. + * + * @param {string} name + * @returns {string} + */ +export function getQuotesEncodedName(name) { + // Browsers seem to be encoding the double quotation mark character as + // `%22` (URI encoded version) when used inside an input's name. It is + // actually quite weird as a sent `` + // will actually be received as `Hello %22world%22 %22` on the server, + // making it impossible to know which is actually a real double + // quotation mark and not the "%22" string. Values do not have this + // problem: `Hello "world" %22` would be received as-is on the server. + // In the future, we should consider not using label values as input + // names anyway; the idea was bad in the first place. We should probably + // assign random field names (as we do for IDs) and send a mapping + // with the labels, as values (TODO ?). + return name.replaceAll(/"/g, (character) => `"`); +} + +/** + * Renders a field of the form based on its description + * + * @param {Object} field + * @returns {HTMLElement} + */ +export function renderField(field, resetId = false) { + if (!field.id) { + field.id = generateHTMLId(); + } + const params = { field: { ...field } }; + if (["url", "email", "tel"].includes(field.type)) { + params.field.inputType = field.type; + } + if (["boolean", "selection", "binary"].includes(field.type)) { + params.field.isCheck = true; + } + if (field.type === "one2many" && field.relation !== "ir.attachment") { + params.field.isCheck = true; + } + if (field.custom && !field.string) { + params.field.string = field.name; + } + if (field.description) { + params.default_description = _t("Describe your field here."); + } else if (["email_cc", "email_to"].includes(field.name)) { + params.default_description = _t("Separate email addresses with a comma."); + } + const template = document.createElement("template"); + const renderType = field.type === "tags" ? "many2many" : field.type; + template.content.append(renderToElement("website.form_field_" + renderType, params)); + if (field.description && field.description !== true) { + $(template.content.querySelector(".s_website_form_field_description")).replaceWith( + field.description + ); + } + template.content + .querySelectorAll("input.datetimepicker-input") + .forEach((el) => (el.value = field.propertyValue)); + template.content.querySelectorAll("[name]").forEach((el) => { + el.name = getQuotesEncodedName(el.name); + }); + template.content.querySelectorAll("[data-name]").forEach((el) => { + el.dataset.name = getQuotesEncodedName(el.dataset.name); + }); + return template.content.firstElementChild; +} diff --git a/addons/html_editor/static/src/core/history_plugin.js b/addons/html_editor/static/src/core/history_plugin.js index 46e378bfcef26..3c2e369cf56ad 100644 --- a/addons/html_editor/static/src/core/history_plugin.js +++ b/addons/html_editor/static/src/core/history_plugin.js @@ -168,6 +168,13 @@ export class HistoryPlugin extends Plugin { this.enableObserver(); this.reset(this.config.content); }, + // Resource definitions: + normalize_handlers: [ + // (commonRootOfModifiedEl or editableEl) => { + // clean up DOM before taking into account for next history step + // remaining in edit mode + // } + ], }; setup() { diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index beb63abd722e9..8bc8ba6a54858 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -388,7 +388,6 @@ 'website/static/src/snippets/s_dynamic_snippet_carousel/options.js', 'website/static/src/snippets/s_website_controller_page_listing_layout/options.js', 'website/static/src/snippets/s_website_form/options.js', - 'website/static/src/js/form_editor_registry.js', 'website/static/src/js/send_mail_form.js', 'website/static/src/xml/website_form.xml', 'website/static/src/xml/website.editor.xml', diff --git a/addons/website/static/src/js/form_editor_registry.js b/addons/website/static/src/js/form_editor_registry.js deleted file mode 100644 index 4d29b01d4ed02..0000000000000 --- a/addons/website/static/src/js/form_editor_registry.js +++ /dev/null @@ -1,3 +0,0 @@ -import { Registry } from "@web/core/registry"; - -export default new Registry(); diff --git a/addons/website/static/src/js/send_mail_form.js b/addons/website/static/src/js/send_mail_form.js index 81e793abfc5f8..752bc4c63b6aa 100644 --- a/addons/website/static/src/js/send_mail_form.js +++ b/addons/website/static/src/js/send_mail_form.js @@ -1,7 +1,7 @@ import { _t } from "@web/core/l10n/translation"; -import FormEditorRegistry from "@website/js/form_editor_registry"; +import { registry } from '@web/core/registry'; -FormEditorRegistry.add('send_mail', { +registry.category("website.form_editor_actions").add('send_mail', { formFields: [{ type: 'char', custom: true, diff --git a/addons/website/static/src/snippets/s_website_form/options.js b/addons/website/static/src/snippets/s_website_form/options.js index eb9e8f9537b80..4d2635a97b6d9 100644 --- a/addons/website/static/src/snippets/s_website_form/options.js +++ b/addons/website/static/src/snippets/s_website_form/options.js @@ -1,4 +1,3 @@ -import FormEditorRegistry from "@website/js/form_editor_registry"; import options from "@web_editor/js/editor/snippets.options"; import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import weUtils from "@web_editor/js/common/utils"; @@ -6,6 +5,7 @@ import "@website/js/editor/snippets.options"; import { unique } from "@web/core/utils/arrays"; import { redirect } from "@web/core/utils/urls"; import { _t } from "@web/core/l10n/translation"; +import { registry } from '@web/core/registry'; import { memoize } from "@web/core/utils/functions"; import { renderToElement } from "@web/core/utils/render"; import { escape } from "@web/core/utils/strings"; @@ -736,7 +736,7 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ // Add Action related options const formKey = this.activeForm.website_form_key; - const formInfo = FormEditorRegistry.get(formKey, null); + const formInfo = registry.category("website.form_editor_actions").get(formKey, null); if (!formInfo || !formInfo.fields) { return; } @@ -870,17 +870,18 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ */ _applyFormModel: async function (modelId) { let oldFormInfo; + const actionsRegistry = registry.category("website.form_editor_actions"); if (modelId) { const oldFormKey = this.activeForm.website_form_key; if (oldFormKey) { - oldFormInfo = FormEditorRegistry.get(oldFormKey, null); + oldFormInfo = actionsRegistry.get(oldFormKey, null); } this.$target.find('.s_website_form_field').remove(); this.activeForm = this.models.find(model => model.id === modelId); currentActionName = this.activeForm.website_form_label; } const formKey = this.activeForm.website_form_key; - const formInfo = FormEditorRegistry.get(formKey, null); + const formInfo = actionsRegistry.get(formKey, null); // Success page if (!this.$target[0].dataset.successMode) { this.$target[0].dataset.successMode = 'redirect'; diff --git a/addons/website_crm/static/src/js/website_crm_editor.js b/addons/website_crm/static/src/js/website_crm_editor.js index ccf6fbb915f58..d594eb107f165 100644 --- a/addons/website_crm/static/src/js/website_crm_editor.js +++ b/addons/website_crm/static/src/js/website_crm_editor.js @@ -1,7 +1,7 @@ import { _t } from "@web/core/l10n/translation"; -import FormEditorRegistry from "@website/js/form_editor_registry"; +import { registry } from '@web/core/registry'; -FormEditorRegistry.add('create_lead', { +registry.category("website.form_editor_actions").add('create_lead', { formFields: [{ type: 'char', required: true, diff --git a/addons/website_hr_recruitment/static/src/js/website_hr_recruitment_editor.js b/addons/website_hr_recruitment/static/src/js/website_hr_recruitment_editor.js index 252c882ce0e3f..486601d6d4cc7 100644 --- a/addons/website_hr_recruitment/static/src/js/website_hr_recruitment_editor.js +++ b/addons/website_hr_recruitment/static/src/js/website_hr_recruitment_editor.js @@ -1,7 +1,7 @@ import { _t } from "@web/core/l10n/translation"; -import FormEditorRegistry from "@website/js/form_editor_registry"; +import { registry } from '@web/core/registry'; -FormEditorRegistry.add('apply_job', { +registry.category("website.form_editor_actions").add('apply_job', { formFields: [{ type: 'char', modelRequired: true, diff --git a/addons/website_mass_mailing/static/src/js/mass_mailing_form_editor.js b/addons/website_mass_mailing/static/src/js/mass_mailing_form_editor.js index 455ffbf0445af..611285de169e7 100644 --- a/addons/website_mass_mailing/static/src/js/mass_mailing_form_editor.js +++ b/addons/website_mass_mailing/static/src/js/mass_mailing_form_editor.js @@ -1,7 +1,7 @@ import { _t } from "@web/core/l10n/translation"; -import FormEditorRegistry from "@website/js/form_editor_registry"; +import { registry } from '@web/core/registry'; -FormEditorRegistry.add('create_mailing_contact', { +registry.category("website.form_editor_actions").add('create_mailing_contact', { formFields: [{ name: 'name', required: true, diff --git a/addons/website_project/static/src/js/website_project_editor.js b/addons/website_project/static/src/js/website_project_editor.js index e3990b1edfc0f..a7d5a416c8ec9 100644 --- a/addons/website_project/static/src/js/website_project_editor.js +++ b/addons/website_project/static/src/js/website_project_editor.js @@ -1,7 +1,7 @@ import { _t } from "@web/core/l10n/translation"; -import FormEditorRegistry from "@website/js/form_editor_registry"; +import { registry } from '@web/core/registry'; -FormEditorRegistry.add('create_task', { +registry.category("website.form_editor_actions").add('create_task', { formFields: [{ type: 'char', required: true, diff --git a/addons/website_sale/static/src/js/website_sale_form_editor.js b/addons/website_sale/static/src/js/website_sale_form_editor.js index 0f20f5e10d0b8..db66e2ec8115e 100644 --- a/addons/website_sale/static/src/js/website_sale_form_editor.js +++ b/addons/website_sale/static/src/js/website_sale_form_editor.js @@ -1,7 +1,7 @@ import { _t } from "@web/core/l10n/translation"; -import FormEditorRegistry from "@website/js/form_editor_registry"; +import { registry } from '@web/core/registry'; -FormEditorRegistry.add('create_customer', { +registry.category("website.form_editor_actions").add('create_customer', { formFields: [{ type: 'char', modelRequired: true, From fd4427c453911aea9ff321239736a9d9f0dae5d0 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Fri, 14 Mar 2025 08:33:25 +0100 Subject: [PATCH 2/3] form add field buttons --- .../plugins/form/form_option.xml | 9 +++ .../form/form_option_add_field_button.js | 16 +++++ .../plugins/form/form_option_plugin.js | 53 +++++++++++++++- .../src/website_builder/plugins/form/utils.js | 60 +++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 addons/html_builder/static/src/website_builder/plugins/form/form_option_add_field_button.js diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_option.xml b/addons/html_builder/static/src/website_builder/plugins/form/form_option.xml index 31eef8dbaeb18..7677a83d1ac23 100644 --- a/addons/html_builder/static/src/website_builder/plugins/form/form_option.xml +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_option.xml @@ -71,4 +71,13 @@ + + + + diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_option_add_field_button.js b/addons/html_builder/static/src/website_builder/plugins/form/form_option_add_field_button.js new file mode 100644 index 0000000000000..cbe9c74d1c2b3 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_option_add_field_button.js @@ -0,0 +1,16 @@ +import { BaseOptionComponent, useDomState } from "@html_builder/core/utils"; + +export class FormOptionAddFieldButton extends BaseOptionComponent { + // TODO create +Field template + static template = "html_builder.website.s_website_form_form_option_add_field_button"; + static props = { + addField: Function, + tooltip: String, + }; + setup() { + super.setup(); + this.domState = useDomState((el) => ({ + el, + })); + } +} diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_option_plugin.js b/addons/html_builder/static/src/website_builder/plugins/form/form_option_plugin.js index a723b5ba3bd3b..a71b21436e918 100644 --- a/addons/html_builder/static/src/website_builder/plugins/form/form_option_plugin.js +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_option_plugin.js @@ -2,14 +2,44 @@ import { registry } from "@web/core/registry"; import { Cache } from "@web/core/utils/cache"; import { Plugin } from "@html_editor/plugin"; import { FormOption } from "./form_option"; -import { getDefaultFormat, getMark, isOptionalMark, isRequiredMark, renderField } from "./utils"; +import { FormOptionAddFieldButton } from "./form_option_add_field_button"; +import { + getCustomField, + getDefaultFormat, + getFieldFormat, + getMark, + isOptionalMark, + isRequiredMark, + renderField, +} from "./utils"; import { SyncCache } from "@html_builder/utils/sync_cache"; +import { _t } from "@web/core/l10n/translation"; import { renderToElement } from "@web/core/utils/render"; export class FormOptionPlugin extends Plugin { static id = "websiteFormOption"; static dependencies = ["builderActions", "builder-options"]; resources = { + builder_header_middle_buttons: [ + { + Component: FormOptionAddFieldButton, + selector: ".s_website_form", + applyTo: "form", + props: { + addField: (formEl) => this.addFieldToForm(formEl), + tooltip: _t("Add a new field at the end"), + }, + }, + { + Component: FormOptionAddFieldButton, + selector: ".s_website_form_field", + exclude: ".s_website_form_dnone", + props: { + addField: (fieldEl) => this.addFieldAfterField(fieldEl), + tooltip: _t("Add a new field after this one"), + }, + }, + ], builder_options: [ { OptionComponent: FormOption, @@ -462,6 +492,27 @@ export class FormOptionPlugin extends Plugin { field.querySelector(".s_website_form_label").appendChild(span); }); } + addFieldToForm(formEl) { + const field = getCustomField("char", _t("Custom Text")); + field.formatInfo = getDefaultFormat(formEl); + const fieldEl = renderField(field); + const locationEl = formEl.querySelector( + ".s_website_form_submit, .s_website_form_recaptcha" + ); + locationEl.insertAdjacentElement("beforebegin", fieldEl); + this.dependencies["builder-options"].updateContainers(fieldEl); + } + addFieldAfterField(fieldEl) { + const formEl = fieldEl.closest("form"); + const field = getCustomField("char", _t("Custom Text")); + field.formatInfo = getFieldFormat(fieldEl); + field.formatInfo.requiredMark = isRequiredMark(formEl); + field.formatInfo.optionalMark = isOptionalMark(formEl); + field.formatInfo.mark = getMark(formEl); + const newFieldEl = renderField(field); + fieldEl.insertAdjacentElement("afterend", newFieldEl); + this.dependencies["builder-options"].updateContainers(newFieldEl); + } } registry.category("website-plugins").add(FormOptionPlugin.id, FormOptionPlugin); diff --git a/addons/html_builder/static/src/website_builder/plugins/form/utils.js b/addons/html_builder/static/src/website_builder/plugins/form/utils.js index 1dac804143b48..30c4f3358f627 100644 --- a/addons/html_builder/static/src/website_builder/plugins/form/utils.js +++ b/addons/html_builder/static/src/website_builder/plugins/form/utils.js @@ -148,3 +148,63 @@ export function renderField(field, resetId = false) { }); return template.content.firstElementChild; } + +/** + * Returns true if the field is required by the model or by the user. + * + * @param {HTMLElement} fieldEl + * @returns {boolean} + */ +export function isFieldRequired(fieldEl) { + const classList = fieldEl.classList; + return ( + classList.contains("s_website_form_required") || + classList.contains("s_website_form_model_required") + ); +} + +/** + * Returns the multiple checkbox/radio element if it exist else null + * + * @param {HTMLElement} fieldEl + * @returns {HTMLElement} + */ +export function getMultipleInputs(fieldEl) { + return fieldEl.querySelector(".s_website_form_multiple"); +} + +export function getLabelPosition(fieldEl) { + const label = fieldEl.querySelector(".s_website_form_label"); + if (fieldEl.querySelector(".row:not(.s_website_form_multiple)")) { + return label.classList.contains("text-end") ? "right" : "left"; + } else { + return label.classList.contains("d-none") ? "none" : "top"; + } +} + +/** + * Returns the format object of a field containing + * the position, labelWidth and bootstrap col class + * + * @param {HTMLElement} fieldEl + * @returns {Object} + */ +export function getFieldFormat(fieldEl) { + let requiredMark, optionalMark; + const mark = fieldEl.querySelector(".s_website_form_mark"); + if (mark) { + requiredMark = isFieldRequired(fieldEl); + optionalMark = !requiredMark; + } + const multipleInputEl = getMultipleInputs(fieldEl); + const format = { + labelPosition: getLabelPosition(fieldEl), + labelWidth: fieldEl.querySelector(".s_website_form_label").style.width, + multiPosition: (multipleInputEl && multipleInputEl.dataset.display) || "horizontal", + col: [...fieldEl.classList].filter((el) => el.match(/^col-/g)).join(" "), + requiredMark: requiredMark, + optionalMark: optionalMark, + mark: mark && mark.textContent, + }; + return format; +} From 740ed44d9d4f5f471e9184885dfaf0c821b3dc0c Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Mon, 17 Mar 2025 11:29:24 +0100 Subject: [PATCH 3/3] form field options --- .../plugins/form/form_field_option.js | 102 ++++ .../plugins/form/form_field_option_redraw.js | 19 + .../plugins/form/form_option.inside.scss | 20 +- .../plugins/form/form_option.xml | 287 +++++++++++ .../plugins/form/form_option_plugin.js | 445 ++++++++++++++++++ .../src/website_builder/plugins/form/utils.js | 316 ++++++++++++- 6 files changed, 1183 insertions(+), 6 deletions(-) create mode 100644 addons/html_builder/static/src/website_builder/plugins/form/form_field_option.js create mode 100644 addons/html_builder/static/src/website_builder/plugins/form/form_field_option_redraw.js diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_field_option.js b/addons/html_builder/static/src/website_builder/plugins/form/form_field_option.js new file mode 100644 index 0000000000000..ef5889e75f4aa --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_field_option.js @@ -0,0 +1,102 @@ +import { BaseOptionComponent, useDomState } from "@html_builder/core/utils"; +import { onWillStart, onWillUpdateProps, useState } from "@odoo/owl"; +import { FormActionFieldsOption } from "./form_action_fields_option"; +import { getDependencyEl, getMultipleInputs, isFieldCustom } from "./utils"; + +export class FormFieldOption extends BaseOptionComponent { + static template = "html_builder.website.s_website_form_field_option"; + static props = { + loadFieldOptionData: Function, + redrawSequence: { type: Number, optional: true }, + }; + static components = { FormActionFieldsOption }; + + setup() { + super.setup(); + this.state = useState({ + availableFields: [], + conditionInputs: [], + conditionValueList: [], + dependencyEl: null, + }); + this.domState = useDomState((el) => ({ el })); + onWillStart(async () => { + const el = this.env.getEditingElement(); + const fieldOptionData = await this.props.loadFieldOptionData(el); + this.state.availableFields.push(...fieldOptionData.availableFields); + this.state.conditionInputs.push(...fieldOptionData.conditionInputs); + this.state.conditionValueList.push(...fieldOptionData.conditionValueList); + this.state.dependencyEl = getDependencyEl(el); + }); + onWillUpdateProps(async (props) => { + const el = this.env.getEditingElement(); + const fieldOptionData = await props.loadFieldOptionData(el); + this.state.availableFields.length = 0; + this.state.availableFields.push(...fieldOptionData.availableFields); + this.state.conditionInputs.length = 0; + this.state.conditionInputs.push(...fieldOptionData.conditionInputs); + this.state.conditionValueList.length = 0; + this.state.conditionValueList.push(...fieldOptionData.conditionValueList); + this.state.dependencyEl = getDependencyEl(el); + }); + // TODO select field's hack ? + } + get isTextConditionValueVisible() { + const el = this.env.getEditingElement(); + const dependencyEl = getDependencyEl(el); + if ( + !el.classList.contains("s_website_form_field_hidden_if") || + (dependencyEl && + (["checkbox", "radio"].includes(dependencyEl.type) || + dependencyEl.nodeName === "SELECT")) + ) { + return false; + } + if (!dependencyEl) { + return true; + } + if (dependencyEl?.classList.contains("datetimepicker-input")) { + return false; + } + return ( + (["text", "email", "tel", "url", "search", "password", "number"].includes( + dependencyEl.type + ) || + dependencyEl.nodeName === "TEXTAREA") && + !["set", "!set"].includes(el.dataset.visibilityComparator) + ); + } + get isTextConditionOperatorVisible() { + const el = this.env.getEditingElement(); + const dependencyEl = getDependencyEl(el); + if ( + !el.classList.contains("s_website_form_field_hidden_if") || + dependencyEl?.classList.contains("datetimepicker-input") + ) { + return false; + } + return ( + !dependencyEl || + ["text", "email", "tel", "url", "search", "password"].includes(dependencyEl.type) || + dependencyEl.nodeName === "TEXTAREA" + ); + } + get isExisingFieldSelectType() { + const el = this.env.getEditingElement(); + return !isFieldCustom(el) && ["selection", "many2one"].includes(el.dataset.type); + } + get isMultipleInputs() { + const el = this.env.getEditingElement(); + return !!getMultipleInputs(el); + } + get isMaxFilesVisible() { + // Do not display the option if only one file is supposed to be + // uploaded in the field. + const el = this.env.getEditingElement(); + const fieldEl = el.closest(".s_website_form_field"); + return ( + fieldEl.classList.contains("s_website_form_custom") || + ["one2many", "many2many"].includes(fieldEl.dataset.type) + ); + } +} diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_field_option_redraw.js b/addons/html_builder/static/src/website_builder/plugins/form/form_field_option_redraw.js new file mode 100644 index 0000000000000..f409c6e94b3f5 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_field_option_redraw.js @@ -0,0 +1,19 @@ +import { BaseOptionComponent, useDomState } from "@html_builder/core/utils"; +import { FormFieldOption } from "./form_field_option"; + +export class FormFieldOptionRedraw extends BaseOptionComponent { + static template = "html_builder.website.s_website_form_field_option_redraw"; + static props = FormFieldOption.props; + static components = { FormFieldOption }; + + setup() { + super.setup(); + this.count = 0; + this.domState = useDomState((el) => { + this.count++; + return { + redrawSequence: this.count++, + }; + }); + } +} diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_option.inside.scss b/addons/html_builder/static/src/website_builder/plugins/form/form_option.inside.scss index 94ff20c877cdc..3a7370db96ded 100644 --- a/addons/html_builder/static/src/website_builder/plugins/form/form_option.inside.scss +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_option.inside.scss @@ -7,6 +7,20 @@ } } -.s_website_form input:not(.o_translatable_attribute) { - pointer-events: none; -} \ No newline at end of file +.s_website_form { + input, textarea, select, .s_website_form_label { + &:not(.o_translatable_attribute) { + pointer-events: none; + } + } + // Hidden field is only partially hidden in editor + .s_website_form_field_hidden { + display: block !important; + opacity: 0.5; + } + // Fields with conditional visibility are visible and identifiable in the editor + .s_website_form_field_hidden_if { + display: block !important; + background-color: $o-we-fg-light; + } +} diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_option.xml b/addons/html_builder/static/src/website_builder/plugins/form/form_option.xml index 7677a83d1ac23..8c1eff6a1de75 100644 --- a/addons/html_builder/static/src/website_builder/plugins/form/form_option.xml +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_option.xml @@ -80,4 +80,291 @@ + + + + + + + +
Custom Field
+ + Text + Long Text + Email + Telephone + Url + Number + Decimal Number + Checkbox + Multiple Checkboxes + Radio Buttons + Selection + Date + Date & Time + File Upload + + +
Existing fields
+ + + +
+
+
+ + + Text + Email + Telephone + Url + + + + + Dropdown List + Radio + + + + + Horizontal + Vertical + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TODO
+
+ + + + Always Visible + Hidden + Visible only if + + + +
+ + + + + + Is equal to + Is not equal to + Contains + Doesn't contain + + + + Contains + Doesn't contain + Is equal to + Is not equal to + Is set + Is not set + + + + Is equal to + Is not equal to + Is greater than + Is less than + Is greater than or equal to + Is less than or equal to + Is set + Is not set + + + + Is equal to + Is not equal to + Is after + Is before + Is after or equal to + Is before or equal to + Is set + Is not set + Is between (included) + Is not between (excluded) + + + + Is set + Is not set + + + Is equal to + Is not equal to + +
+
+ + + + + + + + + + + + + +
+
+
+ + + + + Left + Center + Right + Input Aligned + + + + + + + + + + diff --git a/addons/html_builder/static/src/website_builder/plugins/form/form_option_plugin.js b/addons/html_builder/static/src/website_builder/plugins/form/form_option_plugin.js index a71b21436e918..0d4642d65ac2f 100644 --- a/addons/html_builder/static/src/website_builder/plugins/form/form_option_plugin.js +++ b/addons/html_builder/static/src/website_builder/plugins/form/form_option_plugin.js @@ -1,16 +1,35 @@ import { registry } from "@web/core/registry"; import { Cache } from "@web/core/utils/cache"; import { Plugin } from "@html_editor/plugin"; +import { reactive } from "@odoo/owl"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { FormOption } from "./form_option"; +import { FormFieldOptionRedraw } from "./form_field_option_redraw"; import { FormOptionAddFieldButton } from "./form_option_add_field_button"; import { + deleteConditionalVisibility, + findCircular, + getActiveField, getCustomField, getDefaultFormat, + getDependencyEl, + getDomain, getFieldFormat, + getFieldName, + getFieldType, + getLabelPosition, getMark, + getMultipleInputs, + getNewRecordId, + getQuotesEncodedName, + getSelect, + isFieldCustom, isOptionalMark, isRequiredMark, renderField, + replaceFieldElement, + setActiveProperties, + setVisibilityDependency, } from "./utils"; import { SyncCache } from "@html_builder/utils/sync_cache"; import { _t } from "@web/core/l10n/translation"; @@ -52,6 +71,19 @@ export class FormOptionPlugin extends Plugin { selector: ".s_website_form", applyTo: "form", }, + { + OptionComponent: FormFieldOptionRedraw, + props: { + loadFieldOptionData: this.loadFieldOptionData.bind(this), + }, + selector: ".s_website_form_field", + exclude: ".s_website_form_dnone", + }, + { + template: "html_builder.website.s_website_form_submit_option", + selector: ".s_website_form_submit", + exclude: ".s_website_form_no_submit_options", + }, ], builder_actions: this.getActions(), system_classes: ["o_builder_form_show_message"], @@ -83,6 +115,7 @@ export class FormOptionPlugin extends Plugin { }; getActions() { return { + // Form actions // Components that use this action MUST await fetchModels before they start. selectAction: { load: async ({ editingElement: el, value: modelId }) => { @@ -266,6 +299,203 @@ export class FormOptionPlugin extends Plugin { return !!recaptchaLegalEl; }, }, + // Field actions + customField: { + load: this.prepareFields.bind(this), + apply: ({ editingElement: fieldEl, value, loadResult: fields }) => { + const oldLabelText = fieldEl.querySelector( + ".s_website_form_label_content" + ).textContent; + const field = getCustomField(value, oldLabelText); + setActiveProperties(fieldEl, field); + this.replaceField(fieldEl, field, fields); + }, + isApplied: ({ editingElement: fieldEl, value }) => { + const currentValue = isFieldCustom(fieldEl) ? getFieldType(fieldEl) : ""; + return currentValue === value; + }, + }, + existingField: { + load: this.prepareFields.bind(this), + apply: ({ editingElement: fieldEl, value, loadResult: fields }) => { + const field = fields[value]; + setActiveProperties(fieldEl, field); + this.replaceField(fieldEl, field, fields); + }, + isApplied: ({ editingElement: fieldEl, value }) => { + const currentValue = isFieldCustom(fieldEl) ? "" : getFieldName(fieldEl); + return currentValue === value; + }, + }, + selectType: { + load: this.prepareFields.bind(this), + apply: ({ editingElement: fieldEl, value, loadResult: fields }) => { + const field = getActiveField(fieldEl, { fields }); + field.type = value; + this.replaceField(fieldEl, field, fields); + }, + isApplied: ({ editingElement: fieldEl, value }) => { + const currentValue = getFieldType(fieldEl); + return currentValue === value; + }, + }, + existingFieldSelectType: { + load: this.prepareFields.bind(this), + apply: ({ editingElement: fieldEl, value, loadResult: fields }) => { + const field = getActiveField(fieldEl, { fields }); + field.type = value; + this.replaceField(fieldEl, field, fields); + }, + isApplied: ({ editingElement: fieldEl, value }) => { + const currentValue = getFieldType(fieldEl); + return currentValue === value; + }, + }, + multiCheckboxDisplay: { + apply: ({ editingElement: fieldEl, value }) => { + const targetEl = getMultipleInputs(fieldEl); + const isHorizontal = value === "horizontal"; + for (const el of targetEl.querySelectorAll(".checkbox, .radio")) { + el.classList.toggle("col-lg-4", isHorizontal); + el.classList.toggle("col-md-6", isHorizontal); + } + targetEl.dataset.display = value; + }, + isApplied: ({ editingElement: fieldEl, value }) => { + const targetEl = getMultipleInputs(fieldEl); + const currentValue = targetEl ? targetEl.dataset.display : ""; + return currentValue === value; + }, + }, + setLabelText: { + apply: ({ editingElement: fieldEl, value }) => { + const labelEl = fieldEl.querySelector(".s_website_form_label_content"); + labelEl.textContent = value; + if (isFieldCustom(fieldEl)) { + value = getQuotesEncodedName(value); + const multiple = fieldEl.querySelector(".s_website_form_multiple"); + if (multiple) { + multiple.dataset.name = value; + } + const inputEls = fieldEl.querySelectorAll(".s_website_form_input"); + const previousInputName = fieldEl.name; + inputEls.forEach((el) => (el.name = value)); + + // Synchronize the fields whose visibility depends on this field + const dependentEls = fieldEl + .closest("form") + .querySelectorAll( + `.s_website_form_field[data-visibility-dependency="${CSS.escape( + previousInputName + )}"]` + ); + for (const dependentEl of dependentEls) { + if (findCircular(fieldEl, dependentEl)) { + // For all the fields whose visibility depends on this + // field, check if the new name creates a circular + // dependency and remove the problematic conditional + // visibility if it is the case. E.g. a field (A) depends on + // another (B) and the user renames "B" by "A". + deleteConditionalVisibility(dependentEl); + } else { + dependentEl.dataset.visibilityDependency = value; + } + } + /* TODO: make sure this is handled on non-preview: + if (!previewMode) { + // TODO: @owl-options is this still true ? + // As the field label changed, the list of available visibility + // dependencies needs to be updated in order to not propose a + // field that would create a circular dependency. + this.rerender = true; + } + */ + } + }, + getValue: ({ editingElement: fieldEl }) => { + const labelEl = fieldEl.querySelector(".s_website_form_label_content"); + return labelEl.textContent; + }, + }, + selectLabelPosition: { + load: this.prepareFields.bind(this), + apply: ({ editingElement: fieldEl, value, loadResult: fields }) => { + const field = getActiveField(fieldEl, { fields }); + field.formatInfo.labelPosition = value; + this.replaceField(fieldEl, field, fields); + }, + isApplied: ({ editingElement: fieldEl, value }) => { + const currentValue = getLabelPosition(fieldEl); + return currentValue === value; + }, + }, + toggleDescription: { + load: this.prepareFields.bind(this), + apply: ({ editingElement: fieldEl, loadResult: fields, value }) => { + const description = fieldEl.querySelector(".s_website_form_field_description"); + const hasDescription = !!description; + const field = getActiveField(fieldEl, { fields }); + field.description = !hasDescription; // Will be changed to default description in qweb + this.replaceField(fieldEl, field, fields); + }, + isApplied: ({ editingElement: fieldEl }) => { + const description = fieldEl.querySelector(".s_website_form_field_description"); + return !!description; + }, + }, + selectTextareaValue: { + apply: ({ editingElement: fieldEl, value }) => { + fieldEl.textContent = value; + fieldEl.value = value; + }, + getValue: ({ editingElement: fieldEl }) => fieldEl.textContent, + }, + toggleRequired: { + apply: ({ editingElement: fieldEl, param: { mainParam: activeValue } }) => { + fieldEl.classList.add(activeValue); + fieldEl + .querySelectorAll("input, select, textarea") + .forEach((el) => el.toggleAttribute("required", true)); + this.setLabelsMark(fieldEl.closest("form")); + }, + clean: ({ editingElement: fieldEl, param: { mainParam: activeValue } }) => { + fieldEl.classList.remove(activeValue); + fieldEl + .querySelectorAll("input, select, textarea") + .forEach((el) => el.removeAttribute("required")); + this.setLabelsMark(fieldEl.closest("form")); + }, + isApplied: ({ editingElement: fieldEl, param: { mainParam: activeValue } }) => + fieldEl.classList.contains(activeValue), + }, + setVisibility: { + load: this.prepareConditionInputs.bind(this), + apply: ({ editingElement: fieldEl, value, loadResult: conditionInputs }) => { + if (value === "conditional") { + for (const conditionInput of conditionInputs) { + if (conditionInput.name) { + // Set a default visibility dependency + setVisibilityDependency(fieldEl, conditionInput.name); + return; + } + } + this.services.dialog.add(ConfirmationDialog, { + body: _t("There is no field available for this option."), + }); + } + deleteConditionalVisibility(fieldEl); + }, + isApplied: () => true, + }, + setVisibilityDependency: { + apply: ({ editingElement: fieldEl, value }) => { + setVisibilityDependency(fieldEl, value); + }, + isApplied: ({ editingElement: fieldEl, value }) => { + const currentValue = fieldEl.dataset.visibilityDependency || ""; + return currentValue === value; + }, + }, }; } setup() { @@ -299,6 +529,10 @@ export class FormOptionPlugin extends Plugin { * @returns {Promise} */ async _fetchFieldRecords(field) { + // TODO remove this - put there to avoid crash + if (!field) { + return; + } // Convert the required boolean to a value directly usable // in qweb js to avoid duplicating this in the templates field.required = field.required ? 1 : null; @@ -513,6 +747,217 @@ export class FormOptionPlugin extends Plugin { fieldEl.insertAdjacentElement("afterend", newFieldEl); this.dependencies["builder-options"].updateContainers(newFieldEl); } + /** + * To be used in load for any action that uses getActiveField or + * replaceField + */ + async prepareFields({ editingElement: fieldEl, value }) { + // TODO Through cache ? + const fieldOptionData = await this.loadFieldOptionData(fieldEl); + const fieldName = getFieldName(fieldEl); + const field = fieldOptionData.fields[fieldName]; + await this.fetchFieldRecords(field); + if (fieldOptionData.fields[value]) { + await this.fetchFieldRecords(fieldOptionData.fields[value]); + } + return fieldOptionData.fields; + } + async prepareConditionInputs({ editingElement: fieldEl, value }) { + // TODO Through cache ? + const fieldOptionData = await this.loadFieldOptionData(fieldEl); + const fieldName = getFieldName(fieldEl); + const field = fieldOptionData.fields[fieldName]; + await this.fetchFieldRecords(field); + if (fieldOptionData.fields[value]) { + await this.fetchFieldRecords(fieldOptionData.fields[value]); + } + return fieldOptionData.conditionInputs; + } + /** + * Replaces the old field content with the field provided. + * + * @param {HTMLElement} oldFieldEl + * @param {Object} field + * @param {Array} fields + * @returns {Promise} + */ + replaceField(oldFieldEl, field, fields) { + const activeField = getActiveField(oldFieldEl, { fields }); + if (activeField.type !== field.type) { + field.value = ""; + } + const targetEl = oldFieldEl.querySelector(".s_website_form_input"); + if (targetEl) { + if (["checkbox", "radio"].includes(targetEl.getAttribute("type"))) { + // Remove first checkbox/radio's id's final '0'. + field.id = targetEl.id.slice(0, -1); + } else { + field.id = targetEl.id; + } + } + const fieldEl = renderField(field); + replaceFieldElement(oldFieldEl, fieldEl); + } + async loadFieldOptionData(fieldEl) { + const formEl = fieldEl.closest("form"); + const fields = {}; + // Get the authorized existing fields for the form model + // Do it on each render because of custom property fields which can + // change depending on the project selected. + const existingFields = await this.fetchAuthorizedFields(formEl).then((fieldsFromCache) => { + for (const [fieldName, field] of Object.entries(fieldsFromCache)) { + field.name = fieldName; + const fieldDomain = getDomain(formEl, field.name, field.type, field.relation); + field.domain = fieldDomain || field.domain || []; + fields[fieldName] = field; + } + return Object.keys(fieldsFromCache) + .map((key) => { + const field = fieldsFromCache[key]; + return { + name: field.name, + string: field.string, + }; + }) + .sort((a, b) => + a.string.localeCompare(b.string, undefined, { + numeric: true, + sensitivity: "base", + }) + ); + }); + // Update available visibility dependencies + const existingDependencyNames = []; + const conditionInputs = []; + for (const el of formEl.querySelectorAll( + ".s_website_form_field:not(.s_website_form_dnone)" + )) { + const inputEl = el.querySelector(".s_website_form_input"); + if ( + el.querySelector(".s_website_form_label_content") && + inputEl && + inputEl.name && + inputEl.name !== fieldEl.querySelector(".s_website_form_input").name && + !existingDependencyNames.includes(inputEl.name) && + !findCircular(el, fieldEl) + ) { + conditionInputs.push({ + name: inputEl.name, + textContent: el.querySelector(".s_website_form_label_content").textContent, + }); + existingDependencyNames.push(inputEl.name); + } + } + + const comparator = fieldEl.dataset.visibilityComparator; + const dependencyEl = getDependencyEl(fieldEl); + const conditionValueList = []; + if (dependencyEl) { + if ( + ["radio", "checkbox"].includes(dependencyEl.type) || + dependencyEl.nodeName === "SELECT" + ) { + // Update available visibility options + const inputContainerEl = fieldEl; + if (dependencyEl.nodeName === "SELECT") { + for (const option of dependencyEl.querySelectorAll("option")) { + conditionValueList.push({ + value: option.value, + textContent: option.textContent || `<${_t("no value")}>`, + }); + } + if (!inputContainerEl.dataset.visibilityCondition) { + inputContainerEl.dataset.visibilityCondition = + dependencyEl.querySelector("option").value; + } + } else { + // DependencyEl is a radio or a checkbox + const dependencyContainerEl = dependencyEl.closest(".s_website_form_field"); + const inputsInDependencyContainer = + dependencyContainerEl.querySelectorAll(".s_website_form_input"); + // TODO: @owl-options already wrong in master for e.g. Project/Tags + for (const el of inputsInDependencyContainer) { + conditionValueList.push({ + value: el.value, + textContent: el.value, + }); + } + if (!inputContainerEl.dataset.visibilityCondition) { + inputContainerEl.dataset.visibilityCondition = + inputsInDependencyContainer[0].value; + } + } + if (!inputContainerEl.dataset.visibilityComparator) { + inputContainerEl.dataset.visibilityComparator = "selected"; + } + } + if (!comparator) { + // Set a default comparator according to the type of dependency + if (dependencyEl.dataset.target) { + fieldEl.dataset.visibilityComparator = "after"; + } else if ( + ["text", "email", "tel", "url", "search", "password", "number"].includes( + dependencyEl.type + ) || + dependencyEl.nodeName === "TEXTAREA" + ) { + fieldEl.dataset.visibilityComparator = "equal"; + } else if (dependencyEl.type === "file") { + fieldEl.dataset.visibilityComparator = "fileSet"; + } + } + } + + const currentFieldName = getFieldName(fieldEl); + const fieldsInForm = Array.from( + formEl.querySelectorAll( + ".s_website_form_field:not(.s_website_form_custom) .s_website_form_input" + ) + ) + .map((el) => el.name) + .filter((el) => el !== currentFieldName); + const availableFields = existingFields.filter( + (field) => !fieldsInForm.includes(field.name) + ); + + const selectEl = getSelect(fieldEl); + const multipleInputsEl = getMultipleInputs(fieldEl); + let valueList = undefined; + if (selectEl || multipleInputsEl) { + const field = Object.assign({}, fields[getFieldName(fieldEl)]); + const type = getFieldType(fieldEl); + + const optionText = selectEl + ? "Option" + : type === "selection" + ? _t("Radio") + : _t("Checkbox"); + const defaults = [...fieldEl.querySelectorAll("[checked], [selected]")].map((el) => + /^-?[0-9]{1,15}$/.test(el.value) ? parseInt(el.value) : el.value + ); + let availableRecords = undefined; + if (!isFieldCustom(fieldEl)) { + await this.fetchFieldRecords(field); + availableRecords = JSON.stringify(field.records); + } + valueList = reactive({ + title: _t("%s List", optionText), + addItemTitle: _t("Add new %s", optionText), + hasDefault: ["one2many", "many2many"].includes(type) ? "multiple" : "unique", + defaults: JSON.stringify(defaults), + availableRecords: availableRecords, + newRecordId: isFieldCustom(fieldEl) ? getNewRecordId(fieldEl) : "", + }); + } + return { + fields, + existingFields, + conditionInputs, + availableFields, + valueList, + conditionValueList, + }; + } } registry.category("website-plugins").add(FormOptionPlugin.id, FormOptionPlugin); diff --git a/addons/html_builder/static/src/website_builder/plugins/form/utils.js b/addons/html_builder/static/src/website_builder/plugins/form/utils.js index 30c4f3358f627..2e2ab974f4cce 100644 --- a/addons/html_builder/static/src/website_builder/plugins/form/utils.js +++ b/addons/html_builder/static/src/website_builder/plugins/form/utils.js @@ -2,6 +2,13 @@ import { _t } from "@web/core/l10n/translation"; import { renderToElement } from "@web/core/utils/render"; import { generateHTMLId } from "@html_builder/utils/utils_css"; +export const VISIBILITY_DATASET = [ + "visibilityDependency", + "visibilityCondition", + "visibilityComparator", + "visibilityBetween", +]; + /** * Returns the parsed data coming from the data-for element for the given form. * TODO Note that we should rely on the same util as the website form interaction. @@ -133,9 +140,8 @@ export function renderField(field, resetId = false) { const renderType = field.type === "tags" ? "many2many" : field.type; template.content.append(renderToElement("website.form_field_" + renderType, params)); if (field.description && field.description !== true) { - $(template.content.querySelector(".s_website_form_field_description")).replaceWith( - field.description - ); + const descriptionEl = template.content.querySelector(".s_website_form_field_description"); + descriptionEl.replaceWith(field.description); } template.content .querySelectorAll("input.datetimepicker-input") @@ -208,3 +214,307 @@ export function getFieldFormat(fieldEl) { }; return format; } + +/** + * Returns true if the field is a custom field, false if it is an existing field + * + * @param {HTMLElement} fieldEl + * @returns {boolean} + */ +export function isFieldCustom(fieldEl) { + return !!fieldEl.classList.contains("s_website_form_custom"); +} + +/** + * Returns the name of the field + * + * @param {HTMLElement} fieldEl + * @returns {string} + */ +export function getFieldName(fieldEl = this.$target[0]) { + const multipleName = fieldEl.querySelector(".s_website_form_multiple"); + return multipleName + ? multipleName.dataset.name + : fieldEl.querySelector(".s_website_form_input").name; +} +/** + * Returns the type of the field, can be used for both custom and existing fields + * + * @param {HTMLElement} fieldEl + * @returns {string} + */ +export function getFieldType(fieldEl) { + return fieldEl.dataset.type; +} + +/** + * Set the active field properties on the field Object + * + * @param {HTMLElement} fieldEl + * @param {Object} field Field to complete with the active field info + */ +export function setActiveProperties(fieldEl, field) { + const classList = fieldEl.classList; + const textarea = fieldEl.querySelector("textarea"); + const input = fieldEl.querySelector( + 'input[type="text"], input[type="email"], input[type="number"], input[type="tel"], input[type="url"], textarea' + ); + const fileInputEl = fieldEl.querySelector("input[type=file]"); + const description = fieldEl.querySelector(".s_website_form_field_description"); + field.placeholder = input && input.placeholder; + if (input) { + // textarea value has no attribute, date/datetime timestamp property is formated + field.value = input.getAttribute("value") || input.value; + } else if (field.type === "boolean") { + field.value = !!fieldEl.querySelector('input[type="checkbox"][checked]'); + } else if (fileInputEl) { + field.maxFilesNumber = fileInputEl.dataset.maxFilesNumber; + field.maxFileSize = fileInputEl.dataset.maxFileSize; + } + // property value is needed for date/datetime (formated date). + field.propertyValue = input && input.value; + field.description = description && description.outerHTML; + field.rows = textarea && textarea.rows; + field.required = classList.contains("s_website_form_required"); + field.modelRequired = classList.contains("s_website_form_model_required"); + field.hidden = classList.contains("s_website_form_field_hidden"); + field.formatInfo = getFieldFormat(fieldEl); +} + +/** + * Replaces the target with provided field. + * + * @param {HTMLElement} oldFieldEl + * @param {HTMLElement} fieldEl + */ +export function replaceFieldElement(oldFieldEl, fieldEl) { + const inputEl = oldFieldEl.querySelector("input"); + const dataFillWith = inputEl ? inputEl.dataset.fillWith : undefined; + const hasConditionalVisibility = oldFieldEl.classList.contains( + "s_website_form_field_hidden_if" + ); + const previousInputEl = oldFieldEl.querySelector(".s_website_form_input"); + const previousName = previousInputEl.name; + const previousType = previousInputEl.type; + [...oldFieldEl.childNodes].forEach((node) => node.remove()); + [...fieldEl.childNodes].forEach((node) => oldFieldEl.appendChild(node)); + [...fieldEl.attributes].forEach((el) => oldFieldEl.removeAttribute(el.nodeName)); + [...fieldEl.attributes].forEach((el) => oldFieldEl.setAttribute(el.nodeName, el.nodeValue)); + if (hasConditionalVisibility) { + oldFieldEl.classList.add("s_website_form_field_hidden_if", "d-none"); + } + const dependentFieldEls = oldFieldEl + .closest("form") + .querySelectorAll( + `.s_website_form_field[data-visibility-dependency="${CSS.escape(previousName)}"]` + ); + const newFormInputEl = oldFieldEl.querySelector(".s_website_form_input"); + const newName = newFormInputEl.name; + const newType = newFormInputEl.type; + if ((previousName !== newName || previousType !== newType) && dependentFieldEls) { + // In order to keep the visibility conditions consistent, + // when the name has changed, it means that the type has changed so + // all fields whose visibility depends on this field must be updated so that + // they no longer have conditional visibility + for (const fieldEl of dependentFieldEls) { + deleteConditionalVisibility(fieldEl); + } + } + const newInputEl = oldFieldEl.querySelector("input"); + if (newInputEl) { + newInputEl.dataset.fillWith = dataFillWith; + } +} + +/** + * Returns the target as a field Object + * + * @param {HTMLElement} fieldEl + * @param {boolean} noRecords + * @returns {Object} + */ +export function getActiveField(fieldEl, { noRecords, fields } = {}) { + let field; + const labelText = fieldEl.querySelector(".s_website_form_label_content")?.innerText || ""; + if (isFieldCustom(fieldEl)) { + field = getCustomField(fieldEl.dataset.type, labelText); + } else { + field = Object.assign({}, fields[getFieldName(fieldEl)]); + field.string = labelText; + field.type = getFieldType(fieldEl); + } + if (!noRecords) { + field.records = getListItems(fieldEl); + } + setActiveProperties(fieldEl, field); + return field; +} + +/** + * Deletes all attributes related to conditional visibility. + * + * @param {HTMLElement} fieldEl + */ +export function deleteConditionalVisibility(fieldEl) { + for (const name of VISIBILITY_DATASET) { + delete fieldEl.dataset[name]; + } + fieldEl.classList.remove("s_website_form_field_hidden_if", "d-none"); +} + +/** + * Returns the select element if it exist else null + * + * @param {HTMLElement} fieldEl + * @returns {HTMLElement} + */ +export function getSelect(fieldEl) { + return fieldEl.querySelector("select"); +} + +/** + * Returns the next new record id. + * + * @param {HTMLElement} fieldEl + */ +export function getNewRecordId(fieldEl) { + const selectEl = getSelect(fieldEl); + const multipleInputsEl = getMultipleInputs(fieldEl); + let options = []; + if (selectEl) { + options = [...selectEl.querySelectorAll("option")]; + } else if (multipleInputsEl) { + options = [...multipleInputsEl.querySelectorAll(".checkbox input, .radio input")]; + } + // TODO: @owl-option factorize code above + const targetEl = fieldEl.querySelector(".s_website_form_input"); + let id; + if (["checkbox", "radio"].includes(targetEl.getAttribute("type"))) { + // Remove first checkbox/radio's id's final '0'. + id = targetEl.id.slice(0, -1); + } else { + id = targetEl.id; + } + return id + options.length; +} + +/** + * @param {HTMLElement} fieldEl + * @returns {HTMLElement} The visibility dependency of the field + */ +export function getDependencyEl(fieldEl) { + const dependencyName = fieldEl.dataset.visibilityDependency; + return fieldEl + .closest("form") + .querySelector(`.s_website_form_input[name="${CSS.escape(dependencyName)}"]`); +} + +/** + * @param {HTMLElement} dependentFieldEl + * @param {HTMLElement} targetFieldEl + * @returns {boolean} "true" if adding "dependentFieldEl" or any other field + * with the same label in the conditional visibility of "targetFieldEl" + * would create a circular dependency involving "targetFieldEl". + */ +export function findCircular(dependentFieldEl, targetFieldEl) { + const formEl = targetFieldEl.closest("form"); + // Keep a register of the already visited fields to not enter an + // infinite check loop. + const visitedFields = new Set(); + const recursiveFindCircular = (dependentFieldEl, targetFieldEl) => { + const dependentFieldName = getFieldName(dependentFieldEl); + // Get all the fields that have the same label as the dependent + // field. + let dependentFieldEls = Array.from( + formEl.querySelectorAll( + `.s_website_form_input[name="${CSS.escape(dependentFieldName)}"]` + ) + ).map((el) => el.closest(".s_website_form_field")); + // Remove the duplicated fields. This could happen if the field has + // multiple inputs ("Multiple Checkboxes" for example.) + dependentFieldEls = new Set(dependentFieldEls); + const fieldName = getFieldName(targetFieldEl); + for (const dependentFieldEl of dependentFieldEls) { + // Only check for circular dependencies on fields that do not + // already have been checked. + if (!visitedFields.has(dependentFieldEl)) { + // Add the dependentFieldEl in the set of checked field. + visitedFields.add(dependentFieldEl); + if (dependentFieldEl.dataset.visibilityDependency === fieldName) { + return true; + } + const dependencyInputEl = getDependencyEl(dependentFieldEl); + if ( + dependencyInputEl && + recursiveFindCircular( + dependencyInputEl.closest(".s_website_form_field"), + targetFieldEl + ) + ) { + return true; + } + } + } + return false; + }; + return recursiveFindCircular(dependentFieldEl, targetFieldEl); +} + +/** + * Returns the domain of a field. + * + * @param {HTMLElement} formEl + * @param {String} name + * @param {String} type + * @param {String} relation + * @returns {Object|false} + */ +// TODO Solve this variable differently +const allFormsInfo = new Map(); +export function getDomain(formEl, name, type, relation) { + // We need this because the field domain is in formInfo in the + // WebsiteFormEditor but we need it in the WebsiteFieldEditor. + if (!allFormsInfo.get(formEl) || !name || !type || !relation) { + return false; + } + const field = allFormsInfo + .get(formEl) + .fields.find((el) => el.name === name && el.type === type && el.relation === relation); + return field && field.domain; +} + +export function getListItems(fieldEl) { + const selectEl = getSelect(fieldEl); + const multipleInputsEl = getMultipleInputs(fieldEl); + let options = []; + if (selectEl) { + options = [...selectEl.querySelectorAll("option")]; + } else if (multipleInputsEl) { + options = [...multipleInputsEl.querySelectorAll(".checkbox input, .radio input")]; + } + const isFieldElCustom = isFieldCustom(fieldEl); + return options.map((opt) => { + const name = selectEl ? opt : opt.nextElementSibling; + return { + id: isFieldElCustom + ? opt.id + : /^-?[0-9]{1,15}$/.test(opt.value) + ? parseInt(opt.value) + : opt.value, + display_name: name.textContent.trim(), + selected: selectEl ? opt.selected : opt.checked, + }; + }); +} + +/** + * Sets the visibility dependency of the field. + * + * @param {HTMLElement} fieldEl + * @param {string} value name of the dependency input + */ +export function setVisibilityDependency(fieldEl, value) { + delete fieldEl.dataset.visibilityCondition; + delete fieldEl.dataset.visibilityComparator; + fieldEl.dataset.visibilityDependency = value; +}