summaryrefslogtreecommitdiff
path: root/addons/website_form/static
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/website_form/static
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website_form/static')
-rw-r--r--addons/website_form/static/description/icon.pngbin0 -> 5565 bytes
-rw-r--r--addons/website_form/static/description/icon.svg1
-rw-r--r--addons/website_form/static/src/js/website_form_editor_registry.js57
-rw-r--r--addons/website_form/static/src/scss/wysiwyg_snippets.scss37
-rw-r--r--addons/website_form/static/src/snippets/s_website_form/000.js334
-rw-r--r--addons/website_form/static/src/snippets/s_website_form/000.scss61
-rw-r--r--addons/website_form/static/src/snippets/s_website_form/001.scss57
-rw-r--r--addons/website_form/static/src/snippets/s_website_form/options.js1348
-rw-r--r--addons/website_form/static/src/xml/website_form.xml18
-rw-r--r--addons/website_form/static/src/xml/website_form_editor.xml363
-rw-r--r--addons/website_form/static/tests/tours/website_form_editor.js438
11 files changed, 2714 insertions, 0 deletions
diff --git a/addons/website_form/static/description/icon.png b/addons/website_form/static/description/icon.png
new file mode 100644
index 00000000..212df7a9
--- /dev/null
+++ b/addons/website_form/static/description/icon.png
Binary files differ
diff --git a/addons/website_form/static/description/icon.svg b/addons/website_form/static/description/icon.svg
new file mode 100644
index 00000000..c6dd1249
--- /dev/null
+++ b/addons/website_form/static/description/icon.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="100%"><stop offset="0%" stop-color="#B06161"/><stop offset="45.785%" stop-color="#984E4E"/><stop offset="100%" stop-color="#7C3838"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M41.118 69H4c-2 0-4-1-4-4V28.375L15 12h41v42L41.118 69z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><path fill="#FFF" d="M34 17v-2h2v2h2v2h-2v2h-2v-2h-2v-2h2zM15 42h41v12H15V42zm19.51 8.985a.608.608 0 0 0 .73 0l5.942-4.792c.202-.162.202-.426 0-.589l-.73-.59a.608.608 0 0 0-.731 0l-4.846 3.909-2.262-1.825a.608.608 0 0 0-.731 0l-.73.59c-.202.162-.202.426 0 .589l3.358 2.708zM15 12h41v12H15V12zm0 15h41v12H15V27zm2-13v8h37v-8H17zm0 15v8h37v-8H17zm15 3h6v2h-6v-2z"/></g></g></svg> \ No newline at end of file
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>
diff --git a/addons/website_form/static/tests/tours/website_form_editor.js b/addons/website_form/static/tests/tours/website_form_editor.js
new file mode 100644
index 00000000..97708dcc
--- /dev/null
+++ b/addons/website_form/static/tests/tours/website_form_editor.js
@@ -0,0 +1,438 @@
+odoo.define('website_form_editor.tour', function (require) {
+ 'use strict';
+
+ const rpc = require('web.rpc');
+ const tour = require("web_tour.tour");
+
+ const selectButtonByText = function (text) {
+ return [{
+ content: "Open the select",
+ trigger: `we-select:has(we-button:contains("${text}")) we-toggler`,
+ },
+ {
+ content: "Click on the option",
+ trigger: `we-select we-button:contains("${text}")`,
+ }];
+ };
+ const selectButtonByData = function (data) {
+ return [{
+ content: "Open the select",
+ trigger: `we-select:has(we-button[${data}]) we-toggler`,
+ }, {
+ content: "Click on the option",
+ trigger: `we-select we-button[${data}]`,
+ }];
+ };
+ const addField = function (data, name, type, label, required, hidden) {
+ const ret = [{
+ content: "Select form",
+ extra_trigger: '.s_website_form_field',
+ trigger: 'section.s_website_form',
+ }, {
+ content: "Add field",
+ trigger: 'we-button[data-add-field]',
+ },
+ ...selectButtonByData(data),
+ {
+ content: "Wait for field to load",
+ trigger: `.s_website_form_field[data-type="${name}"], .s_website_form_input[name="${name}"]`, //custom or existing field
+ run: function () {},
+ }];
+ let testText = '.s_website_form_field';
+ if (required) {
+ testText += '.s_website_form_required';
+ ret.push({
+ content: "Mark the field as required",
+ trigger: 'we-button[data-name="required_opt"] we-checkbox',
+ });
+ }
+ if (hidden) {
+ testText += '.s_website_form_field_hidden';
+ ret.push({
+ content: "Mark the field as hidden",
+ trigger: 'we-button[data-name="hidden_opt"] we-checkbox',
+ });
+ }
+ if (label) {
+ testText += `:has(label:contains("${label}"))`;
+ ret.push({
+ content: "Change the label text",
+ trigger: 'we-input[data-set-label-text] input',
+ run: `text ${label}`,
+ });
+ }
+ if (type !== 'checkbox' && type !== 'radio' && type !== 'select') {
+ let inputType = type === 'textarea' ? type : `input[type="${type}"]`;
+ testText += `:has(${inputType}[name="${name}"]${required ? '[required]' : ''})`;
+ }
+ ret.push({
+ content: "Check the resulting field",
+ trigger: testText,
+ run: function () {},
+ });
+ return ret;
+ };
+ const addCustomField = function (name, type, label, required, hidden) {
+ return addField(`data-custom-field="${name}"`, name, type, label, required, hidden);
+ };
+ const addExistingField = function (name, type, label, required, hidden) {
+ return addField(`data-existing-field="${name}"`, name, type, label, required, hidden);
+ };
+
+ tour.register("website_form_editor_tour", {
+ test: true,
+ }, [
+ // Drop a form builder snippet and configure it
+ {
+ content: "Enter edit mode",
+ trigger: 'a[data-action=edit]',
+ }, {
+ content: "Drop the form snippet",
+ trigger: '#oe_snippets .oe_snippet:has(.s_website_form) .oe_snippet_thumbnail',
+ run: 'drag_and_drop #wrap',
+ }, {
+ content: "Check dropped snippet and select it",
+ extra_trigger: '.s_website_form_field',
+ trigger: 'section.s_website_form',
+ },
+ ...selectButtonByText('Send an E-mail'),
+ {
+ content: "Form has a model name",
+ trigger: 'section.s_website_form form[data-model_name="mail.mail"]',
+ }, {
+ content: "Complete Recipient E-mail",
+ trigger: '[data-field-name="email_to"] input',
+ run: 'text_blur test@test.test',
+ },
+ ...addExistingField('date', 'text', 'Test Date', true),
+
+ ...addExistingField('record_name', 'text', 'Awesome Label', false, true),
+
+ ...addExistingField('body_html', 'textarea', 'Your Message', true),
+
+ ...addExistingField('recipient_ids', 'checkbox'),
+
+ ...addCustomField('one2many', 'checkbox', 'Products', true),
+ {
+ content: "Change Option 1 label",
+ trigger: 'we-list table input:eq(0)',
+ run: 'text Iphone',
+ }, {
+ content: "Change Option 2 label",
+ trigger: 'we-list table input:eq(1)',
+ run: 'text Galaxy S',
+ }, {
+ content: "Change first Option 3 label",
+ trigger: 'we-list table input:eq(2)',
+ run: 'text Xperia',
+ }, {
+ content: "Click on Add new Checkbox",
+ trigger: 'we-list we-button.o_we_list_add_optional',
+ }, {
+ content: "Change added Option label",
+ trigger: 'we-list table input:eq(3)',
+ run: 'text Wiko Stairway',
+ }, {
+ content: "Check the resulting field",
+ trigger: ".s_website_form_field.s_website_form_custom.s_website_form_required" +
+ ":has(.s_website_form_multiple[data-display='horizontal'])" +
+ ":has(.checkbox:has(label:contains('Iphone')):has(input[type='checkbox'][required]))" +
+ ":has(.checkbox:has(label:contains('Galaxy S')):has(input[type='checkbox'][required]))" +
+ ":has(.checkbox:has(label:contains('Xperia')):has(input[type='checkbox'][required]))" +
+ ":has(.checkbox:has(label:contains('Wiko Stairway')):has(input[type='checkbox'][required]))",
+ run: function () {},
+ },
+ ...selectButtonByData('data-multi-checkbox-display="vertical"'),
+ {
+ content: "Check the resulting field",
+ trigger: ".s_website_form_field.s_website_form_custom.s_website_form_required" +
+ ":has(.s_website_form_multiple[data-display='vertical'])" +
+ ":has(.checkbox:has(label:contains('Iphone')):has(input[type='checkbox'][required]))" +
+ ":has(.checkbox:has(label:contains('Galaxy S')):has(input[type='checkbox'][required]))" +
+ ":has(.checkbox:has(label:contains('Xperia')):has(input[type='checkbox'][required]))" +
+ ":has(.checkbox:has(label:contains('Wiko Stairway')):has(input[type='checkbox'][required]))",
+ run: function () {},
+ },
+
+ ...addCustomField('selection', 'radio', 'Service', true),
+ {
+ content: "Change Option 1 label",
+ trigger: 'we-list table input:eq(0)',
+ run: 'text After-sales Service',
+ }, {
+ content: "Change Option 2 label",
+ trigger: 'we-list table input:eq(1)',
+ run: 'text Invoicing Service',
+ }, {
+ content: "Change first Option 3 label",
+ trigger: 'we-list table input:eq(2)',
+ run: 'text Development Service',
+ }, {
+ content: "Click on Add new Checkbox",
+ trigger: 'we-list we-button.o_we_list_add_optional',
+ }, {
+ content: "Change last Option label",
+ trigger: 'we-list table input:eq(3)',
+ run: 'text Management Service',
+ }, {
+ content: "Mark the field as not required",
+ trigger: 'we-button[data-name="required_opt"] we-checkbox',
+ }, {
+ content: "Check the resulting field",
+ trigger: ".s_website_form_field.s_website_form_custom:not(.s_website_form_required)" +
+ ":has(.radio:has(label:contains('After-sales Service')):has(input[type='radio']:not([required])))" +
+ ":has(.radio:has(label:contains('Invoicing Service')):has(input[type='radio']:not([required])))" +
+ ":has(.radio:has(label:contains('Development Service')):has(input[type='radio']:not([required])))" +
+ ":has(.radio:has(label:contains('Management Service')):has(input[type='radio']:not([required])))",
+ run: function () {},
+ },
+
+ ...addCustomField('many2one', 'select', 'State', true),
+
+ // Customize custom selection field
+ {
+ content: "Change Option 1 Label",
+ trigger: 'we-list table input:eq(0)',
+ run: 'text Germany',
+ }, {
+ content: "Change Option 2 Label",
+ trigger: 'we-list table input:eq(1)',
+ run: 'text Belgium',
+ }, {
+ content: "Change first Option 3 label",
+ trigger: 'we-list table input:eq(2)',
+ run: 'text France',
+ }, {
+ content: "Click on Add new Checkbox",
+ trigger: 'we-list we-button.o_we_list_add_optional',
+ }, {
+ content: "Change last Option label",
+ trigger: 'we-list table input:eq(3)',
+ run: 'text Canada',
+ }, {
+ content: "Remove Germany Option",
+ trigger: '.o_we_select_remove_option:eq(0)',
+ }, {
+ content: "Check the resulting snippet",
+ trigger: ".s_website_form_field.s_website_form_custom.s_website_form_required" +
+ ":has(label:contains('State'))" +
+ ":has(select[required]:hidden)" +
+ ":has(.s_website_form_select_item:contains('Belgium'))" +
+ ":has(.s_website_form_select_item:contains('France'))" +
+ ":has(.s_website_form_select_item:contains('Canada'))" +
+ ":not(:has(.s_website_form_select_item:contains('Germany')))",
+ run: function () {},
+ },
+
+ ...addExistingField('attachment_ids', 'file', 'Invoice Scan'),
+
+ // Edit the submit button using linkDialog.
+ {
+ content: "Double click submit button to edit it",
+ trigger: '.s_website_form_send',
+ run: 'dblclick',
+ }, {
+ content: "Check that no URL field is suggested",
+ trigger: 'form:has(#o_link_dialog_label_input:hidden)',
+ run: () => null,
+ }, {
+ content: "Check that preview element has the same style",
+ trigger: '.o_link_dialog_preview:has(.s_website_form_send.btn.btn-lg.btn-primary)',
+ run: () => null,
+ }, {
+ content: "Change button's style",
+ trigger: 'label:has(input[name="link_style_color"][value="secondary"])',
+ run: () => {
+ $('input[name="link_style_color"][value="secondary"]').click();
+ $('select[name="link_style_shape"]').val('rounded-circle').change();
+ $('select[name="link_style_size"]').val('sm').change();
+ },
+ }, {
+ content: "Check that preview is updated too",
+ trigger: '.o_link_dialog_preview:has(.s_website_form_send.btn.btn-sm.btn-secondary.rounded-circle)',
+ run: () => null,
+ }, {
+ content: "Save changes from linkDialog",
+ trigger: '.modal-footer .btn-primary',
+ }, {
+ content: "Check the resulting button",
+ trigger: '.s_website_form_send.btn.btn-sm.btn-secondary.rounded-circle',
+ run: () => null,
+ },
+ // Save the page
+ {
+ trigger: 'body',
+ run: function () {
+ $('body').append('<div id="completlyloaded"></div>');
+ },
+ },
+ {
+ content: "Save the page",
+ trigger: "button[data-action=save]",
+ },
+ {
+ content: "Wait reloading...",
+ trigger: "html:not(:has(#completlyloaded)) div",
+ }
+ ]);
+
+ tour.register("website_form_editor_tour_submit", {
+ test: true,
+ },[
+ {
+ content: "Try to send empty form",
+ extra_trigger: "form[data-model_name='mail.mail']" +
+ "[data-success-page='/contactus-thank-you']" +
+ ":has(.s_website_form_field:has(label:contains('Your Name')):has(input[type='text'][name='Your Name'][required]))" +
+ ":has(.s_website_form_field:has(label:contains('Email')):has(input[type='email'][name='email_from'][required]))" +
+ ":has(.s_website_form_field:has(label:contains('Your Question')):has(textarea[name='Your Question'][required]))" +
+ ":has(.s_website_form_field:has(label:contains('Subject')):has(input[type='text'][name='subject'][required]))" +
+ ":has(.s_website_form_field:has(label:contains('Test Date')):has(input[type='text'][name='date'][required]))" +
+ ":has(.s_website_form_field:has(label:contains('Awesome Label')):hidden)" +
+ ":has(.s_website_form_field:has(label:contains('Your Message')):has(textarea[name='body_html'][required]))" +
+ ":has(.s_website_form_field:has(label:contains('Products')):has(input[type='checkbox'][name='Products'][value='Iphone'][required]))" +
+ ":has(.s_website_form_field:has(label:contains('Products')):has(input[type='checkbox'][name='Products'][value='Galaxy S'][required]))" +
+ ":has(.s_website_form_field:has(label:contains('Products')):has(input[type='checkbox'][name='Products'][value='Xperia'][required]))" +
+ ":has(.s_website_form_field:has(label:contains('Products')):has(input[type='checkbox'][name='Products'][value='Wiko Stairway'][required]))" +
+ ":has(.s_website_form_field:has(label:contains('Service')):has(input[type='radio'][name='Service'][value='After-sales Service']:not([required])))" +
+ ":has(.s_website_form_field:has(label:contains('Service')):has(input[type='radio'][name='Service'][value='Invoicing Service']:not([required])))" +
+ ":has(.s_website_form_field:has(label:contains('Service')):has(input[type='radio'][name='Service'][value='Development Service']:not([required])))" +
+ ":has(.s_website_form_field:has(label:contains('Service')):has(input[type='radio'][name='Service'][value='Management Service']:not([required])))" +
+ ":has(.s_website_form_field:has(label:contains('State')):has(select[name='State'][required]:has(option[value='Belgium'])))" +
+ ":has(.s_website_form_field.s_website_form_required:has(label:contains('State')):has(select[name='State'][required]:has(option[value='France'])))" +
+ ":has(.s_website_form_field:has(label:contains('State')):has(select[name='State'][required]:has(option[value='Canada'])))" +
+ ":has(.s_website_form_field:has(label:contains('Invoice Scan')))" +
+ ":has(.s_website_form_field:has(input[name='email_to'][value='test@test.test']))",
+ trigger: ".s_website_form_send"
+ },
+ {
+ content: "Check if required fields were detected and complete the Subject field",
+ extra_trigger: "form:has(#s_website_form_result.text-danger)" +
+ ":has(.s_website_form_field:has(label:contains('Your Name')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Email')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Your Question')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Subject')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Test Date')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Your Message')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Products')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Service')):not(.o_has_error))" +
+ ":has(.s_website_form_field:has(label:contains('State')):not(.o_has_error))" +
+ ":has(.s_website_form_field:has(label:contains('Invoice Scan')):not(.o_has_error))",
+ trigger: "input[name=subject]",
+ run: "text Jane Smith"
+ },
+ {
+ content: "Update required field status by trying to Send again",
+ trigger: ".s_website_form_send"
+ },
+ {
+ content: "Check if required fields were detected and complete the Message field",
+ extra_trigger: "form:has(#s_website_form_result.text-danger)" +
+ ":has(.s_website_form_field:has(label:contains('Your Name')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Email')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Your Question')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Subject')):not(.o_has_error))" +
+ ":has(.s_website_form_field:has(label:contains('Test Date')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Your Message')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Products')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Service')):not(.o_has_error))" +
+ ":has(.s_website_form_field:has(label:contains('State')):not(.o_has_error))" +
+ ":has(.s_website_form_field:has(label:contains('Invoice Scan')):not(.o_has_error))",
+ trigger: "textarea[name=body_html]",
+ run: "text A useless message"
+ },
+ {
+ content: "Update required field status by trying to Send again",
+ trigger: ".s_website_form_send"
+ },
+ {
+ content: "Check if required fields was detected and check a product. If this fails, you probably broke the cleanForSave.",
+ extra_trigger: "form:has(#s_website_form_result.text-danger)" +
+ ":has(.s_website_form_field:has(label:contains('Your Name')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Email')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Your Question')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Subject')):not(.o_has_error))" +
+ ":has(.s_website_form_field:has(label:contains('Test Date')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Your Message')):not(.o_has_error))" +
+ ":has(.s_website_form_field:has(label:contains('Products')).o_has_error)" +
+ ":has(.s_website_form_field:has(label:contains('Service')):not(.o_has_error))" +
+ ":has(.s_website_form_field:has(label:contains('State')):not(.o_has_error))" +
+ ":has(.s_website_form_field:has(label:contains('Invoice Scan')):not(.o_has_error))",
+ trigger: "input[name=Products][value='Wiko Stairway']"
+ },
+ {
+ content: "Complete Date field",
+ trigger: ".s_website_form_datetime [data-toggle='datetimepicker']",
+ },
+ {
+ content: "Check another product",
+ trigger: "input[name='Products'][value='Xperia']"
+ },
+ {
+ content: "Check a service",
+ trigger: "input[name='Service'][value='Development Service']"
+ },
+ {
+ content: "Complete Your Name field",
+ trigger: "input[name='Your Name']",
+ run: "text chhagan"
+ },
+ {
+ content: "Complete Email field",
+ trigger: "input[name=email_from]",
+ run: "text test@mail.com"
+ },
+ {
+ content: "Complete Subject field",
+ trigger: 'input[name="subject"]',
+ run: 'text subject',
+ },
+ {
+ content: "Complete Your Question field",
+ trigger: "textarea[name='Your Question']",
+ run: "text magan"
+ },
+ {
+ content: "Send the form",
+ trigger: ".s_website_form_send"
+ },
+ {
+ content: "Check form is submitted without errors",
+ trigger: "#wrap:has(h1:contains('Thank You!'))"
+ }
+ ]);
+
+ tour.register("website_form_editor_tour_results", {
+ test: true,
+ }, [
+ {
+ content: "Check mail.mail records have been created",
+ trigger: "body",
+ run: function () {
+ var mailDef = rpc.query({
+ model: 'mail.mail',
+ method: 'search_count',
+ args: [[
+ ['email_to', '=', 'test@test.test'],
+ ['body_html', 'like', 'A useless message'],
+ ['body_html', 'like', 'Service : Development Service'],
+ ['body_html', 'like', 'State : Belgium'],
+ ['body_html', 'like', 'Products : Xperia,Wiko Stairway']
+ ]],
+ });
+ var success = function(model, count) {
+ if (count > 0) {
+ $('body').append('<div id="website_form_editor_success_test_tour_'+model+'"></div>');
+ }
+ };
+ mailDef.then(_.bind(success, this, 'mail_mail'));
+ }
+ },
+ {
+ content: "Check mail.mail records have been created",
+ trigger: "#website_form_editor_success_test_tour_mail_mail"
+ }
+ ]);
+
+ return {};
+});