diff options
Diffstat (limited to 'addons/website_form/static/src')
8 files changed, 2275 insertions, 0 deletions
diff --git a/addons/website_form/static/src/js/website_form_editor_registry.js b/addons/website_form/static/src/js/website_form_editor_registry.js new file mode 100644 index 00000000..35acc904 --- /dev/null +++ b/addons/website_form/static/src/js/website_form_editor_registry.js @@ -0,0 +1,57 @@ +odoo.define('website_form.form_editor_registry', function (require) { +'use strict'; + +var Registry = require('web.Registry'); + +return new Registry(); + +}); + +odoo.define('website_form.send_mail_form', function (require) { +'use strict'; + +var core = require('web.core'); +var FormEditorRegistry = require('website_form.form_editor_registry'); + +var _t = core._t; + +FormEditorRegistry.add('send_mail', { + formFields: [{ + type: 'char', + custom: true, + required: true, + name: 'Your Name', + }, { + type: 'tel', + custom: true, + name: 'Phone Number', + }, { + type: 'email', + modelRequired: true, + name: 'email_from', + string: 'Your Email', + }, { + type: 'char', + custom: true, + name: 'Your Company', + }, { + type: 'char', + modelRequired: true, + name: 'subject', + string: 'Subject', + }, { + type: 'text', + custom: true, + required: true, + name: 'Your Question', + }], + fields: [{ + name: 'email_to', + type: 'char', + required: true, + string: _t('Recipient Email'), + defaultValue: 'info@yourcompany.example.com', + }], +}); + +}); diff --git a/addons/website_form/static/src/scss/wysiwyg_snippets.scss b/addons/website_form/static/src/scss/wysiwyg_snippets.scss new file mode 100644 index 00000000..e4baf8d3 --- /dev/null +++ b/addons/website_form/static/src/scss/wysiwyg_snippets.scss @@ -0,0 +1,37 @@ +#oe_snippets > .o_we_customize_panel we-customizeblock-option { + we-list { + we-title, we-button { + margin-top: $o-we-sidebar-content-field-spacing; + } + .oe_we_table_wraper { + margin-top: $o-we-sidebar-content-field-spacing; + max-height: 200px; + overflow-y: auto; + + table { + table-layout: fixed; + width: 100%; + + input { + width: 100%; + border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color; + border-radius: $o-we-sidebar-content-field-border-radius; + padding: 0 $o-we-sidebar-content-field-clickable-spacing; + background-color: $o-we-sidebar-content-field-input-bg; + color: inherit; + font-family: $o-we-sidebar-content-field-input-font-family; + } + tr { + border: 1px solid rgba(white, 0.1); + border-left: none; + border-right: none; + } + td { + &:first-child, &:last-child { + width: 28px; + } + } + } + } + } +} diff --git a/addons/website_form/static/src/snippets/s_website_form/000.js b/addons/website_form/static/src/snippets/s_website_form/000.js new file mode 100644 index 00000000..492db41b --- /dev/null +++ b/addons/website_form/static/src/snippets/s_website_form/000.js @@ -0,0 +1,334 @@ +odoo.define('website_form.s_website_form', function (require) { + 'use strict'; + + var core = require('web.core'); + var time = require('web.time'); + const {ReCaptcha} = require('google_recaptcha.ReCaptchaV3'); + var ajax = require('web.ajax'); + var publicWidget = require('web.public.widget'); + const dom = require('web.dom'); + + var _t = core._t; + var qweb = core.qweb; + + publicWidget.registry.s_website_form = publicWidget.Widget.extend({ + selector: '.s_website_form form, form.s_website_form', // !compatibility + xmlDependencies: ['/website_form/static/src/xml/website_form.xml'], + events: { + 'click .s_website_form_send, .o_website_form_send': 'send', // !compatibility + }, + + /** + * @constructor + */ + init: function () { + this._super(...arguments); + this._recaptcha = new ReCaptcha(); + this.__started = new Promise(resolve => this.__startResolve = resolve); + }, + willStart: function () { + const res = this._super(...arguments); + if (!this.$target[0].classList.contains('s_website_form_no_recaptcha')) { + this._recaptchaLoaded = true; + this._recaptcha.loadLibs(); + } + return res; + }, + start: function () { + var self = this; + + // Initialize datetimepickers + var datepickers_options = { + minDate: moment({ y: 1000 }), + maxDate: moment({y: 9999, M: 11, d: 31}), + calendarWeeks: true, + icons: { + time: 'fa fa-clock-o', + date: 'fa fa-calendar', + next: 'fa fa-chevron-right', + previous: 'fa fa-chevron-left', + up: 'fa fa-chevron-up', + down: 'fa fa-chevron-down', + }, + locale: moment.locale(), + format: time.getLangDatetimeFormat(), + }; + this.$target.find('.s_website_form_datetime, .o_website_form_datetime').datetimepicker(datepickers_options); // !compatibility + + // Adapt options to date-only pickers + datepickers_options.format = time.getLangDateFormat(); + this.$target.find('.s_website_form_date, .o_website_form_date').datetimepicker(datepickers_options); // !compatibility + + // 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 + var $values = $('[data-for=' + this.$target.attr('id') + ']'); + if ($values.length) { + var values = JSON.parse($values.data('values').replace('False', '""').replace('None', '""').replace(/'/g, '"')); + var fields = _.pluck(this.$target.serializeArray(), 'name'); + _.each(fields, function (field) { + if (_.has(values, field)) { + var $field = self.$target.find('input[name="' + field + '"], textarea[name="' + field + '"]'); + if (!$field.val()) { + $field.val(values[field]); + $field.data('website_form_original_default_value', $field.val()); + } + } + }); + } + + return this._super(...arguments).then(() => this.__startResolve()); + }, + + destroy: function () { + this._super.apply(this, arguments); + this.$target.find('button').off('click'); + + // Empty imputs + this.$target[0].reset(); + + // Remove saving of the error colors + this.$target.find('.o_has_error').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid'); + + // Remove the status message + this.$target.find('#s_website_form_result, #o_website_form_result').empty(); // !compatibility + + // Remove the success message and display the form + this.$target.removeClass('d-none'); + this.$target.parent().find('.s_website_form_end_message').addClass('d-none'); + }, + + send: async function (e) { + e.preventDefault(); // Prevent the default submit behavior + // Prevent users from crazy clicking + this.$target.find('.s_website_form_send, .o_website_form_send') + .addClass('disabled') // !compatibility + .attr('disabled', 'disabled'); + + var self = this; + + self.$target.find('#s_website_form_result, #o_website_form_result').empty(); // !compatibility + if (!self.check_error_fields({})) { + self.update_status('error', _t("Please fill in the form correctly.")); + return false; + } + + // Prepare form inputs + this.form_fields = this.$target.serializeArray(); + $.each(this.$target.find('input[type=file]'), function (outer_index, input) { + $.each($(input).prop('files'), function (index, file) { + // Index field name as ajax won't accept arrays of files + // when aggregating multiple files into a single field value + self.form_fields.push({ + name: input.name + '[' + outer_index + '][' + index + ']', + value: file + }); + }); + }); + + // Serialize form inputs into a single object + // Aggregate multiple values into arrays + var form_values = {}; + _.each(this.form_fields, function (input) { + if (input.name in form_values) { + // If a value already exists for this field, + // we are facing a x2many field, so we store + // the values in an array. + if (Array.isArray(form_values[input.name])) { + form_values[input.name].push(input.value); + } else { + form_values[input.name] = [form_values[input.name], input.value]; + } + } else { + if (input.value !== '') { + form_values[input.name] = input.value; + } + } + }); + + // force server date format usage for existing fields + this.$target.find('.s_website_form_field:not(.s_website_form_custom)') + .find('.s_website_form_date, .s_website_form_datetime').each(function () { + var date = $(this).datetimepicker('viewDate').clone().locale('en'); + var format = 'YYYY-MM-DD'; + if ($(this).hasClass('s_website_form_datetime')) { + date = date.utc(); + format = 'YYYY-MM-DD HH:mm:ss'; + } + form_values[$(this).find('input').attr('name')] = date.format(format); + }); + + if (this._recaptchaLoaded) { + const tokenObj = await this._recaptcha.getToken('website_form'); + if (tokenObj.token) { + form_values['recaptcha_token_response'] = tokenObj.token; + } else if (tokenObj.error) { + self.update_status('error', tokenObj.error); + return false; + } + } + + // Post form and handle result + ajax.post(this.$target.attr('action') + (this.$target.data('force_action') || this.$target.data('model_name')), form_values) + .then(function (result_data) { + // Restore send button behavior + self.$target.find('.s_website_form_send, .o_website_form_send') + .removeAttr('disabled') + .removeClass('disabled'); // !compatibility + result_data = JSON.parse(result_data); + if (!result_data.id) { + // Failure, the server didn't return the created record ID + self.update_status('error', result_data.error ? result_data.error : false); + if (result_data.error_fields) { + // If the server return a list of bad fields, show these fields for users + self.check_error_fields(result_data.error_fields); + } + } else { + // Success, redirect or update status + let successMode = self.$target[0].dataset.successMode; + let successPage = self.$target[0].dataset.successPage; + if (!successMode) { + successPage = self.$target.attr('data-success_page'); // Compatibility + successMode = successPage ? 'redirect' : 'nothing'; + } + switch (successMode) { + case 'redirect': + if (successPage.charAt(0) === "#") { + dom.scrollTo($(successPage)[0], { + duration: 500, + extraOffset: 0, + }); + } else { + $(window.location).attr('href', successPage); + } + break; + case 'message': + self.$target[0].classList.add('d-none'); + self.$target[0].parentElement.querySelector('.s_website_form_end_message').classList.remove('d-none'); + break; + default: + self.update_status('success'); + break; + } + + // Reset the form + self.$target[0].reset(); + } + }) + .guardedCatch(function () { + self.update_status('error'); + }); + }, + + check_error_fields: function (error_fields) { + var self = this; + var form_valid = true; + // Loop on all fields + this.$target.find('.form-field, .s_website_form_field').each(function (k, field) { // !compatibility + var $field = $(field); + var field_name = $field.find('.col-form-label').attr('for'); + + // Validate inputs for this field + var inputs = $field.find('.s_website_form_input, .o_website_form_input').not('#editable_select'); // !compatibility + var invalid_inputs = inputs.toArray().filter(function (input, k, inputs) { + // Special check for multiple required checkbox for same + // field as it seems checkValidity forces every required + // checkbox to be checked, instead of looking at other + // checkboxes with the same name and only requiring one + // of them to be checked. + if (input.required && input.type === 'checkbox') { + // Considering we are currently processing a single + // field, we can assume that all checkboxes in the + // inputs variable have the same name + var checkboxes = _.filter(inputs, function (input) { + return input.required && input.type === 'checkbox'; + }); + return !_.any(checkboxes, checkbox => checkbox.checked); + + // Special cases for dates and datetimes + } else if ($(input).hasClass('s_website_form_date') || $(input).hasClass('o_website_form_date')) { // !compatibility + if (!self.is_datetime_valid(input.value, 'date')) { + return true; + } + } else if ($(input).hasClass('s_website_form_datetime') || $(input).hasClass('o_website_form_datetime')) { // !compatibility + if (!self.is_datetime_valid(input.value, 'datetime')) { + return true; + } + } + return !input.checkValidity(); + }); + + // Update field color if invalid or erroneous + $field.removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid'); + if (invalid_inputs.length || error_fields[field_name]) { + $field.addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid'); + if (_.isString(error_fields[field_name])) { + $field.popover({content: error_fields[field_name], trigger: 'hover', container: 'body', placement: 'top'}); + // update error message and show it. + $field.data("bs.popover").config.content = error_fields[field_name]; + $field.popover('show'); + } + form_valid = false; + } + }); + return form_valid; + }, + + is_datetime_valid: function (value, type_of_date) { + if (value === "") { + return true; + } else { + try { + this.parse_date(value, type_of_date); + return true; + } catch (e) { + return false; + } + } + }, + + // This is a stripped down version of format.js parse_value function + parse_date: function (value, type_of_date, value_if_empty) { + var date_pattern = time.getLangDateFormat(), + time_pattern = time.getLangTimeFormat(); + var date_pattern_wo_zero = date_pattern.replace('MM', 'M').replace('DD', 'D'), + time_pattern_wo_zero = time_pattern.replace('HH', 'H').replace('mm', 'm').replace('ss', 's'); + switch (type_of_date) { + case 'datetime': + var datetime = moment(value, [date_pattern + ' ' + time_pattern, date_pattern_wo_zero + ' ' + time_pattern_wo_zero], true); + if (datetime.isValid()) { + return time.datetime_to_str(datetime.toDate()); + } + throw new Error(_.str.sprintf(_t("'%s' is not a correct datetime"), value)); + case 'date': + var date = moment(value, [date_pattern, date_pattern_wo_zero], true); + if (date.isValid()) { + return time.date_to_str(date.toDate()); + } + throw new Error(_.str.sprintf(_t("'%s' is not a correct date"), value)); + } + return value; + }, + + update_status: function (status, message) { + if (status !== 'success') { // Restore send button behavior if result is an error + this.$target.find('.s_website_form_send, .o_website_form_send') + .removeAttr('disabled') + .removeClass('disabled'); // !compatibility + } + var $result = this.$('#s_website_form_result, #o_website_form_result'); // !compatibility + + if (status === 'error' && !message) { + message = _t("An error has occured, the form has not been sent."); + } + + // Note: we still need to wait that the widget is properly started + // before any qweb rendering which depends on xmlDependencies + // because the event handlers are binded before the call to + // willStart for public widgets... + this.__started.then(() => $result.replaceWith(qweb.render(`website_form.status_${status}`, { + message: message, + }))); + }, + }); +}); diff --git a/addons/website_form/static/src/snippets/s_website_form/000.scss b/addons/website_form/static/src/snippets/s_website_form/000.scss new file mode 100644 index 00000000..0ba43a0c --- /dev/null +++ b/addons/website_form/static/src/snippets/s_website_form/000.scss @@ -0,0 +1,61 @@ +.editor_enable .s_website_form:not([data-vcss]) { + // Select inputs do not trigger the default browser behavior + // Since we use a custom editable element + .form-field select { + pointer-events: none; + } + + .o_website_form_field_hidden { + display: flex; + opacity: 0.5; + } + + // Quickfix to display the editable select as a single big field + #editable_select.form-control { + height: 100%; + } +} + +.s_website_form:not([data-vcss]) { + // Radio buttons and checkboxes flex layout + .o_website_form_flex { + display: flex; + flex-wrap: wrap; + + &.o_website_form_flex_fw > .o_website_form_flex_item { + flex-basis: 100%; + } + &:not(.o_website_form_flex_fw) > .o_website_form_flex_item { + // col-lg-4 + flex-basis: 33%; + + // col-md-6 + @include media-breakpoint-down(md) { + flex-basis: 50%; + } + + // col-12 + @include media-breakpoint-down(sm) { + flex-basis: 100%; + } + } + } + + // Hidden field is only partially hidden in editor + .o_website_form_field_hidden { + display: none; + } + + // Required fields have a star which is not part of the field label + .o_website_form_required, .o_website_form_required_custom { + .col-form-label:after { + content: ' *'; + } + } + + // Fix for firefox browse button which is too big for Bootstrap form-field + // http://stackoverflow.com/questions/22049739/fix-for-firefox-file-input-using-bootstrap-3-1 + .form-field input[type=file].form-control { + height: 100%; + } +} diff --git a/addons/website_form/static/src/snippets/s_website_form/001.scss b/addons/website_form/static/src/snippets/s_website_form/001.scss new file mode 100644 index 00000000..375a4bb5 --- /dev/null +++ b/addons/website_form/static/src/snippets/s_website_form/001.scss @@ -0,0 +1,57 @@ +.editor_enable .s_website_form[data-vcss="001"] { + // Hidden field is only partially hidden in editor + .s_website_form_field_hidden { + display: block; + opacity: 0.5; + } + // Select inputs do not trigger the default browser behavior + // Since we use a custom editable element + .s_website_form_field select { + pointer-events: none; + } + // Display the editable select as a single big field + #editable_select.form-control { + height: auto; + } +} + +.s_website_form[data-vcss="001"] { + .s_website_form_label { + @include media-breakpoint-down(xs) { + width: auto !important; + } + } + + .s_website_form_field_hidden { + display: none; + } + + span.s_website_form_mark { + font-size: 0.85em; + font-weight: 400; + } + + .s_website_form_dnone { + display: none; + } + + // The snippet editor uses padding and not margin. + // This will include bootstrap margin in the dragable y axes + .s_website_form_rows > .form-group { + margin-bottom: 0; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + .s_website_form_submit, .s_website_form_recaptcha { + .s_website_form_label { + float: left; + height: 1px; + } + } + .s_website_form_no_submit_label { + .s_website_form_label { + display: none; + } + } +} diff --git a/addons/website_form/static/src/snippets/s_website_form/options.js b/addons/website_form/static/src/snippets/s_website_form/options.js new file mode 100644 index 00000000..774a4707 --- /dev/null +++ b/addons/website_form/static/src/snippets/s_website_form/options.js @@ -0,0 +1,1348 @@ +odoo.define('website_form_editor', function (require) { +'use strict'; + +const core = require('web.core'); +const FormEditorRegistry = require('website_form.form_editor_registry'); +const options = require('web_editor.snippets.options'); + +const qweb = core.qweb; +const _t = core._t; + +const FormEditor = options.Class.extend({ + xmlDependencies: [ + '/website_form/static/src/xml/website_form_editor.xml', + '/google_recaptcha/static/src/xml/recaptcha.xml', + ], + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * Returns a promise which is resolved once the records of the field + * have been retrieved. + * + * @private + * @param {Object} field + * @returns {Promise<Object>} + */ + _fetchFieldRecords: async function (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; + } + // Set selection as records to avoid added conplexity + if (field.type === 'selection') { + field.records = field.selection.map(el => ({ + id: el[0], + display_name: el[1], + })); + } else if (field.relation && field.relation !== 'ir.attachment') { + field.records = await this._rpc({ + model: field.relation, + method: 'search_read', + args: [ + field.domain, + ['display_name'] + ], + }); + } + return field.records; + }, + /** + * Returns a field object + * + * @private + * @param {string} type the type of the field + * @param {string} name The name of the field used also as label + * @returns {Object} + */ + _getCustomField: function (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'), + }], + }; + }, + /** + * Returns the default formatInfos of a field. + * + * @private + * @returns {Object} + */ + _getDefaultFormat: function () { + return { + labelWidth: this.$target[0].querySelector('.s_website_form_label').style.width, + labelPosition: 'left', + multiPosition: 'horizontal', + requiredMark: this._isRequiredMark(), + optionalMark: this._isOptionalMark(), + mark: this._getMark(), + }; + }, + /** + * @private + * @returns {string} + */ + _getMark: function () { + return this.$target[0].dataset.mark; + }, + /** + * @private + * @returns {boolean} + */ + _isOptionalMark: function () { + return this.$target[0].classList.contains('o_mark_optional'); + }, + /** + * @private + * @returns {boolean} + */ + _isRequiredMark: function () { + return this.$target[0].classList.contains('o_mark_required'); + }, + /** + * @private + * @param {Object} field + * @returns {Promise<HTMLElement>} + */ + _renderField: function (field) { + field.id = Math.random().toString(36).substring(2, 15); // Big unique ID + const template = document.createElement('template'); + template.innerHTML = qweb.render("website_form.field_" + field.type, {field: field}).trim(); + return template.content.firstElementChild; + }, +}); + +const FieldEditor = FormEditor.extend({ + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.formEl = this.$target[0].closest('form'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the target as a field Object + * + * @private + * @returns {Object} + */ + _getActiveField: function () { + let field; + const labelText = this.$target.find('.s_website_form_label_content').text(); + if (this._isFieldCustom()) { + field = this._getCustomField(this.$target[0].dataset.type, labelText); + } else { + field = Object.assign({}, this.fields[this._getFieldName()]); + field.string = labelText; + } + field.records = this._getListItems(); + this._setActiveProperties(field); + return field; + }, + /** + * Returns the format object of a field containing + * the position, labelWidth and bootstrap col class + * + * @private + * @returns {Object} + */ + _getFieldFormat: function () { + let requiredMark, optionalMark; + const mark = this.$target[0].querySelector('.s_website_form_mark'); + if (mark) { + requiredMark = this._isFieldRequired(); + optionalMark = !requiredMark; + } + const multipleInput = this._getMultipleInputs(); + const format = { + labelPosition: this._getLabelPosition(), + labelWidth: this.$target[0].querySelector('.s_website_form_label').style.width, + multiPosition: multipleInput && multipleInput.dataset.display || 'horizontal', + col: [...this.$target[0].classList].filter(el => el.match(/^col-/g)).join(' '), + requiredMark: requiredMark, + optionalMark: optionalMark, + mark: mark && mark.textContent, + }; + return format; + }, + /** + * Returns the name of the field + * + * @private + * @returns {string} + */ + _getFieldName: function () { + const multipleName = this.$target[0].querySelector('.s_website_form_multiple'); + return multipleName ? multipleName.dataset.name : this.$target[0].querySelector('.s_website_form_input').name; + }, + /** + * Returns the type of the field, can be used for both custom and existing fields + * + * @private + * @returns {string} + */ + _getFieldType: function () { + return this.$target[0].dataset.type; + }, + /** + * @private + * @returns {string} + */ + _getLabelPosition: function () { + const label = this.$target[0].querySelector('.s_website_form_label'); + if (this.$target[0].querySelector('.row:not(.s_website_form_multiple)')) { + return label.classList.contains('text-right') ? 'right' : 'left'; + } else { + return label.classList.contains('d-none') ? 'none' : 'top'; + } + }, + /** + * Returns the multiple checkbox/radio element if it exist else null + * + * @private + * @returns {HTMLElement} + */ + _getMultipleInputs: function () { + return this.$target[0].querySelector('.s_website_form_multiple'); + }, + /** + * @private + * @returns {string} + */ + _getPlaceholder: function () { + const input = this._getPlaceholderInput(); + return input ? input.placeholder : ''; + }, + /** + * Returns the field's input if it is placeholder compatible, else null + * + * @private + * @returns {HTMLElement} + */ + _getPlaceholderInput: function () { + return this.$target[0].querySelector('input[type="text"], input[type="email"], input[type="number"], input[type="tel"], input[type="url"], textarea'); + }, + /** + * Returns true if the field is a custom field, false if it is an existing field + * + * @private + * @returns {boolean} + */ + _isFieldCustom: function () { + return !!this.$target[0].classList.contains('s_website_form_custom'); + }, + /** + * Returns true if the field is required by the model or by the user. + * + * @private + * @returns {boolean} + */ + _isFieldRequired: function () { + const classList = this.$target[0].classList; + return classList.contains('s_website_form_required') || classList.contains('s_website_form_model_required'); + }, + /** + * Set the active field properties on the field Object + * + * @param {Object} field Field to complete with the active field info + */ + _setActiveProperties(field) { + const classList = this.$target[0].classList; + const textarea = this.$target[0].querySelector('textarea'); + field.placeholder = this._getPlaceholder(); + 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 = this._getFieldFormat(); + }, + /** + * Set the placeholder on the current field if the input allow it + * + * @private + * @param {string} value + */ + _setPlaceholder: function (value) { + const input = this._getPlaceholderInput(); + if (input) { + input.placeholder = value; + } + }, +}); + +options.registry.WebsiteFormEditor = FormEditor.extend({ + events: _.extend({}, options.Class.prototype.events || {}, { + 'click .toggle-edit-message': '_onToggleEndMessageClick', + }), + + /** + * @override + */ + willStart: async function () { + const _super = this._super.bind(this); + + // 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 = this.$target.attr('hide-change-model') !== undefined; + if (this.modelCantChange) { + return _super(...arguments); + } + + // Get list of website_form compatible models. + this.models = await this._rpc({ + model: "ir.model", + method: "search_read", + args: [ + [['website_form_access', '=', true]], + ['id', 'model', 'name', 'website_form_label', 'website_form_key'] + ], + }); + + const targetModelName = this.$target[0].dataset.model_name || 'mail.mail'; + this.activeForm = _.findWhere(this.models, {model: targetModelName}); + // Create the Form Action select + this.selectActionEl = document.createElement('we-select'); + this.selectActionEl.setAttribute('string', 'Action'); + this.selectActionEl.dataset.noPreview = 'true'; + this.models.forEach(el => { + const option = document.createElement('we-button'); + option.textContent = el.website_form_label; + option.dataset.selectAction = el.id; + this.selectActionEl.append(option); + }); + + return _super(...arguments); + }, + /** + * @override + */ + start: function () { + const proms = [this._super(...arguments)]; + // Disable text edition + this.$target.attr('contentEditable', false); + // Make button and recaptcha editable + this.$target.find('.s_website_form_send, .s_website_form_recaptcha').attr('contentEditable', true); + // Get potential message + this.$message = this.$target.parent().find('.s_website_form_end_message'); + this.showEndMessage = false; + // If the form has no model it means a new snippet has been dropped. + // Apply the default model selected in willStart on it. + if (!this.$target[0].dataset.model_name) { + proms.push(this._applyFormModel()); + } + return Promise.all(proms); + }, + /** + * @override + */ + cleanForSave: function () { + const model = this.$target[0].dataset.model_name; + // because apparently this can be called on the wrong widget and + // we may not have a model, or fields... + if (model) { + // we may be re-whitelisting already whitelisted fields. Doesn't + // really matter. + const fields = [...this.$target[0].querySelectorAll('.s_website_form_field:not(.s_website_form_custom) .s_website_form_input')].map(el => el.name); + if (fields.length) { + // ideally we'd only do this if saving the form + // succeeds... but no idea how to do that + this._rpc({ + model: 'ir.model.fields', + method: 'formbuilder_whitelist', + args: [model, _.uniq(fields)], + }); + } + } + if (this.$message.length) { + this.$target.removeClass('d-none'); + this.$message.addClass("d-none"); + } + + // Clear default values coming from data-for/data-values attributes + this.$target.find('input[name],textarea[name]').each(function () { + var original = $(this).data('website_form_original_default_value'); + if (original !== undefined && $(this).val() === original) { + $(this).val('').removeAttr('value'); + } + }); + }, + /** + * @override + */ + updateUI: async function () { + // If we want to rerender the xml we need to avoid the updateUI + // as they are asynchronous and the ui might try to update while + // we are building the UserValueWidgets. + if (this.rerender) { + this.rerender = false; + await this._rerenderXML(); + return; + } + await this._super.apply(this, arguments); + // End Message UI + this.updateUIEndMessage(); + }, + /** + * @see this.updateUI + */ + updateUIEndMessage: function () { + this.$target.toggleClass("d-none", this.showEndMessage); + this.$message.toggleClass("d-none", !this.showEndMessage); + this.$el.find(".toggle-edit-message").toggleClass('text-primary', this.showEndMessage); + }, + /** + * @override + */ + notify: function (name, data) { + this._super(...arguments); + if (name === 'field_mark') { + this._setLabelsMark(); + } else if (name === 'add_field') { + const field = this._getCustomField('char', 'Custom Text'); + field.formatInfo = data.formatInfo; + field.formatInfo.requiredMark = this._isRequiredMark(); + field.formatInfo.optionalMark = this._isOptionalMark(); + field.formatInfo.mark = this._getMark(); + const htmlField = this._renderField(field); + data.$target.after(htmlField); + this.trigger_up('activate_snippet', { + $snippet: $(htmlField), + }); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * 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: function (previewMode, value, params) { + const fieldName = params.fieldName; + if (params.isSelect === 'true') { + value = parseInt(value); + } + this._addHiddenField(value, fieldName); + }, + /** + * Changes the onSuccess event. + */ + onSuccess: function (previewMode, value, params) { + this.$target[0].dataset.successMode = value; + if (value === 'message') { + if (!this.$message.length) { + this.$message = $(qweb.render('website_form.s_website_form_end_message')); + } + this.$target.after(this.$message); + } else { + this.showEndMessage = false; + this.$message.remove(); + } + }, + /** + * Select the model to create with the form. + */ + selectAction: async function (previewMode, value, params) { + if (this.modelCantChange) { + return; + } + await this._applyFormModel(parseInt(value)); + this.rerender = true; + }, + /** + * @override + */ + selectClass: function (previewMode, value, params) { + this._super(...arguments); + if (params.name === 'field_mark_select') { + this._setLabelsMark(); + } + }, + /** + * Set the mark string on the form + */ + setMark: function (previewMode, value, params) { + this.$target[0].dataset.mark = value.trim(); + this._setLabelsMark(); + }, + /** + * Toggle the recaptcha legal terms + */ + toggleRecaptchaLegal: function (previewMode, value, params) { + const recaptchaLegalEl = this.$target[0].querySelector('.s_website_form_recaptcha'); + if (recaptchaLegalEl) { + recaptchaLegalEl.remove(); + } else { + const template = document.createElement('template'); + const labelWidth = this.$target[0].querySelector('.s_website_form_label').style.width; + template.innerHTML = qweb.render("webite_form.s_website_form_recaptcha_legal", {labelWidth: labelWidth}); + const legal = template.content.firstElementChild; + legal.setAttribute('contentEditable', true); + this.$target.find('.s_website_form_submit').before(legal); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'selectAction': + return this.activeForm.id; + case 'addActionField': { + const value = this.$target.find(`.s_website_form_dnone input[name="${params.fieldName}"]`).val(); + if (value) { + return value; + } else { + return params.isSelect ? '0' : ''; + } + } + case 'onSuccess': + return this.$target[0].dataset.successMode; + case 'setMark': + return this._getMark(); + case 'toggleRecaptchaLegal': + return !this.$target[0].querySelector('.s_website_form_recaptcha') || ''; + } + return this._super(...arguments); + }, + /** + * @override + */ + _renderCustomXML: function (uiFragment) { + if (this.modelCantChange) { + return; + } + // Add Action select + const firstOption = uiFragment.childNodes[0]; + uiFragment.insertBefore(this.selectActionEl.cloneNode(true), firstOption); + + // Add Action related options + const formKey = this.activeForm.website_form_key; + const formInfo = FormEditorRegistry.get(formKey); + if (!formInfo || !formInfo.fields) { + return; + } + const proms = formInfo.fields.map(field => this._fetchFieldRecords(field)); + return Promise.all(proms).then(() => { + formInfo.fields.forEach(field => { + let option; + switch (field.type) { + case 'many2one': + option = this._buildSelect(field); + break; + case 'char': + option = this._buildInput(field); + break; + } + if (field.required) { + // Try to retrieve hidden value in form, else, + // get default value or for many2one fields the first option. + const currentValue = this.$target.find(`.s_website_form_dnone input[name="${field.name}"]`).val(); + const defaultValue = field.defaultValue || field.records[0].id; + this._addHiddenField(currentValue || defaultValue, field.name); + } + uiFragment.insertBefore(option, firstOption); + }); + }); + }, + /** + * Add a hidden field to the form + * + * @private + * @param {string} value + * @param {string} fieldName + */ + _addHiddenField: function (value, fieldName) { + this.$target.find(`.s_website_form_dnone:has(input[name="${fieldName}"])`).remove(); + if (value) { + const hiddenField = qweb.render('website_form.field_hidden', { + field: { + name: fieldName, + value: value, + }, + }); + this.$target.find('.s_website_form_submit').before(hiddenField); + } + }, + /** + * Returns a we-input element from the field + * + * @private + * @param {Object} field + * @returns {HTMLElement} + */ + _buildInput: function (field) { + const inputEl = document.createElement('we-input'); + inputEl.dataset.noPreview = 'true'; + inputEl.dataset.fieldName = field.name; + inputEl.dataset.addActionField = ''; + inputEl.setAttribute('string', field.string); + inputEl.classList.add('o_we_large_input'); + return inputEl; + }, + /** + * Returns a we-select element with field's records as it's options + * + * @private + * @param {Object} field + * @return {HTMLElement} + */ + _buildSelect: function (field) { + const selectEl = document.createElement('we-select'); + selectEl.dataset.noPreview = 'true'; + selectEl.dataset.fieldName = field.name; + selectEl.dataset.isSelect = 'true'; + selectEl.setAttribute('string', field.string); + if (!field.required) { + const noneButton = document.createElement('we-button'); + noneButton.textContent = 'None'; + noneButton.dataset.addActionField = 0; + selectEl.append(noneButton); + } + field.records.forEach(el => { + const button = document.createElement('we-button'); + button.textContent = el.display_name; + button.dataset.addActionField = el.id; + selectEl.append(button); + }); + return selectEl; + }, + /** + * Apply the model on the form changing it's fields + * + * @private + * @param {Integer} modelId + */ + _applyFormModel: async function (modelId) { + let oldFormInfo; + if (modelId) { + const oldFormKey = this.activeForm.website_form_key; + if (oldFormKey) { + oldFormInfo = FormEditorRegistry.get(oldFormKey); + } + this.$target.find('.s_website_form_field').remove(); + this.activeForm = _.findWhere(this.models, {id: modelId}); + } + const formKey = this.activeForm.website_form_key; + const formInfo = FormEditorRegistry.get(formKey); + // Success page + if (!this.$target[0].dataset.successMode) { + this.$target[0].dataset.successMode = 'redirect'; + } + if (this.$target[0].dataset.successMode === 'redirect') { + const currentSuccessPage = this.$target[0].dataset.successPage; + if (formInfo && formInfo.successPage) { + this.$target[0].dataset.successPage = formInfo.successPage; + } else if (!oldFormInfo || (oldFormInfo !== formInfo && oldFormInfo.successPage && currentSuccessPage === oldFormInfo.successPage)) { + this.$target[0].dataset.successPage = '/contactus-thank-you'; + } + } + // Model name + this.$target[0].dataset.model_name = this.activeForm.model; + // Load template + if (formInfo) { + const formatInfo = this._getDefaultFormat(); + await formInfo.formFields.forEach(async field => { + field.formatInfo = formatInfo; + await this._fetchFieldRecords(field); + this.$target.find('.s_website_form_submit, .s_website_form_recaptcha').first().before(this._renderField(field)); + }); + } + }, + /** + * Set the correct mark on all fields. + * + * @private + */ + _setLabelsMark: function () { + this.$target[0].querySelectorAll('.s_website_form_mark').forEach(el => el.remove()); + const mark = this._getMark(); + if (!mark) { + return; + } + let fieldsToMark = []; + const requiredSelector = '.s_website_form_model_required, .s_website_form_required'; + const fields = Array.from(this.$target[0].querySelectorAll('.s_website_form_field')); + if (this._isRequiredMark()) { + fieldsToMark = fields.filter(el => el.matches(requiredSelector)); + } else if (this._isOptionalMark()) { + fieldsToMark = fields.filter(el => !el.matches(requiredSelector)); + } + fieldsToMark.forEach(field => { + let span = document.createElement('span'); + span.classList.add('s_website_form_mark'); + span.textContent = ` ${mark}`; + field.querySelector('.s_website_form_label').appendChild(span); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onToggleEndMessageClick: function () { + this.showEndMessage = !this.showEndMessage; + this.updateUIEndMessage(); + this.trigger_up('activate_snippet', { + $snippet: this.showEndMessage ? this.$message : this.$target, + }); + }, +}); + +const authorizedFieldsCache = {}; + +options.registry.WebsiteFieldEditor = FieldEditor.extend({ + events: _.extend({}, FieldEditor.prototype.events, { + 'click we-button.o_we_select_remove_option': '_onRemoveItemClick', + 'click we-button.o_we_list_add_optional': '_onAddCustomItemClick', + 'click we-button.o_we_list_add_existing': '_onAddExistingItemClick', + 'click we-list we-select': '_onAddItemSelectClick', + 'input we-list input': '_onListItemInput', + }), + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.rerender = true; + }, + /** + * @override + */ + willStart: async function () { + const _super = this._super.bind(this); + // Get the authorized existing fields for the form model + const model = this.formEl.dataset.model_name; + let getFields; + if (model in authorizedFieldsCache) { + getFields = authorizedFieldsCache[model]; + } else { + getFields = this._rpc({ + model: "ir.model", + method: "get_authorized_fields", + args: [model], + }); + authorizedFieldsCache[model] = getFields; + } + + this.existingFields = await getFields.then(fields => { + this.fields = _.each(fields, function (field, fieldName) { + field.name = fieldName; + field.domain = field.domain || []; + }); + // Create the buttons for the type we-select + return Object.keys(fields).map(key => { + const field = fields[key]; + const button = document.createElement('we-button'); + button.textContent = field.string; + button.dataset.existingField = field.name; + return button; + }).sort((a, b) => (a.textContent > b.textContent) ? 1 : (a.textContent < b.textContent) ? -1 : 0); + }); + return _super(...arguments); + }, + /** + * @override + */ + cleanForSave: function () { + this.$target[0].querySelectorAll('#editable_select').forEach(el => el.remove()); + const select = this._getSelect(); + if (select && this.listTable) { + select.style.display = ''; + select.innerHTML = ''; + // Rebuild the select from the we-list + this.listTable.querySelectorAll('input').forEach(el => { + const option = document.createElement('option'); + option.textContent = el.value; + option.value = this._isFieldCustom() ? el.value : el.name; + select.appendChild(option); + }); + } + }, + /** + * @override + */ + updateUI: async function () { + // See Form updateUI + if (this.rerender) { + const select = this._getSelect(); + if (select && !this.$target[0].querySelector('#editable_select')) { + select.style.display = 'none'; + const editableSelect = document.createElement('div'); + editableSelect.id = 'editable_select'; + editableSelect.classList = 'form-control s_website_form_input'; + select.parentElement.appendChild(editableSelect); + } + this.rerender = false; + await this._rerenderXML().then(() => this._renderList()); + return; + } + await this._super.apply(this, arguments); + }, + /** + * @override + */ + onFocus: function () { + // Other fields type might have change to an existing type. + // We need to reload the existing type list. + this.rerender = true; + }, + + //---------------------------------------------------------------------- + // Options + //---------------------------------------------------------------------- + + /** + * Replace the current field with the custom field selected. + */ + customField: async function (previewMode, value, params) { + // Both custom Field and existingField are called when selecting an option + // value is '' for the method that should not be called. + if (!value) { + return; + } + const name = this.el.querySelector(`[data-custom-field="${value}"]`).textContent; + const field = this._getCustomField(value, `Custom ${name}`); + this._setActiveProperties(field); + await this._replaceField(field); + this.rerender = true; + }, + /** + * Replace the current field with the existing field selected. + */ + existingField: async function (previewMode, value, params) { + // see customField + if (!value) { + return; + } + const field = Object.assign({}, this.fields[value]); + this._setActiveProperties(field); + await this._replaceField(field); + this.rerender = true; + }, + /** + * Set the name of the field on the label + */ + setLabelText: function (previewMode, value, params) { + this.$target.find('.s_website_form_label_content').text(value); + if (this._isFieldCustom()) { + const multiple = this.$target[0].querySelector('.s_website_form_multiple'); + if (multiple) { + multiple.dataset.name = value; + } + this.$target[0].querySelectorAll('.s_website_form_input').forEach(el => el.name = value); + } + }, + /* + * Set the placeholder of the input + */ + setPlaceholder: function (previewMode, value, params) { + this._setPlaceholder(value); + }, + /** + * Replace the field with the same field having the label in a different position. + */ + selectLabelPosition: async function (previewMode, value, params) { + const field = this._getActiveField(); + field.formatInfo.labelPosition = value; + await this._replaceField(field); + this.rerender = true; + }, + selectType: async function (previewMode, value, params) { + const field = this._getActiveField(); + field.type = value; + await this._replaceField(field); + }, + /** + * Select the display of the multicheckbox field (vertical & horizontal) + */ + multiCheckboxDisplay: function (previewMode, value, params) { + const target = this._getMultipleInputs(); + target.querySelectorAll('.checkbox, .radio').forEach(el => { + if (value === 'horizontal') { + el.classList.add('col-lg-4', 'col-md-6'); + } else { + el.classList.remove('col-lg-4', 'col-md-6'); + } + }); + target.dataset.display = value; + }, + /** + * Set the field as required or not + */ + toggleRequired: function (previewMode, value, params) { + const isRequired = this.$target[0].classList.contains(params.activeValue); + this.$target[0].classList.toggle(params.activeValue, !isRequired); + this.$target[0].querySelectorAll('input, select, textarea').forEach(el => el.toggleAttribute('required', !isRequired)); + this.trigger_up('option_update', { + optionName: 'WebsiteFormEditor', + name: 'field_mark', + }); + }, + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'customField': + return this._isFieldCustom() ? this._getFieldType() : ''; + case 'existingField': + return this._isFieldCustom() ? '' : this._getFieldName(); + case 'setLabelText': + return this.$target.find('.s_website_form_label_content').text(); + case 'setPlaceholder': + return this._getPlaceholder(); + case 'selectLabelPosition': + return this._getLabelPosition(); + case 'selectType': + return this._getFieldType(); + case 'multiCheckboxDisplay': { + const target = this._getMultipleInputs(); + return target ? target.dataset.display : ''; + } + case 'toggleRequired': + return this.$target[0].classList.contains(params.activeValue) ? params.activeValue : 'false'; + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility: function (widgetName, params) { + switch (widgetName) { + case 'char_input_type_opt': + return !this.$target[0].classList.contains('s_website_form_custom') && ['char', 'email', 'tel', 'url'].includes(this.$target[0].dataset.type); + case 'multi_check_display_opt': + return !!this._getMultipleInputs(); + case 'placeholder_opt': + return !!this._getPlaceholderInput(); + case 'required_opt': + case 'hidden_opt': + case 'type_opt': + return !this.$target[0].classList.contains('s_website_form_model_required'); + } + return this._super(...arguments); + }, + /** + * @override + */ + _renderCustomXML: function (uiFragment) { + const selectEl = uiFragment.querySelector('we-select[data-name="type_opt"]'); + const currentFieldName = this._getFieldName(); + const fieldsInForm = Array.from(this.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 = this.existingFields.filter(el => !fieldsInForm.includes(el.dataset.existingField)); + if (availableFields.length) { + const title = document.createElement('we-title'); + title.textContent = 'Existing fields'; + availableFields.unshift(title); + availableFields.forEach(option => selectEl.append(option.cloneNode(true))); + } + }, + /** + * Replace the target content with the field provided + * + * @private + * @param {Object} field + * @returns {Promise} + */ + _replaceField: async function (field) { + await this._fetchFieldRecords(field); + const htmlField = this._renderField(field); + [...this.$target[0].childNodes].forEach(node => node.remove()); + [...htmlField.childNodes].forEach(node => this.$target[0].appendChild(node)); + [...htmlField.attributes].forEach(el => this.$target[0].removeAttribute(el.nodeName)); + [...htmlField.attributes].forEach(el => this.$target[0].setAttribute(el.nodeName, el.nodeValue)); + }, + + /** + * To do after rerenderXML to add the list to the options + * + * @private + */ + _renderList: function () { + let addItemButton, addItemTitle, listTitle; + const select = this._getSelect(); + const multipleInputs = this._getMultipleInputs(); + this.listTable = document.createElement('table'); + const isCustomField = this._isFieldCustom(); + + if (select) { + listTitle = 'Options List'; + addItemTitle = 'Add new Option'; + select.querySelectorAll('option').forEach(opt => { + this._addItemToTable(opt.value, opt.textContent.trim()); + }); + this._renderListItems(); + } else if (multipleInputs) { + listTitle = multipleInputs.querySelector('.radio') ? 'Radio List' : 'Checkbox List'; + addItemTitle = 'Add new Checkbox'; + multipleInputs.querySelectorAll('.checkbox, .radio').forEach(opt => { + this._addItemToTable(opt.querySelector('input').value, opt.querySelector('.s_website_form_check_label').textContent.trim()); + }); + } else { + return; + } + + if (isCustomField) { + addItemButton = document.createElement('we-button'); + addItemButton.textContent = addItemTitle; + addItemButton.classList.add('o_we_list_add_optional'); + addItemButton.dataset.noPreview = 'true'; + } else { + addItemButton = document.createElement('we-select'); + addItemButton.classList.add('o_we_user_value_widget'); // Todo dont use user value widget class + const togglerEl = document.createElement('we-toggler'); + togglerEl.textContent = addItemTitle; + addItemButton.appendChild(togglerEl); + const selectMenuEl = document.createElement('we-selection-items'); + addItemButton.appendChild(selectMenuEl); + this._loadListDropdown(selectMenuEl); + } + const selectInputEl = document.createElement('we-list'); + const title = document.createElement('we-title'); + title.textContent = listTitle; + selectInputEl.appendChild(title); + const tableWrapper = document.createElement('div'); + tableWrapper.classList.add('oe_we_table_wraper'); + tableWrapper.appendChild(this.listTable); + selectInputEl.appendChild(tableWrapper); + selectInputEl.appendChild(addItemButton); + this.el.insertBefore(selectInputEl, this.el.querySelector('[data-set-placeholder]')); + this._makeListItemsSortable(); + }, + /** + * Load the dropdown of the list with the records missing from the list. + * + * @private + * @param {HTMLElement} selectMenu + */ + _loadListDropdown: function (selectMenu) { + selectMenu = selectMenu || this.el.querySelector('we-list we-selection-items'); + if (selectMenu) { + selectMenu.innerHTML = ''; + const field = Object.assign({}, this.fields[this._getFieldName()]); + this._fetchFieldRecords(field).then(() => { + let buttonItems; + const optionIds = Array.from(this.listTable.querySelectorAll('input')).map(opt => { + return field.type === 'selection' ? opt.name : parseInt(opt.name); + }); + const availableRecords = (field.records || []).filter(el => !optionIds.includes(el.id)); + if (availableRecords.length) { + buttonItems = availableRecords.map(el => { + const option = document.createElement('we-button'); + option.classList.add('o_we_list_add_existing'); + option.dataset.addOption = el.id; + option.dataset.noPreview = 'true'; + option.textContent = el.display_name; + return option; + }); + } else { + const title = document.createElement('we-title'); + title.textContent = 'No more records'; + buttonItems = [title]; + } + buttonItems.forEach(button => selectMenu.appendChild(button)); + }); + } + }, + /** + * @private + */ + _makeListItemsSortable: function () { + $(this.listTable).sortable({ + axis: 'y', + handle: '.o_we_drag_handle', + items: 'tr', + cursor: 'move', + opacity: 0.6, + stop: (event, ui) => { + this._renderListItems(); + }, + }); + }, + /** + * @private + * @param {string} id + * @param {string} text + */ + _addItemToTable: function (id, text) { + const isCustomField = this._isFieldCustom(); + const draggableEl = document.createElement('we-button'); + draggableEl.classList.add('o_we_drag_handle', 'o_we_link', 'fa', 'fa-fw', 'fa-arrows'); + draggableEl.dataset.noPreview = 'true'; + const inputEl = document.createElement('input'); + inputEl.type = 'text'; + if (text) { + inputEl.value = text; + } + if (!isCustomField && id) { + inputEl.name = id; + } + inputEl.disabled = !isCustomField; + const trEl = document.createElement('tr'); + const buttonEl = document.createElement('we-button'); + buttonEl.classList.add('o_we_select_remove_option', 'o_we_link', 'o_we_text_danger', 'fa', 'fa-fw', 'fa-minus'); + buttonEl.dataset.removeOption = id; + buttonEl.dataset.noPreview = 'true'; + const draggableTdEl = document.createElement('td'); + const inputTdEl = document.createElement('td'); + const buttonTdEl = document.createElement('td'); + draggableTdEl.appendChild(draggableEl); + trEl.appendChild(draggableTdEl); + inputTdEl.appendChild(inputEl); + trEl.appendChild(inputTdEl); + buttonTdEl.appendChild(buttonEl); + trEl.appendChild(buttonTdEl); + this.listTable.appendChild(trEl); + if (isCustomField) { + inputEl.focus(); + } + }, + /** + * Apply the we-list on the target and rebuild the input(s) + * + * @private + */ + _renderListItems: function () { + const multiInputsWrap = this._getMultipleInputs(); + const selectWrap = this.$target[0].querySelector('#editable_select'); + const isRequiredField = this._isFieldRequired(); + const name = this._getFieldName(); + if (multiInputsWrap) { + const type = multiInputsWrap.querySelector('.radio') ? 'radio' : 'checkbox'; + multiInputsWrap.innerHTML = ''; + const params = { + field: { + name: name, + id: Math.random().toString(36).substring(2, 15), // Big unique ID + required: isRequiredField, + formatInfo: { + multiPosition: multiInputsWrap.dataset.display, + } + } + }; + this._getListItems().forEach((record, idx) => { + params.record_index = idx; + params.record = record; + const template = document.createElement('template'); + template.innerHTML = qweb.render(`website_form.${type}`, params); + multiInputsWrap.appendChild(template.content.firstElementChild); + }); + } else if (selectWrap) { + selectWrap.innerHTML = ''; + this.listTable.querySelectorAll('input').forEach(el => { + const option = document.createElement('div'); + option.id = (el.name || el.value); + option.classList.add('s_website_form_select_item'); + option.textContent = el.value; + selectWrap.appendChild(option); + }); + } + }, + /** + * Returns an array based on the we-list containing the field's records + * + * @returns {Array} + */ + _getListItems: function () { + if (!this.listTable) { + return null; + } + const isCustomField = this._isFieldCustom(); + const records = []; + this.listTable.querySelectorAll('input').forEach(el => { + const id = isCustomField ? el.value : el.name; + records.push({ + id: id, + display_name: el.value, + }); + }); + return records; + }, + /** + * Returns the select element if it exist else null + * + * @private + * @returns {HTMLElement} + */ + _getSelect: function () { + return this.$target[0].querySelector('select'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onRemoveItemClick: function (ev) { + ev.target.closest('tr').remove(); + this._loadListDropdown(); + this._renderListItems(); + }, + /** + * @private + * @param {Event} ev + */ + _onAddCustomItemClick: function (ev) { + this._addItemToTable(); + this._makeListItemsSortable(); + this._renderListItems(); + }, + /** + * @private + * @param {Event} ev + */ + _onAddExistingItemClick: function (ev) { + const value = ev.currentTarget.dataset.addOption; + this._addItemToTable(value, ev.currentTarget.textContent); + this._makeListItemsSortable(); + this._loadListDropdown(); + this._renderListItems(); + }, + /** + * @private + * @param {Event} ev + */ + _onAddItemSelectClick: function (ev) { + ev.currentTarget.querySelector('we-toggler').classList.toggle('active'); + }, + /** + * @private + */ + _onListItemInput: function () { + this._renderListItems(); + }, +}); + +options.registry.AddFieldForm = FormEditor.extend({ + isTopOption: true, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Add a char field at the end of the form. + * New field is set as active + */ + addField: async function (previewMode, value, params) { + const field = this._getCustomField('char', 'Custom Text'); + field.formatInfo = this._getDefaultFormat(); + const htmlField = this._renderField(field); + this.$target.find('.s_website_form_submit, .s_website_form_recaptcha').first().before(htmlField); + this.trigger_up('activate_snippet', { + $snippet: $(htmlField), + }); + }, +}); + +options.registry.AddField = FieldEditor.extend({ + isTopOption: true, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Add a char field with active field properties after the active field. + * New field is set as active + */ + addField: async function (previewMode, value, params) { + this.trigger_up('option_update', { + optionName: 'WebsiteFormEditor', + name: 'add_field', + data: { + formatInfo: this._getFieldFormat(), + $target: this.$target, + }, + }); + }, +}); + +// Superclass for options that need to disable a button from the snippet overlay +const DisableOverlayButtonOption = options.Class.extend({ + // Disable a button of the snippet overlay + disableButton: function (buttonName, message) { + // TODO refactor in master + const className = 'oe_snippet_' + buttonName; + this.$overlay.add(this.$overlay.data('$optionsSection')).on('click', '.' + className, this.preventButton); + const $button = this.$overlay.add(this.$overlay.data('$optionsSection')).find('.' + className); + $button.attr('title', message).tooltip({delay: 0}); + $button.removeClass(className); // Disable the functionnality + }, + + preventButton: function (event) { + // Snippet options bind their functions before the editor, so we + // can't cleanly unbind the editor onRemove function from here + event.preventDefault(); + event.stopImmediatePropagation(); + } +}); + +// Disable duplicate button for model fields +options.registry.WebsiteFormFieldModel = DisableOverlayButtonOption.extend({ + start: function () { + this.disableButton('clone', _t('You can\'t duplicate a model field.')); + return this._super.apply(this, arguments); + } +}); + +// Disable delete button for model required fields +options.registry.WebsiteFormFieldRequired = DisableOverlayButtonOption.extend({ + start: function () { + this.disableButton('remove', _t('You can\'t remove a field that is required by the model itself.')); + return this._super.apply(this, arguments); + } +}); + +// Disable delete and duplicate button for submit +options.registry.WebsiteFormSubmitRequired = DisableOverlayButtonOption.extend({ + start: function () { + this.disableButton('remove', _t('You can\'t remove the submit button of the form')); + this.disableButton('clone', _t('You can\'t duplicate the submit button of the form.')); + return this._super.apply(this, arguments); + } +}); +}); diff --git a/addons/website_form/static/src/xml/website_form.xml b/addons/website_form/static/src/xml/website_form.xml new file mode 100644 index 00000000..9c1390c7 --- /dev/null +++ b/addons/website_form/static/src/xml/website_form.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <!-- Success status --> + <t t-name="website_form.status_success"> + <span id="s_website_form_result" class="text-success ml8"> + <i class="fa fa-check mr4" role="img" aria-label="Success" title="Success"/>The form has been sent successfully. + </span> + </t> + + <!-- Error status --> + <t t-name="website_form.status_error"> + <span id="s_website_form_result" class="text-danger ml8"> + <i class="fa fa-close mr4" role="img" aria-label="Error" title="Error"/> + <t t-esc="message"/> + </span> + </t> +</templates> diff --git a/addons/website_form/static/src/xml/website_form_editor.xml b/addons/website_form/static/src/xml/website_form_editor.xml new file mode 100644 index 00000000..1450fb8c --- /dev/null +++ b/addons/website_form/static/src/xml/website_form_editor.xml @@ -0,0 +1,363 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <!-- End Message --> + <t t-name="website_form.s_website_form_end_message"> + <div class="s_website_form_end_message d-none"> + <div class="oe_structure"> + <section class="s_text_block pt64 pb64 o_colored_level o_cc o_cc2" data-snippet="s_text_block"> + <div class="container"> + <h2 class="text-center"> + <span class="fa fa-check-circle"/> + Thank You For Your Feedback + </h2> + <p class="text-center"> + Our team will message you back as soon as possible.<br/> + In the meantime we invite you to visit our <a href="/">website</a>.<br/> + </p> + </div> + </section> + </div> + </div> + </t> + + <t t-name="webite_form.s_website_form_recaptcha_legal"> + <div class="col-12 s_website_form_recaptcha" data-name="Recaptcha Legal"> + <div t-attf-style="width: #{labelWidth or '200px'}" class="s_website_form_label"/> + <div class="col-sm"> + <t t-call="google_recaptcha.recaptcha_legal_terms"/> + </div> + </div> + </t> + + <!-- Generic Field Layout --> + <!-- Changes made here needs to be reflected in the different Form view (Contact Us, Jobs, ...) --> + <t t-name="website_form.field"> + <div t-attf-class="form-group s_website_form_field #{field.formatInfo.col or 'col-12'} #{field.custom and 's_website_form_custom' or ''} #{(field.required and 's_website_form_required' or '') or (field.modelRequired and 's_website_form_model_required' or '')} #{field.hidden and 's_website_form_field_hidden' or ''} #{field.dnone and 's_website_form_dnone' or ''}" + t-att-data-type="field.type" + data-name="Field"> + <div t-if="field.formatInfo.labelPosition != 'none' and field.formatInfo.labelPosition != 'top'" class="row s_col_no_resize s_col_no_bgcolor"> + <label t-attf-class="#{!field.isCheck and 'col-form-label' or ''} col-sm-auto s_website_form_label #{field.formatInfo.labelPosition == 'right' and 'text-right' or ''}" t-attf-style="width: #{field.formatInfo.labelWidth or '200px'}" t-att-for="field.id"> + <t t-call="website_form.label_content"/> + </label> + <div class="col-sm"> + <t t-raw="0"/> + </div> + </div> + <t t-else=""> + <label t-attf-class="s_website_form_label #{field.formatInfo.labelPosition == 'none' and 'd-none' or ''}" t-attf-style="width: #{field.formatInfo.labelWidth or '200px'}" t-att-for="field.id"> + <t t-call="website_form.label_content"/> + </label> + <t t-raw="0"/> + </t> + </div> + </t> + + <t t-name="website_form.label_content"> + <t t-if="field.custom" t-set="field.string" t-value="field.name"/> + <span class="s_website_form_label_content" t-esc="field.string"/> + <t t-if="field.required or field.modelRequired"> + <span class="s_website_form_mark" t-if="field.formatInfo.requiredMark" t-esc="' ' + field.formatInfo.mark"/> + </t> + <t t-else=""> + <span class="s_website_form_mark" t-if="field.formatInfo.optionalMark" t-esc="' ' + field.formatInfo.mark"/> + </t> + <span t-if="['email_cc', 'email_to'].includes(field.name)" title="Separate email addresses with a comma."> + <i class="fa fa-info-circle"/> + </span> + </t> + + <!-- Hidden Field --> + <t t-name="website_form.field_hidden"> + <t t-set="field.dnone" t-value="true"/> + <t t-set="field.formatInfo" t-value="{}"/> + <t t-call="website_form.field"> + <input + type="hidden" + class="form-control s_website_form_input" + t-att-name="field.name" + t-att-value="field.value" + t-att-id="field.id" + /> + </t> + </t> + + <!-- Char Field --> + <t t-name="website_form.field_char"> + <t t-call="website_form.field"> + <input + t-att-type="field.inputType || 'text'" + class="form-control s_website_form_input" + t-att-name="field.name" + t-att-required="field.required || field.modelRequired || None" + t-att-value="field.value" + t-att-placeholder="field.placeholder" + t-att-id="field.id" + /> + </t> + </t> + + <!-- Email Field --> + <t t-name="website_form.field_email"> + <t t-set="field.inputType" t-value="'email'"/> + <t t-call="website_form.field_char"/> + </t> + + <!-- Telephone Field --> + <t t-name="website_form.field_tel"> + <t t-set="field.inputType" t-value="'tel'"/> + <t t-call="website_form.field_char"/> + </t> + + <!-- Url Field --> + <t t-name="website_form.field_url"> + <t t-set="field.inputType" t-value="'url'"/> + <t t-call="website_form.field_char"/> + </t> + + <!-- Text Field --> + <t t-name="website_form.field_text"> + <t t-call="website_form.field"> + <textarea + class="form-control s_website_form_input" + t-att-name="field.name" + t-att-required="field.required || field.modelRequired || None" + t-att-placeholder="field.placeholder" + t-att-id="field.id" + t-att-rows="field.rows || 3" + /> + </t> + </t> + + <!-- HTML Field --> + <t t-name="website_form.field_html"> + <!-- + Maybe use web_editor ? Not sure it actually makes + sense to have random people editing html in a form... + --> + <t t-call="website_form.field_text"/> + </t> + + <!-- Integer Field --> + <t t-name="website_form.field_integer"> + <t t-call="website_form.field"> + <input + type="number" + class="form-control s_website_form_input" + t-att-name="field.name" + step="1" + t-att-required="field.required || field.modelRequired || None" + t-att-placeholder="field.placeholder" + t-att-id="field.id" + /> + </t> + </t> + + <!-- Float Field --> + <t t-name="website_form.field_float"> + <t t-call="website_form.field"> + <input + type="number" + class="form-control s_website_form_input" + t-att-name="field.name" + step="any" + t-att-required="field.required || field.modelRequired || None" + t-att-placeholder="field.placeholder" + t-att-id="field.id" + /> + </t> + </t> + + <!-- Date Field --> + <t t-name="website_form.field_date"> + <t t-call="website_form.field"> + <t t-set="datepickerID" t-value="'datepicker' + Math.random().toString().substring(2)"/> + <div class="s_website_form_date input-group date" t-att-id="datepickerID" data-target-input="nearest"> + <input + type="text" + class="form-control datetimepicker-input s_website_form_input" + t-attf-data-target="##{datepickerID}" + t-att-name="field.name" + t-att-required="field.required || field.modelRequired || None" + t-att-placeholder="field.placeholder" + t-att-id="field.id" + /> + <div class="input-group-append" t-attf-data-target="##{datepickerID}" data-toggle="datetimepicker"> + <div class="input-group-text"><i class="fa fa-calendar"></i></div> + </div> + </div> + </t> + </t> + + <!-- Datetime Field --> + <t t-name="website_form.field_datetime"> + <t t-call="website_form.field"> + <t t-set="datetimepickerID" t-value="'datetimepicker' + Math.random().toString().substring(2)"/> + <div class="s_website_form_datetime input-group date" t-att-id="datetimepickerID" data-target-input="nearest"> + <input + type="text" + class="form-control datetimepicker-input s_website_form_input" + t-attf-data-target="##{datetimepickerID}" + t-att-name="field.name" + t-att-required="field.required || field.modelRequired || None" + t-att-placeholder="field.placeholder" + t-att-id="field.id" + /> + <div class="input-group-append" t-attf-data-target="##{datetimepickerID}" data-toggle="datetimepicker"> + <div class="input-group-text"><i class="fa fa-calendar"></i></div> + </div> + </div> + </t> + </t> + + <!-- Boolean Field --> + <t t-name="website_form.field_boolean"> + <t t-set="field.isCheck" t-value="true"/> + <t t-call="website_form.field"> + <input + type="checkbox" + value="Yes" + class="s_website_form_input" + t-att-name="field.name" + t-att-required="field.required || field.modelRequired || None" + t-att-id="field.id" + /> + </t> + </t> + + <!-- Selection Field --> + <t t-name="website_form.field_selection"> + <t t-set="field.isCheck" t-value="true"/> + <t t-call="website_form.field"> + <div class="row s_col_no_resize s_col_no_bgcolor s_website_form_multiple" t-att-data-name="field.name" t-att-data-display="field.formatInfo.multiPosition"> + <t t-if="!field.records"> + <input + class="s_website_form_input" + t-att-name="field.name" + t-att-value="record.id" + t-att-required="field.required || field.modelRequired || None" + placeholder="No matching record !" + /> + </t> + <t t-foreach="field.records" t-as="record"> + <t t-call="website_form.radio"/> + </t> + </div> + </t> + </t> + + <!-- Radio --> + <t t-name="website_form.radio"> + <t t-set="recordId" t-value="field.id + record_index"/> + <div t-attf-class="radio col-12 #{field.formatInfo.multiPosition === 'horizontal' and 'col-lg-4 col-md-6' or ''}"> + <div class="form-check"> + <input + type="radio" + class="s_website_form_input form-check-input" + t-att-id="recordId" + t-att-name="field.name" + t-att-value="record.id" + t-att-required="field.required || field.modelRequired || None" + /> + <label class="form-check-label s_website_form_check_label" t-att-for="recordId"> + <t t-esc="record.display_name"/> + </label> + </div> + </div> + </t> + + <!-- Many2One Field --> + <t t-name="website_form.field_many2one"> + <!-- Binary one2many --> + <t t-if="field.relation == 'ir.attachment'"> + <t t-call="website_form.field_binary"/> + </t> + <!-- Generic one2many --> + <t t-if="field.relation != 'ir.attachment'"> + <t t-call="website_form.field"> + <select class="form-control s_website_form_input" t-att-name="field.name" t-att-required="field.required || field.modelRequired || None" t-att-id="field.id"> + <t t-foreach="field.records" t-as="record"> + <option t-att-value="record.id" t-att-selected="record.selected"> + <t t-esc="record.display_name"/> + </option> + </t> + </select> + </t> + </t> + </t> + + <!-- One2Many Field --> + <t t-name="website_form.field_one2many"> + <!-- Binary one2many --> + <t t-if="field.relation == 'ir.attachment'"> + <t t-call="website_form.field_binary"> + <t t-set="multiple" t-value="1"/> + </t> + </t> + <!-- Generic one2many --> + <t t-if="field.relation != 'ir.attachment'"> + <t t-set="field.isCheck" t-value="true"/> + <t t-call="website_form.field"> + <div class="row s_col_no_resize s_col_no_bgcolor s_website_form_multiple" t-att-data-name="field.name" t-att-data-display="field.formatInfo.multiPosition"> + <t t-if="!field.records"> + <input + class="s_website_form_input" + t-att-name="field.name" + t-att-value="record.id" + t-att-required="field.required || field.modelRequired || None" + placeholder="No matching record !" + /> + </t> + <t t-foreach="field.records" t-as="record"> + <t t-call="website_form.checkbox"/> + </t> + </div> + </t> + </t> + </t> + + <!-- Checkbox --> + <t t-name="website_form.checkbox"> + <t t-set="recordId" t-value="field.id + record_index"/> + <div t-attf-class="checkbox col-12 #{field.formatInfo.multiPosition === 'horizontal' and 'col-lg-4 col-md-6' or ''}"> + <div class="form-check"> + <input + type="checkbox" + class="s_website_form_input form-check-input" + t-att-id="recordId" + t-att-name="field.name" + t-att-value="record.id" + t-att-required="field.required || field.modelRequired || None" + /> + <label class="form-check-label s_website_form_check_label" t-att-for="recordId"> + <t t-esc="record.display_name"/> + </label> + </div> + </div> + </t> + + <!-- Many2Many Field --> + <t t-name="website_form.field_many2many"> + <t t-call="website_form.field_one2many"/> + </t> + + <!-- Binary Field --> + <t t-name="website_form.field_binary"> + <t t-set="field.isCheck" t-value="true"/> + <t t-call="website_form.field"> + <input + type="file" + class="form-control-file s_website_form_input" + t-att-name="field.name" + t-att-required="field.required || field.modelRequired || None" + t-att-multiple="multiple" + t-att-id="field.id" + /> + </t> + </t> + + <!-- Monetary Field --> + <t t-name="website_form.field_monetary"> + <t t-call="website_form.field_float" /> + </t> +</templates> |
