[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.
{
'name': 'Surveys',
'version': '3.5',
'version': '3.6',
'category': 'Marketing/Surveys',
'description': """
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)
}
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({
'triggering_answer_by_question': {
question.id: triggering_answer_by_question[question].id for question in triggering_answer_by_question.keys()
if triggering_answer_by_question[question]
'triggering_answers_by_question': {
question.id: triggering_answers.ids
for question, triggering_answers in triggering_answers_by_question.items() if triggering_answers
},
'triggered_questions_by_answer': {
answer.id: triggered_questions_by_answer[answer].ids
for answer in triggered_questions_by_answer.keys()
answer.id: triggered_questions.ids
for answer, triggered_questions in triggered_questions_by_answer.items()
},
'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="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<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"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug1'))]"/>
</record>
<record id="survey_demo_burger_quiz_p2_q1_sug1" model="survey.question.answer">
<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="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<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"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug1'))]"/>
</record>
<record id="survey_demo_burger_quiz_p2_q2_sug1" model="survey.question.answer">
<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="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<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"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug1'))]"/>
</record>
<record id="survey_demo_burger_quiz_p2_q3_sug1" model="survey.question.answer">
<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="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<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"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug2'))]"/>
</record>
<record id="survey_demo_burger_quiz_p3_q1_sug1" model="survey.question.answer">
<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="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<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"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug2'))]"/>
</record>
<record id="survey_demo_burger_quiz_p3_q2_sug1" model="survey.question.answer">
<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="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<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"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug2'))]"/>
</record>
<record id="survey_demo_burger_quiz_p3_q3_sug1" model="survey.question.answer">
<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="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<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"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug3'))]"/>
</record>
<record id="survey_demo_burger_quiz_p4_q1_sug1" model="survey.question.answer">
<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="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<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"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug3'))]"/>
</record>
<record id="survey_demo_burger_quiz_p4_q2_sug1" model="survey.question.answer">
<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="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<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"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug3'))]"/>
</record>
<record id="survey_demo_burger_quiz_p4_q3_sug1" model="survey.question.answer">
<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">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q3"/>
<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>
<!-- Page 5 : Art & Culture -->
@@ -371,9 +353,7 @@
<field name="title">Which Musician is not in the 27th Club?</field>
<field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<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"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug4'))]"/>
</record>
<record id="survey_demo_burger_quiz_p5_q1_sug1" model="survey.question.answer">
<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="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<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"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug4'))]"/>
</record>
<record id="survey_demo_burger_quiz_p5_q2_sug1" model="survey.question.answer">
<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="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<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"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug4'))]"/>
</record>
<record id="survey_demo_burger_quiz_p5_q3_sug1" model="survey.question.answer">
<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 -->
</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>
+59 -81
View File
@@ -3,9 +3,10 @@
import collections
import contextlib
import json
import itertools
import json
import operator
from textwrap import shorten
from odoo import api, fields, models, tools, _
from odoo.exceptions import UserError, ValidationError
@@ -132,27 +133,28 @@ class SurveyQuestion(models.Model):
'survey.user_input.line', 'question_id', string='Answers',
domain=[('skipped', '=', False)], groups='survey.group_survey_user')
# Conditional display
is_conditional = fields.Boolean(
string='Conditional Display', copy=False, help="""If checked, this question will be displayed only
if the specified conditional answer have been selected in a previous question""")
triggering_question_id = fields.Many2one(
'survey.question', string="Triggering Question", copy=False, compute="_compute_triggering_question_id",
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)]")
# Not stored, convenient for trigger display computation.
triggering_question_ids = fields.Many2many(
'survey.question', string="Triggering Questions", compute="_compute_triggering_question_ids",
store=False, help="Questions containing the triggering answer(s) to display the current question.")
allowed_triggering_question_ids = fields.Many2many(
'survey.question', string="Allowed Triggering Questions", copy=False, compute="_compute_allowed_triggering_question_ids")
is_placed_before_trigger = fields.Boolean(
string='Is misplaced?', help="Is this question placed before its trigger question?",
string='Is misplaced?', help="Is this question placed before any of its trigger questions?",
compute="_compute_allowed_triggering_question_ids")
triggering_answer_id = fields.Many2one(
'survey.question.answer', string="Triggering Answer", copy=False, compute="_compute_triggering_answer_id",
store=True, readonly=False, help="Answer that will trigger the display of the current question.",
domain="[('question_id', '=', triggering_question_id)]")
triggering_answer_ids = fields.Many2many(
'survey.question.answer', string="Triggering Answers", copy=False, store=True,
readonly=False, help="Picking any of these answers will trigger this question.\n"
"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 = [
('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'),
('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'),
('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']:
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):
""" 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
avoid cascading rpc calls when reordering questions via the webclient.
"""
conditional_questions = self.filtered(lambda q: q.is_conditional)
non_conditional_questions = self - conditional_questions
non_conditional_questions.allowed_triggering_question_ids = False
non_conditional_questions.is_placed_before_trigger = False
if not conditional_questions:
return
possible_trigger_questions = self.search([
('is_page', '=', False),
('question_type', 'in', ['simple_choice', 'multiple_choice']),
@@ -301,14 +290,14 @@ class SurveyQuestion(models.Model):
])
# Using the sequence stored in db is necessary for existing questions that are passed as
# NewIds because the sequence provided by the JS client can be incorrect.
(conditional_questions | possible_trigger_questions).flush_recordset()
(self | possible_trigger_questions).flush_recordset()
self.env.cr.execute(
"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
for question in conditional_questions:
for question in self:
question_id = question._origin.id
if not question_id: # New question
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
and (q.sequence < question_sequence or q.sequence == question_sequence and q.id < question_id)
)
question.is_placed_before_trigger = (
question.triggering_question_id
and question.triggering_question_id.id not in question.allowed_triggering_question_ids.ids)
question.is_placed_before_trigger = bool(
set(question.triggering_answer_ids.question_id.ids)
- set(question.allowed_triggering_question_ids.ids) # .ids necessary to match ids with newIds
)
@api.depends('is_conditional')
def _compute_triggering_question_id(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."""
@api.depends('triggering_answer_ids')
def _compute_triggering_question_ids(self):
for question in self:
if not question.is_conditional or question.triggering_question_id is None:
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
question.triggering_question_ids = question.triggering_answer_ids.question_id
@api.depends('question_type', 'scoring_type', 'answer_date', 'answer_datetime', 'answer_numerical_box', 'suggested_answer_ids.is_correct')
def _compute_is_scored_question(self):
@@ -391,22 +367,10 @@ class SurveyQuestion(models.Model):
def copy(self, default=None):
self.ensure_one()
clone = super().copy(default)
if self.is_conditional:
clone.is_conditional = True
clone.triggering_question_id = self.triggering_question_id.id
clone.triggering_answer_id = self.triggering_answer_id.id
if self.triggering_answer_ids:
clone.triggering_answer_ids = self.triggering_answer_ids
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
# ------------------------------------------------------------
@@ -698,9 +662,12 @@ class SurveyQuestionAnswer(models.Model):
"""
_name = 'survey.question.answer'
_rec_name = 'value'
_order = 'sequence, id'
_rec_names_search = ['question_id.title', 'value']
_order = 'question_id, sequence, id'
_description = 'Survey Label'
MAX_ANSWER_NAME_LENGTH = 90 # empirically tested in client dropdown
# question and question related fields
question_id = fields.Many2one('survey.question', string='Question', 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')
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')
def _check_question_not_empty(self):
"""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'):
return ['&', ('question_id', '=', self.question_id.id), ('suggested_answer_id', '=', self.id)]
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 random
import uuid
from collections import defaultdict
import werkzeug
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_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):
for survey in self:
if not survey.is_attempts_limited 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
@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 \
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):
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')
def _compute_certification(self):
@@ -381,14 +383,14 @@ class Survey(models.Model):
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
""" Correctly copy the 'triggering_question_id' and 'triggering_answer_id' fields from the original
to the clone.
This needs to be done in post-processing to make sure we get references to the newly created
answers/questions from the copy instead of references to the answers/questions of the original.
This implementation assumes that the order of created questions/answers will be kept between
"""Correctly copy the 'triggering_answer_ids' field from the original to the clone.
This needs to be done in post-processing to make sure we get references to the newly
created answers from the copy instead of references to the answers of the original.
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.
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.
"""
self.ensure_one()
@@ -396,23 +398,18 @@ class Survey(models.Model):
if default and 'question_ids' in default:
return clone
src_questions = self.question_ids
dst_questions = clone.question_ids.sorted()
cloned_question_ids = clone.question_ids.sorted()
questions_map = {src.id: dst.id for src, dst in zip(src_questions, dst_questions)}
answers_map = {
src_answer.id: dst_answer.id
for src, dst
in zip(src_questions, dst_questions)
in zip(self.question_ids, cloned_question_ids)
for src_answer, dst_answer
in zip(src.suggested_answer_ids, dst.suggested_answer_ids.sorted())
}
for src, dst in zip(src_questions, dst_questions):
if src.is_conditional:
dst.is_conditional = True
dst.triggering_question_id = questions_map.get(src.triggering_question_id.id)
dst.triggering_answer_id = answers_map.get(src.triggering_answer_id.id)
for src, dst in zip(self.question_ids, cloned_question_ids):
if src.triggering_answer_ids:
dst.triggering_answer_ids = [answers_map[src_answer_id.id] for src_answer_id in src.triggering_answer_ids]
return clone
def copy_data(self, default=None):
@@ -615,22 +612,31 @@ class Survey(models.Model):
return result
def _get_pages_and_questions_to_show(self):
"""
:return: survey.question recordset excluding invalid conditional questions and pages without description
"""
"""Filter question_and_pages_ids to include only valid pages and questions.
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()
invalid_questions = self.env['survey.question']
questions_and_valid_pages = self.question_and_page_ids.filtered(
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
if (trigger in invalid_questions
or trigger.is_page
or trigger.question_type not in ['simple_choice', 'multiple_choice']
or not trigger.suggested_answer_ids
or trigger.sequence > question.sequence
or (trigger.sequence == question.sequence and trigger.id > question.id)):
for question in questions_and_valid_pages.filtered(lambda q: q.triggering_answer_ids).sorted():
for trigger in question.triggering_question_ids:
if (trigger not in invalid_questions
and not trigger.is_page
and trigger.question_type in ['simple_choice', 'multiple_choice']
and (trigger.sequence < question.sequence
or (trigger.sequence == question.sequence and trigger.id < question.id))):
break
else:
# No valid trigger found
invalid_questions |= question
return questions_and_valid_pages - invalid_questions
@@ -674,7 +680,7 @@ class Survey(models.Model):
return Question
# 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()
if survey.questions_layout == 'page_per_question':
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:
return question
else:
triggering_answer = triggering_answer_by_question.get(question)
if not triggering_answer or triggering_answer in selected_answers:
triggering_answers = triggering_answers_by_question.get(question)
if not triggering_answers or triggering_answers & selected_answers:
# question is visible because not conditioned or conditioned by a selected answer
return question
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:]
if next_page_or_question_candidates:
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':
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)
@@ -794,17 +800,15 @@ class Survey(models.Model):
# ------------------------------------------------------------
def _get_conditional_maps(self):
triggering_answer_by_question = {}
triggered_questions_by_answer = {}
triggering_answers_by_question = defaultdict(lambda: self.env['survey.question.answer'])
triggered_questions_by_answer = defaultdict(lambda: self.env['survey.question'])
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:
if question.triggering_answer_id in triggered_questions_by_answer:
triggered_questions_by_answer[question.triggering_answer_id] |= question
else:
triggered_questions_by_answer[question.triggering_answer_id] = question
return triggering_answer_by_question, triggered_questions_by_answer
for triggering_answer_id in question.triggering_answer_ids:
triggered_questions_by_answer[triggering_answer_id] |= question
return triggering_answers_by_question, triggered_questions_by_answer
# ------------------------------------------------------------
# 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
is triggered by an already selected answer.
To do all this, we need to return:
- list of all selected answers: [answer_id1, answer_id2, ...] (for survey reloading, otherwise, this list is
updated at client side)
- triggering_answers_by_question: dict -> for a given question, the answers that triggers it
Used mainly to ease template rendering
- 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
- triggering_answer_by_question: dict -> for a given question, the answer that triggers it
Used mainly to ease template rendering
- list of all selected answers: [answer_id1, answer_id2, ...] (for survey reloading, otherwise, this list is
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
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()
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):
"""
@@ -585,14 +586,13 @@ class SurveyUserInput(models.Model):
answers_to_delete.unlink()
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
inactive_questions = self.env['survey.question']
for answer in triggered_questions_by_answer.keys():
if answer not in selected_answers:
for question in triggered_questions_by_answer[answer]:
inactive_questions |= question
for question, triggering_answers in triggering_answers_by_question.items():
if triggering_answers and not triggering_answers & selected_answers:
inactive_questions |= question
return inactive_questions
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.
* If not checked : Clear the comment textarea, hide and disable it
* If checked : enable the comment textarea, show and focus on it
*
* @private
* @param {Event} event
* @param {JQuery<HTMLElement>} $choiceItemGroup
*/
_onChangeChoiceItem: function (event) {
var self = this;
var $target = $(event.currentTarget);
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"]');
_applyCommentAreaVisibility: function ($choiceItemGroup) {
const $otherItem = $choiceItemGroup.find('.o_survey_js_form_other_comment');
const $commentInput = $choiceItemGroup.find('textarea[type="text"]');
if ($otherItem.prop('checked') || $commentInput.hasClass('o_survey_comment')) {
$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.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') {
var isQuestionComplete = false;
if ($matrixBtn.length > 0) {
$matrixBtn.closest('tr').find('td').removeClass('o_survey_selected');
if ($target.is(':checked')) {
@@ -181,86 +214,56 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
var subQuestionsIds = $matrixBtn.closest('table').data('subQuestions');
var completedQuestions = [];
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);
}
});
isQuestionComplete = completedQuestions.length === subQuestionsIds.length;
}
} else {
var previouslySelectedAnswer = $choiceItemGroup.find('label.o_survey_selected');
const previouslySelectedAnswer = $choiceItemGroup.find('label.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');
if (newlySelectedAnswer.find('input').val() !== previouslySelectedAnswer.find('input').val()) {
const newlySelectedAnswer = $target.closest('label');
const newlySelectedAnswerId = $target.val();
const isNewSelection = newlySelectedAnswerId !== previouslySelectedAnswerId;
if (isNewSelection) {
newlySelectedAnswer.addClass('o_survey_selected');
isQuestionComplete = this.options.questionsLayout === 'page_per_question';
if (!isQuestionComplete) {
this.selectedAnswers.push(parseInt(newlySelectedAnswerId));
}
}
// Conditional display
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
if (Object.keys(this.options.triggeredQuestionsByAnswer).includes(previouslySelectedAnswer.find('input').val())) {
// Hide and clear depending question
this.options.triggeredQuestionsByAnswer[previouslySelectedAnswer.find('input').val()].forEach(function (questionId) {
var dependingQuestion = $('.js_question-wrapper#' + questionId);
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()));
}
});
}
const conditionalQuestionsToRecomputeVisibility = new Set(
(this.options.triggeredQuestionsByAnswer[previouslySelectedAnswerId] || [])
.concat(this.options.triggeredQuestionsByAnswer[newlySelectedAnswerId] || [])
)
this._applyConditionalQuestionsVisibility(conditionalQuestionsToRecomputeVisibility)
}
}
// 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'
if ($matrixBtn.length > 0) {
$matrixBtn.toggleClass('o_survey_selected', !$matrixBtn.hasClass('o_survey_selected'));
} else {
var $label = $target.closest('label');
const $label = $target.closest('label');
$label.toggleClass('o_survey_selected', !$label.hasClass('o_survey_selected'));
const answerId = $target.val();
// Conditional display
if (this.options.questionsLayout !== 'page_per_question' && Object.keys(this.options.triggeredQuestionsByAnswer).includes($target.val())) {
var isInputSelected = $label.hasClass('o_survey_selected');
// Hide and clear or display depending question
this.options.triggeredQuestionsByAnswer[$target.val()].forEach(function (questionId) {
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()));
}
if (this.options.questionsLayout !== 'page_per_question') {
$label.hasClass('o_survey_selected')
? this.selectedAnswers.push(parseInt(answerId))
: this.selectedAnswers.splice(this.selectedAnswers.indexOf(parseInt(answerId)), 1);
this._applyConditionalQuestionsVisibility(this.options.triggeredQuestionsByAnswer[answerId]);
}
}
}
return isQuestionComplete;
},
/**
@@ -534,7 +537,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
*
* @param {Object} options see '_submitForm' for details
*/
_onNextScreenDone: function (options) {
_onNextScreenDone: function (options) {
var self = this;
var result = this.nextScreenResult;
@@ -1134,23 +1137,6 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
// 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.
* 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
*/
_getInactiveConditionalQuestionIds: function () {
var self = this;
var inactiveQuestionIds = [];
if (this.options.triggeredQuestionsByAnswer) {
Object.keys(this.options.triggeredQuestionsByAnswer).forEach(function (answerId) {
if (!self.selectedAnswers.includes(parseInt(answerId))) {
self.options.triggeredQuestionsByAnswer[answerId].forEach(function (questionId) {
inactiveQuestionIds.push(questionId);
});
}
});
const inactiveQuestionIds = [];
for (const [questionId, answerIds] of Object.entries(this.options.triggeringAnswersByQuestion || {})) {
if (!answerIds.some(answerId => this.selectedAnswers.includes(parseInt(answerId)))) {
inactiveQuestionIds.push(parseInt(questionId));
}
}
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
// -------------------------------------------------------------------------
@@ -134,7 +134,7 @@ export class QuestionPageListRenderer extends ListRenderer {
*/
async onDeleteRecord(record) {
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) {
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;
export class SurveyQuestionTriggerWidget extends Component {
static template = "survey.surveyQuestionTrigger";
static props = {
...standardWidgetProps,
};
setup() {
super.setup();
this.button = useRef('survey_question_trigger');
@@ -15,19 +20,20 @@ export class SurveyQuestionTriggerWidget extends Component {
triggerTooltip: "",
});
useEffect(() => {
if (this.button && this.button.el) {
const triggeringQuestionTitle = this.props.record.data.triggering_question_id[1];
const triggerError = this.surveyQuestionTriggerError;
if (this.button?.el && this.props.record.data.triggering_question_ids.records?.length !== 0) {
const { triggerError, misplacedTriggerQuestionRecords } = this.surveyQuestionTriggerError;
if (triggerError === "MISPLACED_TRIGGER_WARNING") {
this.state.surveyIconWarning = true;
this.state.triggerTooltip = _t(
'⚠ This question is positioned before its trigger ("%s") and will be skipped.',
triggeringQuestionTitle
this.state.triggerTooltip = '⚠ ' + _t(
'Triggers based on the following questions will not work because they are positioned after this question:\n"%s".',
misplacedTriggerQuestionRecords
.map((question) => question.data.title)
.join('", "')
);
} else if (triggerError === "WRONG_QUESTIONS_SELECTION_WARNING") {
this.state.surveyIconWarning = true;
this.state.triggerTooltip = _t(
"⚠ Conditional display is not available when questions are randomly picked."
this.state.triggerTooltip = '⚠ ' + _t(
"Conditional display is not available when questions are randomly picked."
);
} else if (triggerError === "MISSING_TRIGGER_ERROR") {
// This case must be handled to not temporarily render the "normal" icon if previously
@@ -36,9 +42,10 @@ export class SurveyQuestionTriggerWidget extends Component {
} else {
this.state.surveyIconWarning = false;
this.state.triggerTooltip = _t(
'Displayed if "%s: %s"',
triggeringQuestionTitle,
this.props.record.data.triggering_answer_id[1]
'Displayed if "%s".',
this.props.record.data.triggering_answer_ids.records
.map((answer) => answer.data.display_name)
.join('", "'),
);
}
} else {
@@ -55,12 +62,12 @@ export class SurveyQuestionTriggerWidget extends Component {
* 2. Robustness, as sequences values do not always match between server
* provided values when the records are not saved.
*
* @returns { String }
* @returns {{ triggerError: String, misplacedTriggerQuestionRecords: Record[] }}
* * `""`: No trigger error (also if `triggering_question_id`
* field is not set).
* * `"MISSING_TRIGGER_ERROR"`: `triggering_question_id` field is set
* and trigger record is not found. This can happen when a question
* used as trigger is deleted on the client but not yet saved to DB.
* * `"MISSING_TRIGGER_ERROR"`: `triggering_questions_ids` field is set
* but trigger record is not found. This can happen if all questions
* used as triggers are deleted on the client but not yet saved to DB.
* * `"MISPLACED_TRIGGER_WARNING"`: a `triggering_question_id` is set
* but is positioned after the current record in the list. This can
* happen if the triggering or the triggered question is moved.
@@ -71,37 +78,48 @@ export class SurveyQuestionTriggerWidget extends Component {
*/
get surveyQuestionTriggerError() {
const record = this.props.record;
if (!record.data.triggering_question_id) {
return "";
if (!record.data.triggering_question_ids.records.length) {
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) {
return "MISSING_TRIGGER_ERROR";
const missingTriggerQuestionsIds = [];
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 ||
(record.data.sequence === triggerRecord.data.sequence && record.resId < triggerId)) {
return "MISPLACED_TRIGGER_WARNING";
const misplacedTriggerQuestionRecords = [];
for (const triggerQuestionRecord of triggerQuestionsRecords) {
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 = {
component: SurveyQuestionTriggerWidget,
displayName: "Trigger",
fieldDependencies: [
{ name: "triggering_question_id", type: "many2one" },
{ name: "triggering_answer_id", type: "many2one" },
{ name: "triggering_question_ids", type: "many2one" },
{ name: "triggering_answer_ids", type: "many2one" },
],
};
registry.category("view_widgets").add("survey_question_trigger", surveyQuestionTriggerWidget);
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<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'">
<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"/>
@@ -26,17 +26,17 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
survey_question: {
fields: {
sequence: { type: "number" },
name: { type: "char", string: "name", },
triggering_question_id: {
type: "many2one",
title: { type: "char", string: "title", },
triggering_question_ids: {
type: "many2many",
string: "Triggering question",
relation: "survey_question",
required: false,
searchable: true,
},
triggering_answer_id: {
type: "many2one",
string: "Triggering answer",
triggering_answer_ids: {
type: "many2many",
string: "Triggering answers",
relation: "survey_question_answer",
required: false,
searchable: true,
@@ -46,15 +46,15 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
{
id: 1,
sequence: 1,
name: "Question 1",
triggering_question_id: null,
triggering_answer_id: null,
title: "Question 1",
triggering_question_ids: null,
triggering_answer_ids: null,
}, {
id: 2,
sequence: 2,
name: "Question 2",
triggering_question_id: 1,
triggering_answer_id: 1,
title: "Question 2",
triggering_question_ids: [1],
triggering_answer_ids: [1],
},
],
},
@@ -65,7 +65,8 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
records: [
{
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": `
<form>
<group>
<field name="name"/>
<field name="triggering_answer_id" invisible="1"/>
<field name="triggering_question_id" invisible="1"/>
<field name="title"/>
<field name="triggering_question_ids" invisible="1"/>
<field name="triggering_answer_ids" invisible="1" widget="many2many_tags"/>
</group>
</form>
`,
@@ -97,9 +98,9 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
<field name="question_and_page_ids">
<tree>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="triggering_answer_id" invisible="1"/>
<field name="triggering_question_id" invisible="1"/>
<field name="title"/>
<field name="triggering_question_ids" invisible="1"/>
<field name="triggering_answer_ids" invisible="1" widget="many2many_tags"/> <!-- widget to fetch display_name -->
<widget name="survey_question_trigger"/>
</tree>
</field>
@@ -121,8 +122,8 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
// Question 2 is correctly placed after Question 1
let triggerIcon = q2TriggerDiv.querySelector("button i");
assert.doesNotHaveClass(triggerIcon, "text-warning");
assert.hasAttrValue(triggerIcon, 'data-tooltip', 'Displayed if "Question 1: Answer 1"',
'Trigger tooltip should be \'Displayed if "Question 1: Answer 1"\'.');
assert.hasAttrValue(triggerIcon, 'data-tooltip', 'Displayed if "Question 1: Answer 1".',
'Trigger tooltip should be \'Displayed if "Question 1: Answer 1".\'.');
// drag and drop Question 2 (triggered) before Question 1 (trigger)
await dragAndDrop("tbody tr:nth-child(2) .o_handle_cell", "tbody tr:nth-child(1)");
@@ -136,7 +137,7 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
assert.hasClass(triggerIcon, "text-warning");
assert.strictEqual(
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.'
);
@@ -148,7 +149,7 @@ QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
assert.strictEqual(rows[1].textContent, "Question 2");
assert.doesNotHaveClass(rows[1].querySelector("td.o_data_cell div.o_widget_survey_question_trigger button i"), "text-warning");
assert.hasAttrValue(triggerIcon, 'data-tooltip', 'Displayed if "Question 1: Answer 1"',
'Trigger tooltip should be back to \'Displayed if "Question 1: Answer 1"\'.');
assert.hasAttrValue(triggerIcon, 'data-tooltip', 'Displayed if "Question 1: Answer 1".',
'Trigger tooltip should be back to \'Displayed if "Question 1: Answer 1".\'.');
});
});
@@ -1,6 +1,7 @@
/** @odoo-module **/
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', {
test: true,
@@ -19,17 +20,45 @@ registry.category("web_tour.tours").add('test_survey_chained_conditional_questio
content: 'Answer Q3 with 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")',
}, {
content: 'Check that only question 1 is now visible',
trigger: 'div.js_question-wrapper:contains("Q1")',
run: () => {
const selector = 'div.js_question-wrapper.d-none';
if (document.querySelectorAll(selector).length !== 2) {
throw new Error('Q2 and Q3 should have been hidden.');
}
}
run : () => {
expectHiddenQuestion("Q2", "Q2's trigger is gone.");
expectHiddenQuestion("Q3", "No reason to show it now.");
},
}, {
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',
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,
},
...addTwoAnswers(),
...toggleIsConditional(),
...changeTab("options"),
{
content: "Set a trigger question for the first question",
trigger: ".o_field_widget[name=triggering_question_id] input",
content: "Set a trigger for the first question",
trigger: ".o_field_widget[name=triggering_answer_ids] input",
run: "click",
in_modal: true,
}, {
content: "Set the first question as trigger",
trigger: 'ul.ui-autocomplete a:contains("Question 1")',
content: "Set the first question's first answer as trigger",
trigger: 'ul.ui-autocomplete a:contains("Question 1 : Answer A")',
run: 'click',
in_modal: true,
}, {
content: "Set a trigger answer for first question",
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
run: "click",
in_modal: true,
}, {
content: "Set the first answer as trigger",
trigger: 'ul.ui-autocomplete a:contains("Answer A")',
run: 'click',
},
...changeTab("answers"),
...saveAndNew(),
@@ -65,38 +56,27 @@ registry.category("web_tour.tours").add('survey_tour_test_survey_form_triggers',
in_modal: true,
},
...addTwoAnswers(),
...toggleIsConditional(),
...changeTab("options"),
{
content: "Set a trigger question for the second question",
trigger: ".o_field_widget[name=triggering_question_id] input",
content: "Set a trigger for the second question",
trigger: ".o_field_widget[name=triggering_answer_ids] input",
run: "click",
in_modal: true,
}, {
content: "Set the second question as trigger",
trigger: 'ul.ui-autocomplete a:contains("Question 2")',
content: "Set the second question's second answer as trigger",
trigger: 'ul.ui-autocomplete a:contains("Question 2 : Answer B")',
run: 'click',
in_modal: true,
}, {
content: "Set a trigger answer for second question",
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
run: "click",
in_modal: true,
}, {
content: "Set the second answer as trigger",
trigger: 'ul.ui-autocomplete a:contains("Answer B")',
run: 'click',
},
...changeTab("answers"),
...stepUtils.saveForm(),
// Q2 and Q3 should have fa-fork icons. Assumes that the Trigger widget's column is 2 places after the title's.
{
content: "Check that Question 2 has 'normal' trigger icon",
trigger: "tr:contains('Question 2') button i.fa-code-fork",
run: () => {}, // it's a check
isCheck: true,
}, {
content: "Check that Question 3 has 'normal' trigger icon",
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)",
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",
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",
trigger: "tr.o_data_row td[data-tooltip='Question 3']",
trigger: "tr.o_data_row td:contains('Question 3')",
run: "click",
}, {
content: "Check that an alert is shown",
trigger: ".o_form_sheet_bg div:first-child.alert-warning:contains('positioned before its trigger')",
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: "Change trigger question",
trigger: ".o_field_widget[name=triggering_question_id] input",
content: "Remove invalid trigger",
trigger: ".o_field_widget[name=triggering_answer_ids] span:contains('Question 2') a.o_delete",
run: "click",
in_modal: true,
}, {
content: "Set the first question as trigger instead",
trigger: 'ul.ui-autocomplete a:contains("Question 1")',
run: 'click',
in_modal: true,
}, {
content: "Check that the alert is gone",
trigger: `.o_form_sheet_bg div:first-child:not(.alert-warning).o_form_sheet`,
in_modal: true,
run: () => {}, // it's a check
isCheck: true,
}, {
content: "Change the trigger answer as well",
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
content: "Choose a new valid trigger",
trigger: ".o_field_widget[name=triggering_answer_ids] input",
run: "click",
in_modal: true,
}, {
content: "Set the second answer as trigger, then",
trigger: 'ul.ui-autocomplete a:contains("Answer B")',
content: "Set the first question's second answer as trigger, then",
trigger: 'ul.ui-autocomplete a:contains("Question 1 : Answer B")',
run: 'click',
},
...stepUtils.saveForm(),
{
{
content: "Check that Question 3 has its 'normal' trigger icon back",
trigger: "tr:contains('Question 3') button i.fa-code-fork",
run: () => {}, // it's a check
isCheck: true,
}, {
content: "Move Question 3 back below Question 2",
trigger: "tr.o_data_row:nth-child(2) td[name=sequence]",
run: "drag_and_drop_native div[name=question_and_page_ids] table tbody tr:nth-child(3)",
}, {
content: "Open that question again",
trigger: "tr.o_data_row td[data-tooltip='Question 3']",
trigger: "tr.o_data_row td:contains('Question 3')",
run: "click",
},
...changeTab("options"),
{
content: "Change trigger to confirm we can now use Question 2 again",
trigger: ".modal-content .o_field_widget[name=triggering_question_id] input",
content: "Add a second trigger to confirm we can now use Question 2 again",
trigger: ".modal-content .o_field_widget[name=triggering_answer_ids] input",
run: "click",
in_modal: true,
}, {
content: "Question 2 is allowed as trigger again",
trigger: '.modal-content ul.ui-autocomplete a:contains("Question 2")',
in_modal: true,
}, {
content: "Change the trigger answer back as well",
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
run: "text A",
in_modal: true,
}, {
content: "Set the second answer as trigger, then",
trigger: '.modal-content ul.ui-autocomplete a:contains("Answer B")',
content: "Add the second question's second answer as trigger, then",
trigger: '.modal-content ul.ui-autocomplete a:contains("Question 2 : Answer B")',
run: "click",
},
...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
{
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",
}, {
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",
trigger: "div[name=question_and_page_ids] tr:contains('Question 3') div.o_widget_survey_question_trigger:not(:has(button))",
allowInvisible: true,
run: () => {}, // it's a check
isCheck: true,
}, {
content: "Check that Question 2 however still has a trigger icon",
trigger: "tr:contains('Question 2') button i.fa-code-fork",
run: () => {}, // it's a check
isCheck: true,
}, {
content: "Delete Question 1",
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",
trigger: "tr:contains('Question 2') div.o_widget_survey_question_trigger:not(:has(button))",
allowInvisible: true,
run: () => {}, // it's a check
isCheck: true,
}, {
content: 'Go back to Kanban View',
trigger: '[data-menu-xmlid="survey.menu_survey_form"]',
}, {
content: "Check that we arrived on the kanban view",
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...
trigger: "div[name=suggested_answer_ids] .o_list_table tbody tr:first-child:not(.o_data_row)", // empty answers list
in_modal: true,
run: () => {}, // it's a check
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) {
// Currently, .modal-content is required even though "in_modal"
@@ -274,7 +269,7 @@ function changeTab(tabName) {
content: `Wait for tab ${tabName} tab`,
trigger: `.modal-content a[name=${tabName}].nav-link.active`,
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_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'):
# 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_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_2_new_question = self.env['survey.question'].new({'survey_id': survey_2, '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})
with self.subTest('New questions'):
# 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
& survey_2_new_question.allowed_triggering_question_ids)
self.assertFalse(
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_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 = self._add_question(
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'
self._add_question(self.page_0, q_food_vegetarian_text, 'multiple_choice',
is_conditional=True, sequence=101,
triggering_question_id=q_is_vegetarian.id,
triggering_answer_id=q_is_vegetarian.suggested_answer_ids[0].id,
sequence=101,
triggering_answer_ids=[q_is_vegetarian.suggested_answer_ids[0].id,
q_is_vegetarian.suggested_answer_ids[2].id],
survey_id=self.survey.id,
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',
is_conditional=True, sequence=102,
triggering_question_id=q_is_vegetarian.id,
triggering_answer_id=q_is_vegetarian.suggested_answer_ids[1].id,
sequence=102,
triggering_answer_ids=q_is_vegetarian.suggested_answer_ids[1].ids,
survey_id=self.survey.id,
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_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
self.assertTrue(q_food_vegetarian_cloned)
self.assertTrue(bool(q_food_vegetarian_cloned))
# 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_id.id,
q_is_vegetarian_cloned.suggested_answer_ids[0].id)
self.assertEqual(q_food_vegetarian_cloned.triggering_answer_ids.ids,
[q_is_vegetarian_cloned.suggested_answer_ids[0].id, q_is_vegetarian_cloned.suggested_answer_ids[2].id])
# 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_id.id,
q_is_vegetarian.suggested_answer_ids[0].id)
self.assertNotEqual(q_food_vegetarian_cloned.triggering_answer_ids.ids,
[q_is_vegetarian.suggested_answer_ids[0].id, q_is_vegetarian.suggested_answer_ids[2].id])
# 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
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_id.id,
q_is_vegetarian_cloned.suggested_answer_ids[1].id)
self.assertEqual(q_food_not_vegetarian_cloned.triggering_answer_ids.ids,
q_is_vegetarian_cloned.suggested_answer_ids[1].ids)
# 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_id.id,
q_is_vegetarian.suggested_answer_ids[1].id)
self.assertNotEqual(q_food_not_vegetarian_cloned.triggering_answer_ids.ids,
q_is_vegetarian.suggested_answer_ids[1].ids)
@users('survey_manager')
def test_copy_conditional_question_with_sequence_changed(self):
@@ -427,11 +411,7 @@ class TestSurveyInternals(common.TestSurveyCommon):
q_2.write({'sequence': 100})
# Set a conditional question on the first question
q_1.write({
'is_conditional': True,
'triggering_question_id': q_2.id,
'triggering_answer_id': q_2.suggested_answer_ids[0].id,
})
q_1.write({'triggering_answer_ids': [Command.set([q_2.suggested_answer_ids[0].id])]})
(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)
# 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.assertFalse(get_question_by_title(cloned_survey, 'Q2').triggering_question_id)
self.assertEqual(
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')
def test_unlink_triggers(self):
# Create the survey questions
q_is_vegetarian_text = 'Are you vegetarian?'
q_is_vegetarian = self._add_question(
self.page_0, q_is_vegetarian_text, 'multiple_choice', survey_id=self.survey.id,
sequence=100, labels=[{'value': 'Yes'}, {'value': 'No'}])
self.page_0, q_is_vegetarian_text, 'simple_choice', survey_id=self.survey.id, sequence=100,
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'
veggie_question = self._add_question(
self.page_0, q_food_vegetarian_text, 'multiple_choice',
is_conditional=True, sequence=101,
triggering_question_id=q_is_vegetarian.id,
triggering_answer_id=q_is_vegetarian.suggested_answer_ids[0].id,
survey_id=self.survey.id,
labels=[{'value': 'Vegetarian pizza'}, {'value': 'Vegetarian burger'}])
self.page_0, q_food_vegetarian_text, 'simple_choice', survey_id=self.survey.id, sequence=102,
labels=[{'value': 'Vegetarian pizza'}, {'value': 'Vegetarian burger'}], constr_mandatory=True,
triggering_answer_ids=[
Command.link(q_is_vegetarian.suggested_answer_ids[0].id), # Veggie
Command.link(q_is_kinda_vegetarian.suggested_answer_ids[0].id), # Would prefer veggie
])
q_food_not_vegetarian_text = 'Choose your meal'
not_veggie_question = self._add_question(
self.page_0, q_food_not_vegetarian_text, 'multiple_choice',
is_conditional=True, sequence=102,
triggering_question_id=q_is_vegetarian.id,
triggering_answer_id=q_is_vegetarian.suggested_answer_ids[1].id,
survey_id=self.survey.id,
labels=[{'value': 'Steak with french fries'}, {'value': 'Fish'}])
self.page_0, q_food_not_vegetarian_text, 'simple_choice', survey_id=self.survey.id, sequence=103,
labels=[{'value': 'Steak with french fries'}, {'value': 'Fish'}], constr_mandatory=True,
triggering_answer_ids=[
Command.link(q_is_vegetarian.suggested_answer_ids[1].id), # Not a veggie
Command.link(q_is_kinda_vegetarian.suggested_answer_ids[1].id), # Would not prefer veggie
],
)
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()
# Deleting answer Yes makes the following question always visible
self.assertEqual(veggie_question.is_conditional, False)
self.assertEqual(veggie_question.triggering_question_id.id, False)
self.assertEqual(veggie_question.triggering_answer_id.id, False)
self.assertFalse(bool(veggie_question.triggering_answer_ids))
# But the other is still conditional
self.assertEqual(not_veggie_question.is_conditional, True)
self.assertEqual(not_veggie_question.triggering_question_id.id, q_is_vegetarian.id)
self.assertEqual(not_veggie_question.triggering_answer_id.id, q_is_vegetarian.suggested_answer_ids[0].id)
self.assertEqual(not_veggie_question.triggering_answer_ids[0].id, q_is_vegetarian.suggested_answer_ids[0].id)
q_is_vegetarian.unlink()
# Now it will also be always visible
self.assertEqual(not_veggie_question.is_conditional, False)
self.assertEqual(not_veggie_question.triggering_question_id.id, False)
self.assertEqual(not_veggie_question.triggering_answer_id.id, False)
self.assertFalse(bool(not_veggie_question.triggering_answer_ids))
def test_get_pages_and_questions_to_show(self):
"""
@@ -497,20 +527,15 @@ class TestSurveyInternals(common.TestSurveyCommon):
Structure of the test survey:
sequence | type | trigger | validity
sequence | type | trigger | validity
----------------------------------------------------------------------
1 | page, no description | / | X
2 | text_box | trigger is 6 | X
3 | numerical_box | trigger is 2 | X
4 | simple_choice | / | V
5 | page, description | / | V
6 | multiple_choice | / | V
7 | multiple_choice, no answers | / | 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
1 | page, no description | / | X
2 | simple_choice | trigger is 5 | X
3 | simple_choice | trigger is 2 | X
4 | page, description | / | V
5 | multiple_choice | / | V
6 | text_box | triggers are 5+7 | V
7 | multiple_choice | | V
"""
my_survey = self.env['survey.survey'].create({
@@ -521,17 +546,12 @@ class TestSurveyInternals(common.TestSurveyCommon):
})
[
page_without_description,
text_box_1,
numerical_box,
_simple_choice_1,
page_with_description,
multiple_choice_1,
multiple_choice_2,
text_box_2,
matrix,
simple_choice_1,
simple_choice_2,
simple_choice_3,
text_box_3,
_page_with_description,
multiple_choice_1,
text_box_2,
multiple_choice_2,
] = self.env['survey.question'].create([{
'title': 'no desc',
'survey_id': my_survey.id,
@@ -540,86 +560,52 @@ class TestSurveyInternals(common.TestSurveyCommon):
'is_page': True,
'description': False,
}, {
'title': 'text_box with invalid trigger',
'title': 'simple choice with invalid trigger',
'survey_id': my_survey.id,
'sequence': 2,
'is_page': False,
'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,
'sequence': 3,
'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',
'suggested_answer_ids': [(0, 0, {'value': 'a'})],
}, {
'title': 'with desc',
'survey_id': my_survey.id,
'sequence': 5,
'sequence': 4,
'is_page': True,
'question_type': False,
'description': 'This page has a description',
}, {
'title': 'multiple choice not conditional',
'survey_id': my_survey.id,
'sequence': 6,
'sequence': 5,
'is_page': False,
'question_type': 'multiple_choice',
'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,
'sequence': 7,
'is_page': False,
'question_type': 'multiple_choice',
}, {
'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',
'suggested_answer_ids': [(0, 0, {'value': 'a'})]
}])
text_box_1.write({'is_conditional': True, 'triggering_question_id': multiple_choice_1.id})
numerical_box.write({'is_conditional': True, 'triggering_question_id': text_box_1.id})
text_box_2.write({'is_conditional': True, 'triggering_question_id': multiple_choice_1.id})
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})
simple_choice_1.write({'triggering_answer_ids': multiple_choice_1.suggested_answer_ids})
simple_choice_2.write({'triggering_answer_ids': multiple_choice_1.suggested_answer_ids})
text_box_2.write({'triggering_answer_ids': (multiple_choice_1 | multiple_choice_2).suggested_answer_ids})
invalid_records = page_without_description + text_box_1 + numerical_box \
+ matrix + simple_choice_2 + simple_choice_3 + text_box_3
invalid_records = page_without_description + simple_choice_1 + simple_choice_2
question_and_page_ids = my_survey.question_and_page_ids
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',
sequence=2,
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=[
{'value': 'Answer 1'},
{'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}
])
q03_suggested_answers_triggering_q04 = q03.suggested_answer_ids.filtered(lambda q: q.is_correct)
self._add_question( # q04
page_0, 'Question 4', 'simple_choice',
sequence=2,
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=[
{'value': 'Answer 1'},
{'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',
sequence=2,
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=[
{'value': 'Answer 1'},
{'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
user_inputs = self.env['survey.user_input'].search([('survey_id', '=', survey.id)])
self.assertEqual(len(user_inputs), 1)
self.assertEqual(len(user_inputs.predefined_question_ids), 7)
answer_token = user_inputs.access_token
# User begins survey with first page
@@ -117,10 +136,12 @@ class TestSurveyFlowWithConditions(common.TestSurveyCommon, HttpCase):
q03: q03.suggested_answer_ids[0], # Wrong
q05: q05.suggested_answer_ids[3], # Right
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.assertEqual(len(user_inputs.predefined_question_ids), 6, "q04 should have been removed as not triggered.")
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)
+76 -35
View File
@@ -1,7 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
from odoo import Command
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
@@ -150,7 +150,6 @@ class TestUiFeedback(HttpCaseWithUserDemo):
],
})
def test_01_admin_survey_tour(self):
access_token = self.survey_feedback.access_token
self.start_tour("/survey/start/%s" % access_token, 'test_survey', login="admin")
@@ -187,41 +186,27 @@ class TestUiFeedback(HttpCaseWithUserDemo):
'sequence': 1,
'question_type': 'simple_choice',
'suggested_answer_ids': [
(0, 0, {
'value': 'Answer 1',
'sequence': 1,
}), (0, 0, {
'value': 'Answer 2',
'sequence': 2,
}),
Command.create({'value': 'Answer 1'}),
Command.create({'value': 'Answer 2'}),
Command.create({'value': 'Answer 3'}),
],
'constr_mandatory': True,
}), (0, 0, {
}), Command.create({
'title': 'Q2',
'sequence': 2,
'question_type': 'simple_choice',
'suggested_answer_ids': [
(0, 0, {
'value': 'Answer 1',
'sequence': 1,
}), (0, 0, {
'value': 'Answer 2',
'sequence': 2,
})
Command.create({'value': 'Answer 1'}),
Command.create({'value': 'Answer 2'}),
],
'constr_mandatory': True,
}), (0, 0, {
}), Command.create({
'title': 'Q3',
'sequence': 3,
'question_type': 'simple_choice',
'suggested_answer_ids': [
(0, 0, {
'value': 'Answer 1',
'sequence': 1,
}), (0, 0, {
'value': 'Answer 2',
'sequence': 2,
})
Command.create({'value': 'Answer 1'}),
Command.create({'value': 'Answer 2'}),
],
'constr_mandatory': True,
}),
@@ -230,24 +215,80 @@ class TestUiFeedback(HttpCaseWithUserDemo):
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_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_a1 = q2.suggested_answer_ids.filtered(lambda a: a.value == 'Answer 1')
q3 = survey_with_triggers.question_ids.filtered(lambda q: q.title == 'Q3')
q2.write({
'is_conditional': True,
'triggering_question_id': q1,
'triggering_answer_id': q1_a1,
})
q3.write({
'is_conditional': True,
'triggering_question_id': q2,
'triggering_answer_id': q2_a1,
})
q2.triggering_answer_ids = q1_a1
q3.triggering_answer_ids = q1_a3 | q2_a1
access_token = survey_with_triggers.access_token
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):
access_token = self.survey_feedback.access_token
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">
<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">
⚠️ 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>
<field name="is_page" invisible="1"/>
<field name="page_id" invisible="1" required="False"/>
@@ -217,19 +217,16 @@
<field name='comment_count_as_answer'
invisible="question_type not in ['simple_choice', 'multiple_choice', 'matrix'] or not comments_allowed"/>
</group>
<group string="Layout">
<group string="Conditional display">
<p class="text-muted" colspan="2" invisible="questions_selection == 'all'">
Conditional display is not available when questions are randomly picked.
</p>
<field name="is_conditional" invisible="questions_selection == 'random'"/>
<field name="allowed_triggering_question_ids" invisible="1"/>
<field name="triggering_question_id" options="{'no_create_edit': True, 'no_open': True, 'no_create': True}"
domain="[('id', 'in', allowed_triggering_question_ids)]" placeholder="Pick a previous multiple-choice question"
invisible="questions_selection == 'random' or not is_conditional"
required="is_conditional"/>
<field name="triggering_answer_id" options="{'no_open': True, 'no_create': True}"
invisible="questions_selection == 'random' or not triggering_question_id"
required="is_conditional"/>
<field name="triggering_answer_ids" widget="many2many_tags" options="{'no_open': True, 'no_create': True}"
domain="[('question_id', 'in', allowed_triggering_question_ids)]"
invisible="questions_selection == 'random'"
placeholder="Optional previous answers required"
/>
</group>
</group>
<group>
@@ -304,11 +301,10 @@
<field name="name">survey.question.answer.view.tree</field>
<field name="model">survey.question.answer</field>
<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="question_id"/>
<field name="matrix_question_id"/>
<field name="value"/>
<field name="value" string="Answer"/>
<field name="answer_score" groups="base.group_no_one"/>
</tree>
</field>
+2 -5
View File
@@ -77,11 +77,8 @@
<field name="random_questions_count"
column_invisible="parent.questions_selection == 'all'"
invisible="not is_page"/>
<field name="is_conditional" column_invisible="True"/>
<field name="triggering_question_id" column_invisible="True"/>
<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"/>
<field name="triggering_question_ids" column_invisible="True"/>
<field name="triggering_answer_ids" column_invisible="True" widget="many2many_tags"/> <!-- widget to fetch display_name -->
<widget name="survey_question_trigger"/>
<button name="copy" type="object" icon="fa-clone" title="Duplicate Question"/>
<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-questions-layout="survey.questions_layout"
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-refresh-background="any(page.background_image for page in survey.page_ids)">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
@@ -304,8 +305,9 @@
<t t-set="display_question"
t-value="survey.questions_layout == 'page_per_question'
or survey.questions_selection == 'random'
or (survey.questions_layout == 'one_page' and not question.is_conditional)
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 == 'one_page' and not question.triggering_answer_ids)
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)"/>
<!--Use Key selection if number of choices is < 26 to keep Z for other choice if any-->