[IMP] survey: improve question triggering robustness

The PR improves user experience and database consistency for triggered questions.

* Deleting questions/answers used in triggers will clean those triggers to avoid corrupt
trigger configurations.
* Questions and Survey are saved during edition allowing to add questions with score,
  validation parameters (checked against SQL constraints), and triggers on the fly
 without having to save the survey and reenter edit mode. This required to modify the
 basic_relational_model `Record` `save` method to be able to display a notification
instead of another modal in case of `ValidationError`.
* Questions that are moved and placed before their trigger will show the icon in
`text-danger` with a warning message on hover, because other forms of feedback
would be too strong to be given when users are busy reordering.

Tests are included to ensure the specific behavior and needs of this module in case
some JS framework implementations change later on.

See details on each commit.

Task-2623482
See odoo/upgrade#3613

closes odoo/odoo#78705

Signed-off-by: Warnon Aurélien (awa) <awa@odoo.com>
This commit is contained in:
Odoo's Mergebot
2023-01-10 18:54:29 +01:00
committed by GitHub
17 changed files with 978 additions and 67 deletions
+2 -1
View File
@@ -94,7 +94,8 @@ sent mails with personal token for the invitation of the survey.
'survey/static/src/question_page/*',
'survey/static/src/js/fields_section_one2many.js',
'survey/static/src/js/fields_form_page_description.js',
'survey/static/src/views/*.js',
'survey/static/src/views/**/*.js',
'survey/static/src/views/**/*.xml',
'survey/static/src/scss/survey_survey_views.scss',
'survey/static/src/scss/survey_question_views.scss',
'survey/static/src/scss/survey_templates_results.scss',
+97 -3
View File
@@ -124,7 +124,7 @@ class SurveyQuestion(models.Model):
validation_max_date = fields.Date('Maximum Date')
validation_min_datetime = fields.Datetime('Minimum Datetime')
validation_max_datetime = fields.Datetime('Maximum Datetime')
validation_error_msg = fields.Char('Validation Error message', translate=True)
validation_error_msg = fields.Char('Validation Error', translate=True)
constr_mandatory = fields.Boolean('Mandatory Answer')
constr_error_msg = fields.Char('Error message', translate=True)
# answers
@@ -134,7 +134,7 @@ class SurveyQuestion(models.Model):
# Conditional display
is_conditional = fields.Boolean(
string='Conditional Display', copy=True, help="""If checked, this question will be displayed only
string='Conditional Display', copy=False, help="""If checked, this question will be displayed only
if the specified conditional answer have been selected in a previous question""")
triggering_question_id = fields.Many2one(
'survey.question', string="Triggering Question", copy=False, compute="_compute_triggering_question_id",
@@ -144,6 +144,11 @@ class SurveyQuestion(models.Model):
'|', \
('sequence', '<', sequence), \
'&', ('sequence', '=', sequence), ('id', '<', id)]")
allowed_triggering_question_ids = fields.Many2many(
'survey.question', string="Allowed Triggering Questions", copy=False, compute="_compute_allowed_triggering_question_ids")
is_placed_before_trigger = fields.Boolean(
string='Is misplaced?', help="Is this question placed before its trigger question?",
compute="_compute_allowed_triggering_question_ids")
triggering_answer_id = fields.Many2one(
'survey.question.answer', string="Triggering Answer", copy=False, compute="_compute_triggering_answer_id",
store=True, readonly=False, help="Answer that will trigger the display of the current question.",
@@ -160,7 +165,13 @@ class SurveyQuestion(models.Model):
('scored_datetime_have_answers', "CHECK (is_scored_question != True OR question_type != 'datetime' OR answer_datetime is not null)",
'All "Is a scored question = True" and "Question Type: Datetime" questions need an answer'),
('scored_date_have_answers', "CHECK (is_scored_question != True OR question_type != 'date' OR answer_date is not null)",
'All "Is a scored question = True" and "Question Type: Date" questions need an answer')
'All "Is a scored question = True" and "Question Type: Date" questions need an answer'),
('conditional_questions_have_triggering_question', 'CHECK (is_conditional != True OR triggering_question_id is not null)',
'All conditional display questions need a triggering question.\n'
'Please disable "Conditional Display" or specify a triggering question.'),
('triggered_questions_have_triggering_answer', 'CHECK (triggering_question_id is null OR triggering_answer_id is not null)',
'All questions triggered by another need a triggering answer.\n'
'Please disable "Conditional Display" or specify a triggering answer.'),
]
# -------------------------------------------------------------------------
@@ -269,6 +280,51 @@ class SurveyQuestion(models.Model):
if not question.validation_required or question.question_type not in ['char_box', 'numerical_box', 'date', 'datetime']:
question.validation_required = False
@api.depends('is_conditional', 'survey_id', 'survey_id.question_ids', 'triggering_question_id')
def _compute_allowed_triggering_question_ids(self):
""" Although the question (and possible trigger questions) sequence
is used here, we do not add these fields to the dependency list to
avoid cascading rpc calls when reordering questions via the webclient.
"""
conditional_questions = self.filtered(lambda q: q.is_conditional)
non_conditional_questions = self - conditional_questions
non_conditional_questions.allowed_triggering_question_ids = False
non_conditional_questions.is_placed_before_trigger = False
if not conditional_questions:
return
possible_trigger_questions = self.search([
('is_page', '=', False),
('question_type', 'in', ['simple_choice', 'multiple_choice']),
('suggested_answer_ids', '!=', False),
('survey_id', 'in', self.survey_id.ids)
])
# Using the sequence stored in db is necessary for existing questions that are passed as
# NewIds because the sequence provided by the JS client can be incorrect.
(conditional_questions | possible_trigger_questions).flush_recordset()
self.env.cr.execute(
"SELECT id, sequence FROM survey_question WHERE id =ANY(%s)",
[conditional_questions.ids]
)
conditional_questions_sequences = dict(self.env.cr.fetchall()) # id: sequence mapping
for question in conditional_questions:
question_id = question._origin.id
if not question_id: # New question
conditional_questions.allowed_triggering_question_ids = possible_trigger_questions
question.is_placed_before_trigger = False
continue
question_sequence = conditional_questions_sequences[question_id]
question.allowed_triggering_question_ids = possible_trigger_questions.filtered(
lambda q: q.survey_id.id == question.survey_id._origin.id
and (q.sequence < question_sequence or q.sequence == question_sequence and q.id < question_id)
)
question.is_placed_before_trigger = (
question.triggering_question_id
and question.triggering_question_id.id not in question.allowed_triggering_question_ids.ids)
@api.depends('is_conditional')
def _compute_triggering_question_id(self):
""" Used as an 'onchange' : Reset the triggering question if user uncheck 'Conditional Display'
@@ -312,6 +368,34 @@ class SurveyQuestion(models.Model):
else:
question.is_scored_question = False
@api.onchange('question_type', 'validation_required')
def _onchange_validation_parameters(self):
"""Ensure no value stays set but not visible on form,
preventing saving (+consistency with question type)."""
self.validation_email = False
self.validation_length_min = 0
self.validation_length_max = 0
self.validation_min_date = False
self.validation_max_date = False
self.validation_min_datetime = False
self.validation_max_datetime = False
self.validation_min_float_value = 0
self.validation_max_float_value = 0
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
def unlink(self):
""" Makes sure no question is left depending on the question we're deleting."""
depending_questions = self.env['survey.question'].search([('triggering_question_id', 'in', self.ids)])
depending_questions.write({
'is_conditional': False,
'triggering_question_id': False,
'triggering_answer_id': False,
})
return super().unlink()
# ------------------------------------------------------------
# VALIDATION
# ------------------------------------------------------------
@@ -612,3 +696,13 @@ class SurveyQuestionAnswer(models.Model):
for label in self:
if not bool(label.question_id) != bool(label.matrix_question_id):
raise ValidationError(_("A label must be attached to only one question."))
def unlink(self):
""" Makes sure no question is left depending on the answer we're deleting."""
depending_questions = self.env['survey.question'].search([('triggering_answer_id', 'in', self.ids)])
depending_questions.write({
'is_conditional': False,
'triggering_question_id': False,
'triggering_answer_id': False,
})
return super().unlink()
+3 -1
View File
@@ -367,7 +367,8 @@ class Survey(models.Model):
This implementation assumes that the order of created questions/answers will be kept between
the original and the clone, using 'zip()' to match the records between the two.
Note that when question_ids is provided in the default parameter, it falls back to the standard copy.
Note that when question_ids is provided in the default parameter, it falls back to the
standard copy, meaning that triggering logic will not be maintained.
"""
self.ensure_one()
clone = super(Survey, self).copy(default)
@@ -382,6 +383,7 @@ class Survey(models.Model):
}
for src, dst in zip(self.question_ids, clone.question_ids):
if src.is_conditional:
dst.is_conditional = True
dst.triggering_question_id = questions_map.get(src.triggering_question_id.id)
dst.triggering_answer_id = answers_map.get(src.triggering_answer_id.id)
return clone
@@ -96,4 +96,24 @@ export class QuestionPageListRenderer extends ListRenderer {
}
return super.onCellKeydownEditMode(...arguments);
}
/**
* Save the survey after a question used as trigger is deleted. This allows
* immediate feedback on the form view as the triggers will be removed
* anyway on the records by the ORM.
*
* @override
* @param record
* @return {Promise<void>}
*/
async onDeleteRecord(record) {
const triggeredRecords = this.props.list.records.filter(rec => rec.data.triggering_question_id[0] === record.data.id);
if (triggeredRecords.length) {
const res = await super.onDeleteRecord(record);
await this.props.list.model.root.save({stayInEdition: true});
return res;
} else {
return super.onDeleteRecord(record);
}
}
}
@@ -2,6 +2,8 @@
import { QuestionPageListRenderer } from "./question_page_list_renderer";
import { registry } from "@web/core/registry";
import { useOpenX2ManyRecord, useX2ManyCrud } from "@web/views/fields/relational_utils";
import { useService } from "@web/core/utils/hooks";
import { X2ManyField } from "@web/views/fields/x2many/x2many_field";
const { useSubEnv } = owl;
@@ -12,6 +14,88 @@ class QuestionPageOneToManyField extends X2ManyField {
useSubEnv({
openRecord: (record) => this.openRecord(record),
});
this.notificationService = useService("notification");
// Systematically and automatically save SurveyForm at each question edit/creation
// enables checking validation parameters consistency and using questions as triggers
// immediately during question creation.
// Preparing everything in order to override `this._openRecord` below.
const { saveRecord: superSaveRecord, updateRecord: superUpdateRecord } = useX2ManyCrud(
() => this.list,
this.isMany2Many
);
const self = this;
const saveRecord = async (record) => {
const saveResponse = await superSaveRecord(record);
try {
await self.props.record.save({stayInEdition: true, throwOnError: true});
} catch (error) {
return self.handleSurveySaveError(error, record);
}
return saveResponse;
};
const updateRecord = async (record) => {
const updateResponse = await superUpdateRecord(record);
try {
await self.props.record.save({stayInEdition: true, throwOnError: true});
} catch (error) {
return self.handleSurveySaveError(error);
}
return updateResponse;
};
const openRecord = useOpenX2ManyRecord({
resModel: this.list.resModel,
activeField: this.activeField,
activeActions: this.activeActions,
getList: () => this.list,
saveRecord,
updateRecord,
});
this._openRecord = async (params) => {
if (!await self.props.record.save({ stayInEdition: true })) {
// do not open question form as it won't be savable either.
return;
}
if (params.record) {
// Force synchronization of fields that depend on sequence
// (allowed_triggering_question_ids, is_placed_before_trigger)
// as records may have been re-ordered before opening this one.
await params.record.load();
}
openRecord(params);
};
}
/**
* For convenience, we'll prevent closing the question form dialog and
* stay in edit mode to make sure only valid records are saved. Therefore,
* two things should be cared for in case of error occurring when saving
* the question:
* * Remove erroneous question row added to the embedded list
* * Replace default error modal with a notification
*
* @param {Error} error Error thrown when saving survey/question.
* @param {Record?} recordToDelete (optional) In case the error is
* thrown when saving a new question, it should be deleted from the
* list.
*/
async handleSurveySaveError(error, recordToDelete) {
error.event.preventDefault();
if (recordToDelete) {
const listRecord = this.list.records.find(r => r.__bm_handle__ === recordToDelete.__bm_handle__);
await this.list.delete(listRecord.id);
}
this.notificationService.add(
error.message.data.message, {
title: this.env._t("Validation Error"),
type: "danger"
}
);
// Prevent closing the question form view
return Promise.reject("Impossible to save survey, see error notification.");
}
}
QuestionPageOneToManyField.components = {
@@ -23,4 +23,11 @@
.o_preview_questions_choice {
line-height: 1rem;
}
.o_survey_question_validation_parameters {
@extend .oe_inline;
> div, input.o_survey_question_validation_inline {
width: 45% !important;
}
}
}
@@ -0,0 +1,116 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { sprintf } from '@web/core/utils/strings';
import { standardWidgetProps} from "@web/views/widgets/standard_widget_props";
const { Component, useEffect, useRef, useState } = owl;
export class SurveyQuestionTriggerWidget extends Component {
setup() {
super.setup();
this.button = useRef('survey_question_trigger');
this.state = useState({
triggeringQuestionTitle: this.props.record.data.triggering_question_id,
surveyQuestionTriggerTooltip: "",
surveyQuestionTriggerIconClass: "",
});
useEffect(() => {
if (this.button && this.button.el) {
this.state.triggeringQuestionTitle = this.props.record.data.triggering_question_id[1];
const triggerError = this.surveyQuestionTriggerError;
if (triggerError === "MISPLACED_TRIGGER_WARNING") {
this.state.surveyQuestionTriggerTooltip = sprintf(
'⚠ ' + _lt('This question is positioned before its trigger ("%s") and will be skipped.'),
this.state.triggeringQuestionTitle);
this.state.surveyQuestionTriggerIconClass = 'fa-exclamation-triangle text-warning';
} else if (triggerError === "WRONG_QUESTIONS_SELECTION_WARNING") {
this.state.surveyQuestionTriggerTooltip = '⚠ ' + _lt(
'Conditional display is not available when questions are randomly picked.');
this.state.surveyQuestionTriggerIconClass = 'fa-exclamation-triangle text-warning';
} else if (triggerError === "MISSING_TRIGGER_ERROR") {
// This case must be handled to not temporarily render the "normal" icon if previously
// on an error state, which would cause a flicker as the trigger itself will be removed
// at next save (auto on survey form and primary list view).
} else {
this.state.surveyQuestionTriggerTooltip = sprintf(_lt('Displayed if "%s: %s"'),
this.state.triggeringQuestionTitle, this.props.record.data.triggering_answer_id[1]);
this.state.surveyQuestionTriggerIconClass = 'fa-code-fork';
}
} else {
this.state.triggeringQuestionTitle = "";
this.state.surveyQuestionTriggerTooltip = "";
this.state.surveyQuestionTriggerIconClass = "";
}
});
}
/**
* `surveyQuestionTriggerError` is computed here and does not rely on
* record data (is_placed_before_trigger) for two linked reasons:
* 1. Performance: we avoid saving the survey each time a line is moved.
* 2. Robustness, as sequences values do not always match between server
* provided values when the records are not saved.
*
* @returns { String }
* * `""`: No trigger error (also if `triggering_question_id`
* field is not set).
* * `"MISSING_TRIGGER_ERROR"`: `triggering_question_id` field is set
* and trigger record is not found. This can happen when a question
* used as trigger is deleted on the client but not yet saved to DB.
* * `"MISPLACED_TRIGGER_WARNING"`: a `triggering_question_id` is set
* but is positioned after the current record in the list. This can
* happen if the triggering or the triggered question is moved.
* * `"WRONG_QUESTIONS_SELECTION_WARNING"`: a `triggering_question_id`
* is set but the survey is configured to randomize questions asked
* mode which ignores the triggers. This can happen if the survey mode
* is changed after triggers are set.
*/
get surveyQuestionTriggerError() {
const record = this.props.record;
if (!record.data.triggering_question_id) {
return "";
}
const triggerId = record.data.triggering_question_id[0];
let triggerRecord;
if (this.props.isSurveyForm) { // embedded list
triggerRecord = record.model.root.data.question_and_page_ids.records.find(rec => rec.data.id === triggerId);
} else {
triggerRecord = record.model.root.records.find(rec => rec.resId === triggerId);
}
if (!triggerRecord) {
return "MISSING_TRIGGER_ERROR";
}
if (record.data.questions_selection === 'random') {
return "WRONG_QUESTIONS_SELECTION_WARNING";
}
if (record.data.sequence < triggerRecord.data.sequence ||
(record.data.sequence === triggerRecord.data.sequence && record.data.id < triggerId)) {
return "MISPLACED_TRIGGER_WARNING";
}
return "";
}
}
SurveyQuestionTriggerWidget.template = "survey.surveyQuestionTrigger";
SurveyQuestionTriggerWidget.props = {
...standardWidgetProps,
isSurveyForm: { type: Boolean, optional: true },
};
SurveyQuestionTriggerWidget.defaultProps = {
isSurveyForm: false
};
SurveyQuestionTriggerWidget.extractProps = ({ attrs }) => {
return {
isSurveyForm: attrs.options.isSurveyForm
};
};
SurveyQuestionTriggerWidget.displayName = 'Trigger';
SurveyQuestionTriggerWidget.supportedTypes = ['many2one'];
registry.category("view_widgets").add("survey_question_trigger", SurveyQuestionTriggerWidget);
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="survey.surveyQuestionTrigger" owl="1">
<button t-if="this.props.record.data.triggering_question_id" disabled="disabled" t-ref="survey_question_trigger"
class="btn btn-link px-1 py-0 pe-auto" t-att-class="this.surveyQuestionTriggerError ? 'opacity-100 ' : 'icon_rotates'">
<i class="fa fa-fw o_button_icon " t-att-class="this.state.surveyQuestionTriggerIconClass"
t-att-data-tooltip="this.state.surveyQuestionTriggerTooltip"/>
</button>
</t>
</templates>
@@ -2,6 +2,7 @@
import { click, editInput, getFixture, nextTick, triggerHotkey } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { RPCError } from "@web/core/network/rpc_service";
QUnit.module("QuestionPageOneToManyField", (hooks) => {
let serverData;
@@ -12,16 +13,20 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
serverData = {
models: {
partner: {
fields: { lines: { type: "one2many", relation: "lines_sections" } },
survey: {
fields: {
question_and_page_ids: { type: "one2many", relation: "survey_question" },
favorite_color: { string: "Favorite color", type: "char" }
},
records: [
{
id: 1,
lines: [1, 2],
question_and_page_ids: [1, 2],
favorite_color: ""
},
],
},
lines_sections: {
survey_question: {
fields: {
is_page: { type: "boolean" },
title: { type: "char", string: "Title" },
@@ -44,7 +49,7 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
},
},
views: {
"lines_sections,false,form": `
"survey_question,false,form": `
<form>
<field name="title" />
</form>
@@ -58,12 +63,12 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
QUnit.test("basic rendering", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resModel: "survey",
resId: 1,
serverData,
arch: `
<form>
<field name="lines" widget="question_page_one2many">
<field name="question_and_page_ids" widget="question_page_one2many">
<tree>
<field name="is_page" invisible="1" />
<field name="title" />
@@ -87,12 +92,12 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
QUnit.test("click on section behaves as usual in readonly mode", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resModel: "survey",
resId: 1,
serverData,
arch: `
<form>
<field name="lines" widget="question_page_one2many">
<field name="question_and_page_ids" widget="question_page_one2many">
<tree>
<field name="is_page" invisible="1" />
<field name="title" />
@@ -112,12 +117,12 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
QUnit.test("click on section edit the section in place", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resModel: "survey",
resId: 1,
serverData,
arch: `
<form>
<field name="lines" widget="question_page_one2many">
<field name="question_and_page_ids" widget="question_page_one2many">
<tree>
<field name="is_page" invisible="1" />
<field name="title" />
@@ -131,15 +136,16 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
assert.containsNone(target, ".modal .o_form_view");
});
QUnit.test("click on real line opens a dialog", async (assert) => {
QUnit.test("click on real line saves form and opens a dialog", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resModel: "survey",
resId: 1,
serverData,
arch: `
<form>
<field name="lines" widget="question_page_one2many">
<field name="favorite_color"/>
<field name="question_and_page_ids" widget="question_page_one2many">
<tree>
<field name="is_page" invisible="1" />
<field name="title" />
@@ -148,21 +154,68 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
</field>
</form>
`,
mockRPC(route, args) {
if (args.method === "write" && args.model === "survey") {
assert.step("save parent form");
}
},
});
await editInput(target, "[name='favorite_color'] input", "Yellow");
await click(target.querySelector(".o_data_row:nth-child(2) .o_data_cell"));
// Edit content to trigger the expected actual save at row opening
assert.verifySteps(["save parent form"]);
assert.containsNone(target, ".o_selected_row");
assert.containsOnce(target, ".modal .o_form_view");
});
QUnit.test("A validation error from saving parent form notifies and prevents dialog from closing", async (assert) => {
await makeView({
type: "form",
resModel: "survey",
resId: 1,
serverData,
arch: `
<form>
<field name="question_and_page_ids" widget="question_page_one2many">
<tree>
<field name="is_page" invisible="1" />
<field name="title" />
<field name="random_questions_count" />
</tree>
</field>
</form>
`,
mockRPC(route, args) {
if (args.method === "write" && args.model === "survey") {
assert.step("save parent form");
const surveyValidationError = new RPCError();
Object.assign(surveyValidationError, {
exceptionName: "odoo.exceptions.ValidationError",
data: {
message: "This isn't right!",
},
});
return Promise.reject(surveyValidationError);
}
},
});
await click(target.querySelector(".o_data_row:nth-child(2) .o_data_cell"));
assert.containsNone(target, ".o_selected_row");
await editInput(target, ".o_dialog:not(.o_inactive_modal) .modal-body [name='title'] input", "Invalid RecordTitle");
await click(target.querySelector(".o_dialog:not(.o_inactive_modal) .o_form_button_save"));
assert.verifySteps(["save parent form"]);
assert.containsOnce(document.body, ".o_notification");
assert.containsOnce(target, ".modal .o_form_view");
});
QUnit.test("can create section inline", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resModel: "survey",
resId: 1,
serverData,
arch: `
<form>
<field name="lines" widget="question_page_one2many">
<field name="question_and_page_ids" widget="question_page_one2many">
<tree>
<field name="is_page" invisible="1" />
<field name="title" />
@@ -187,12 +240,12 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
QUnit.test("creates real record in form dialog", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resModel: "survey",
resId: 1,
serverData,
arch: `
<form>
<field name="lines" widget="question_page_one2many">
<field name="question_and_page_ids" widget="question_page_one2many">
<tree>
<field name="is_page" invisible="1" />
<field name="title" />
@@ -217,12 +270,12 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resModel: "survey",
resId: 1,
serverData,
arch: `
<form>
<field name="lines" widget="question_page_one2many">
<field name="question_and_page_ids" widget="question_page_one2many">
<tree>
<field name="is_page" invisible="1" />
<field name="title" />
@@ -0,0 +1,155 @@
/** @odoo-module */
import { dragAndDrop, getFixture, nextTick } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
let serverData;
let target;
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
survey: {
fields: {
question_and_page_ids: { type: "one2many", relation: "survey_question" },
},
records: [
{
id: 1,
question_and_page_ids: [1, 2],
},
],
},
survey_question: {
fields: {
sequence: { type: "number" },
name: { type: "char", string: "name", },
triggering_question_id: {
type: "many2one",
string: "Triggering question",
relation: "survey_question",
required: false,
searchable: true,
},
triggering_answer_id: {
type: "many2one",
string: "Triggering answer",
relation: "survey_question_answer",
required: false,
searchable: true,
},
},
records: [
{
id: 1,
sequence: 1,
name: "Question 1",
triggering_question_id: null,
triggering_answer_id: null,
}, {
id: 2,
sequence: 2,
name: "Question 2",
triggering_question_id: 1,
triggering_answer_id: 1,
},
],
},
survey_question_answer: {
fields: {
name: {type: "char", string: "name"},
},
records: [
{
id: 1,
name: "Answer 1"
},
]
}
},
views: {
"survey_question,false,form": `
<form>
<group>
<field name="name"/>
<field name="triggering_answer_id" invisible="1"/>
<field name="triggering_question_id" invisible="1"/>
<widget name="survey_question_trigger" nolabel="1"/>
</group>
</form>
`,
},
};
setupViewRegistries();
});
QUnit.test("dynamic rendering of surveyQuestionTriggerError rows", async (assert) => {
await makeView({
type: "form",
resModel: "survey",
resId: 1,
serverData,
arch: `
<form>
<field name="question_and_page_ids">
<tree>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="triggering_answer_id" invisible="1"/>
<field name="triggering_question_id" invisible="1"/>
<widget name="survey_question_trigger" options="{'isSurveyForm': True}"/>
</tree>
</field>
</form>
`,
});
assert.containsOnce(target, ".o_field_x2many .o_list_renderer table.o_list_table");
assert.containsN(target, ".o_data_row", 2);
let rows = target.querySelectorAll(".o_data_row");
assert.strictEqual(rows[0].textContent, "Question 1");
let q1TriggerDiv = rows[0].querySelector("td.o_data_cell div.o_widget_survey_question_trigger");
assert.containsNone(q1TriggerDiv, "button");
assert.strictEqual(rows[1].textContent, "Question 2");
let q2TriggerDiv = rows[1].querySelector("td.o_data_cell div.o_widget_survey_question_trigger");
assert.containsOnce(q2TriggerDiv, "button");
// Question 2 is correctly placed after Question 1
let triggerIcon = q2TriggerDiv.querySelector("button i");
assert.doesNotHaveClass(triggerIcon, "text-warning");
assert.hasAttrValue(triggerIcon, 'data-tooltip', 'Displayed if "Question 1: Answer 1"',
'Trigger tooltip should be \'Displayed if "Question 1: Answer 1"\'.');
// drag and drop Question 2 (triggered) before Question 1 (trigger)
await dragAndDrop("tbody tr:nth-child(2) .o_handle_cell", "tbody tr:nth-child(1)");
await nextTick();
rows = target.querySelectorAll(".o_data_row");
assert.strictEqual(rows[0].textContent, "Question 2");
q2TriggerDiv = rows[0].querySelector("td.o_data_cell div.o_widget_survey_question_trigger");
assert.containsOnce(q2TriggerDiv, "button");
triggerIcon = q2TriggerDiv.querySelector("button i");
assert.hasClass(triggerIcon, "text-warning");
assert.strictEqual(
triggerIcon.getAttribute('data-tooltip'),
'⚠ This question is positioned before its trigger ("Question 1") and will be skipped.',
'Trigger tooltip should have been changed to misplacement error message.'
);
// drag and drop Question 1 (trigger) back before Question 2 (triggered)
await dragAndDrop("tbody tr:nth-child(2) .o_handle_cell", "tbody tr:nth-child(1)");
await nextTick();
rows = target.querySelectorAll(".o_data_row");
assert.strictEqual(rows[1].textContent, "Question 2");
assert.doesNotHaveClass(rows[1].querySelector("td.o_data_cell div.o_widget_survey_question_trigger button i"), "text-warning");
assert.hasAttrValue(triggerIcon, 'data-tooltip', 'Displayed if "Question 1: Answer 1"',
'Trigger tooltip should be back to \'Displayed if "Question 1: Answer 1"\'.');
});
});
@@ -0,0 +1,276 @@
/** @odoo-module **/
import tour from 'web_tour.tour';
tour.register('survey_tour_test_survey_form_triggers', {
test: true,
url: '/web',
}, [
tour.stepUtils.showAppsMenuItem(),
{
content: 'Go to Survey',
trigger: '.o_app[data-menu-xmlid="survey.menu_surveys"]',
}, {
content: "Create a new survey",
trigger: ".o-kanban-button-new",
}, {
content: "Set the Survey's title",
trigger: ".o_field_widget[name=title] input",
run: "text Test survey",
}, {
content: "Add a first question",
trigger: "td.o_field_x2many_list_row_add a",
}, {
content: "Set the first question's title",
trigger: ".modal-content .o_field_widget[name=title] input",
run: "text Question 1",
},
...addTwoAnswers(),
...saveAndNew(),
{
content: "Set the second question's title",
trigger: ".o_field_widget[name=title] input",
run: "text Question 2",
in_modal: true,
},
...addTwoAnswers(),
...toggleIsConditional(),
{
content: "Set a trigger question for the first question",
trigger: ".o_field_widget[name=triggering_question_id] input",
run: "click",
in_modal: true,
}, {
content: "Set the first question as trigger",
trigger: 'ul.ui-autocomplete a:contains("Question 1")',
run: 'click',
in_modal: true,
}, {
content: "Set a trigger answer for first question",
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
run: "click",
in_modal: true,
}, {
content: "Set the first answer as trigger",
trigger: 'ul.ui-autocomplete a:contains("Answer A")',
run: 'click',
},
...changeTab("answers"),
...saveAndNew(),
{
content: "Set the third question's title",
trigger: ".o_field_widget[name=title] input",
run: "text Question 3",
in_modal: true,
},
...addTwoAnswers(),
...toggleIsConditional(),
{
content: "Set a trigger question for the second question",
trigger: ".o_field_widget[name=triggering_question_id] input",
run: "click",
in_modal: true,
}, {
content: "Set the second question as trigger",
trigger: 'ul.ui-autocomplete a:contains("Question 2")',
run: 'click',
in_modal: true,
}, {
content: "Set a trigger answer for second question",
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
run: "click",
in_modal: true,
}, {
content: "Set the second answer as trigger",
trigger: 'ul.ui-autocomplete a:contains("Answer B")',
run: 'click',
},
...changeTab("answers"),
...tour.stepUtils.saveForm(),
// Q2 and Q3 should have fa-fork icons. Assumes that the Trigger widget's column is 2 places after the title's.
{
content: "Check that Question 2 has 'normal' trigger icon",
trigger: "tr:contains('Question 2') button i.fa-code-fork",
run: () => {}, // it's a check
}, {
content: "Check that Question 3 has 'normal' trigger icon",
trigger: "tr:contains('Question 3') button i.fa-code-fork",
run: () => {}, // it's a check
}, {
content: "Move Question 3 above its trigger (Question 2)",
trigger: "tr.o_data_row:nth-child(3) td[name=sequence]",
run: "drag_and_drop_native div[name=question_and_page_ids] table tbody tr:nth-child(2)",
}, {
content: "Check that Question 3 has 'warning' trigger icon",
trigger: "tr:contains('Question 3') button i.fa-exclamation-triangle",
run: () => {}, // it's a check
}, {
content: "Open that question to check the server's misplacement evaluation agrees",
trigger: "tr.o_data_row td[data-tooltip='Question 3']",
run: "click",
}, {
content: "Check that an alert is shown",
trigger: ".o_form_sheet_bg div:first-child.alert-warning:contains('positioned before its trigger')",
in_modal: true,
},
...changeTab("options"),
{
content: "Change trigger question",
trigger: ".o_field_widget[name=triggering_question_id] input",
run: "click",
in_modal: true,
}, {
content: "Set the first question as trigger instead",
trigger: 'ul.ui-autocomplete a:contains("Question 1")',
run: 'click',
in_modal: true,
}, {
content: "Check that the alert is gone",
trigger: `.o_form_sheet_bg div:first-child:not(.alert-warning).o_form_sheet`,
in_modal: true,
run: () => {}, // it's a check
}, {
content: "Change the trigger answer as well",
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
run: "click",
in_modal: true,
}, {
content: "Set the second answer as trigger, then",
trigger: 'ul.ui-autocomplete a:contains("Answer B")',
run: 'click',
},
...tour.stepUtils.saveForm(),
{
content: "Check that Question 3 has its 'normal' trigger icon back",
trigger: "tr:contains('Question 3') button i.fa-code-fork",
run: () => {}, // it's a check
}, {
content: "Move Question 3 back below Question 2",
trigger: "tr.o_data_row:nth-child(2) td[name=sequence]",
run: "drag_and_drop_native div[name=question_and_page_ids] table tbody tr:nth-child(3)",
}, {
content: "Open that question again",
trigger: "tr.o_data_row td[data-tooltip='Question 3']",
run: "click",
},
...changeTab("options"),
{
content: "Change trigger to confirm we can now use Question 2 again",
trigger: ".modal-content .o_field_widget[name=triggering_question_id] input",
run: "click",
in_modal: true,
}, {
content: "Question 2 is allowed as trigger again",
trigger: '.modal-content ul.ui-autocomplete a:contains("Question 2")',
in_modal: true,
}, {
content: "Change the trigger answer back as well",
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
in_modal: true,
}, {
content: "Set the second answer as trigger, then",
trigger: '.modal-content ul.ui-autocomplete a:contains("Answer B")',
run: "click",
},
...tour.stepUtils.saveForm(),
// Deleting trigger answers or whole question gracefully remove the trigger automatically
{
content: "Open Question 2 again",
trigger: "tr.o_data_row td[data-tooltip='Question 2']",
run: "click",
}, {
content: "Delete Answer B",
trigger: "div[name=suggested_answer_ids] tr:contains('Answer B') button[name=delete]",
},
...tour.stepUtils.saveForm(),
{
content: "Check that Question 3 no longer has a trigger icon",
trigger: "div[name=question_and_page_ids] tr:contains('Question 3') div.o_widget_survey_question_trigger:not(:has(button))",
run: () => {}, // it's a check
}, {
content: "Check that Question 2 however still has a trigger icon",
trigger: "tr:contains('Question 2') button i.fa-code-fork",
run: () => {}, // it's a check
}, {
content: "Delete Question 1",
trigger: "tr:contains('Question 1') button[name=delete]",
run: "click",
}, {
content: "Check that now Question 2 too does no longer have a trigger icon",
trigger: "tr:contains('Question 2') div.o_widget_survey_question_trigger:not(:has(button))",
run: () => {}, // it's a check
}, {
content: 'Go back to Kanban View',
trigger: 'a[data-menu-xmlid="survey.menu_surveys"]',
}, {
content: "Check that we arrived on the kanban view",
trigger: ".o-kanban-button-new",
run: () => {}, // it's a check
}
]);
function addTwoAnswers() {
return [
{
content: "Add the first answer",
trigger: "div[name=suggested_answer_ids] .o_field_x2many_list_row_add a",
in_modal: true,
}, {
trigger: 'tr.o_selected_row div[name=value] input',
run: 'text Answer A',
in_modal: true,
}, {
content: "Add the second answer",
trigger: "div[name=suggested_answer_ids] .o_field_x2many_list_row_add a",
in_modal: true,
}, {
trigger: 'tr.o_selected_row div[name=value] input',
run: 'text Answer B',
in_modal: true,
}
];
}
function saveAndNew() {
return [
{
content: "Click Save & New",
trigger: "button.o_form_button_save_new",
in_modal: true,
}, {
content: "Wait for the dialog to render new question form",
// suggested_answer_ids required even though in_modal is specified...
trigger: "div[name=suggested_answer_ids] .o_list_table tbody tr:first-child:not(.o_data_row)", // empty answers list
in_modal: true,
run: () => {}, // it's a check
}
];
}
function toggleIsConditional() {
return [
...changeTab("options"),
{
content: "Toggle is_conditional",
trigger: "div[name=is_conditional] input",
in_modal: true,
run: "click",
}
];
}
function changeTab(tabName) {
// Currently, .modal-content is required even though "in_modal"
return [
{
content: `Go to ${tabName} tab`,
trigger: `.modal-content a[name=${tabName}].nav-link`,
in_modal: true
}, {
content: `Wait for tab ${tabName} tab`,
trigger: `.modal-content a[name=${tabName}].nav-link.active`,
in_modal: true,
run: () => {}, // it's a check
}
];
}
+1
View File
@@ -9,6 +9,7 @@ from . import test_certification_flow
from . import test_survey_invite
from . import test_survey_security
from . import test_survey_randomize
from . import test_survey_ui_backend
from . import test_survey_ui_certification
from . import test_survey_ui_feedback
from . import test_survey_ui_session
+43
View File
@@ -296,3 +296,46 @@ class TestSurveyInternals(common.TestSurveyCommon):
self.assertNotEqual(q_food_not_vegetarian_cloned.triggering_question_id.id, q_is_vegetarian.id)
self.assertNotEqual(q_food_not_vegetarian_cloned.triggering_answer_id.id,
q_is_vegetarian.suggested_answer_ids[1].id)
@users('survey_manager')
def test_unlink_triggers(self):
# Create the survey questions
q_is_vegetarian_text = 'Are you vegetarian ?'
q_is_vegetarian = self._add_question(
self.page_0, q_is_vegetarian_text, 'multiple_choice', survey_id=self.survey.id,
sequence=100, labels=[{'value': 'Yes'}, {'value': 'No'}])
q_food_vegetarian_text = 'Choose your green meal'
veggie_question = self._add_question(
self.page_0, q_food_vegetarian_text, 'multiple_choice',
is_conditional=True, sequence=101,
triggering_question_id=q_is_vegetarian.id,
triggering_answer_id=q_is_vegetarian.suggested_answer_ids[0].id,
survey_id=self.survey.id,
labels=[{'value': 'Vegetarian pizza'}, {'value': 'Vegetarian burger'}])
q_food_not_vegetarian_text = 'Choose your meal'
not_veggie_question = self._add_question(
self.page_0, q_food_not_vegetarian_text, 'multiple_choice',
is_conditional=True, sequence=102,
triggering_question_id=q_is_vegetarian.id,
triggering_answer_id=q_is_vegetarian.suggested_answer_ids[1].id,
survey_id=self.survey.id,
labels=[{'value': 'Steak with french fries'}, {'value': 'Fish'}])
q_is_vegetarian.suggested_answer_ids[0].unlink()
# Deleting answer Yes makes the following question always visible
self.assertEqual(veggie_question.is_conditional, False)
self.assertEqual(veggie_question.triggering_question_id.id, False)
self.assertEqual(veggie_question.triggering_answer_id.id, False)
# But the other is still conditional
self.assertEqual(not_veggie_question.is_conditional, True)
self.assertEqual(not_veggie_question.triggering_question_id.id, q_is_vegetarian.id)
self.assertEqual(not_veggie_question.triggering_answer_id.id, q_is_vegetarian.suggested_answer_ids[0].id)
q_is_vegetarian.unlink()
# Now it will also be always visible
self.assertEqual(not_veggie_question.is_conditional, False)
self.assertEqual(not_veggie_question.triggering_question_id.id, False)
self.assertEqual(not_veggie_question.triggering_answer_id.id, False)
@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import HttpCase, tagged
@tagged('post_install', '-at_install')
class TestUi(HttpCase):
def test_tour_test_survey_form_triggers(self):
self.start_tour('/web', 'survey_tour_test_survey_form_triggers', login='admin')
+64 -37
View File
@@ -7,19 +7,23 @@
<field name="model">survey.question</field>
<field name="arch" type="xml">
<form string="Survey Question" create="false" class="o_survey_question_view_form">
<field name="is_placed_before_trigger" invisible="1"/>
<div class="alert alert-warning mb-0 text-center" role="alert" attrs="{'invisible': [('is_placed_before_trigger', '=', False)]}">
⚠️ This question is positioned before its trigger and will be skipped.
</div>
<field name="is_page" invisible="1"/>
<field name="page_id" invisible="1" required="False"/>
<field name="survey_id" invisible="1"/>
<field name="sequence" invisible="1"/>
<field name="scoring_type" invisible="1"/>
<sheet>
<field name="survey_id" invisible="not context.get('show_survey_field')" readonly="1"/>
<div class="float-end d-flex flex-column text-center" attrs="{'invisible': [('is_page', '=', False)]}">
<label for="background_image"/>
<field name="background_image" widget="image" class="oe_avatar"/>
</div>
<div class="oe_title" style="width: 100%;">
<label for="title" string="Section" attrs="{'invisible': [('is_page', '=', False)]}"/>
<label for="title" string="Question" attrs="{'invisible': [('is_page', '=', True)]}"/>
<label for="title" string="Question" attrs="{'invisible': [('is_page', '=', True)]}"/><br/>
<field name="title" placeholder="e.g. &quot;What is the...&quot;" colspan="4"/>
<field name="questions_selection" invisible="1"/>
</div>
@@ -115,12 +119,15 @@
]}">
<group>
<group attrs="{'invisible': [('question_type', 'not in', ['char_box', 'numerical_box', 'date', 'datetime'])]}">
<field name="answer_numerical_box" string="Correct Answer"
attrs="{'invisible': [('question_type', '!=', 'numerical_box')], 'required': [('is_scored_question', '=', True), ('question_type', '=', 'numerical_box')]}" class="oe_inline"/>
<field name="answer_date" string="Correct Answer"
attrs="{'invisible': [('question_type', '!=', 'date')], 'required': [('is_scored_question', '=', True), ('question_type', '=', 'date')]}" class="oe_inline"/>
<field name="answer_datetime" string="Correct Answer"
attrs="{'invisible': [('question_type', '!=', 'datetime')], 'required': [('is_scored_question', '=', True), ('question_type', '=', 'datetime')]}" class="oe_inline"/>
<field name="answer_numerical_box" string="Correct Answer" class="oe_inline"
attrs="{'invisible': [('question_type', '!=', 'numerical_box')],
'required': [('is_scored_question', '=', True), ('question_type', '=', 'numerical_box')]}" />
<field name="answer_date" string="Correct Answer" class="oe_inline"
attrs="{'invisible': [('question_type', '!=', 'date')],
'required': [('is_scored_question', '=', True), ('question_type', '=', 'date')]}"/>
<field name="answer_datetime" string="Correct Answer" class="oe_inline"
attrs="{'invisible': [('question_type', '!=', 'datetime')],
'required': [('is_scored_question', '=', True), ('question_type', '=', 'datetime')]}"/>
<field name="validation_email" attrs="{'invisible': [('question_type', '!=', 'char_box')]}"/>
<field name="save_as_email" attrs="{'invisible': ['|', ('validation_email', '=', False), ('question_type', '!=', 'char_box')]}"/>
@@ -171,44 +178,64 @@
<group string="Answers">
<!-- Global validation setting -->
<field name="validation_required"
attrs="{'invisible': [('question_type', 'not in', ['char_box', 'numerical_box', 'date', 'datetime'])]}"/>
<!-- Char -->
<field name="validation_length_min" attrs="{'invisible': ['|', ('question_type', '!=', 'char_box'), ('validation_required', '=', False)]}"/>
<field name="validation_length_max" attrs="{'invisible': ['|', ('question_type', '!=', 'char_box'), ('validation_required', '=', False)]}"/>
<!-- Numerical -->
<field name="validation_min_float_value"
attrs="{'invisible': ['|', ('question_type', '!=', 'numerical_box'), ('validation_required', '=', False)]}"/>
<field name="validation_max_float_value"
attrs="{'invisible': ['|', ('question_type', '!=', 'numerical_box'), ('validation_required', '=', False)]}"/>
<!-- Date and datetime -->
<field name="validation_min_date" attrs="{'invisible': ['|', ('question_type', '!=', 'date'), ('validation_required', '=', False)]}"/>
<field name="validation_max_date" attrs="{'invisible': ['|', ('question_type', '!=', 'date'), ('validation_required', '=', False)]}"/>
<field name="validation_min_datetime" widget="datetime"
attrs="{'invisible': ['|', ('question_type', '!=', 'datetime'), ('validation_required', '=', False)]}"/>
<field name="validation_max_datetime" widget="datetime"
attrs="{'invisible': ['|', ('question_type', '!=', 'datetime'), ('validation_required', '=', False)]}"/>
attrs="{'invisible': [('question_type', 'not in', ['char_box', 'numerical_box', 'date', 'datetime'])]}"/>
<div class="o_wrap_label o_form_label" attrs="{'invisible': [('validation_required', '=', False)]}">Min/Max Limits</div>
<div class="o_survey_question_validation_parameters" attrs="{'invisible': [('validation_required', '=', False)]}">
<!-- Minima -->
<field name="validation_length_min" class="o_survey_question_validation_inline" nolabel="1" placeholder="Minimum"
attrs="{'invisible': [('question_type', '!=', 'char_box')],
'required': [('validation_required', '=', True), ('question_type', '=', 'char_box')]}"/>
<field name="validation_min_float_value" class="o_survey_question_validation_inline" nolabel="1" placeholder="Minimum"
attrs="{'invisible': [('question_type', '!=', 'numerical_box')],
'required': [('validation_required', '=', True), ('question_type', '=', 'numerical_box')]}"/>
<field name="validation_min_date" nolabel="1" placeholder="Minimum"
attrs="{'invisible': [('question_type', '!=', 'date')],
'required': [('validation_required', '=', True), ('question_type', '=', 'date'), ('validation_max_date', '=', False)]}"/>
<field name="validation_min_datetime" nolabel="1" placeholder="Minimum"
attrs="{'invisible': [('question_type', '!=', 'datetime')],
'required': [('validation_required', '=', True), ('question_type', '=', 'datetime'), ('validation_max_datetime', '=', False)]}"/>
<i class="fa fa-long-arrow-right mx-2" aria-label="Arrow icon" title="Arrow"/>
<!-- Maxima -->
<field name="validation_length_max" class="o_survey_question_validation_inline" nolabel="1" placeholder="Maximum"
attrs="{'invisible': [('question_type', '!=', 'char_box')],
'required': [('validation_required', '=', True), ('question_type', '=', 'char_box')]}"/>
<field name="validation_max_float_value" class="o_survey_question_validation_inline" nolabel="1" placeholder="Maximum"
attrs="{'invisible': [('question_type', '!=', 'numerical_box')],
'required': [('validation_required', '=', True), ('question_type', '=', 'numerical_box')]}"/>
<field name="validation_max_date" nolabel="1" placeholder="Maximum"
attrs="{'invisible': [('question_type', '!=', 'date')],
'required': [('validation_required', '=', True), ('question_type', '=', 'date'), ('validation_min_date', '=', False)]}"/>
<field name="validation_max_datetime" nolabel="1" placeholder="Maximum"
attrs="{'invisible': [('question_type', '!=', 'datetime')],
'required': [('validation_required', '=', True), ('question_type', '=', 'datetime'), ('validation_min_datetime', '=', False)]}"/>
</div>
<field name="validation_error_msg" attrs="{
'invisible': [('validation_required', '=', False)]}"
placeholder="The answer you entered is not valid."/>
placeholder="Displayed when the answer entered is not valid."/>
<field name="matrix_subtype" attrs="{'invisible':[('question_type','not in',['matrix'])],'required':[('question_type','=','matrix')]}"/>
<field name="question_placeholder"
attrs="{'invisible': [('question_type', 'not in', ['text_box', 'char_box', 'date', 'datetime', 'numerical_box'])]}"
placeholder="Help Participants know what to write"/>
<field name='comments_allowed' attrs="{'invisible':[('question_type','not in',['simple_choice','multiple_choice', 'matrix'])]}"/>
<field name='comments_message'
attrs="{'invisible': ['|', ('question_type', 'not in', ['simple_choice','multiple_choice', 'matrix']), ('comments_allowed', '=', False)]}"
placeholder="If other, please specify:"/>
attrs="{'invisible': [('question_type', 'not in', ['text_box', 'char_box', 'date', 'datetime', 'numerical_box'])]}"
placeholder="Help Participants know what to write"/>
<field name="comments_allowed" attrs="{'invisible':[('question_type','not in',['simple_choice','multiple_choice', 'matrix'])]}"/>
<field name="comments_message"
attrs="{'invisible': ['|', ('question_type', 'not in', ['simple_choice','multiple_choice', 'matrix']), ('comments_allowed', '=', False)]}"
placeholder="If other, please specify:"/>
<field name='comment_count_as_answer'
attrs="{'invisible': ['|', ('question_type', 'not in', ['simple_choice','multiple_choice', 'matrix']), ('comments_allowed', '=', False)]}"/>
</group>
<group string="Layout">
<p class="text-muted" colspan="2" attrs="{'invisible': [('questions_selection', '=', 'all')]}">
Conditional display is not available when questions are randomly picked.
</p>
<field name="is_conditional" attrs="{'invisible': [('questions_selection', '=', 'random')]}"/>
<field name="triggering_question_id" options="{'no_open': True, 'no_create': True}"
attrs="{'invisible': [('is_conditional','=', False)], 'required': [('is_conditional','=', True)]}"/>
<field name="allowed_triggering_question_ids" invisible="1"/>
<field name="triggering_question_id" options="{'no_create_edit': True, 'no_open': True, 'no_create': True}"
domain="[('id', 'in', allowed_triggering_question_ids)]" placeholder="Pick a previous multiple-choice question"
attrs="{'invisible': ['|', ('questions_selection', '=', 'random'), ('is_conditional','=', False)], 'required': [('is_conditional','=', True)]}"/>
<field name="triggering_answer_id" options="{'no_open': True, 'no_create': True}"
attrs="{'invisible': ['|', ('is_conditional','=', False), ('triggering_question_id','=', False)],
attrs="{'invisible': ['|', ('questions_selection', '=', 'random'), ('triggering_question_id','=', False)],
'required': [('is_conditional','=', True)]}"/>
</group>
</group>
@@ -245,8 +272,8 @@
<field name="survey_id"/>
<field name="question_type"/>
<field name="triggering_question_id" invisible="1"/>
<button disabled="disabled" icon="fa-code-fork" attrs="{'invisible': [('triggering_question_id', '=', False)]}"
title="This question depends on another question's answer." class="icon_rotates"/>
<field name="triggering_answer_id" invisible="1"/>
<widget name="survey_question_trigger"/>
</tree>
</field>
</record>
@@ -271,7 +298,7 @@
<field name="res_model">survey.question</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="survey_question_search"/>
<field name="context">{'search_default_group_by_page': True}</field>
<field name="context">{'search_default_group_by_page': True, 'show_survey_field': True}</field>
<field name="domain">[('is_page', '=', False)]</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
+7 -4
View File
@@ -65,7 +65,7 @@
</group>
<notebook>
<page string="Questions" name="questions">
<field name="question_and_page_ids" nolabel="1" widget="question_page_one2many" mode="tree,kanban" context="{'default_survey_id': active_id, 'default_questions_selection': questions_selection}">
<field name="question_and_page_ids" nolabel="1" widget="question_page_one2many" mode="tree,kanban" context="{'default_survey_id': active_id}">
<tree decoration-bf="is_page">
<field name="sequence" widget="handle"/>
<field name="title" widget="survey_description_page"/>
@@ -76,11 +76,14 @@
<field name="suggested_answer_ids" invisible="1"/>
<field name="matrix_row_ids" invisible="1"/>
<field name="survey_id" invisible="1"/>
<field name="triggering_question_id" invisible="1"/>
<field name="random_questions_count"
attrs="{'column_invisible': [('parent.questions_selection', '=', 'all')], 'invisible': [('is_page', '=', False)]}"/>
<button disabled="disabled" icon="fa-code-fork" attrs="{'invisible': [('triggering_question_id', '=', False)]}"
title="This question depends on another question's answer." class="icon_rotates"/>
<field name="is_conditional" invisible="1"/>
<field name="triggering_question_id" invisible="1"/>
<field name="triggering_answer_id" invisible="1"/>
<field name="is_placed_before_trigger" invisible="1"/>
<field name="allowed_triggering_question_ids" invisible="1"/>
<widget name="survey_question_trigger" options="{'isSurveyForm': True}"/>
<control>
<create name="add_question_control" string="Add a question"/>
<create name="add_section_control" string="Add a section" context="{'default_is_page': True, 'default_questions_selection': 'all'}"/>
@@ -578,6 +578,8 @@ export class Record extends DataPoint {
* @param {boolean} [options.useSaveErrorDialog=false] displays a custom
* dialog and await the response from this dialog when an error is
* returned by the server.
* @param {boolean} [options.throwOnError=false] throws the saving error if
* applicable, allowing to catch it.
* @returns {Promise<boolean>}
*/
async save(
@@ -586,6 +588,7 @@ export class Record extends DataPoint {
noReload: false,
savePoint: false,
useSaveErrorDialog: false,
throwOnError: false,
}
) {
const shouldSwitchToReadonly = !options.stayInEdition && this.isInEdition;
@@ -635,6 +638,9 @@ export class Record extends DataPoint {
await this.load();
this.model.notify();
}
if (options.throwOnError) {
throw _e;
}
return canProceed;
}
this.__syncData(true);