[IMP] survey: enable multiple trigger questions and answers

Purpose: allowing users to select multiple answers, even from
different questions, as triggers to display a subsequent question.

For example, we could ask the question
"What qualities do you look for in a desk?"
if the participant selected one of the following answers before:
"What furniture did you already buy from us?" - "A desk"
"What kind of furniture are you looking for?" - "Office furniture"

Demo data and tests are adapted and new ones are added.

We also take this opportunity to remove `is_conditional` because:
1. This field isn't useful anymore.
2. It could cause inconsistencies as it is not supported to check
 with a sql constraint that `suggested_answer_ids` is set when this
  flag is `True`.

Task-2937533

Part-of: odoo/odoo#109903
Co-authored-by: Pratik Raval <prra@odoo.com>
This commit is contained in:
Florian Charlier
2023-08-18 19:16:15 +02:00
co-authored by Pratik Raval
parent c9af4abfd3
commit 55fa52be8a
20 changed files with 847 additions and 626 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
{ {
'name': 'Surveys', 'name': 'Surveys',
'version': '3.5', 'version': '3.6',
'category': 'Marketing/Surveys', 'category': 'Marketing/Surveys',
'description': """ 'description': """
Create beautiful surveys and visualize answers Create beautiful surveys and visualize answers
+6 -6
View File
@@ -270,15 +270,15 @@ class Survey(http.Controller):
'format_date': lambda date: format_date(request.env, date) 'format_date': lambda date: format_date(request.env, date)
} }
if survey_sudo.questions_layout != 'page_per_question': if survey_sudo.questions_layout != 'page_per_question':
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = answer_sudo._get_conditional_values() triggering_answers_by_question, triggered_questions_by_answer, selected_answers = answer_sudo._get_conditional_values()
data.update({ data.update({
'triggering_answer_by_question': { 'triggering_answers_by_question': {
question.id: triggering_answer_by_question[question].id for question in triggering_answer_by_question.keys() question.id: triggering_answers.ids
if triggering_answer_by_question[question] for question, triggering_answers in triggering_answers_by_question.items() if triggering_answers
}, },
'triggered_questions_by_answer': { 'triggered_questions_by_answer': {
answer.id: triggered_questions_by_answer[answer].ids answer.id: triggered_questions.ids
for answer in triggered_questions_by_answer.keys() for answer, triggered_questions in triggered_questions_by_answer.items()
}, },
'selected_answers': selected_answers.ids 'selected_answers': selected_answers.ids
}) })
+119 -37
View File
@@ -67,9 +67,7 @@
<field name="title">How long is the White Nile river?</field> <field name="title">How long is the White Nile river?</field>
<field name="question_type">simple_choice</field> <field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/> <field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/> <field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug1'))]"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug1"/>
</record> </record>
<record id="survey_demo_burger_quiz_p2_q1_sug1" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p2_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q1"/> <field name="question_id" ref="survey_demo_burger_quiz_p2_q1"/>
@@ -96,9 +94,7 @@
<field name="title">What is the biggest city in the world?</field> <field name="title">What is the biggest city in the world?</field>
<field name="question_type">simple_choice</field> <field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/> <field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/> <field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug1'))]"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug1"/>
</record> </record>
<record id="survey_demo_burger_quiz_p2_q2_sug1" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p2_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q2"/> <field name="question_id" ref="survey_demo_burger_quiz_p2_q2"/>
@@ -129,9 +125,7 @@
<field name="title">Which is the highest volcano in Europe?</field> <field name="title">Which is the highest volcano in Europe?</field>
<field name="question_type">simple_choice</field> <field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/> <field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/> <field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug1'))]"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug1"/>
</record> </record>
<record id="survey_demo_burger_quiz_p2_q3_sug1" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p2_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q3"/> <field name="question_id" ref="survey_demo_burger_quiz_p2_q3"/>
@@ -170,9 +164,7 @@
<field name="title">When did Genghis Khan die?</field> <field name="title">When did Genghis Khan die?</field>
<field name="question_type">simple_choice</field> <field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/> <field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/> <field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug2'))]"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug2"/>
</record> </record>
<record id="survey_demo_burger_quiz_p3_q1_sug1" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p3_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q1"/> <field name="question_id" ref="survey_demo_burger_quiz_p3_q1"/>
@@ -198,9 +190,7 @@
<field name="title">Who is the architect of the Great Pyramid of Giza?</field> <field name="title">Who is the architect of the Great Pyramid of Giza?</field>
<field name="question_type">simple_choice</field> <field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/> <field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/> <field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug2'))]"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug2"/>
</record> </record>
<record id="survey_demo_burger_quiz_p3_q2_sug1" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p3_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q2"/> <field name="question_id" ref="survey_demo_burger_quiz_p3_q2"/>
@@ -231,9 +221,7 @@
<field name="title">How many years did the 100 years war last?</field> <field name="title">How many years did the 100 years war last?</field>
<field name="question_type">simple_choice</field> <field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/> <field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/> <field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug2'))]"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug2"/>
</record> </record>
<record id="survey_demo_burger_quiz_p3_q3_sug1" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p3_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q3"/> <field name="question_id" ref="survey_demo_burger_quiz_p3_q3"/>
@@ -272,9 +260,7 @@
<field name="title">Who received a Nobel prize in Physics for the discovery of neutrino oscillations, which shows that neutrinos have mass?</field> <field name="title">Who received a Nobel prize in Physics for the discovery of neutrino oscillations, which shows that neutrinos have mass?</field>
<field name="question_type">multiple_choice</field> <field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/> <field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/> <field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug3'))]"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug3"/>
</record> </record>
<record id="survey_demo_burger_quiz_p4_q1_sug1" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p4_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q1"/> <field name="question_id" ref="survey_demo_burger_quiz_p4_q1"/>
@@ -307,9 +293,7 @@
<field name="title">What is, approximately, the critical mass of plutonium-239?</field> <field name="title">What is, approximately, the critical mass of plutonium-239?</field>
<field name="question_type">simple_choice</field> <field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/> <field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/> <field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug3'))]"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug3"/>
</record> </record>
<record id="survey_demo_burger_quiz_p4_q2_sug1" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p4_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q2"/> <field name="question_id" ref="survey_demo_burger_quiz_p4_q2"/>
@@ -340,9 +324,7 @@
<field name="title">Can Humans ever directly see a photon?</field> <field name="title">Can Humans ever directly see a photon?</field>
<field name="question_type">simple_choice</field> <field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/> <field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/> <field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug3'))]"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug3"/>
</record> </record>
<record id="survey_demo_burger_quiz_p4_q3_sug1" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p4_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q3"/> <field name="question_id" ref="survey_demo_burger_quiz_p4_q3"/>
@@ -354,7 +336,7 @@
<record id="survey_demo_burger_quiz_p4_q3_sug2" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p4_q3_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q3"/> <field name="question_id" ref="survey_demo_burger_quiz_p4_q3"/>
<field name="sequence">2</field> <field name="sequence">2</field>
<field name="value">No, it's to small for the human eye.</field> <field name="value">No, it's too small for the human eye.</field>
</record> </record>
<!-- Page 5 : Art & Culture --> <!-- Page 5 : Art & Culture -->
@@ -371,9 +353,7 @@
<field name="title">Which Musician is not in the 27th Club?</field> <field name="title">Which Musician is not in the 27th Club?</field>
<field name="question_type">multiple_choice</field> <field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/> <field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/> <field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug4'))]"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug4"/>
</record> </record>
<record id="survey_demo_burger_quiz_p5_q1_sug1" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p5_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q1"/> <field name="question_id" ref="survey_demo_burger_quiz_p5_q1"/>
@@ -406,9 +386,7 @@
<field name="title">Which painting/drawing was not made by Pablo Picasso?</field> <field name="title">Which painting/drawing was not made by Pablo Picasso?</field>
<field name="question_type">simple_choice</field> <field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/> <field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/> <field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug4'))]"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug4"/>
</record> </record>
<record id="survey_demo_burger_quiz_p5_q2_sug1" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p5_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q2"/> <field name="question_id" ref="survey_demo_burger_quiz_p5_q2"/>
@@ -443,9 +421,7 @@
<field name="title">Which quote is from Jean-Claude Van Damme</field> <field name="title">Which quote is from Jean-Claude Van Damme</field>
<field name="question_type">simple_choice</field> <field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/> <field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/> <field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug4'))]"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug4"/>
</record> </record>
<record id="survey_demo_burger_quiz_p5_q3_sug1" model="survey.question.answer"> <record id="survey_demo_burger_quiz_p5_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q3"/> <field name="question_id" ref="survey_demo_burger_quiz_p5_q3"/>
@@ -470,4 +446,110 @@
<field name="value">I actually don't like thinking. I think people think I like to think a lot. And I don't. I do not like to think at all.</field> <!-- Kanye West --> <field name="value">I actually don't like thinking. I think people think I like to think a lot. And I don't. I do not like to think at all.</field> <!-- Kanye West -->
</record> </record>
<!-- Multiple triggers -->
<record id="survey_demo_food_preferences" model="survey.survey">
<field name="title">Food Preferences</field>
<field name="survey_type">survey</field>
<field name="access_token">foodpref-eren-ces1-abcd-344ca2tgb31e</field>
<field name="user_id" ref="base.user_demo"/>
<field name="access_mode">public</field>
<field name="questions_layout">one_page</field>
<field name="description" type="html">
<p>Please give us your preferences for this event's dinner!</p>
</field>
<field name="description_done" type="html">
<p>Got it!</p>
<p>See you soon!</p>
</field>
</record>
<record id="survey_demo_food_preferences_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_food_preferences"/>
<field name="sequence">1</field>
<field name="title">Are you vegetarian?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
</record>
<record id="survey_demo_food_preferences_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q1"/>
<field name="sequence">1</field>
<field name="value">Yes</field>
</record>
<record id="survey_demo_food_preferences_q1_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q1"/>
<field name="sequence">2</field>
<field name="value">No</field>
</record>
<record id="survey_demo_food_preferences_q1_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q1"/>
<field name="sequence">3</field>
<field name="value">It depends</field>
</record>
<record id="survey_demo_food_preferences_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_food_preferences"/>
<field name="sequence">2</field>
<field name="title">Would you prefer a veggie meal if possible?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="triggering_answer_ids" eval="[
Command.link(ref('survey.survey_demo_food_preferences_q1_sug3')),
]"/>
</record>
<record id="survey_demo_food_preferences_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q2"/>
<field name="sequence">1</field>
<field name="value">Yes</field>
</record>
<record id="survey_demo_food_preferences_q2_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q2"/>
<field name="sequence">2</field>
<field name="value">No</field>
</record>
<record id="survey_demo_food_preferences_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_food_preferences"/>
<field name="sequence">3</field>
<field name="title">Choose your green meal</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="triggering_answer_ids" eval="[
Command.link(ref('survey.survey_demo_food_preferences_q1_sug1')),
Command.link(ref('survey.survey_demo_food_preferences_q2_sug1')),
]"/>
</record>
<record id="survey_demo_food_preferences_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q3"/>
<field name="sequence">1</field>
<field name="value">Vegetarian pizza</field>
</record>
<record id="survey_demo_food_preferences_q3_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q3"/>
<field name="sequence">2</field>
<field name="value">Vegetarian burger</field>
</record>
<record id="survey_demo_food_preferences_q4" model="survey.question">
<field name="survey_id" ref="survey_demo_food_preferences"/>
<field name="sequence">4</field>
<field name="title">Choose your meal</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="triggering_answer_ids" eval="[
Command.link(ref('survey.survey_demo_food_preferences_q1_sug2')),
Command.link(ref('survey.survey_demo_food_preferences_q2_sug2')),
]"/>
</record>
<record id="survey_demo_food_preferences_q4_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q4"/>
<field name="sequence">1</field>
<field name="value">Steak with french fries</field>
</record>
<record id="survey_demo_food_preferences_q4_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q4"/>
<field name="sequence">2</field>
<field name="value">Fish</field>
</record>
</data></odoo> </data></odoo>
+59 -81
View File
@@ -3,9 +3,10 @@
import collections import collections
import contextlib import contextlib
import json
import itertools import itertools
import json
import operator import operator
from textwrap import shorten
from odoo import api, fields, models, tools, _ from odoo import api, fields, models, tools, _
from odoo.exceptions import UserError, ValidationError from odoo.exceptions import UserError, ValidationError
@@ -132,27 +133,28 @@ class SurveyQuestion(models.Model):
'survey.user_input.line', 'question_id', string='Answers', 'survey.user_input.line', 'question_id', string='Answers',
domain=[('skipped', '=', False)], groups='survey.group_survey_user') domain=[('skipped', '=', False)], groups='survey.group_survey_user')
# Conditional display # Not stored, convenient for trigger display computation.
is_conditional = fields.Boolean( triggering_question_ids = fields.Many2many(
string='Conditional Display', copy=False, help="""If checked, this question will be displayed only 'survey.question', string="Triggering Questions", compute="_compute_triggering_question_ids",
if the specified conditional answer have been selected in a previous question""") store=False, help="Questions containing the triggering answer(s) to display the current question.")
triggering_question_id = fields.Many2one(
'survey.question', string="Triggering Question", copy=False, compute="_compute_triggering_question_id",
store=True, readonly=False, help="Question containing the triggering answer to display the current question.",
domain="[('survey_id', '=', survey_id), \
'&', ('question_type', 'in', ['simple_choice', 'multiple_choice']), \
'|', \
('sequence', '<', sequence), \
'&', ('sequence', '=', sequence), ('id', '<', id)]")
allowed_triggering_question_ids = fields.Many2many( allowed_triggering_question_ids = fields.Many2many(
'survey.question', string="Allowed Triggering Questions", copy=False, compute="_compute_allowed_triggering_question_ids") 'survey.question', string="Allowed Triggering Questions", copy=False, compute="_compute_allowed_triggering_question_ids")
is_placed_before_trigger = fields.Boolean( is_placed_before_trigger = fields.Boolean(
string='Is misplaced?', help="Is this question placed before its trigger question?", string='Is misplaced?', help="Is this question placed before any of its trigger questions?",
compute="_compute_allowed_triggering_question_ids") compute="_compute_allowed_triggering_question_ids")
triggering_answer_id = fields.Many2one( triggering_answer_ids = fields.Many2many(
'survey.question.answer', string="Triggering Answer", copy=False, compute="_compute_triggering_answer_id", 'survey.question.answer', string="Triggering Answers", copy=False, store=True,
store=True, readonly=False, help="Answer that will trigger the display of the current question.", readonly=False, help="Picking any of these answers will trigger this question.\n"
domain="[('question_id', '=', triggering_question_id)]") "Leave the field empty if the question should always be displayed.",
domain="""[
('question_id.survey_id', '=', survey_id),
'&', ('question_id.question_type', 'in', ['simple_choice', 'multiple_choice']),
'|',
('question_id.sequence', '<', sequence),
'&', ('question_id.sequence', '=', sequence), ('question_id.id', '<', id)
]"""
)
_sql_constraints = [ _sql_constraints = [
('positive_len_min', 'CHECK (validation_length_min >= 0)', 'A length must be positive!'), ('positive_len_min', 'CHECK (validation_length_min >= 0)', 'A length must be positive!'),
@@ -166,12 +168,6 @@ class SurveyQuestion(models.Model):
'All "Is a scored question = True" and "Question Type: Datetime" questions need an answer'), '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)", ('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.'),
] ]
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -280,19 +276,12 @@ class SurveyQuestion(models.Model):
if not question.validation_required or question.question_type not in ['char_box', 'numerical_box', 'date', 'datetime']: if not question.validation_required or question.question_type not in ['char_box', 'numerical_box', 'date', 'datetime']:
question.validation_required = False question.validation_required = False
@api.depends('is_conditional', 'survey_id', 'survey_id.question_ids', 'triggering_question_id') @api.depends('survey_id', 'survey_id.question_ids', 'triggering_answer_ids')
def _compute_allowed_triggering_question_ids(self): def _compute_allowed_triggering_question_ids(self):
""" Although the question (and possible trigger questions) sequence """Although the question (and possible trigger questions) sequence
is used here, we do not add these fields to the dependency list to is used here, we do not add these fields to the dependency list to
avoid cascading rpc calls when reordering questions via the webclient. 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([ possible_trigger_questions = self.search([
('is_page', '=', False), ('is_page', '=', False),
('question_type', 'in', ['simple_choice', 'multiple_choice']), ('question_type', 'in', ['simple_choice', 'multiple_choice']),
@@ -301,14 +290,14 @@ class SurveyQuestion(models.Model):
]) ])
# Using the sequence stored in db is necessary for existing questions that are passed as # 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. # NewIds because the sequence provided by the JS client can be incorrect.
(conditional_questions | possible_trigger_questions).flush_recordset() (self | possible_trigger_questions).flush_recordset()
self.env.cr.execute( self.env.cr.execute(
"SELECT id, sequence FROM survey_question WHERE id =ANY(%s)", "SELECT id, sequence FROM survey_question WHERE id =ANY(%s)",
[conditional_questions.ids] [self.ids]
) )
conditional_questions_sequences = dict(self.env.cr.fetchall()) # id: sequence mapping conditional_questions_sequences = dict(self.env.cr.fetchall()) # id: sequence mapping
for question in conditional_questions: for question in self:
question_id = question._origin.id question_id = question._origin.id
if not question_id: # New question if not question_id: # New question
question.allowed_triggering_question_ids = possible_trigger_questions.filtered( question.allowed_triggering_question_ids = possible_trigger_questions.filtered(
@@ -322,28 +311,15 @@ class SurveyQuestion(models.Model):
lambda q: q.survey_id.id == question.survey_id._origin.id 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) and (q.sequence < question_sequence or q.sequence == question_sequence and q.id < question_id)
) )
question.is_placed_before_trigger = ( question.is_placed_before_trigger = bool(
question.triggering_question_id set(question.triggering_answer_ids.question_id.ids)
and question.triggering_question_id.id not in question.allowed_triggering_question_ids.ids) - set(question.allowed_triggering_question_ids.ids) # .ids necessary to match ids with newIds
)
@api.depends('is_conditional') @api.depends('triggering_answer_ids')
def _compute_triggering_question_id(self): def _compute_triggering_question_ids(self):
""" Used as an 'onchange' : Reset the triggering question if user uncheck 'Conditional Display'
Avoid CacheMiss : set the value to False if the value is not set yet."""
for question in self: for question in self:
if not question.is_conditional or question.triggering_question_id is None: question.triggering_question_ids = question.triggering_answer_ids.question_id
question.triggering_question_id = False
@api.depends('triggering_question_id')
def _compute_triggering_answer_id(self):
""" Used as an 'onchange' : Reset the triggering answer if user unset or change the triggering question
or uncheck 'Conditional Display'.
Avoid CacheMiss : set the value to False if the value is not set yet."""
for question in self:
if not question.triggering_question_id \
or question.triggering_question_id != question.triggering_answer_id.question_id\
or question.triggering_answer_id is None:
question.triggering_answer_id = False
@api.depends('question_type', 'scoring_type', 'answer_date', 'answer_datetime', 'answer_numerical_box', 'suggested_answer_ids.is_correct') @api.depends('question_type', 'scoring_type', 'answer_date', 'answer_datetime', 'answer_numerical_box', 'suggested_answer_ids.is_correct')
def _compute_is_scored_question(self): def _compute_is_scored_question(self):
@@ -391,22 +367,10 @@ class SurveyQuestion(models.Model):
def copy(self, default=None): def copy(self, default=None):
self.ensure_one() self.ensure_one()
clone = super().copy(default) clone = super().copy(default)
if self.is_conditional: if self.triggering_answer_ids:
clone.is_conditional = True clone.triggering_answer_ids = self.triggering_answer_ids
clone.triggering_question_id = self.triggering_question_id.id
clone.triggering_answer_id = self.triggering_answer_id.id
return clone return clone
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()
# ------------------------------------------------------------ # ------------------------------------------------------------
# CRUD # CRUD
# ------------------------------------------------------------ # ------------------------------------------------------------
@@ -698,9 +662,12 @@ class SurveyQuestionAnswer(models.Model):
""" """
_name = 'survey.question.answer' _name = 'survey.question.answer'
_rec_name = 'value' _rec_name = 'value'
_order = 'sequence, id' _rec_names_search = ['question_id.title', 'value']
_order = 'question_id, sequence, id'
_description = 'Survey Label' _description = 'Survey Label'
MAX_ANSWER_NAME_LENGTH = 90 # empirically tested in client dropdown
# question and question related fields # question and question related fields
question_id = fields.Many2one('survey.question', string='Question', ondelete='cascade') question_id = fields.Many2one('survey.question', string='Question', ondelete='cascade')
matrix_question_id = fields.Many2one('survey.question', string='Question (as matrix row)', ondelete='cascade') matrix_question_id = fields.Many2one('survey.question', string='Question (as matrix row)', ondelete='cascade')
@@ -714,6 +681,27 @@ class SurveyQuestionAnswer(models.Model):
is_correct = fields.Boolean('Correct') is_correct = fields.Boolean('Correct')
answer_score = fields.Float('Score', help="A positive score indicates a correct choice; a negative or null score indicates a wrong answer") answer_score = fields.Float('Score', help="A positive score indicates a correct choice; a negative or null score indicates a wrong answer")
@api.depends('value', 'question_id.title')
def _compute_display_name(self):
"""Render an answer name as "Question title : Answer value" making sure it is not too long.
This implementation makes sure we have at least 30 characters for the question title,
then we elide it, leaving the rest of the space for the answer.
"""
for answer in self:
# _origin (or fallback title) is (likely temporarily) needed to support survey snapshot
# during onchange for a deleted answer used as trigger in another question.
title = answer._origin.question_id.title
n_extra_characters = len(title) + len(answer.value) + 3 - self.MAX_ANSWER_NAME_LENGTH # 3 for `" : "`
if n_extra_characters <= 0:
answer.display_name = f'{title} : {answer.value}'
else:
answer.display_name = shorten(
f'{shorten(title, max(30, len(title) - n_extra_characters), placeholder="...")} : {answer.value}',
self.MAX_ANSWER_NAME_LENGTH,
placeholder="..."
)
@api.constrains('question_id', 'matrix_question_id') @api.constrains('question_id', 'matrix_question_id')
def _check_question_not_empty(self): def _check_question_not_empty(self):
"""Ensure that field question_id XOR field matrix_question_id is not null""" """Ensure that field question_id XOR field matrix_question_id is not null"""
@@ -728,13 +716,3 @@ class SurveyQuestionAnswer(models.Model):
elif self.question_type in ('multiple_choice', 'simple_choice'): elif self.question_type in ('multiple_choice', 'simple_choice'):
return ['&', ('question_id', '=', self.question_id.id), ('suggested_answer_id', '=', self.id)] return ['&', ('question_id', '=', self.question_id.id), ('suggested_answer_id', '=', self.id)]
return [] return []
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()
+48 -44
View File
@@ -4,6 +4,8 @@
import json import json
import random import random
import uuid import uuid
from collections import defaultdict
import werkzeug import werkzeug
from odoo import api, exceptions, fields, models, _ from odoo import api, exceptions, fields, models, _
@@ -253,12 +255,12 @@ class Survey(models.Model):
survey.question_ids = survey.question_and_page_ids - survey.page_ids survey.question_ids = survey.question_and_page_ids - survey.page_ids
survey.question_count = len(survey.question_ids) survey.question_count = len(survey.question_ids)
@api.depends('question_and_page_ids.is_conditional', 'users_login_required', 'access_mode') @api.depends('question_and_page_ids.triggering_answer_ids', 'users_login_required', 'access_mode')
def _compute_is_attempts_limited(self): def _compute_is_attempts_limited(self):
for survey in self: for survey in self:
if not survey.is_attempts_limited or \ if not survey.is_attempts_limited or \
(survey.access_mode == 'public' and not survey.users_login_required) or \ (survey.access_mode == 'public' and not survey.users_login_required) or \
any(question.is_conditional for question in survey.question_and_page_ids): any(question.triggering_answer_ids for question in survey.question_and_page_ids):
survey.is_attempts_limited = False survey.is_attempts_limited = False
@api.depends('session_start_time', 'user_input_ids') @api.depends('session_start_time', 'user_input_ids')
@@ -310,10 +312,10 @@ class Survey(models.Model):
survey.session_show_leaderboard = survey.scoring_type != 'no_scoring' and \ survey.session_show_leaderboard = survey.scoring_type != 'no_scoring' and \
any(question.save_as_nickname for question in survey.question_and_page_ids) any(question.save_as_nickname for question in survey.question_and_page_ids)
@api.depends('question_and_page_ids.is_conditional') @api.depends('question_and_page_ids.triggering_answer_ids')
def _compute_has_conditional_questions(self): def _compute_has_conditional_questions(self):
for survey in self: for survey in self:
survey.has_conditional_questions = any(question.is_conditional for question in survey.question_and_page_ids) survey.has_conditional_questions = any(question.triggering_answer_ids for question in survey.question_and_page_ids)
@api.depends('scoring_type') @api.depends('scoring_type')
def _compute_certification(self): def _compute_certification(self):
@@ -381,14 +383,14 @@ class Survey(models.Model):
@api.returns('self', lambda value: value.id) @api.returns('self', lambda value: value.id)
def copy(self, default=None): def copy(self, default=None):
""" Correctly copy the 'triggering_question_id' and 'triggering_answer_id' fields from the original """Correctly copy the 'triggering_answer_ids' field from the original to the clone.
to the clone.
This needs to be done in post-processing to make sure we get references to the newly created This needs to be done in post-processing to make sure we get references to the newly
answers/questions from the copy instead of references to the answers/questions of the original. created answers from the copy instead of references to the answers of the original.
This implementation assumes that the order of created questions/answers will be kept between This implementation assumes that the order of created answers will be kept between
the original and the clone, using 'zip()' to match the records between the two. 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 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. standard copy, meaning that triggering logic will not be maintained.
""" """
self.ensure_one() self.ensure_one()
@@ -396,23 +398,18 @@ class Survey(models.Model):
if default and 'question_ids' in default: if default and 'question_ids' in default:
return clone return clone
src_questions = self.question_ids cloned_question_ids = clone.question_ids.sorted()
dst_questions = clone.question_ids.sorted()
questions_map = {src.id: dst.id for src, dst in zip(src_questions, dst_questions)}
answers_map = { answers_map = {
src_answer.id: dst_answer.id src_answer.id: dst_answer.id
for src, dst for src, dst
in zip(src_questions, dst_questions) in zip(self.question_ids, cloned_question_ids)
for src_answer, dst_answer for src_answer, dst_answer
in zip(src.suggested_answer_ids, dst.suggested_answer_ids.sorted()) in zip(src.suggested_answer_ids, dst.suggested_answer_ids.sorted())
} }
for src, dst in zip(self.question_ids, cloned_question_ids):
for src, dst in zip(src_questions, dst_questions): if src.triggering_answer_ids:
if src.is_conditional: dst.triggering_answer_ids = [answers_map[src_answer_id.id] for src_answer_id in src.triggering_answer_ids]
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 return clone
def copy_data(self, default=None): def copy_data(self, default=None):
@@ -615,22 +612,31 @@ class Survey(models.Model):
return result return result
def _get_pages_and_questions_to_show(self): def _get_pages_and_questions_to_show(self):
""" """Filter question_and_pages_ids to include only valid pages and questions.
:return: survey.question recordset excluding invalid conditional questions and pages without description
"""
Pages are invalid if they have no description. Questions are invalid if
they are conditional and all their triggers are invalid.
Triggers are invalid if they:
- Are a page (not a question)
- Have the wrong question type (`simple_choice` and `multiple_choice` are supported)
- Are misplaced (positioned after the conditional question)
- They are themselves conditional and were found invalid
"""
self.ensure_one() self.ensure_one()
invalid_questions = self.env['survey.question'] invalid_questions = self.env['survey.question']
questions_and_valid_pages = self.question_and_page_ids.filtered( questions_and_valid_pages = self.question_and_page_ids.filtered(
lambda question: not question.is_page or not is_html_empty(question.description)) lambda question: not question.is_page or not is_html_empty(question.description))
for question in questions_and_valid_pages.filtered(lambda q: q.is_conditional).sorted():
trigger = question.triggering_question_id for question in questions_and_valid_pages.filtered(lambda q: q.triggering_answer_ids).sorted():
if (trigger in invalid_questions for trigger in question.triggering_question_ids:
or trigger.is_page if (trigger not in invalid_questions
or trigger.question_type not in ['simple_choice', 'multiple_choice'] and not trigger.is_page
or not trigger.suggested_answer_ids and trigger.question_type in ['simple_choice', 'multiple_choice']
or trigger.sequence > question.sequence and (trigger.sequence < question.sequence
or (trigger.sequence == question.sequence and trigger.id > question.id)): or (trigger.sequence == question.sequence and trigger.id < question.id))):
break
else:
# No valid trigger found
invalid_questions |= question invalid_questions |= question
return questions_and_valid_pages - invalid_questions return questions_and_valid_pages - invalid_questions
@@ -674,7 +680,7 @@ class Survey(models.Model):
return Question return Question
# Conditional Questions Management # Conditional Questions Management
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = user_input._get_conditional_values() triggering_answers_by_question, _, selected_answers = user_input._get_conditional_values()
inactive_questions = user_input._get_inactive_conditional_questions() inactive_questions = user_input._get_inactive_conditional_questions()
if survey.questions_layout == 'page_per_question': if survey.questions_layout == 'page_per_question':
question_candidates = pages_or_questions[0:current_page_index] if go_back \ question_candidates = pages_or_questions[0:current_page_index] if go_back \
@@ -687,8 +693,8 @@ class Survey(models.Model):
if contains_active_question or is_description_section: if contains_active_question or is_description_section:
return question return question
else: else:
triggering_answer = triggering_answer_by_question.get(question) triggering_answers = triggering_answers_by_question.get(question)
if not triggering_answer or triggering_answer in selected_answers: if not triggering_answers or triggering_answers & selected_answers:
# question is visible because not conditioned or conditioned by a selected answer # question is visible because not conditioned or conditioned by a selected answer
return question return question
elif survey.questions_layout == 'page_per_section': elif survey.questions_layout == 'page_per_section':
@@ -726,7 +732,7 @@ class Survey(models.Model):
next_page_or_question_candidates = pages_or_questions[current_page_index + 1:] next_page_or_question_candidates = pages_or_questions[current_page_index + 1:]
if next_page_or_question_candidates: if next_page_or_question_candidates:
inactive_questions = user_input._get_inactive_conditional_questions() inactive_questions = user_input._get_inactive_conditional_questions()
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = user_input._get_conditional_values() _, triggered_questions_by_answer, _ = user_input._get_conditional_values()
if self.questions_layout == 'page_per_question': if self.questions_layout == 'page_per_question':
next_active_question = any(next_question not in inactive_questions for next_question in next_page_or_question_candidates) next_active_question = any(next_question not in inactive_questions for next_question in next_page_or_question_candidates)
is_triggering_question = any(triggering_answer in triggered_questions_by_answer.keys() for triggering_answer in page_or_question.suggested_answer_ids) is_triggering_question = any(triggering_answer in triggered_questions_by_answer.keys() for triggering_answer in page_or_question.suggested_answer_ids)
@@ -794,17 +800,15 @@ class Survey(models.Model):
# ------------------------------------------------------------ # ------------------------------------------------------------
def _get_conditional_maps(self): def _get_conditional_maps(self):
triggering_answer_by_question = {} triggering_answers_by_question = defaultdict(lambda: self.env['survey.question.answer'])
triggered_questions_by_answer = {} triggered_questions_by_answer = defaultdict(lambda: self.env['survey.question'])
for question in self.question_ids: for question in self.question_ids:
triggering_answer_by_question[question] = question.is_conditional and question.triggering_answer_id triggering_answers_by_question[question] |= question.triggering_answer_ids
if question.is_conditional: for triggering_answer_id in question.triggering_answer_ids:
if question.triggering_answer_id in triggered_questions_by_answer: triggered_questions_by_answer[triggering_answer_id] |= question
triggered_questions_by_answer[question.triggering_answer_id] |= question
else: return triggering_answers_by_question, triggered_questions_by_answer
triggered_questions_by_answer[question.triggering_answer_id] = question
return triggering_answer_by_question, triggered_questions_by_answer
# ------------------------------------------------------------ # ------------------------------------------------------------
# SESSIONS MANAGEMENT # SESSIONS MANAGEMENT
+12 -12
View File
@@ -536,20 +536,21 @@ class SurveyUserInput(models.Model):
that is the next in sequence and that is either not triggered by another question's answer, or that that is the next in sequence and that is either not triggered by another question's answer, or that
is triggered by an already selected answer. is triggered by an already selected answer.
To do all this, we need to return: To do all this, we need to return:
- list of all selected answers: [answer_id1, answer_id2, ...] (for survey reloading, otherwise, this list is - triggering_answers_by_question: dict -> for a given question, the answers that triggers it
updated at client side) Used mainly to ease template rendering
- triggered_questions_by_answer: dict -> for a given answer, list of questions triggered by this answer; - triggered_questions_by_answer: dict -> for a given answer, list of questions triggered by this answer;
Used mainly for dynamic show/hide behaviour at client side Used mainly for dynamic show/hide behaviour at client side
- triggering_answer_by_question: dict -> for a given question, the answer that triggers it - list of all selected answers: [answer_id1, answer_id2, ...] (for survey reloading, otherwise, this list is
Used mainly to ease template rendering updated at client side)
""" """
triggering_answer_by_question, triggered_questions_by_answer = {}, {} triggering_answers_by_question = {}
triggered_questions_by_answer = {}
# Ignore conditional configuration if randomised questions selection # Ignore conditional configuration if randomised questions selection
if self.survey_id.questions_selection != 'random': if self.survey_id.questions_selection != 'random':
triggering_answer_by_question, triggered_questions_by_answer = self.survey_id._get_conditional_maps() triggering_answers_by_question, triggered_questions_by_answer = self.survey_id._get_conditional_maps()
selected_answers = self._get_selected_suggested_answers() selected_answers = self._get_selected_suggested_answers()
return triggering_answer_by_question, triggered_questions_by_answer, selected_answers return triggering_answers_by_question, triggered_questions_by_answer, selected_answers
def _get_selected_suggested_answers(self): def _get_selected_suggested_answers(self):
""" """
@@ -585,14 +586,13 @@ class SurveyUserInput(models.Model):
answers_to_delete.unlink() answers_to_delete.unlink()
def _get_inactive_conditional_questions(self): def _get_inactive_conditional_questions(self):
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = self._get_conditional_values() triggering_answers_by_question, _, selected_answers = self._get_conditional_values()
# get questions that should not be answered # get questions that should not be answered
inactive_questions = self.env['survey.question'] inactive_questions = self.env['survey.question']
for answer in triggered_questions_by_answer.keys(): for question, triggering_answers in triggering_answers_by_question.items():
if answer not in selected_answers: if triggering_answers and not triggering_answers & selected_answers:
for question in triggered_questions_by_answer[answer]: inactive_questions |= question
inactive_questions |= question
return inactive_questions return inactive_questions
def _get_print_questions(self): def _get_print_questions(self):
+107 -94
View File
@@ -142,20 +142,43 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
} }
}, },
/**
* Handle visibility of comment area and conditional questions
* The form (page) is then automatically submitted if:
* - Survey is configured with one page per question and participants are allowed to go back,
* - It is not the last question of the survey,
* - The question is not waiting for a comment (with "Other" answer),
*
* @param event
*/
_onChangeChoiceItem: function (event) {
const $target = $(event.currentTarget);
const $choiceItemGroup = $target.closest('.o_survey_form_choice');
this._applyCommentAreaVisibility($target);
const isQuestionComplete = this._checkConditionalQuestionsConfiguration($target, $choiceItemGroup);
if (isQuestionComplete && this.options.usersCanGoBack) {
const isLastQuestion = this.$('button[value="finish"]').length !== 0;
if (!isLastQuestion) {
const questionHasComment = $target.hasClass('o_survey_js_form_other_comment') || $target
.closest('.o_survey_form_choice')
.find('.o_survey_comment').length !== 0;
if (!questionHasComment) {
this._submitForm({});
}
}
}
},
/** /**
* Checks, if the 'other' choice is checked. Applies only if the comment count as answer. * Checks, if the 'other' choice is checked. Applies only if the comment count as answer.
* If not checked : Clear the comment textarea, hide and disable it * If not checked : Clear the comment textarea, hide and disable it
* If checked : enable the comment textarea, show and focus on it * If checked : enable the comment textarea, show and focus on it
* *
* @private * @param {JQuery<HTMLElement>} $choiceItemGroup
* @param {Event} event
*/ */
_onChangeChoiceItem: function (event) { _applyCommentAreaVisibility: function ($choiceItemGroup) {
var self = this; const $otherItem = $choiceItemGroup.find('.o_survey_js_form_other_comment');
var $target = $(event.currentTarget); const $commentInput = $choiceItemGroup.find('textarea[type="text"]');
var $choiceItemGroup = $target.closest('.o_survey_form_choice');
var $otherItem = $choiceItemGroup.find('.o_survey_js_form_other_comment');
var $commentInput = $choiceItemGroup.find('textarea[type="text"]');
if ($otherItem.prop('checked') || $commentInput.hasClass('o_survey_comment')) { if ($otherItem.prop('checked') || $commentInput.hasClass('o_survey_comment')) {
$commentInput.each((idx, $input) => $input.disabled = false); $commentInput.each((idx, $input) => $input.disabled = false);
@@ -168,10 +191,20 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
$commentInput.closest('.o_survey_comment_container').addClass('d-none'); $commentInput.closest('.o_survey_comment_container').addClass('d-none');
$commentInput.each((idx, $input) => $input.disabled = true); $commentInput.each((idx, $input) => $input.disabled = true);
} }
},
var $matrixBtn = $target.closest('.o_survey_matrix_btn'); /**
* For single and multiple choice questions, propagate questions visibility
* based on conditional questions and (de)selected triggers
*
* @param {JQuery<HTMLElement>} $target
* @param {JQuery<HTMLElement>} $choiceItemGroup
* @returns {boolean} Whether the question is considered completed
*/
_checkConditionalQuestionsConfiguration: function ($target, $choiceItemGroup) {
let isQuestionComplete = false;
const $matrixBtn = $target.closest('.o_survey_matrix_btn');
if ($target.attr('type') === 'radio') { if ($target.attr('type') === 'radio') {
var isQuestionComplete = false;
if ($matrixBtn.length > 0) { if ($matrixBtn.length > 0) {
$matrixBtn.closest('tr').find('td').removeClass('o_survey_selected'); $matrixBtn.closest('tr').find('td').removeClass('o_survey_selected');
if ($target.is(':checked')) { if ($target.is(':checked')) {
@@ -181,86 +214,56 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
var subQuestionsIds = $matrixBtn.closest('table').data('subQuestions'); var subQuestionsIds = $matrixBtn.closest('table').data('subQuestions');
var completedQuestions = []; var completedQuestions = [];
subQuestionsIds.forEach(function (id) { subQuestionsIds.forEach(function (id) {
if (self.$('tr#' + id).find('input:checked').length !== 0) { if (this.$('tr#' + id).find('input:checked').length !== 0) {
completedQuestions.push(id); completedQuestions.push(id);
} }
}); });
isQuestionComplete = completedQuestions.length === subQuestionsIds.length; isQuestionComplete = completedQuestions.length === subQuestionsIds.length;
} }
} else { } else {
var previouslySelectedAnswer = $choiceItemGroup.find('label.o_survey_selected'); const previouslySelectedAnswer = $choiceItemGroup.find('label.o_survey_selected');
previouslySelectedAnswer.removeClass('o_survey_selected'); previouslySelectedAnswer.removeClass('o_survey_selected');
const previouslySelectedAnswerId = previouslySelectedAnswer.find('input').val();
if (previouslySelectedAnswerId && this.options.questionsLayout !== 'page_per_question') {
this.selectedAnswers.splice(this.selectedAnswers.indexOf(parseInt(previouslySelectedAnswerId)), 1);
}
var newlySelectedAnswer = $target.closest('label'); const newlySelectedAnswer = $target.closest('label');
if (newlySelectedAnswer.find('input').val() !== previouslySelectedAnswer.find('input').val()) { const newlySelectedAnswerId = $target.val();
const isNewSelection = newlySelectedAnswerId !== previouslySelectedAnswerId;
if (isNewSelection) {
newlySelectedAnswer.addClass('o_survey_selected'); newlySelectedAnswer.addClass('o_survey_selected');
isQuestionComplete = this.options.questionsLayout === 'page_per_question'; isQuestionComplete = this.options.questionsLayout === 'page_per_question';
if (!isQuestionComplete) {
this.selectedAnswers.push(parseInt(newlySelectedAnswerId));
}
} }
// Conditional display
if (this.options.questionsLayout !== 'page_per_question') { if (this.options.questionsLayout !== 'page_per_question') {
var treatedQuestionIds = []; // Needed to avoid show (1st 'if') then immediately hide (2nd 'if') question during conditional propagation cascade const conditionalQuestionsToRecomputeVisibility = new Set(
if (Object.keys(this.options.triggeredQuestionsByAnswer).includes(previouslySelectedAnswer.find('input').val())) { (this.options.triggeredQuestionsByAnswer[previouslySelectedAnswerId] || [])
// Hide and clear depending question .concat(this.options.triggeredQuestionsByAnswer[newlySelectedAnswerId] || [])
this.options.triggeredQuestionsByAnswer[previouslySelectedAnswer.find('input').val()].forEach(function (questionId) { )
var dependingQuestion = $('.js_question-wrapper#' + questionId); this._applyConditionalQuestionsVisibility(conditionalQuestionsToRecomputeVisibility)
dependingQuestion.addClass('d-none');
self._clearQuestionInputs(dependingQuestion);
treatedQuestionIds.push(questionId);
});
// Remove answer from selected answer
self.selectedAnswers.splice(self.selectedAnswers.indexOf(parseInt($target.val())), 1);
}
if (Object.keys(this.options.triggeredQuestionsByAnswer).includes($target.val())) {
// Display depending question
this.options.triggeredQuestionsByAnswer[$target.val()].forEach(function (questionId) {
if (!treatedQuestionIds.includes(questionId)) {
var dependingQuestion = $('.js_question-wrapper#' + questionId);
dependingQuestion.removeClass('d-none');
// Add answer to selected answer
self.selectedAnswers.push(parseInt($target.val()));
}
});
}
} }
} }
// Auto Submit Form
var isLastQuestion = this.$('button[value="finish"]').length !== 0;
var questionHasComment = $target.closest('.o_survey_form_choice').find('.o_survey_comment').length !== 0
|| $target.hasClass('o_survey_js_form_other_comment');
if (!isLastQuestion && this.options.usersCanGoBack && isQuestionComplete && !questionHasComment) {
this._submitForm({});
}
} else { // $target.attr('type') === 'checkbox' } else { // $target.attr('type') === 'checkbox'
if ($matrixBtn.length > 0) { if ($matrixBtn.length > 0) {
$matrixBtn.toggleClass('o_survey_selected', !$matrixBtn.hasClass('o_survey_selected')); $matrixBtn.toggleClass('o_survey_selected', !$matrixBtn.hasClass('o_survey_selected'));
} else { } else {
var $label = $target.closest('label'); const $label = $target.closest('label');
$label.toggleClass('o_survey_selected', !$label.hasClass('o_survey_selected')); $label.toggleClass('o_survey_selected', !$label.hasClass('o_survey_selected'));
const answerId = $target.val();
// Conditional display if (this.options.questionsLayout !== 'page_per_question') {
if (this.options.questionsLayout !== 'page_per_question' && Object.keys(this.options.triggeredQuestionsByAnswer).includes($target.val())) { $label.hasClass('o_survey_selected')
var isInputSelected = $label.hasClass('o_survey_selected'); ? this.selectedAnswers.push(parseInt(answerId))
// Hide and clear or display depending question : this.selectedAnswers.splice(this.selectedAnswers.indexOf(parseInt(answerId)), 1);
this.options.triggeredQuestionsByAnswer[$target.val()].forEach(function (questionId) { this._applyConditionalQuestionsVisibility(this.options.triggeredQuestionsByAnswer[answerId]);
var dependingQuestion = $('.js_question-wrapper#' + questionId);
dependingQuestion.toggleClass('d-none', !isInputSelected);
if (!isInputSelected) {
self._clearQuestionInputs(dependingQuestion);
}
});
// Add/remove answer to/from selected answer
if (!isInputSelected) {
self.selectedAnswers.splice(self.selectedAnswers.indexOf(parseInt($target.val())), 1);
} else {
self.selectedAnswers.push(parseInt($target.val()));
}
} }
} }
} }
return isQuestionComplete;
}, },
/** /**
@@ -534,7 +537,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
* *
* @param {Object} options see '_submitForm' for details * @param {Object} options see '_submitForm' for details
*/ */
_onNextScreenDone: function (options) { _onNextScreenDone: function (options) {
var self = this; var self = this;
var result = this.nextScreenResult; var result = this.nextScreenResult;
@@ -1134,23 +1137,6 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
// CONDITIONAL QUESTIONS MANAGEMENT TOOLS // CONDITIONAL QUESTIONS MANAGEMENT TOOLS
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/**
* Clear / Un-select all the input from the given question
* + propagate conditional hierarchy by triggering change on choice inputs.
*
* @private
*/
_clearQuestionInputs: function (question) {
question.find('input').each(function () {
if ($(this).attr('type') === 'text' || $(this).attr('type') === 'number') {
$(this).val('');
} else if ($(this).prop('checked')) {
$(this).prop('checked', false).change();
}
});
question.find('textarea').val('');
},
/** /**
* Get questions that are not supposed to be answered by the user. * Get questions that are not supposed to be answered by the user.
* Those are the ones triggered by answers that the user did not selected. * Those are the ones triggered by answers that the user did not selected.
@@ -1158,20 +1144,47 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
* @private * @private
*/ */
_getInactiveConditionalQuestionIds: function () { _getInactiveConditionalQuestionIds: function () {
var self = this; const inactiveQuestionIds = [];
var inactiveQuestionIds = []; for (const [questionId, answerIds] of Object.entries(this.options.triggeringAnswersByQuestion || {})) {
if (this.options.triggeredQuestionsByAnswer) { if (!answerIds.some(answerId => this.selectedAnswers.includes(parseInt(answerId)))) {
Object.keys(this.options.triggeredQuestionsByAnswer).forEach(function (answerId) { inactiveQuestionIds.push(parseInt(questionId));
if (!self.selectedAnswers.includes(parseInt(answerId))) { }
self.options.triggeredQuestionsByAnswer[answerId].forEach(function (questionId) {
inactiveQuestionIds.push(questionId);
});
}
});
} }
return inactiveQuestionIds; return inactiveQuestionIds;
}, },
/**
* Apply visibility rules of conditional questions.
*
* @param {Number[] | String[] | Set | undefined} questionIds Conditional questions ids
*/
_applyConditionalQuestionsVisibility: function(questionIds) {
if (!questionIds || (!questionIds.length && !questionIds.size)) {
return;
}
for (const questionId of questionIds) {
const dependingQuestion = document.querySelector(`.js_question-wrapper[id="${questionId}"]`);
if (!dependingQuestion) { // Could be on different page
continue;
}
const hasNoSelectedTriggers = !this.options.triggeringAnswersByQuestion[questionId]
.some(answerId => this.selectedAnswers.includes(parseInt(answerId)));
dependingQuestion.classList.toggle('d-none', hasNoSelectedTriggers);
if (hasNoSelectedTriggers) {
// Clear / Un-select all the input from the given question
// + propagate conditional hierarchy by triggering change on choice inputs.
$(dependingQuestion).find('input').each(function () {
if ($(this).attr('type') === 'text' || $(this).attr('type') === 'number') {
$(this).val('');
} else if ($(this).prop('checked')) {
$(this).prop('checked', false).change();
}
});
$(dependingQuestion).find('textarea').val('');
}
}
},
// ERRORS TOOLS // ERRORS TOOLS
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -134,7 +134,7 @@ export class QuestionPageListRenderer extends ListRenderer {
*/ */
async onDeleteRecord(record) { async onDeleteRecord(record) {
const triggeredRecords = this.props.list.records.filter( const triggeredRecords = this.props.list.records.filter(
(rec) => rec.data.triggering_question_id[0] === record.resId (rec) => rec.data.triggering_question_ids.records.map(a => a.resId).includes(record.resId)
); );
if (triggeredRecords.length) { if (triggeredRecords.length) {
const res = await super.onDeleteRecord(record); const res = await super.onDeleteRecord(record);
@@ -7,6 +7,11 @@ import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
const { Component, useEffect, useRef, useState } = owl; const { Component, useEffect, useRef, useState } = owl;
export class SurveyQuestionTriggerWidget extends Component { export class SurveyQuestionTriggerWidget extends Component {
static template = "survey.surveyQuestionTrigger";
static props = {
...standardWidgetProps,
};
setup() { setup() {
super.setup(); super.setup();
this.button = useRef('survey_question_trigger'); this.button = useRef('survey_question_trigger');
@@ -15,19 +20,20 @@ export class SurveyQuestionTriggerWidget extends Component {
triggerTooltip: "", triggerTooltip: "",
}); });
useEffect(() => { useEffect(() => {
if (this.button && this.button.el) { if (this.button?.el && this.props.record.data.triggering_question_ids.records?.length !== 0) {
const triggeringQuestionTitle = this.props.record.data.triggering_question_id[1]; const { triggerError, misplacedTriggerQuestionRecords } = this.surveyQuestionTriggerError;
const triggerError = this.surveyQuestionTriggerError;
if (triggerError === "MISPLACED_TRIGGER_WARNING") { if (triggerError === "MISPLACED_TRIGGER_WARNING") {
this.state.surveyIconWarning = true; this.state.surveyIconWarning = true;
this.state.triggerTooltip = _t( this.state.triggerTooltip = '⚠ ' + _t(
'⚠ This question is positioned before its trigger ("%s") and will be skipped.', 'Triggers based on the following questions will not work because they are positioned after this question:\n"%s".',
triggeringQuestionTitle misplacedTriggerQuestionRecords
.map((question) => question.data.title)
.join('", "')
); );
} else if (triggerError === "WRONG_QUESTIONS_SELECTION_WARNING") { } else if (triggerError === "WRONG_QUESTIONS_SELECTION_WARNING") {
this.state.surveyIconWarning = true; this.state.surveyIconWarning = true;
this.state.triggerTooltip = _t( this.state.triggerTooltip = '⚠ ' + _t(
"⚠ Conditional display is not available when questions are randomly picked." "Conditional display is not available when questions are randomly picked."
); );
} else if (triggerError === "MISSING_TRIGGER_ERROR") { } else if (triggerError === "MISSING_TRIGGER_ERROR") {
// This case must be handled to not temporarily render the "normal" icon if previously // This case must be handled to not temporarily render the "normal" icon if previously
@@ -36,9 +42,10 @@ export class SurveyQuestionTriggerWidget extends Component {
} else { } else {
this.state.surveyIconWarning = false; this.state.surveyIconWarning = false;
this.state.triggerTooltip = _t( this.state.triggerTooltip = _t(
'Displayed if "%s: %s"', 'Displayed if "%s".',
triggeringQuestionTitle, this.props.record.data.triggering_answer_ids.records
this.props.record.data.triggering_answer_id[1] .map((answer) => answer.data.display_name)
.join('", "'),
); );
} }
} else { } else {
@@ -55,12 +62,12 @@ export class SurveyQuestionTriggerWidget extends Component {
* 2. Robustness, as sequences values do not always match between server * 2. Robustness, as sequences values do not always match between server
* provided values when the records are not saved. * provided values when the records are not saved.
* *
* @returns { String } * @returns {{ triggerError: String, misplacedTriggerQuestionRecords: Record[] }}
* * `""`: No trigger error (also if `triggering_question_id` * * `""`: No trigger error (also if `triggering_question_id`
* field is not set). * field is not set).
* * `"MISSING_TRIGGER_ERROR"`: `triggering_question_id` field is set * * `"MISSING_TRIGGER_ERROR"`: `triggering_questions_ids` field is set
* and trigger record is not found. This can happen when a question * but trigger record is not found. This can happen if all questions
* used as trigger is deleted on the client but not yet saved to DB. * used as triggers are deleted on the client but not yet saved to DB.
* * `"MISPLACED_TRIGGER_WARNING"`: a `triggering_question_id` is set * * `"MISPLACED_TRIGGER_WARNING"`: a `triggering_question_id` is set
* but is positioned after the current record in the list. This can * but is positioned after the current record in the list. This can
* happen if the triggering or the triggered question is moved. * happen if the triggering or the triggered question is moved.
@@ -71,37 +78,48 @@ export class SurveyQuestionTriggerWidget extends Component {
*/ */
get surveyQuestionTriggerError() { get surveyQuestionTriggerError() {
const record = this.props.record; const record = this.props.record;
if (!record.data.triggering_question_id) { if (!record.data.triggering_question_ids.records.length) {
return ""; return { triggerError: "", misplacedTriggerQuestionRecords: [] };
}
if (this.props.record.data.questions_selection === 'random') {
return { triggerError: 'WRONG_QUESTIONS_SELECTION_WARNING', misplacedTriggerQuestionRecords: [] };
} }
const triggerId = record.data.triggering_question_id[0];
let triggerRecord = record.model.root.data.question_and_page_ids.records.find(rec => rec.resId === triggerId);
if (!triggerRecord) { const missingTriggerQuestionsIds = [];
return "MISSING_TRIGGER_ERROR"; let triggerQuestionsRecords = [];
for (const triggeringQuestion of record.data.triggering_question_ids.records) {
const triggeringQuestionRecord = record.model.root.data.question_and_page_ids.records.find(
rec => rec.resId === triggeringQuestion.resId);
if (triggeringQuestionRecord) {
triggerQuestionsRecords.push(triggeringQuestionRecord);
} else { // Trigger question was deleted from the list
missingTriggerQuestionsIds.push(triggeringQuestion.resId);
}
} }
if (record.data.questions_selection === 'random') {
return "WRONG_QUESTIONS_SELECTION_WARNING"; if (missingTriggerQuestionsIds.length === this.props.record.data.triggering_question_ids.records.length) {
return { triggerError: 'MISSING_TRIGGER_ERROR', misplacedTriggerQuestionRecords: [] }; // only if all are missing
} }
if (record.data.sequence < triggerRecord.data.sequence || const misplacedTriggerQuestionRecords = [];
(record.data.sequence === triggerRecord.data.sequence && record.resId < triggerId)) { for (const triggerQuestionRecord of triggerQuestionsRecords) {
return "MISPLACED_TRIGGER_WARNING"; if (record.data.sequence < triggerQuestionRecord.data.sequence ||
(record.data.sequence === triggerQuestionRecord.data.sequence && record.resId < triggerQuestionRecord.resId)) {
misplacedTriggerQuestionRecords.push(triggerQuestionRecord);
}
} }
return ""; return {
triggerError: misplacedTriggerQuestionRecords.length ? "MISPLACED_TRIGGER_WARNING" : "",
misplacedTriggerQuestionRecords: misplacedTriggerQuestionRecords,
};
} }
} }
SurveyQuestionTriggerWidget.template = "survey.surveyQuestionTrigger";
SurveyQuestionTriggerWidget.props = {
...standardWidgetProps,
};
export const surveyQuestionTriggerWidget = { export const surveyQuestionTriggerWidget = {
component: SurveyQuestionTriggerWidget, component: SurveyQuestionTriggerWidget,
displayName: "Trigger", displayName: "Trigger",
fieldDependencies: [ fieldDependencies: [
{ name: "triggering_question_id", type: "many2one" }, { name: "triggering_question_ids", type: "many2one" },
{ name: "triggering_answer_id", type: "many2one" }, { name: "triggering_answer_ids", type: "many2one" },
], ],
}; };
registry.category("view_widgets").add("survey_question_trigger", surveyQuestionTriggerWidget); registry.category("view_widgets").add("survey_question_trigger", surveyQuestionTriggerWidget);
@@ -2,7 +2,7 @@
<templates xml:space="preserve"> <templates xml:space="preserve">
<t t-name="survey.surveyQuestionTrigger"> <t t-name="survey.surveyQuestionTrigger">
<button t-if="this.props.record.data.triggering_question_id" disabled="disabled" t-ref="survey_question_trigger" <button t-if="this.props.record.data.triggering_question_ids.records.length" disabled="disabled" t-ref="survey_question_trigger"
class="btn btn-link px-1 py-0 pe-auto" t-att-class="this.state.surveyIconWarning ? 'opacity-100' : 'icon_rotates'"> class="btn btn-link px-1 py-0 pe-auto" t-att-class="this.state.surveyIconWarning ? 'opacity-100' : 'icon_rotates'">
<i class="fa fa-fw o_button_icon " t-att-class="this.state.surveyIconWarning ? 'fa-exclamation-triangle text-warning' : 'fa-code-fork'" <i class="fa fa-fw o_button_icon " t-att-class="this.state.surveyIconWarning ? 'fa-exclamation-triangle text-warning' : 'fa-code-fork'"
t-att-data-tooltip="this.state.triggerTooltip"/> t-att-data-tooltip="this.state.triggerTooltip"/>
@@ -26,17 +26,17 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
survey_question: { survey_question: {
fields: { fields: {
sequence: { type: "number" }, sequence: { type: "number" },
name: { type: "char", string: "name", }, title: { type: "char", string: "title", },
triggering_question_id: { triggering_question_ids: {
type: "many2one", type: "many2many",
string: "Triggering question", string: "Triggering question",
relation: "survey_question", relation: "survey_question",
required: false, required: false,
searchable: true, searchable: true,
}, },
triggering_answer_id: { triggering_answer_ids: {
type: "many2one", type: "many2many",
string: "Triggering answer", string: "Triggering answers",
relation: "survey_question_answer", relation: "survey_question_answer",
required: false, required: false,
searchable: true, searchable: true,
@@ -46,15 +46,15 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
{ {
id: 1, id: 1,
sequence: 1, sequence: 1,
name: "Question 1", title: "Question 1",
triggering_question_id: null, triggering_question_ids: null,
triggering_answer_id: null, triggering_answer_ids: null,
}, { }, {
id: 2, id: 2,
sequence: 2, sequence: 2,
name: "Question 2", title: "Question 2",
triggering_question_id: 1, triggering_question_ids: [1],
triggering_answer_id: 1, triggering_answer_ids: [1],
}, },
], ],
}, },
@@ -65,7 +65,8 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
records: [ records: [
{ {
id: 1, id: 1,
name: "Answer 1" name: "Answer 1",
display_name: "Question 1: Answer 1",
}, },
] ]
} }
@@ -74,9 +75,9 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
"survey_question,false,form": ` "survey_question,false,form": `
<form> <form>
<group> <group>
<field name="name"/> <field name="title"/>
<field name="triggering_answer_id" invisible="1"/> <field name="triggering_question_ids" invisible="1"/>
<field name="triggering_question_id" invisible="1"/> <field name="triggering_answer_ids" invisible="1" widget="many2many_tags"/>
</group> </group>
</form> </form>
`, `,
@@ -97,9 +98,9 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
<field name="question_and_page_ids"> <field name="question_and_page_ids">
<tree> <tree>
<field name="sequence" widget="handle"/> <field name="sequence" widget="handle"/>
<field name="name"/> <field name="title"/>
<field name="triggering_answer_id" invisible="1"/> <field name="triggering_question_ids" invisible="1"/>
<field name="triggering_question_id" invisible="1"/> <field name="triggering_answer_ids" invisible="1" widget="many2many_tags"/> <!-- widget to fetch display_name -->
<widget name="survey_question_trigger"/> <widget name="survey_question_trigger"/>
</tree> </tree>
</field> </field>
@@ -121,8 +122,8 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
// Question 2 is correctly placed after Question 1 // Question 2 is correctly placed after Question 1
let triggerIcon = q2TriggerDiv.querySelector("button i"); let triggerIcon = q2TriggerDiv.querySelector("button i");
assert.doesNotHaveClass(triggerIcon, "text-warning"); assert.doesNotHaveClass(triggerIcon, "text-warning");
assert.hasAttrValue(triggerIcon, 'data-tooltip', 'Displayed if "Question 1: Answer 1"', assert.hasAttrValue(triggerIcon, 'data-tooltip', 'Displayed if "Question 1: Answer 1".',
'Trigger tooltip should be \'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) // 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 dragAndDrop("tbody tr:nth-child(2) .o_handle_cell", "tbody tr:nth-child(1)");
@@ -136,7 +137,7 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
assert.hasClass(triggerIcon, "text-warning"); assert.hasClass(triggerIcon, "text-warning");
assert.strictEqual( assert.strictEqual(
triggerIcon.getAttribute('data-tooltip'), triggerIcon.getAttribute('data-tooltip'),
'⚠ This question is positioned before its trigger ("Question 1") and will be skipped.', '⚠ Triggers based on the following questions will not work because they are positioned after this question:\n"Question 1".',
'Trigger tooltip should have been changed to misplacement error message.' 'Trigger tooltip should have been changed to misplacement error message.'
); );
@@ -148,7 +149,7 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
assert.strictEqual(rows[1].textContent, "Question 2"); 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.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"', assert.hasAttrValue(triggerIcon, 'data-tooltip', 'Displayed if "Question 1: Answer 1".',
'Trigger tooltip should be back to \'Displayed if "Question 1: Answer 1"\'.'); 'Trigger tooltip should be back to \'Displayed if "Question 1: Answer 1".\'.');
}); });
}); });
@@ -1,6 +1,7 @@
/** @odoo-module **/ /** @odoo-module **/
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { TourError } from "@web_tour/tour_service/tour_utils";
registry.category("web_tour.tours").add('test_survey_chained_conditional_questions', { registry.category("web_tour.tours").add('test_survey_chained_conditional_questions', {
test: true, test: true,
@@ -19,17 +20,45 @@ registry.category("web_tour.tours").add('test_survey_chained_conditional_questio
content: 'Answer Q3 with Answer 1', content: 'Answer Q3 with Answer 1',
trigger: 'div.js_question-wrapper:contains("Q3") label:contains("Answer 1")', trigger: 'div.js_question-wrapper:contains("Q3") label:contains("Answer 1")',
}, { }, {
content: 'Answer Q1 with Answer 2', // This should hide all remaining questions. content: 'Answer Q1 with Answer 3', // This should hide Q2 but not Q3.
trigger: 'div.js_question-wrapper:contains("Q1") label:contains("Answer 3")',
}, {
content: 'Check that Q2 was hidden',
trigger: 'div.js_question-wrapper:contains("Q3")',
run : () => expectHiddenQuestion("Q2"),
}, {
content: 'Answer Q3 with Answer 2',
trigger: 'div.js_question-wrapper:contains("Q3") label:contains("Answer 2")',
}, {
content: 'Answer Q1 with Answer 2', // This should hide all other questions.
trigger: 'div.js_question-wrapper:contains("Q1") label:contains("Answer 2")', trigger: 'div.js_question-wrapper:contains("Q1") label:contains("Answer 2")',
}, { }, {
content: 'Check that only question 1 is now visible', content: 'Check that only question 1 is now visible',
trigger: 'div.js_question-wrapper:contains("Q1")', trigger: 'div.js_question-wrapper:contains("Q1")',
run: () => { run : () => {
const selector = 'div.js_question-wrapper.d-none'; expectHiddenQuestion("Q2", "Q2's trigger is gone.");
if (document.querySelectorAll(selector).length !== 2) { expectHiddenQuestion("Q3", "No reason to show it now.");
throw new Error('Q2 and Q3 should have been hidden.'); },
} }, {
} content: 'Answer Q1 with Answer 3', // This shows Q3.
trigger: 'div.js_question-wrapper:contains("Q1") label:contains("Answer 3")',
}, {
content: 'Check that a question (Q2) is hidden',
trigger: 'div.js_question-wrapper:contains("Q1")',
run : () => expectHiddenQuestion("Q2", "Q2 should stay hidden."),
}, {
content: 'Answer Q3 with Answer 2',
trigger: 'div.js_question-wrapper:contains("Q3") label:contains("Answer 2")',
}, {
content: 'Answer Q1 with Answer 2',
trigger: 'div.js_question-wrapper:contains("Q1") label:contains("Answer 2")',
}, {
content: 'Check that only question 1 is now the only one visible again',
trigger: 'div.js_question-wrapper:contains("Q1")',
run : () => {
expectHiddenQuestion("Q2", "Q2's trigger is gone, again.");
expectHiddenQuestion("Q3", "As Q2's gone, so should this one.");
},
}, { }, {
content: 'Click Submit and finish the survey', content: 'Click Submit and finish the survey',
trigger: 'button[value="finish"]', trigger: 'button[value="finish"]',
@@ -41,3 +70,9 @@ registry.category("web_tour.tours").add('test_survey_chained_conditional_questio
} }
]}); ]});
export function expectHiddenQuestion (questionTitle, msg){
if ($(`div.js_question-wrapper.d-none:contains('${questionTitle}')`).length !== 1) {
throw new TourError(msg);
}
}
@@ -0,0 +1,52 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { expectHiddenQuestion } from "@survey/../tests/tours/survey_chained_conditional_questions";
registry.category("web_tour.tours").add('test_survey_conditional_question_on_different_page', {
test: true,
url: '/survey/start/1cb935bd-2399-4ed1-9e10-c649318fb4dc',
steps: () => [
{
content: 'Click on Start',
trigger: 'button.btn:contains("Start")',
}, {
content: 'Answer Q1 with Answer 1',
trigger: 'div.js_question-wrapper:contains("Q1") label:contains("Answer 1")',
}, {
content: 'Go to next page',
trigger: 'button[value="next"]',
}, {
content: 'Check that Q3 is visible',
trigger: 'div.js_question-wrapper:contains("Q3")',
isCheck: true,
}, {
content: 'Answer Q2 with Answer 2',
trigger: 'div.js_question-wrapper:contains("Q2") label:contains("Answer 2")',
}, {
content: 'Check that Q3 is still visible',
trigger: 'div.js_question-wrapper:contains("Q3")',
isCheck: true,
}, {
content: 'Go back',
trigger: 'button[value="previous"]',
}, {
content: 'Answer Q1 with Answer 2',
trigger: 'div.js_question-wrapper:contains("Q1") label:contains("Answer 2")',
}, {
content: 'Go to next page',
trigger: 'button[value="next"]',
}, {
content: 'Check that Q3 is hidden',
trigger: 'div.js_question-wrapper:contains("Q2")',
run : () => expectHiddenQuestion("Q3", "Q3 should be hidden as q1_a1 trigger is not selected anymore"),
}, {
content: 'Answer Q2 with Answer 1',
trigger: 'div.js_question-wrapper:contains("Q2") label:contains("Answer 1")',
}, {
content: 'Check that Q3 is now visible again',
trigger: 'div.js_question-wrapper:contains("Q3")',
isCheck: true,
}
],
});
+76 -81
View File
@@ -35,26 +35,17 @@ registry.category("web_tour.tours").add('survey_tour_test_survey_form_triggers',
in_modal: true, in_modal: true,
}, },
...addTwoAnswers(), ...addTwoAnswers(),
...toggleIsConditional(), ...changeTab("options"),
{ {
content: "Set a trigger question for the first question", content: "Set a trigger for the first question",
trigger: ".o_field_widget[name=triggering_question_id] input", trigger: ".o_field_widget[name=triggering_answer_ids] input",
run: "click", run: "click",
in_modal: true, in_modal: true,
}, { }, {
content: "Set the first question as trigger", content: "Set the first question's first answer as trigger",
trigger: 'ul.ui-autocomplete a:contains("Question 1")', trigger: 'ul.ui-autocomplete a:contains("Question 1 : Answer A")',
run: 'click', run: 'click',
in_modal: true, 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"), ...changeTab("answers"),
...saveAndNew(), ...saveAndNew(),
@@ -65,38 +56,27 @@ registry.category("web_tour.tours").add('survey_tour_test_survey_form_triggers',
in_modal: true, in_modal: true,
}, },
...addTwoAnswers(), ...addTwoAnswers(),
...toggleIsConditional(), ...changeTab("options"),
{ {
content: "Set a trigger question for the second question", content: "Set a trigger for the second question",
trigger: ".o_field_widget[name=triggering_question_id] input", trigger: ".o_field_widget[name=triggering_answer_ids] input",
run: "click", run: "click",
in_modal: true, in_modal: true,
}, { }, {
content: "Set the second question as trigger", content: "Set the second question's second answer as trigger",
trigger: 'ul.ui-autocomplete a:contains("Question 2")', trigger: 'ul.ui-autocomplete a:contains("Question 2 : Answer B")',
run: 'click', run: 'click',
in_modal: true, 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"),
...stepUtils.saveForm(), ...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", content: "Check that Question 2 has 'normal' trigger icon",
trigger: "tr:contains('Question 2') button i.fa-code-fork", trigger: "tr:contains('Question 2') button i.fa-code-fork",
run: () => {}, // it's a check isCheck: true,
}, { }, {
content: "Check that Question 3 has 'normal' trigger icon", content: "Check that Question 3 has 'normal' trigger icon",
trigger: "tr:contains('Question 3') button i.fa-code-fork", trigger: "tr:contains('Question 3') button i.fa-code-fork",
run: () => {}, // it's a check isCheck: true,
}, { }, {
content: "Move Question 3 above its trigger (Question 2)", content: "Move Question 3 above its trigger (Question 2)",
trigger: "tr.o_data_row:nth-child(3) td[name=sequence]", trigger: "tr.o_data_row:nth-child(3) td[name=sequence]",
@@ -104,81 +84,107 @@ registry.category("web_tour.tours").add('survey_tour_test_survey_form_triggers',
}, { }, {
content: "Check that Question 3 has 'warning' trigger icon", content: "Check that Question 3 has 'warning' trigger icon",
trigger: "tr:contains('Question 3') button i.fa-exclamation-triangle", trigger: "tr:contains('Question 3') button i.fa-exclamation-triangle",
run: () => {}, // it's a check isCheck: true,
}, { }, {
content: "Open that question to check the server's misplacement evaluation agrees", content: "Open that question to check the server's misplacement evaluation agrees",
trigger: "tr.o_data_row td[data-tooltip='Question 3']", trigger: "tr.o_data_row td:contains('Question 3')",
run: "click", run: "click",
}, { }, {
content: "Check that an alert is shown", content: "Check that an alert is shown",
trigger: ".o_form_sheet_bg div:first-child.alert-warning:contains('positioned before its trigger')", trigger: ".o_form_sheet_bg div:first-child.alert-warning:contains('positioned before some or all of its triggers')",
in_modal: true, in_modal: true,
}, },
...changeTab("options"), ...changeTab("options"),
{ {
content: "Change trigger question", content: "Remove invalid trigger",
trigger: ".o_field_widget[name=triggering_question_id] input", trigger: ".o_field_widget[name=triggering_answer_ids] span:contains('Question 2') a.o_delete",
run: "click", run: "click",
in_modal: true, 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", content: "Check that the alert is gone",
trigger: `.o_form_sheet_bg div:first-child:not(.alert-warning).o_form_sheet`, trigger: `.o_form_sheet_bg div:first-child:not(.alert-warning).o_form_sheet`,
in_modal: true, in_modal: true,
run: () => {}, // it's a check isCheck: true,
}, { }, {
content: "Change the trigger answer as well", content: "Choose a new valid trigger",
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input", trigger: ".o_field_widget[name=triggering_answer_ids] input",
run: "click", run: "click",
in_modal: true, in_modal: true,
}, { }, {
content: "Set the second answer as trigger, then", content: "Set the first question's second answer as trigger, then",
trigger: 'ul.ui-autocomplete a:contains("Answer B")', trigger: 'ul.ui-autocomplete a:contains("Question 1 : Answer B")',
run: 'click', run: 'click',
}, },
...stepUtils.saveForm(), ...stepUtils.saveForm(),
{ {
content: "Check that Question 3 has its 'normal' trigger icon back", content: "Check that Question 3 has its 'normal' trigger icon back",
trigger: "tr:contains('Question 3') button i.fa-code-fork", trigger: "tr:contains('Question 3') button i.fa-code-fork",
run: () => {}, // it's a check isCheck: true,
}, { }, {
content: "Move Question 3 back below Question 2", content: "Move Question 3 back below Question 2",
trigger: "tr.o_data_row:nth-child(2) td[name=sequence]", 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)", run: "drag_and_drop_native div[name=question_and_page_ids] table tbody tr:nth-child(3)",
}, { }, {
content: "Open that question again", content: "Open that question again",
trigger: "tr.o_data_row td[data-tooltip='Question 3']", trigger: "tr.o_data_row td:contains('Question 3')",
run: "click", run: "click",
}, },
...changeTab("options"), ...changeTab("options"),
{ {
content: "Change trigger to confirm we can now use Question 2 again", content: "Add a second trigger to confirm we can now use Question 2 again",
trigger: ".modal-content .o_field_widget[name=triggering_question_id] input", trigger: ".modal-content .o_field_widget[name=triggering_answer_ids] input",
run: "click", run: "click",
in_modal: true, in_modal: true,
}, { }, {
content: "Question 2 is allowed as trigger again", content: "Add the second question's second answer as trigger, then",
trigger: '.modal-content ul.ui-autocomplete a:contains("Question 2")', trigger: '.modal-content ul.ui-autocomplete a:contains("Question 2 : Answer B")',
in_modal: true,
}, {
content: "Change the trigger answer back as well",
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
run: "text A",
in_modal: true,
}, {
content: "Set the second answer as trigger, then",
trigger: '.modal-content ul.ui-autocomplete a:contains("Answer B")',
run: "click", run: "click",
}, },
...stepUtils.saveForm(), ...stepUtils.saveForm(),
// Move question 1 below question 3,
{
content: "Move Question 1 back below Question 3",
trigger: "tr.o_data_row:nth-child(1) td[name=sequence]",
run: "drag_and_drop_native div[name=question_and_page_ids] table tbody tr:nth-child(3)",
}, {
content: "Check that Question 3 has 'warning' trigger icon",
trigger: "tr:contains('Question 3') button i.fa-exclamation-triangle",
isCheck: true,
}, {
content: "Open that question again",
trigger: "tr.o_data_row td:contains('Question 3')",
run: "click",
}, {
content: "Check that an alert is shown also when only one trigger is misplaced",
trigger: ".o_form_sheet_bg div:first-child.alert-warning:contains('positioned before some or all of its triggers')",
in_modal: true,
},
...changeTab("options"),
{
content: "Remove temporarily used trigger",
trigger: ".o_field_widget[name=triggering_answer_ids] span:contains('Question 1') a.o_delete",
run: "click",
in_modal: true,
}, {
content: "Check that the alert is gone in this case too",
trigger: `.o_form_sheet_bg div:first-child:not(.alert-warning).o_form_sheet`,
in_modal: true,
isCheck: true,
},
...stepUtils.saveForm(),
{
content: "Check that Question 3 has its 'normal' trigger icon back",
trigger: "tr:contains('Question 3') button i.fa-code-fork",
isCheck: true,
}, {
content: "Move Question 1 back above 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(1)",
},
// Deleting trigger answers or whole question gracefully remove the trigger automatically // Deleting trigger answers or whole question gracefully remove the trigger automatically
{ {
content: "Open Question 2 again", content: "Open Question 2 again",
trigger: "tr.o_data_row td[data-tooltip='Question 2']", trigger: "tr.o_data_row td:contains('Question 2')",
run: "click", run: "click",
}, { }, {
content: "Delete Answer B", content: "Delete Answer B",
@@ -189,11 +195,11 @@ registry.category("web_tour.tours").add('survey_tour_test_survey_form_triggers',
content: "Check that Question 3 no longer has a trigger icon", 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))", trigger: "div[name=question_and_page_ids] tr:contains('Question 3') div.o_widget_survey_question_trigger:not(:has(button))",
allowInvisible: true, allowInvisible: true,
run: () => {}, // it's a check isCheck: true,
}, { }, {
content: "Check that Question 2 however still has a trigger icon", content: "Check that Question 2 however still has a trigger icon",
trigger: "tr:contains('Question 2') button i.fa-code-fork", trigger: "tr:contains('Question 2') button i.fa-code-fork",
run: () => {}, // it's a check isCheck: true,
}, { }, {
content: "Delete Question 1", content: "Delete Question 1",
trigger: "tr:contains('Question 1') button[name=delete]", trigger: "tr:contains('Question 1') button[name=delete]",
@@ -202,14 +208,14 @@ registry.category("web_tour.tours").add('survey_tour_test_survey_form_triggers',
content: "Check that now Question 2 too does no longer have a trigger icon", 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))", trigger: "tr:contains('Question 2') div.o_widget_survey_question_trigger:not(:has(button))",
allowInvisible: true, allowInvisible: true,
run: () => {}, // it's a check isCheck: true,
}, { }, {
content: 'Go back to Kanban View', content: 'Go back to Kanban View',
trigger: '[data-menu-xmlid="survey.menu_survey_form"]', trigger: '[data-menu-xmlid="survey.menu_survey_form"]',
}, { }, {
content: "Check that we arrived on the kanban view", content: "Check that we arrived on the kanban view",
trigger: ".o-kanban-button-new", trigger: ".o-kanban-button-new",
run: () => {}, // it's a check isCheck: true,
} }
]}); ]});
@@ -246,22 +252,11 @@ function saveAndNew() {
// suggested_answer_ids required even though in_modal is specified... // 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 trigger: "div[name=suggested_answer_ids] .o_list_table tbody tr:first-child:not(.o_data_row)", // empty answers list
in_modal: true, in_modal: true,
run: () => {}, // it's a check isCheck: true,
} }
]; ];
} }
function toggleIsConditional() {
return [
...changeTab("options"),
{
content: "Toggle is_conditional",
trigger: "div[name=is_conditional] input",
in_modal: true,
run: "click",
}
];
}
function changeTab(tabName) { function changeTab(tabName) {
// Currently, .modal-content is required even though "in_modal" // Currently, .modal-content is required even though "in_modal"
@@ -274,7 +269,7 @@ function changeTab(tabName) {
content: `Wait for tab ${tabName} tab`, content: `Wait for tab ${tabName} tab`,
trigger: `.modal-content a[name=${tabName}].nav-link.active`, trigger: `.modal-content a[name=${tabName}].nav-link.active`,
in_modal: true, in_modal: true,
run: () => {}, // it's a check isCheck: true,
} }
]; ];
} }
+130 -144
View File
@@ -33,31 +33,20 @@ class TestSurveyInternals(common.TestSurveyCommon):
survey_1_q_1, survey_1_q_2, _ = survey_1.question_ids survey_1_q_1, survey_1_q_2, _ = survey_1.question_ids
survey_2_q_1, survey_2_q_2, _ = survey_2.question_ids survey_2_q_1, survey_2_q_2, _ = survey_2.question_ids
# Make sure they are already configured as conditionally triggered
survey_1_q_2.write({
'is_conditional': True,
'triggering_question_id': survey_1_q_1,
'triggering_answer_id': survey_1_q_1.suggested_answer_ids[0]
})
survey_2_q_2.write({
'is_conditional': True,
'triggering_question_id': survey_2_q_1,
'triggering_answer_id': survey_2_q_1.suggested_answer_ids[0]
})
with self.subTest('Editing existing questions'): with self.subTest('Editing existing questions'):
# Only previous questions from the same survey # Only previous questions from the same survey
self.assertFalse(survey_1_q_2.allowed_triggering_question_ids & survey_2_q_2.allowed_triggering_question_ids) self.assertFalse(bool(survey_1_q_2.allowed_triggering_question_ids & survey_2_q_2.allowed_triggering_question_ids))
self.assertEqual(survey_1_q_2.allowed_triggering_question_ids, survey_1_q_1) self.assertEqual(survey_1_q_2.allowed_triggering_question_ids, survey_1_q_1)
self.assertEqual(survey_2_q_2.allowed_triggering_question_ids, survey_2_q_1) self.assertEqual(survey_2_q_2.allowed_triggering_question_ids, survey_2_q_1)
survey_1_new_question = self.env['survey.question'].new({'survey_id': survey_1, 'is_conditional': True}) survey_1_new_question = self.env['survey.question'].new({'survey_id': survey_1})
survey_2_new_question = self.env['survey.question'].new({'survey_id': survey_2, 'is_conditional': True}) survey_2_new_question = self.env['survey.question'].new({'survey_id': survey_2})
with self.subTest('New questions'): with self.subTest('New questions'):
# New questions should be allowed to use any question with choices from the same survey # New questions should be allowed to use any question with choices from the same survey
self.assertFalse(survey_1_new_question.allowed_triggering_question_ids self.assertFalse(
& survey_2_new_question.allowed_triggering_question_ids) bool(survey_1_new_question.allowed_triggering_question_ids & survey_2_new_question.allowed_triggering_question_ids)
)
self.assertEqual(survey_1_new_question.allowed_triggering_question_ids.ids, survey_1.question_ids.ids) self.assertEqual(survey_1_new_question.allowed_triggering_question_ids.ids, survey_1.question_ids.ids)
self.assertEqual(survey_2_new_question.allowed_triggering_question_ids.ids, survey_2.question_ids.ids) self.assertEqual(survey_2_new_question.allowed_triggering_question_ids.ids, survey_2.question_ids.ids)
@@ -358,19 +347,18 @@ class TestSurveyInternals(common.TestSurveyCommon):
q_is_vegetarian_text = 'Are you vegetarian?' q_is_vegetarian_text = 'Are you vegetarian?'
q_is_vegetarian = self._add_question( q_is_vegetarian = self._add_question(
self.page_0, q_is_vegetarian_text, 'multiple_choice', survey_id=self.survey.id, self.page_0, q_is_vegetarian_text, 'multiple_choice', survey_id=self.survey.id,
sequence=100, labels=[{'value': 'Yes'}, {'value': 'No'}]) sequence=100, labels=[{'value': 'Yes'}, {'value': 'No'}, {'value': 'Sometimes'}])
q_food_vegetarian_text = 'Choose your green meal' q_food_vegetarian_text = 'Choose your green meal'
self._add_question(self.page_0, q_food_vegetarian_text, 'multiple_choice', self._add_question(self.page_0, q_food_vegetarian_text, 'multiple_choice',
is_conditional=True, sequence=101, sequence=101,
triggering_question_id=q_is_vegetarian.id, triggering_answer_ids=[q_is_vegetarian.suggested_answer_ids[0].id,
triggering_answer_id=q_is_vegetarian.suggested_answer_ids[0].id, q_is_vegetarian.suggested_answer_ids[2].id],
survey_id=self.survey.id, survey_id=self.survey.id,
labels=[{'value': 'Vegetarian pizza'}, {'value': 'Vegetarian burger'}]) labels=[{'value': 'Vegetarian pizza'}, {'value': 'Vegetarian burger'}])
q_food_not_vegetarian_text = 'Choose your meal' q_food_not_vegetarian_text = 'Choose your meal in case we serve meet/fish'
self._add_question(self.page_0, q_food_not_vegetarian_text, 'multiple_choice', self._add_question(self.page_0, q_food_not_vegetarian_text, 'multiple_choice',
is_conditional=True, sequence=102, sequence=102,
triggering_question_id=q_is_vegetarian.id, triggering_answer_ids=q_is_vegetarian.suggested_answer_ids[1].ids,
triggering_answer_id=q_is_vegetarian.suggested_answer_ids[1].id,
survey_id=self.survey.id, survey_id=self.survey.id,
labels=[{'value': 'Steak with french fries'}, {'value': 'Fish'}]) labels=[{'value': 'Steak with french fries'}, {'value': 'Fish'}])
@@ -382,29 +370,25 @@ class TestSurveyInternals(common.TestSurveyCommon):
q_food_vegetarian_cloned = get_question_by_title(survey_clone, q_food_vegetarian_text) q_food_vegetarian_cloned = get_question_by_title(survey_clone, q_food_vegetarian_text)
q_food_not_vegetarian_cloned = get_question_by_title(survey_clone, q_food_not_vegetarian_text) q_food_not_vegetarian_cloned = get_question_by_title(survey_clone, q_food_not_vegetarian_text)
self.assertFalse(q_is_vegetarian_cloned.is_conditional) self.assertFalse(bool(q_is_vegetarian_cloned.triggering_answer_ids))
# Vegetarian choice # Vegetarian choice
self.assertTrue(q_food_vegetarian_cloned) self.assertTrue(bool(q_food_vegetarian_cloned))
# Correct conditional layout # Correct conditional layout
self.assertEqual(q_food_vegetarian_cloned.triggering_question_id.id, q_is_vegetarian_cloned.id) self.assertEqual(q_food_vegetarian_cloned.triggering_answer_ids.ids,
self.assertEqual(q_food_vegetarian_cloned.triggering_answer_id.id, [q_is_vegetarian_cloned.suggested_answer_ids[0].id, q_is_vegetarian_cloned.suggested_answer_ids[2].id])
q_is_vegetarian_cloned.suggested_answer_ids[0].id)
# Doesn't reference the original survey # Doesn't reference the original survey
self.assertNotEqual(q_food_vegetarian_cloned.triggering_question_id.id, q_is_vegetarian.id) self.assertNotEqual(q_food_vegetarian_cloned.triggering_answer_ids.ids,
self.assertNotEqual(q_food_vegetarian_cloned.triggering_answer_id.id, [q_is_vegetarian.suggested_answer_ids[0].id, q_is_vegetarian.suggested_answer_ids[2].id])
q_is_vegetarian.suggested_answer_ids[0].id)
# Not vegetarian choice # Not vegetarian choice
self.assertTrue(q_food_not_vegetarian_cloned.is_conditional) self.assertTrue(bool(q_food_not_vegetarian_cloned.triggering_answer_ids))
# Correct conditional layout # Correct conditional layout
self.assertEqual(q_food_not_vegetarian_cloned.triggering_question_id.id, q_is_vegetarian_cloned.id) self.assertEqual(q_food_not_vegetarian_cloned.triggering_answer_ids.ids,
self.assertEqual(q_food_not_vegetarian_cloned.triggering_answer_id.id, q_is_vegetarian_cloned.suggested_answer_ids[1].ids)
q_is_vegetarian_cloned.suggested_answer_ids[1].id)
# Doesn't reference the original survey # Doesn't reference the original survey
self.assertNotEqual(q_food_not_vegetarian_cloned.triggering_question_id.id, q_is_vegetarian.id) self.assertNotEqual(q_food_not_vegetarian_cloned.triggering_answer_ids.ids,
self.assertNotEqual(q_food_not_vegetarian_cloned.triggering_answer_id.id, q_is_vegetarian.suggested_answer_ids[1].ids)
q_is_vegetarian.suggested_answer_ids[1].id)
@users('survey_manager') @users('survey_manager')
def test_copy_conditional_question_with_sequence_changed(self): def test_copy_conditional_question_with_sequence_changed(self):
@@ -427,11 +411,7 @@ class TestSurveyInternals(common.TestSurveyCommon):
q_2.write({'sequence': 100}) q_2.write({'sequence': 100})
# Set a conditional question on the first question # Set a conditional question on the first question
q_1.write({ q_1.write({'triggering_answer_ids': [Command.set([q_2.suggested_answer_ids[0].id])]})
'is_conditional': True,
'triggering_question_id': q_2.id,
'triggering_answer_id': q_2.suggested_answer_ids[0].id,
})
(q_1 | q_2).invalidate_recordset() (q_1 | q_2).invalidate_recordset()
@@ -443,51 +423,101 @@ class TestSurveyInternals(common.TestSurveyCommon):
self.assertEqual(get_question_by_title(cloned_survey, 'Q2').sequence, q_2.sequence) self.assertEqual(get_question_by_title(cloned_survey, 'Q2').sequence, q_2.sequence)
# Check that the conditional question is correctly copied to the right question # Check that the conditional question is correctly copied to the right question
self.assertEqual(get_question_by_title(cloned_survey, 'Q1').triggering_question_id.title, q_1.triggering_question_id.title) self.assertEqual(
self.assertFalse(get_question_by_title(cloned_survey, 'Q2').triggering_question_id) get_question_by_title(cloned_survey, 'Q1').triggering_answer_ids[0].value, q_1.triggering_answer_ids[0].value
)
self.assertFalse(bool(get_question_by_title(cloned_survey, 'Q2').triggering_answer_ids))
@users('survey_manager')
def test_suggested_answer_display_name(self):
"""Check that answers' display name is not too long and allows to identify the question & answer."""
# A case's shape is: (question_title, answer value, expected display name)
cases = [
(
'Question 1',
'Answer A is short',
'Question 1 : Answer A is short',
), (
'Question 2',
'Answer B is a very long answer, so it should itself be shortened or we would go too far',
'Question 2 : Answer B is a very long answer, so it should itself be shortened or we...',
), (
'Question 3 is a very long question, so what can we do?',
'Answer A is short',
'Question 3 is a very long question, so what can we do? : Answer A is short',
), (
'Question 4 is a very long question, so what can we do?',
'Answer B is a bit too long for Q4 now',
'Question 4 is a very long question, so what can... : Answer B is a bit too long for Q4 now',
), (
'Question 5 is a very long question, so what can we do?',
'Answer C is so long that both the question and the answer will be shortened',
'Question 5 is a very long... : Answer C is so long that both the question and the...',
),
]
for question_title, answer_value, exp_display_name in cases:
question = self.env['survey.question'].create({
'title': question_title,
'suggested_answer_ids': [Command.create({'value': answer_value})],
})
with self.subTest(question=question_title, answer=answer_value):
self.assertEqual(question.suggested_answer_ids[0].display_name, exp_display_name)
@users('survey_manager') @users('survey_manager')
def test_unlink_triggers(self): def test_unlink_triggers(self):
# Create the survey questions # Create the survey questions
q_is_vegetarian_text = 'Are you vegetarian?' q_is_vegetarian_text = 'Are you vegetarian?'
q_is_vegetarian = self._add_question( q_is_vegetarian = self._add_question(
self.page_0, q_is_vegetarian_text, 'multiple_choice', survey_id=self.survey.id, self.page_0, q_is_vegetarian_text, 'simple_choice', survey_id=self.survey.id, sequence=100,
sequence=100, labels=[{'value': 'Yes'}, {'value': 'No'}]) labels=[{'value': 'Yes'}, {'value': 'No'}, {'value': 'It depends'}], constr_mandatory=True,
)
q_is_kinda_vegetarian_text = 'Would you prefer a veggie meal if possible?'
q_is_kinda_vegetarian = self._add_question(
self.page_0, q_is_kinda_vegetarian_text, 'simple_choice', survey_id=self.survey.id, sequence=101,
labels=[{'value': 'Yes'}, {'value': 'No'}], constr_mandatory=True, triggering_answer_ids=[
Command.link(q_is_vegetarian.suggested_answer_ids[1].id), # It depends
],
)
q_food_vegetarian_text = 'Choose your green meal' q_food_vegetarian_text = 'Choose your green meal'
veggie_question = self._add_question( veggie_question = self._add_question(
self.page_0, q_food_vegetarian_text, 'multiple_choice', self.page_0, q_food_vegetarian_text, 'simple_choice', survey_id=self.survey.id, sequence=102,
is_conditional=True, sequence=101, labels=[{'value': 'Vegetarian pizza'}, {'value': 'Vegetarian burger'}], constr_mandatory=True,
triggering_question_id=q_is_vegetarian.id, triggering_answer_ids=[
triggering_answer_id=q_is_vegetarian.suggested_answer_ids[0].id, Command.link(q_is_vegetarian.suggested_answer_ids[0].id), # Veggie
survey_id=self.survey.id, Command.link(q_is_kinda_vegetarian.suggested_answer_ids[0].id), # Would prefer veggie
labels=[{'value': 'Vegetarian pizza'}, {'value': 'Vegetarian burger'}]) ])
q_food_not_vegetarian_text = 'Choose your meal' q_food_not_vegetarian_text = 'Choose your meal'
not_veggie_question = self._add_question( not_veggie_question = self._add_question(
self.page_0, q_food_not_vegetarian_text, 'multiple_choice', self.page_0, q_food_not_vegetarian_text, 'simple_choice', survey_id=self.survey.id, sequence=103,
is_conditional=True, sequence=102, labels=[{'value': 'Steak with french fries'}, {'value': 'Fish'}], constr_mandatory=True,
triggering_question_id=q_is_vegetarian.id, triggering_answer_ids=[
triggering_answer_id=q_is_vegetarian.suggested_answer_ids[1].id, Command.link(q_is_vegetarian.suggested_answer_ids[1].id), # Not a veggie
survey_id=self.survey.id, Command.link(q_is_kinda_vegetarian.suggested_answer_ids[1].id), # Would not prefer veggie
labels=[{'value': 'Steak with french fries'}, {'value': 'Fish'}]) ],
)
q_is_kinda_vegetarian.unlink()
# Deleting one trigger but maintaining another keeps conditional behavior
self.assertTrue(bool(veggie_question.triggering_answer_ids))
q_is_vegetarian.suggested_answer_ids[0].unlink() q_is_vegetarian.suggested_answer_ids[0].unlink()
# Deleting answer Yes makes the following question always visible # Deleting answer Yes makes the following question always visible
self.assertEqual(veggie_question.is_conditional, False) self.assertFalse(bool(veggie_question.triggering_answer_ids))
self.assertEqual(veggie_question.triggering_question_id.id, False)
self.assertEqual(veggie_question.triggering_answer_id.id, False)
# But the other is still conditional # But the other is still conditional
self.assertEqual(not_veggie_question.is_conditional, True) self.assertEqual(not_veggie_question.triggering_answer_ids[0].id, q_is_vegetarian.suggested_answer_ids[0].id)
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() q_is_vegetarian.unlink()
# Now it will also be always visible # Now it will also be always visible
self.assertEqual(not_veggie_question.is_conditional, False) self.assertFalse(bool(not_veggie_question.triggering_answer_ids))
self.assertEqual(not_veggie_question.triggering_question_id.id, False)
self.assertEqual(not_veggie_question.triggering_answer_id.id, False)
def test_get_pages_and_questions_to_show(self): def test_get_pages_and_questions_to_show(self):
""" """
@@ -497,20 +527,15 @@ class TestSurveyInternals(common.TestSurveyCommon):
Structure of the test survey: Structure of the test survey:
sequence | type | trigger | validity sequence | type | trigger | validity
---------------------------------------------------------------------- ----------------------------------------------------------------------
1 | page, no description | / | X 1 | page, no description | / | X
2 | text_box | trigger is 6 | X 2 | simple_choice | trigger is 5 | X
3 | numerical_box | trigger is 2 | X 3 | simple_choice | trigger is 2 | X
4 | simple_choice | / | V 4 | page, description | / | V
5 | page, description | / | V 5 | multiple_choice | / | V
6 | multiple_choice | / | V 6 | text_box | triggers are 5+7 | V
7 | multiple_choice, no answers | / | V 7 | multiple_choice | | V
8 | text_box | trigger is 6 | V
9 | matrix | trigger is 5 | X
10 | simple_choice | trigger is 7 | X
11 | simple_choice, no answers | trigger is 8 | X
12 | text_box | trigger is 11 | X
""" """
my_survey = self.env['survey.survey'].create({ my_survey = self.env['survey.survey'].create({
@@ -521,17 +546,12 @@ class TestSurveyInternals(common.TestSurveyCommon):
}) })
[ [
page_without_description, page_without_description,
text_box_1, simple_choice_1,
numerical_box,
_simple_choice_1,
page_with_description,
multiple_choice_1,
multiple_choice_2,
text_box_2,
matrix,
simple_choice_2, simple_choice_2,
simple_choice_3, _page_with_description,
text_box_3, multiple_choice_1,
text_box_2,
multiple_choice_2,
] = self.env['survey.question'].create([{ ] = self.env['survey.question'].create([{
'title': 'no desc', 'title': 'no desc',
'survey_id': my_survey.id, 'survey_id': my_survey.id,
@@ -540,86 +560,52 @@ class TestSurveyInternals(common.TestSurveyCommon):
'is_page': True, 'is_page': True,
'description': False, 'description': False,
}, { }, {
'title': 'text_box with invalid trigger', 'title': 'simple choice with invalid trigger',
'survey_id': my_survey.id, 'survey_id': my_survey.id,
'sequence': 2, 'sequence': 2,
'is_page': False, 'is_page': False,
'question_type': 'simple_choice', 'question_type': 'simple_choice',
'suggested_answer_ids': [(0, 0, {'value': 'a'})],
}, { }, {
'title': 'numerical box with trigger that is invalid', 'title': 'simple_choice with chained invalid trigger',
'survey_id': my_survey.id, 'survey_id': my_survey.id,
'sequence': 3, 'sequence': 3,
'is_page': False, 'is_page': False,
'question_type': 'numerical_box',
}, {
'title': 'valid simple_choice',
'survey_id': my_survey.id,
'sequence': 4,
'is_page': False,
'question_type': 'simple_choice', 'question_type': 'simple_choice',
'suggested_answer_ids': [(0, 0, {'value': 'a'})], 'suggested_answer_ids': [(0, 0, {'value': 'a'})],
}, { }, {
'title': 'with desc', 'title': 'with desc',
'survey_id': my_survey.id, 'survey_id': my_survey.id,
'sequence': 5, 'sequence': 4,
'is_page': True, 'is_page': True,
'question_type': False, 'question_type': False,
'description': 'This page has a description', 'description': 'This page has a description',
}, { }, {
'title': 'multiple choice not conditional', 'title': 'multiple choice not conditional',
'survey_id': my_survey.id, 'survey_id': my_survey.id,
'sequence': 6, 'sequence': 5,
'is_page': False, 'is_page': False,
'question_type': 'multiple_choice', 'question_type': 'multiple_choice',
'suggested_answer_ids': [(0, 0, {'value': 'a'})] 'suggested_answer_ids': [(0, 0, {'value': 'a'})]
}, { }, {
'title': 'multiple_choice with no answers', 'title': 'text_box with valid trigger',
'survey_id': my_survey.id,
'sequence': 6,
'is_page': False,
'question_type': 'text_box',
}, {
'title': 'valid multiple_choice',
'survey_id': my_survey.id, 'survey_id': my_survey.id,
'sequence': 7, 'sequence': 7,
'is_page': False, 'is_page': False,
'question_type': 'multiple_choice', 'question_type': 'multiple_choice',
}, { 'suggested_answer_ids': [(0, 0, {'value': 'a'})]
'title': 'text_box with valid trigger',
'survey_id': my_survey.id,
'sequence': 8,
'is_page': False,
'question_type': 'text_box',
}, {
'title': 'matrix with invalid trigger (page)',
'survey_id': my_survey.id,
'sequence': 9,
'is_page': False,
'question_type': 'matrix',
}, {
'title': 'simple choice w/ invalid trigger (no suggested_answer_ids)',
'survey_id': my_survey.id,
'sequence': 10,
'is_page': False,
'question_type': 'simple_choice',
}, {
'title': 'text_box w/ invalid trigger (not a mcq)',
'survey_id': my_survey.id,
'sequence': 11,
'is_page': False,
'question_type': 'simple_choice',
'suggested_answer_ids': False,
}, {
'title': 'text_box w/ invalid trigger (suggested_answer_ids is False)',
'survey_id': my_survey.id,
'sequence': 12,
'is_page': False,
'question_type': 'text_box',
}]) }])
text_box_1.write({'is_conditional': True, 'triggering_question_id': multiple_choice_1.id}) simple_choice_1.write({'triggering_answer_ids': multiple_choice_1.suggested_answer_ids})
numerical_box.write({'is_conditional': True, 'triggering_question_id': text_box_1.id}) simple_choice_2.write({'triggering_answer_ids': multiple_choice_1.suggested_answer_ids})
text_box_2.write({'is_conditional': True, 'triggering_question_id': multiple_choice_1.id}) text_box_2.write({'triggering_answer_ids': (multiple_choice_1 | multiple_choice_2).suggested_answer_ids})
matrix.write({'is_conditional': True, 'triggering_question_id': page_with_description.id})
simple_choice_2.write({'is_conditional': True, 'triggering_question_id': multiple_choice_2.id})
simple_choice_3.write({'is_conditional': True, 'triggering_question_id': text_box_2.id})
text_box_3.write({'is_conditional': True, 'triggering_question_id': simple_choice_3.id})
invalid_records = page_without_description + text_box_1 + numerical_box \ invalid_records = page_without_description + simple_choice_1 + simple_choice_2
+ matrix + simple_choice_2 + simple_choice_3 + text_box_3
question_and_page_ids = my_survey.question_and_page_ids question_and_page_ids = my_survey.question_and_page_ids
returned_questions_and_pages = my_survey._get_pages_and_questions_to_show() returned_questions_and_pages = my_survey._get_pages_and_questions_to_show()
@@ -41,7 +41,7 @@ class TestSurveyFlowWithConditions(common.TestSurveyCommon, HttpCase):
page_0, 'Question 2', 'simple_choice', page_0, 'Question 2', 'simple_choice',
sequence=2, sequence=2,
constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id, constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id,
is_conditional=True, triggering_question_id=q01.id, triggering_answer_id=q01.suggested_answer_ids.filtered(lambda q: q.is_correct).id, triggering_answer_ids=q01.suggested_answer_ids.filtered(lambda q: q.is_correct),
labels=[ labels=[
{'value': 'Answer 1'}, {'value': 'Answer 1'},
{'value': 'Answer 2', 'is_correct': True, 'answer_score': 1.0}, {'value': 'Answer 2', 'is_correct': True, 'answer_score': 1.0},
@@ -60,11 +60,13 @@ class TestSurveyFlowWithConditions(common.TestSurveyCommon, HttpCase):
{'value': 'Answer 4', 'is_correct': True, 'answer_score': 1.0} {'value': 'Answer 4', 'is_correct': True, 'answer_score': 1.0}
]) ])
q03_suggested_answers_triggering_q04 = q03.suggested_answer_ids.filtered(lambda q: q.is_correct)
self._add_question( # q04 self._add_question( # q04
page_0, 'Question 4', 'simple_choice', page_0, 'Question 4', 'simple_choice',
sequence=2, sequence=2,
constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id, constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id,
is_conditional=True, triggering_question_id=q03.id, triggering_answer_id=q03.suggested_answer_ids.filtered(lambda q: q.is_correct).id, triggering_answer_ids=q03_suggested_answers_triggering_q04,
labels=[ labels=[
{'value': 'Answer 1'}, {'value': 'Answer 1'},
{'value': 'Answer 2', 'is_correct': True, 'answer_score': 1.0}, {'value': 'Answer 2', 'is_correct': True, 'answer_score': 1.0},
@@ -87,7 +89,23 @@ class TestSurveyFlowWithConditions(common.TestSurveyCommon, HttpCase):
page_0, 'Question 6', 'simple_choice', page_0, 'Question 6', 'simple_choice',
sequence=2, sequence=2,
constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id, constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id,
is_conditional=True, triggering_question_id=q05.id, triggering_answer_id=q05.suggested_answer_ids.filtered(lambda q: q.is_correct).id, triggering_answer_ids=q05.suggested_answer_ids.filtered(lambda q: q.is_correct),
labels=[
{'value': 'Answer 1'},
{'value': 'Answer 2', 'is_correct': True, 'answer_score': 1.0},
{'value': 'Answer 3'},
{'value': 'Answer 4'}
])
q03_suggested_answers_triggering_q07 = q03.suggested_answer_ids - q03_suggested_answers_triggering_q04
# Make sure to have a case with multiple possible triggers.
self.assertGreater(len(q03_suggested_answers_triggering_q07), 1)
q07 = self._add_question(
page_0, 'Question 7', 'simple_choice',
sequence=2,
constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id,
triggering_answer_ids=q03_suggested_answers_triggering_q07,
labels=[ labels=[
{'value': 'Answer 1'}, {'value': 'Answer 1'},
{'value': 'Answer 2', 'is_correct': True, 'answer_score': 1.0}, {'value': 'Answer 2', 'is_correct': True, 'answer_score': 1.0},
@@ -101,6 +119,7 @@ class TestSurveyFlowWithConditions(common.TestSurveyCommon, HttpCase):
# -> this should have generated a new user_input with a token # -> this should have generated a new user_input with a token
user_inputs = self.env['survey.user_input'].search([('survey_id', '=', survey.id)]) user_inputs = self.env['survey.user_input'].search([('survey_id', '=', survey.id)])
self.assertEqual(len(user_inputs), 1) self.assertEqual(len(user_inputs), 1)
self.assertEqual(len(user_inputs.predefined_question_ids), 7)
answer_token = user_inputs.access_token answer_token = user_inputs.access_token
# User begins survey with first page # User begins survey with first page
@@ -117,10 +136,12 @@ class TestSurveyFlowWithConditions(common.TestSurveyCommon, HttpCase):
q03: q03.suggested_answer_ids[0], # Wrong q03: q03.suggested_answer_ids[0], # Wrong
q05: q05.suggested_answer_ids[3], # Right q05: q05.suggested_answer_ids[3], # Right
q06: q06.suggested_answer_ids[2], # Wrong q06: q06.suggested_answer_ids[2], # Wrong
q07: q07.suggested_answer_ids[1], # Right
} }
self._answer_page(page_0, answers, answer_token, csrf_token) self._answer_page(page_0, answers, answer_token, csrf_token)
self.assertEqual(len(user_inputs.predefined_question_ids), 6, "q04 should have been removed as not triggered.")
user_inputs.invalidate_recordset() user_inputs.invalidate_recordset()
self.assertEqual(round(user_inputs.scoring_percentage), 60, "Three right answers out of five (the fourth one is still hidden)") self.assertEqual(round(user_inputs.scoring_percentage), 67, "Four right answers out of six questions asked.")
self.assertFalse(user_inputs.scoring_success) self.assertFalse(user_inputs.scoring_success)
+76 -35
View File
@@ -1,7 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests import odoo.tests
from odoo import Command
from odoo.addons.base.tests.common import HttpCaseWithUserDemo from odoo.addons.base.tests.common import HttpCaseWithUserDemo
@@ -150,7 +150,6 @@ class TestUiFeedback(HttpCaseWithUserDemo):
], ],
}) })
def test_01_admin_survey_tour(self): def test_01_admin_survey_tour(self):
access_token = self.survey_feedback.access_token access_token = self.survey_feedback.access_token
self.start_tour("/survey/start/%s" % access_token, 'test_survey', login="admin") self.start_tour("/survey/start/%s" % access_token, 'test_survey', login="admin")
@@ -187,41 +186,27 @@ class TestUiFeedback(HttpCaseWithUserDemo):
'sequence': 1, 'sequence': 1,
'question_type': 'simple_choice', 'question_type': 'simple_choice',
'suggested_answer_ids': [ 'suggested_answer_ids': [
(0, 0, { Command.create({'value': 'Answer 1'}),
'value': 'Answer 1', Command.create({'value': 'Answer 2'}),
'sequence': 1, Command.create({'value': 'Answer 3'}),
}), (0, 0, {
'value': 'Answer 2',
'sequence': 2,
}),
], ],
'constr_mandatory': True, 'constr_mandatory': True,
}), (0, 0, { }), Command.create({
'title': 'Q2', 'title': 'Q2',
'sequence': 2, 'sequence': 2,
'question_type': 'simple_choice', 'question_type': 'simple_choice',
'suggested_answer_ids': [ 'suggested_answer_ids': [
(0, 0, { Command.create({'value': 'Answer 1'}),
'value': 'Answer 1', Command.create({'value': 'Answer 2'}),
'sequence': 1,
}), (0, 0, {
'value': 'Answer 2',
'sequence': 2,
})
], ],
'constr_mandatory': True, 'constr_mandatory': True,
}), (0, 0, { }), Command.create({
'title': 'Q3', 'title': 'Q3',
'sequence': 3, 'sequence': 3,
'question_type': 'simple_choice', 'question_type': 'simple_choice',
'suggested_answer_ids': [ 'suggested_answer_ids': [
(0, 0, { Command.create({'value': 'Answer 1'}),
'value': 'Answer 1', Command.create({'value': 'Answer 2'}),
'sequence': 1,
}), (0, 0, {
'value': 'Answer 2',
'sequence': 2,
})
], ],
'constr_mandatory': True, 'constr_mandatory': True,
}), }),
@@ -230,24 +215,80 @@ class TestUiFeedback(HttpCaseWithUserDemo):
q1 = survey_with_triggers.question_ids.filtered(lambda q: q.title == 'Q1') q1 = survey_with_triggers.question_ids.filtered(lambda q: q.title == 'Q1')
q1_a1 = q1.suggested_answer_ids.filtered(lambda a: a.value == 'Answer 1') q1_a1 = q1.suggested_answer_ids.filtered(lambda a: a.value == 'Answer 1')
q1_a3 = q1.suggested_answer_ids.filtered(lambda a: a.value == 'Answer 3')
q2 = survey_with_triggers.question_ids.filtered(lambda q: q.title == 'Q2') q2 = survey_with_triggers.question_ids.filtered(lambda q: q.title == 'Q2')
q2_a1 = q2.suggested_answer_ids.filtered(lambda a: a.value == 'Answer 1') q2_a1 = q2.suggested_answer_ids.filtered(lambda a: a.value == 'Answer 1')
q3 = survey_with_triggers.question_ids.filtered(lambda q: q.title == 'Q3') q3 = survey_with_triggers.question_ids.filtered(lambda q: q.title == 'Q3')
q2.write({ q2.triggering_answer_ids = q1_a1
'is_conditional': True, q3.triggering_answer_ids = q1_a3 | q2_a1
'triggering_question_id': q1,
'triggering_answer_id': q1_a1,
})
q3.write({
'is_conditional': True,
'triggering_question_id': q2,
'triggering_answer_id': q2_a1,
})
access_token = survey_with_triggers.access_token access_token = survey_with_triggers.access_token
self.start_tour("/survey/start/%s" % access_token, 'test_survey_chained_conditional_questions') self.start_tour("/survey/start/%s" % access_token, 'test_survey_chained_conditional_questions')
def test_05_public_survey_with_trigger_on_different_page(self):
"""Check that conditional questions are shown when triggered from a different page too."""
survey_with_trigger_on_different_page = self.env['survey.survey'].create({
'title': 'Survey With Trigger on a different page',
'access_token': '1cb935bd-2399-4ed1-9e10-c649318fb4dc',
'access_mode': 'public',
'users_can_go_back': True,
'questions_layout': 'page_per_section',
'description': "<p>Test survey with conditional questions triggered from a previous section</p>",
'question_and_page_ids': [
Command.create({
'title': 'Section 1',
'is_page': True,
'sequence': 1,
'question_type': False,
}), Command.create({
'title': 'Q1',
'sequence': 2,
'question_type': 'simple_choice',
'suggested_answer_ids': [
Command.create({'value': 'Answer 1'}),
Command.create({'value': 'Answer 2'}),
Command.create({'value': 'Answer 3'}),
],
'constr_mandatory': False,
}), Command.create({
'title': 'Section 2',
'is_page': True,
'sequence': 3,
'question_type': False,
}), Command.create({
'title': 'Q2',
'sequence': 4,
'question_type': 'simple_choice',
'suggested_answer_ids': [
Command.create({'value': 'Answer 1'}),
Command.create({'value': 'Answer 2'}),
],
'constr_mandatory': False,
}), Command.create({
'title': 'Q3',
'sequence': 3,
'question_type': 'numerical_box',
'constr_mandatory': False,
}),
]
})
q1 = survey_with_trigger_on_different_page.question_ids.filtered(lambda q: q.title == 'Q1')
q1_a1 = q1.suggested_answer_ids.filtered(lambda a: a.value == 'Answer 1')
q2 = survey_with_trigger_on_different_page.question_ids.filtered(lambda q: q.title == 'Q2')
q2_a1 = q2.suggested_answer_ids.filtered(lambda a: a.value == 'Answer 1')
q3 = survey_with_trigger_on_different_page.question_ids.filtered(lambda q: q.title == 'Q3')
q3.triggering_answer_ids = q1_a1 | q2_a1
access_token = survey_with_trigger_on_different_page.access_token
self.start_tour("/survey/start/%s" % access_token, 'test_survey_conditional_question_on_different_page')
def test_06_survey_prefill(self): def test_06_survey_prefill(self):
access_token = self.survey_feedback.access_token access_token = self.survey_feedback.access_token
self.start_tour("/survey/start/%s" % access_token, 'test_survey_prefill') self.start_tour("/survey/start/%s" % access_token, 'test_survey_prefill')
+9 -13
View File
@@ -9,7 +9,7 @@
<form string="Survey Question" create="false" class="o_survey_question_view_form"> <form string="Survey Question" create="false" class="o_survey_question_view_form">
<field name="is_placed_before_trigger" invisible="1"/> <field name="is_placed_before_trigger" invisible="1"/>
<div class="alert alert-warning mb-0 text-center" role="alert" invisible="not is_placed_before_trigger"> <div class="alert alert-warning mb-0 text-center" role="alert" invisible="not is_placed_before_trigger">
⚠️ This question is positioned before its trigger and will be skipped. ⚠️ This question is positioned before some or all of its triggers and could be skipped.
</div> </div>
<field name="is_page" invisible="1"/> <field name="is_page" invisible="1"/>
<field name="page_id" invisible="1" required="False"/> <field name="page_id" invisible="1" required="False"/>
@@ -217,19 +217,16 @@
<field name='comment_count_as_answer' <field name='comment_count_as_answer'
invisible="question_type not in ['simple_choice', 'multiple_choice', 'matrix'] or not comments_allowed"/> invisible="question_type not in ['simple_choice', 'multiple_choice', 'matrix'] or not comments_allowed"/>
</group> </group>
<group string="Layout"> <group string="Conditional display">
<p class="text-muted" colspan="2" invisible="questions_selection == 'all'"> <p class="text-muted" colspan="2" invisible="questions_selection == 'all'">
Conditional display is not available when questions are randomly picked. Conditional display is not available when questions are randomly picked.
</p> </p>
<field name="is_conditional" invisible="questions_selection == 'random'"/>
<field name="allowed_triggering_question_ids" invisible="1"/> <field name="allowed_triggering_question_ids" invisible="1"/>
<field name="triggering_question_id" options="{'no_create_edit': True, 'no_open': True, 'no_create': True}" <field name="triggering_answer_ids" widget="many2many_tags" options="{'no_open': True, 'no_create': True}"
domain="[('id', 'in', allowed_triggering_question_ids)]" placeholder="Pick a previous multiple-choice question" domain="[('question_id', 'in', allowed_triggering_question_ids)]"
invisible="questions_selection == 'random' or not is_conditional" invisible="questions_selection == 'random'"
required="is_conditional"/> placeholder="Optional previous answers required"
<field name="triggering_answer_id" options="{'no_open': True, 'no_create': True}" />
invisible="questions_selection == 'random' or not triggering_question_id"
required="is_conditional"/>
</group> </group>
</group> </group>
<group> <group>
@@ -304,11 +301,10 @@
<field name="name">survey.question.answer.view.tree</field> <field name="name">survey.question.answer.view.tree</field>
<field name="model">survey.question.answer</field> <field name="model">survey.question.answer</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<tree string="Survey Label" create="false"> <tree string="Survey Label" create="false" default_order="question_id">
<field name="sequence" widget="handle"/> <field name="sequence" widget="handle"/>
<field name="question_id"/> <field name="question_id"/>
<field name="matrix_question_id"/> <field name="value" string="Answer"/>
<field name="value"/>
<field name="answer_score" groups="base.group_no_one"/> <field name="answer_score" groups="base.group_no_one"/>
</tree> </tree>
</field> </field>
+2 -5
View File
@@ -77,11 +77,8 @@
<field name="random_questions_count" <field name="random_questions_count"
column_invisible="parent.questions_selection == 'all'" column_invisible="parent.questions_selection == 'all'"
invisible="not is_page"/> invisible="not is_page"/>
<field name="is_conditional" column_invisible="True"/> <field name="triggering_question_ids" column_invisible="True"/>
<field name="triggering_question_id" column_invisible="True"/> <field name="triggering_answer_ids" column_invisible="True" widget="many2many_tags"/> <!-- widget to fetch display_name -->
<field name="triggering_answer_id" column_invisible="True"/>
<field name="is_placed_before_trigger" column_invisible="True"/>
<field name="allowed_triggering_question_ids" column_invisible="True"/>
<widget name="survey_question_trigger"/> <widget name="survey_question_trigger"/>
<button name="copy" type="object" icon="fa-clone" title="Duplicate Question"/> <button name="copy" type="object" icon="fa-clone" title="Duplicate Question"/>
<control> <control>
+4 -2
View File
@@ -113,6 +113,7 @@
t-att-data-is-page-description="bool(question and question.is_page and not is_html_empty(question.description))" t-att-data-is-page-description="bool(question and question.is_page and not is_html_empty(question.description))"
t-att-data-questions-layout="survey.questions_layout" t-att-data-questions-layout="survey.questions_layout"
t-att-data-triggered-questions-by-answer="json.dumps(triggered_questions_by_answer)" t-att-data-triggered-questions-by-answer="json.dumps(triggered_questions_by_answer)"
t-att-data-triggering-answers-by-question="json.dumps(triggering_answers_by_question)"
t-att-data-selected-answers="json.dumps(selected_answers)" t-att-data-selected-answers="json.dumps(selected_answers)"
t-att-data-refresh-background="any(page.background_image for page in survey.page_ids)"> t-att-data-refresh-background="any(page.background_image for page in survey.page_ids)">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
@@ -304,8 +305,9 @@
<t t-set="display_question" <t t-set="display_question"
t-value="survey.questions_layout == 'page_per_question' t-value="survey.questions_layout == 'page_per_question'
or survey.questions_selection == 'random' or survey.questions_selection == 'random'
or (survey.questions_layout == 'one_page' and not question.is_conditional) or (survey.questions_layout == 'one_page' and not question.triggering_answer_ids)
or (survey.questions_layout == 'page_per_section' and (not question.is_conditional or triggering_answer_by_question[question.id] in selected_answers))"/> or (survey.questions_layout == 'page_per_section' and (not question.triggering_answer_ids
or any(triggering_answer in selected_answers for triggering_answer in triggering_answers_by_question[question.id])))"/>
<t t-set="answer_lines" t-value="answer.user_input_line_ids.filtered(lambda line: line.question_id == question)"/> <t t-set="answer_lines" t-value="answer.user_input_line_ids.filtered(lambda line: line.question_id == question)"/>
<!--Use Key selection if number of choices is < 26 to keep Z for other choice if any--> <!--Use Key selection if number of choices is < 26 to keep Z for other choice if any-->