[IMP] survey, web: enable chaining question creation
## Survey Due to the behavior of X2Many fields, the questions created when editing a survey were not usable immediately after creation to be conditional triggers for new questions. The whole survey had to be saved first, which wasn't a smooth experience for the user. This commit will save the survey and question at several times: ### For each new question added Similarly to what was implemented for chatbot scripts, enables the immediate computation of allowed question/answer triggers, permitting their use when chaining question creation. This also enables to check the answers "validation" fields immediately, and activating scoring and add scores to question/answers without requiring the extra steps of saving and re-entering edit mode for the survey. ### When rows are deleted This will trigger the immediate removal of the triggers icons on questions that were triggered by an answer to the deleted question. ### NOT when questions are moved As there is no convenient way to ensure questions are properly ordered with respect to their triggers (order dependency) without popping error messages at every step when reordering questions, we chose to mark the misplaced lines, missing trigger records or triggers that will be ignored (with the new `trigger` widget). This could have been done entirely with 'server' logic, but that would have required additional saves and cleaning fields even more often, even during a temporary reordering step, which isn't convenient. ### Misc Finally, note the extra fields added on the question_and_page_ids field of the survey form view, required to trigger these fields' update consistently (required after reordering or removing records which sometimes doesn't occur without these). Tests ensuring the behavior of the X2Many (saving parent on open/edit and show notifications on error) and the trigger widget are included. ## web To support the functionality described above, we need to allow the caller of 'save' to catch errors to prevent default handling (here to use a notification instead of an additional Dialog). Task-2623482 Part of odoo#78705 Part-of: odoo/odoo#78705
This commit is contained in:
@@ -94,7 +94,8 @@ sent mails with personal token for the invitation of the survey.
|
||||
'survey/static/src/question_page/*',
|
||||
'survey/static/src/js/fields_section_one2many.js',
|
||||
'survey/static/src/js/fields_form_page_description.js',
|
||||
'survey/static/src/views/*.js',
|
||||
'survey/static/src/views/**/*.js',
|
||||
'survey/static/src/views/**/*.xml',
|
||||
'survey/static/src/scss/survey_survey_views.scss',
|
||||
'survey/static/src/scss/survey_question_views.scss',
|
||||
'survey/static/src/scss/survey_templates_results.scss',
|
||||
|
||||
@@ -144,6 +144,11 @@ class SurveyQuestion(models.Model):
|
||||
'|', \
|
||||
('sequence', '<', sequence), \
|
||||
'&', ('sequence', '=', sequence), ('id', '<', id)]")
|
||||
allowed_triggering_question_ids = fields.Many2many(
|
||||
'survey.question', string="Allowed Triggering Questions", copy=False, compute="_compute_allowed_triggering_question_ids")
|
||||
is_placed_before_trigger = fields.Boolean(
|
||||
string='Is misplaced?', help="Is this question placed before its trigger question?",
|
||||
compute="_compute_allowed_triggering_question_ids")
|
||||
triggering_answer_id = fields.Many2one(
|
||||
'survey.question.answer', string="Triggering Answer", copy=False, compute="_compute_triggering_answer_id",
|
||||
store=True, readonly=False, help="Answer that will trigger the display of the current question.",
|
||||
@@ -275,6 +280,51 @@ class SurveyQuestion(models.Model):
|
||||
if not question.validation_required or question.question_type not in ['char_box', 'numerical_box', 'date', 'datetime']:
|
||||
question.validation_required = False
|
||||
|
||||
@api.depends('is_conditional', 'survey_id', 'survey_id.question_ids', 'triggering_question_id')
|
||||
def _compute_allowed_triggering_question_ids(self):
|
||||
""" Although the question (and possible trigger questions) sequence
|
||||
is used here, we do not add these fields to the dependency list to
|
||||
avoid cascading rpc calls when reordering questions via the webclient.
|
||||
"""
|
||||
conditional_questions = self.filtered(lambda q: q.is_conditional)
|
||||
non_conditional_questions = self - conditional_questions
|
||||
non_conditional_questions.allowed_triggering_question_ids = False
|
||||
non_conditional_questions.is_placed_before_trigger = False
|
||||
if not conditional_questions:
|
||||
return
|
||||
|
||||
possible_trigger_questions = self.search([
|
||||
('is_page', '=', False),
|
||||
('question_type', 'in', ['simple_choice', 'multiple_choice']),
|
||||
('suggested_answer_ids', '!=', False),
|
||||
('survey_id', 'in', self.survey_id.ids)
|
||||
])
|
||||
# Using the sequence stored in db is necessary for existing questions that are passed as
|
||||
# NewIds because the sequence provided by the JS client can be incorrect.
|
||||
(conditional_questions | possible_trigger_questions).flush_recordset()
|
||||
self.env.cr.execute(
|
||||
"SELECT id, sequence FROM survey_question WHERE id =ANY(%s)",
|
||||
[conditional_questions.ids]
|
||||
)
|
||||
conditional_questions_sequences = dict(self.env.cr.fetchall()) # id: sequence mapping
|
||||
|
||||
for question in conditional_questions:
|
||||
question_id = question._origin.id
|
||||
if not question_id: # New question
|
||||
conditional_questions.allowed_triggering_question_ids = possible_trigger_questions
|
||||
question.is_placed_before_trigger = False
|
||||
continue
|
||||
|
||||
question_sequence = conditional_questions_sequences[question_id]
|
||||
|
||||
question.allowed_triggering_question_ids = possible_trigger_questions.filtered(
|
||||
lambda q: q.survey_id.id == question.survey_id._origin.id
|
||||
and (q.sequence < question_sequence or q.sequence == question_sequence and q.id < question_id)
|
||||
)
|
||||
question.is_placed_before_trigger = (
|
||||
question.triggering_question_id
|
||||
and question.triggering_question_id.id not in question.allowed_triggering_question_ids.ids)
|
||||
|
||||
@api.depends('is_conditional')
|
||||
def _compute_triggering_question_id(self):
|
||||
""" Used as an 'onchange' : Reset the triggering question if user uncheck 'Conditional Display'
|
||||
|
||||
@@ -96,4 +96,24 @@ export class QuestionPageListRenderer extends ListRenderer {
|
||||
}
|
||||
return super.onCellKeydownEditMode(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the survey after a question used as trigger is deleted. This allows
|
||||
* immediate feedback on the form view as the triggers will be removed
|
||||
* anyway on the records by the ORM.
|
||||
*
|
||||
* @override
|
||||
* @param record
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async onDeleteRecord(record) {
|
||||
const triggeredRecords = this.props.list.records.filter(rec => rec.data.triggering_question_id[0] === record.data.id);
|
||||
if (triggeredRecords.length) {
|
||||
const res = await super.onDeleteRecord(record);
|
||||
await this.props.list.model.root.save({stayInEdition: true});
|
||||
return res;
|
||||
} else {
|
||||
return super.onDeleteRecord(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { QuestionPageListRenderer } from "./question_page_list_renderer";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useOpenX2ManyRecord, useX2ManyCrud } from "@web/views/fields/relational_utils";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { X2ManyField } from "@web/views/fields/x2many/x2many_field";
|
||||
|
||||
const { useSubEnv } = owl;
|
||||
@@ -12,6 +14,88 @@ class QuestionPageOneToManyField extends X2ManyField {
|
||||
useSubEnv({
|
||||
openRecord: (record) => this.openRecord(record),
|
||||
});
|
||||
this.notificationService = useService("notification");
|
||||
|
||||
// Systematically and automatically save SurveyForm at each question edit/creation
|
||||
// enables checking validation parameters consistency and using questions as triggers
|
||||
// immediately during question creation.
|
||||
// Preparing everything in order to override `this._openRecord` below.
|
||||
const { saveRecord: superSaveRecord, updateRecord: superUpdateRecord } = useX2ManyCrud(
|
||||
() => this.list,
|
||||
this.isMany2Many
|
||||
);
|
||||
|
||||
const self = this;
|
||||
const saveRecord = async (record) => {
|
||||
const saveResponse = await superSaveRecord(record);
|
||||
try {
|
||||
await self.props.record.save({stayInEdition: true, throwOnError: true});
|
||||
} catch (error) {
|
||||
return self.handleSurveySaveError(error, record);
|
||||
}
|
||||
return saveResponse;
|
||||
};
|
||||
|
||||
const updateRecord = async (record) => {
|
||||
const updateResponse = await superUpdateRecord(record);
|
||||
try {
|
||||
await self.props.record.save({stayInEdition: true, throwOnError: true});
|
||||
} catch (error) {
|
||||
return self.handleSurveySaveError(error);
|
||||
}
|
||||
return updateResponse;
|
||||
};
|
||||
|
||||
const openRecord = useOpenX2ManyRecord({
|
||||
resModel: this.list.resModel,
|
||||
activeField: this.activeField,
|
||||
activeActions: this.activeActions,
|
||||
getList: () => this.list,
|
||||
saveRecord,
|
||||
updateRecord,
|
||||
});
|
||||
this._openRecord = async (params) => {
|
||||
if (!await self.props.record.save({ stayInEdition: true })) {
|
||||
// do not open question form as it won't be savable either.
|
||||
return;
|
||||
}
|
||||
if (params.record) {
|
||||
// Force synchronization of fields that depend on sequence
|
||||
// (allowed_triggering_question_ids, is_placed_before_trigger)
|
||||
// as records may have been re-ordered before opening this one.
|
||||
await params.record.load();
|
||||
}
|
||||
openRecord(params);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* For convenience, we'll prevent closing the question form dialog and
|
||||
* stay in edit mode to make sure only valid records are saved. Therefore,
|
||||
* two things should be cared for in case of error occurring when saving
|
||||
* the question:
|
||||
* * Remove erroneous question row added to the embedded list
|
||||
* * Replace default error modal with a notification
|
||||
*
|
||||
* @param {Error} error Error thrown when saving survey/question.
|
||||
* @param {Record?} recordToDelete (optional) In case the error is
|
||||
* thrown when saving a new question, it should be deleted from the
|
||||
* list.
|
||||
*/
|
||||
async handleSurveySaveError(error, recordToDelete) {
|
||||
error.event.preventDefault();
|
||||
if (recordToDelete) {
|
||||
const listRecord = this.list.records.find(r => r.__bm_handle__ === recordToDelete.__bm_handle__);
|
||||
await this.list.delete(listRecord.id);
|
||||
}
|
||||
this.notificationService.add(
|
||||
error.message.data.message, {
|
||||
title: this.env._t("Validation Error"),
|
||||
type: "danger"
|
||||
}
|
||||
);
|
||||
// Prevent closing the question form view
|
||||
return Promise.reject("Impossible to save survey, see error notification.");
|
||||
}
|
||||
}
|
||||
QuestionPageOneToManyField.components = {
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { sprintf } from '@web/core/utils/strings';
|
||||
import { standardWidgetProps} from "@web/views/widgets/standard_widget_props";
|
||||
|
||||
const { Component, useEffect, useRef, useState } = owl;
|
||||
|
||||
export class SurveyQuestionTriggerWidget extends Component {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.button = useRef('survey_question_trigger');
|
||||
this.state = useState({
|
||||
triggeringQuestionTitle: this.props.record.data.triggering_question_id,
|
||||
surveyQuestionTriggerTooltip: "",
|
||||
surveyQuestionTriggerIconClass: "",
|
||||
});
|
||||
useEffect(() => {
|
||||
if (this.button && this.button.el) {
|
||||
this.state.triggeringQuestionTitle = this.props.record.data.triggering_question_id[1];
|
||||
const triggerError = this.surveyQuestionTriggerError;
|
||||
if (triggerError === "MISPLACED_TRIGGER_WARNING") {
|
||||
this.state.surveyQuestionTriggerTooltip = sprintf(
|
||||
'⚠ ' + _lt('This question is positioned before its trigger ("%s") and will be skipped.'),
|
||||
this.state.triggeringQuestionTitle);
|
||||
this.state.surveyQuestionTriggerIconClass = 'fa-exclamation-triangle text-warning';
|
||||
} else if (triggerError === "WRONG_QUESTIONS_SELECTION_WARNING") {
|
||||
this.state.surveyQuestionTriggerTooltip = '⚠ ' + _lt(
|
||||
'Conditional display is not available when questions are randomly picked.');
|
||||
this.state.surveyQuestionTriggerIconClass = 'fa-exclamation-triangle text-warning';
|
||||
} else if (triggerError === "MISSING_TRIGGER_ERROR") {
|
||||
// This case must be handled to not temporarily render the "normal" icon if previously
|
||||
// on an error state, which would cause a flicker as the trigger itself will be removed
|
||||
// at next save (auto on survey form and primary list view).
|
||||
} else {
|
||||
this.state.surveyQuestionTriggerTooltip = sprintf(_lt('Displayed if "%s: %s"'),
|
||||
this.state.triggeringQuestionTitle, this.props.record.data.triggering_answer_id[1]);
|
||||
this.state.surveyQuestionTriggerIconClass = 'fa-code-fork';
|
||||
}
|
||||
} else {
|
||||
this.state.triggeringQuestionTitle = "";
|
||||
this.state.surveyQuestionTriggerTooltip = "";
|
||||
this.state.surveyQuestionTriggerIconClass = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `surveyQuestionTriggerError` is computed here and does not rely on
|
||||
* record data (is_placed_before_trigger) for two linked reasons:
|
||||
* 1. Performance: we avoid saving the survey each time a line is moved.
|
||||
* 2. Robustness, as sequences values do not always match between server
|
||||
* provided values when the records are not saved.
|
||||
*
|
||||
* @returns { String }
|
||||
* * `""`: No trigger error (also if `triggering_question_id`
|
||||
* field is not set).
|
||||
* * `"MISSING_TRIGGER_ERROR"`: `triggering_question_id` field is set
|
||||
* and trigger record is not found. This can happen when a question
|
||||
* used as trigger is deleted on the client but not yet saved to DB.
|
||||
* * `"MISPLACED_TRIGGER_WARNING"`: a `triggering_question_id` is set
|
||||
* but is positioned after the current record in the list. This can
|
||||
* happen if the triggering or the triggered question is moved.
|
||||
* * `"WRONG_QUESTIONS_SELECTION_WARNING"`: a `triggering_question_id`
|
||||
* is set but the survey is configured to randomize questions asked
|
||||
* mode which ignores the triggers. This can happen if the survey mode
|
||||
* is changed after triggers are set.
|
||||
*/
|
||||
get surveyQuestionTriggerError() {
|
||||
const record = this.props.record;
|
||||
if (!record.data.triggering_question_id) {
|
||||
return "";
|
||||
}
|
||||
const triggerId = record.data.triggering_question_id[0];
|
||||
let triggerRecord;
|
||||
if (this.props.isSurveyForm) { // embedded list
|
||||
triggerRecord = record.model.root.data.question_and_page_ids.records.find(rec => rec.data.id === triggerId);
|
||||
} else {
|
||||
triggerRecord = record.model.root.records.find(rec => rec.resId === triggerId);
|
||||
}
|
||||
|
||||
if (!triggerRecord) {
|
||||
return "MISSING_TRIGGER_ERROR";
|
||||
}
|
||||
if (record.data.questions_selection === 'random') {
|
||||
return "WRONG_QUESTIONS_SELECTION_WARNING";
|
||||
}
|
||||
if (record.data.sequence < triggerRecord.data.sequence ||
|
||||
(record.data.sequence === triggerRecord.data.sequence && record.data.id < triggerId)) {
|
||||
return "MISPLACED_TRIGGER_WARNING";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
SurveyQuestionTriggerWidget.template = "survey.surveyQuestionTrigger";
|
||||
SurveyQuestionTriggerWidget.props = {
|
||||
...standardWidgetProps,
|
||||
isSurveyForm: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
SurveyQuestionTriggerWidget.defaultProps = {
|
||||
isSurveyForm: false
|
||||
};
|
||||
|
||||
SurveyQuestionTriggerWidget.extractProps = ({ attrs }) => {
|
||||
return {
|
||||
isSurveyForm: attrs.options.isSurveyForm
|
||||
};
|
||||
};
|
||||
|
||||
SurveyQuestionTriggerWidget.displayName = 'Trigger';
|
||||
SurveyQuestionTriggerWidget.supportedTypes = ['many2one'];
|
||||
|
||||
registry.category("view_widgets").add("survey_question_trigger", SurveyQuestionTriggerWidget);
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="survey.surveyQuestionTrigger" owl="1">
|
||||
<button t-if="this.props.record.data.triggering_question_id" disabled="disabled" t-ref="survey_question_trigger"
|
||||
class="btn btn-link px-1 py-0 pe-auto" t-att-class="this.surveyQuestionTriggerError ? 'opacity-100 ' : 'icon_rotates'">
|
||||
<i class="fa fa-fw o_button_icon " t-att-class="this.state.surveyQuestionTriggerIconClass"
|
||||
t-att-data-tooltip="this.state.surveyQuestionTriggerTooltip"/>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { click, editInput, getFixture, nextTick, triggerHotkey } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { RPCError } from "@web/core/network/rpc_service";
|
||||
|
||||
QUnit.module("QuestionPageOneToManyField", (hooks) => {
|
||||
let serverData;
|
||||
@@ -12,16 +13,20 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
|
||||
|
||||
serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: { lines: { type: "one2many", relation: "lines_sections" } },
|
||||
survey: {
|
||||
fields: {
|
||||
question_and_page_ids: { type: "one2many", relation: "survey_question" },
|
||||
favorite_color: { string: "Favorite color", type: "char" }
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
lines: [1, 2],
|
||||
question_and_page_ids: [1, 2],
|
||||
favorite_color: ""
|
||||
},
|
||||
],
|
||||
},
|
||||
lines_sections: {
|
||||
survey_question: {
|
||||
fields: {
|
||||
is_page: { type: "boolean" },
|
||||
title: { type: "char", string: "Title" },
|
||||
@@ -44,7 +49,7 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
|
||||
},
|
||||
},
|
||||
views: {
|
||||
"lines_sections,false,form": `
|
||||
"survey_question,false,form": `
|
||||
<form>
|
||||
<field name="title" />
|
||||
</form>
|
||||
@@ -58,12 +63,12 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
|
||||
QUnit.test("basic rendering", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resModel: "survey",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="lines" widget="question_page_one2many">
|
||||
<field name="question_and_page_ids" widget="question_page_one2many">
|
||||
<tree>
|
||||
<field name="is_page" invisible="1" />
|
||||
<field name="title" />
|
||||
@@ -87,12 +92,12 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
|
||||
QUnit.test("click on section behaves as usual in readonly mode", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resModel: "survey",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="lines" widget="question_page_one2many">
|
||||
<field name="question_and_page_ids" widget="question_page_one2many">
|
||||
<tree>
|
||||
<field name="is_page" invisible="1" />
|
||||
<field name="title" />
|
||||
@@ -112,12 +117,12 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
|
||||
QUnit.test("click on section edit the section in place", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resModel: "survey",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="lines" widget="question_page_one2many">
|
||||
<field name="question_and_page_ids" widget="question_page_one2many">
|
||||
<tree>
|
||||
<field name="is_page" invisible="1" />
|
||||
<field name="title" />
|
||||
@@ -131,15 +136,16 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
|
||||
assert.containsNone(target, ".modal .o_form_view");
|
||||
});
|
||||
|
||||
QUnit.test("click on real line opens a dialog", async (assert) => {
|
||||
QUnit.test("click on real line saves form and opens a dialog", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resModel: "survey",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="lines" widget="question_page_one2many">
|
||||
<field name="favorite_color"/>
|
||||
<field name="question_and_page_ids" widget="question_page_one2many">
|
||||
<tree>
|
||||
<field name="is_page" invisible="1" />
|
||||
<field name="title" />
|
||||
@@ -148,21 +154,68 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
|
||||
</field>
|
||||
</form>
|
||||
`,
|
||||
mockRPC(route, args) {
|
||||
if (args.method === "write" && args.model === "survey") {
|
||||
assert.step("save parent form");
|
||||
}
|
||||
},
|
||||
});
|
||||
await editInput(target, "[name='favorite_color'] input", "Yellow");
|
||||
await click(target.querySelector(".o_data_row:nth-child(2) .o_data_cell"));
|
||||
// Edit content to trigger the expected actual save at row opening
|
||||
assert.verifySteps(["save parent form"]);
|
||||
assert.containsNone(target, ".o_selected_row");
|
||||
assert.containsOnce(target, ".modal .o_form_view");
|
||||
});
|
||||
|
||||
QUnit.test("A validation error from saving parent form notifies and prevents dialog from closing", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "survey",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="question_and_page_ids" widget="question_page_one2many">
|
||||
<tree>
|
||||
<field name="is_page" invisible="1" />
|
||||
<field name="title" />
|
||||
<field name="random_questions_count" />
|
||||
</tree>
|
||||
</field>
|
||||
</form>
|
||||
`,
|
||||
mockRPC(route, args) {
|
||||
if (args.method === "write" && args.model === "survey") {
|
||||
assert.step("save parent form");
|
||||
const surveyValidationError = new RPCError();
|
||||
Object.assign(surveyValidationError, {
|
||||
exceptionName: "odoo.exceptions.ValidationError",
|
||||
data: {
|
||||
message: "This isn't right!",
|
||||
},
|
||||
});
|
||||
return Promise.reject(surveyValidationError);
|
||||
}
|
||||
},
|
||||
});
|
||||
await click(target.querySelector(".o_data_row:nth-child(2) .o_data_cell"));
|
||||
assert.containsNone(target, ".o_selected_row");
|
||||
await editInput(target, ".o_dialog:not(.o_inactive_modal) .modal-body [name='title'] input", "Invalid RecordTitle");
|
||||
await click(target.querySelector(".o_dialog:not(.o_inactive_modal) .o_form_button_save"));
|
||||
assert.verifySteps(["save parent form"]);
|
||||
assert.containsOnce(document.body, ".o_notification");
|
||||
assert.containsOnce(target, ".modal .o_form_view");
|
||||
});
|
||||
|
||||
QUnit.test("can create section inline", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resModel: "survey",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="lines" widget="question_page_one2many">
|
||||
<field name="question_and_page_ids" widget="question_page_one2many">
|
||||
<tree>
|
||||
<field name="is_page" invisible="1" />
|
||||
<field name="title" />
|
||||
@@ -187,12 +240,12 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
|
||||
QUnit.test("creates real record in form dialog", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resModel: "survey",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="lines" widget="question_page_one2many">
|
||||
<field name="question_and_page_ids" widget="question_page_one2many">
|
||||
<tree>
|
||||
<field name="is_page" invisible="1" />
|
||||
<field name="title" />
|
||||
@@ -217,12 +270,12 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
|
||||
async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resModel: "survey",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="lines" widget="question_page_one2many">
|
||||
<field name="question_and_page_ids" widget="question_page_one2many">
|
||||
<tree>
|
||||
<field name="is_page" invisible="1" />
|
||||
<field name="title" />
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { dragAndDrop, getFixture, nextTick } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
|
||||
QUnit.module("SurveyQuestionTriggerWidget", (hooks) => {
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
hooks.beforeEach(() => {
|
||||
target = getFixture();
|
||||
|
||||
serverData = {
|
||||
models: {
|
||||
survey: {
|
||||
fields: {
|
||||
question_and_page_ids: { type: "one2many", relation: "survey_question" },
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
question_and_page_ids: [1, 2],
|
||||
},
|
||||
],
|
||||
},
|
||||
survey_question: {
|
||||
fields: {
|
||||
sequence: { type: "number" },
|
||||
name: { type: "char", string: "name", },
|
||||
triggering_question_id: {
|
||||
type: "many2one",
|
||||
string: "Triggering question",
|
||||
relation: "survey_question",
|
||||
required: false,
|
||||
searchable: true,
|
||||
},
|
||||
triggering_answer_id: {
|
||||
type: "many2one",
|
||||
string: "Triggering answer",
|
||||
relation: "survey_question_answer",
|
||||
required: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
sequence: 1,
|
||||
name: "Question 1",
|
||||
triggering_question_id: null,
|
||||
triggering_answer_id: null,
|
||||
}, {
|
||||
id: 2,
|
||||
sequence: 2,
|
||||
name: "Question 2",
|
||||
triggering_question_id: 1,
|
||||
triggering_answer_id: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
survey_question_answer: {
|
||||
fields: {
|
||||
name: {type: "char", string: "name"},
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Answer 1"
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
views: {
|
||||
"survey_question,false,form": `
|
||||
<form>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="triggering_answer_id" invisible="1"/>
|
||||
<field name="triggering_question_id" invisible="1"/>
|
||||
<widget name="survey_question_trigger" nolabel="1"/>
|
||||
</group>
|
||||
</form>
|
||||
`,
|
||||
},
|
||||
};
|
||||
|
||||
setupViewRegistries();
|
||||
});
|
||||
|
||||
QUnit.test("dynamic rendering of surveyQuestionTriggerError rows", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "survey",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="question_and_page_ids">
|
||||
<tree>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="triggering_answer_id" invisible="1"/>
|
||||
<field name="triggering_question_id" invisible="1"/>
|
||||
<widget name="survey_question_trigger" options="{'isSurveyForm': True}"/>
|
||||
</tree>
|
||||
</field>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
assert.containsOnce(target, ".o_field_x2many .o_list_renderer table.o_list_table");
|
||||
assert.containsN(target, ".o_data_row", 2);
|
||||
let rows = target.querySelectorAll(".o_data_row");
|
||||
|
||||
assert.strictEqual(rows[0].textContent, "Question 1");
|
||||
let q1TriggerDiv = rows[0].querySelector("td.o_data_cell div.o_widget_survey_question_trigger");
|
||||
assert.containsNone(q1TriggerDiv, "button");
|
||||
|
||||
assert.strictEqual(rows[1].textContent, "Question 2");
|
||||
let q2TriggerDiv = rows[1].querySelector("td.o_data_cell div.o_widget_survey_question_trigger");
|
||||
assert.containsOnce(q2TriggerDiv, "button");
|
||||
// Question 2 is correctly placed after Question 1
|
||||
let triggerIcon = q2TriggerDiv.querySelector("button i");
|
||||
assert.doesNotHaveClass(triggerIcon, "text-warning");
|
||||
assert.hasAttrValue(triggerIcon, 'data-tooltip', 'Displayed if "Question 1: Answer 1"',
|
||||
'Trigger tooltip should be \'Displayed if "Question 1: Answer 1"\'.');
|
||||
|
||||
// drag and drop Question 2 (triggered) before Question 1 (trigger)
|
||||
await dragAndDrop("tbody tr:nth-child(2) .o_handle_cell", "tbody tr:nth-child(1)");
|
||||
await nextTick();
|
||||
rows = target.querySelectorAll(".o_data_row");
|
||||
|
||||
assert.strictEqual(rows[0].textContent, "Question 2");
|
||||
q2TriggerDiv = rows[0].querySelector("td.o_data_cell div.o_widget_survey_question_trigger");
|
||||
assert.containsOnce(q2TriggerDiv, "button");
|
||||
triggerIcon = q2TriggerDiv.querySelector("button i");
|
||||
assert.hasClass(triggerIcon, "text-warning");
|
||||
assert.strictEqual(
|
||||
triggerIcon.getAttribute('data-tooltip'),
|
||||
'⚠ This question is positioned before its trigger ("Question 1") and will be skipped.',
|
||||
'Trigger tooltip should have been changed to misplacement error message.'
|
||||
);
|
||||
|
||||
// drag and drop Question 1 (trigger) back before Question 2 (triggered)
|
||||
await dragAndDrop("tbody tr:nth-child(2) .o_handle_cell", "tbody tr:nth-child(1)");
|
||||
await nextTick();
|
||||
|
||||
rows = target.querySelectorAll(".o_data_row");
|
||||
|
||||
assert.strictEqual(rows[1].textContent, "Question 2");
|
||||
assert.doesNotHaveClass(rows[1].querySelector("td.o_data_cell div.o_widget_survey_question_trigger button i"), "text-warning");
|
||||
assert.hasAttrValue(triggerIcon, 'data-tooltip', 'Displayed if "Question 1: Answer 1"',
|
||||
'Trigger tooltip should be back to \'Displayed if "Question 1: Answer 1"\'.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import tour from 'web_tour.tour';
|
||||
|
||||
tour.register('survey_tour_test_survey_form_triggers', {
|
||||
test: true,
|
||||
url: '/web',
|
||||
}, [
|
||||
tour.stepUtils.showAppsMenuItem(),
|
||||
{
|
||||
content: 'Go to Survey',
|
||||
trigger: '.o_app[data-menu-xmlid="survey.menu_surveys"]',
|
||||
}, {
|
||||
content: "Create a new survey",
|
||||
trigger: ".o-kanban-button-new",
|
||||
}, {
|
||||
content: "Set the Survey's title",
|
||||
trigger: ".o_field_widget[name=title] input",
|
||||
run: "text Test survey",
|
||||
}, {
|
||||
content: "Add a first question",
|
||||
trigger: "td.o_field_x2many_list_row_add a",
|
||||
}, {
|
||||
content: "Set the first question's title",
|
||||
trigger: ".modal-content .o_field_widget[name=title] input",
|
||||
run: "text Question 1",
|
||||
},
|
||||
...addTwoAnswers(),
|
||||
...saveAndNew(),
|
||||
{
|
||||
content: "Set the second question's title",
|
||||
trigger: ".o_field_widget[name=title] input",
|
||||
run: "text Question 2",
|
||||
in_modal: true,
|
||||
},
|
||||
...addTwoAnswers(),
|
||||
...toggleIsConditional(),
|
||||
{
|
||||
content: "Set a trigger question for the first question",
|
||||
trigger: ".o_field_widget[name=triggering_question_id] input",
|
||||
run: "click",
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Set the first question as trigger",
|
||||
trigger: 'ul.ui-autocomplete a:contains("Question 1")',
|
||||
run: 'click',
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Set a trigger answer for first question",
|
||||
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
|
||||
run: "click",
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Set the first answer as trigger",
|
||||
trigger: 'ul.ui-autocomplete a:contains("Answer A")',
|
||||
run: 'click',
|
||||
},
|
||||
...changeTab("answers"),
|
||||
...saveAndNew(),
|
||||
{
|
||||
content: "Set the third question's title",
|
||||
trigger: ".o_field_widget[name=title] input",
|
||||
run: "text Question 3",
|
||||
in_modal: true,
|
||||
},
|
||||
...addTwoAnswers(),
|
||||
...toggleIsConditional(),
|
||||
{
|
||||
content: "Set a trigger question for the second question",
|
||||
trigger: ".o_field_widget[name=triggering_question_id] input",
|
||||
run: "click",
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Set the second question as trigger",
|
||||
trigger: 'ul.ui-autocomplete a:contains("Question 2")',
|
||||
run: 'click',
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Set a trigger answer for second question",
|
||||
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
|
||||
run: "click",
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Set the second answer as trigger",
|
||||
trigger: 'ul.ui-autocomplete a:contains("Answer B")',
|
||||
run: 'click',
|
||||
},
|
||||
...changeTab("answers"),
|
||||
...tour.stepUtils.saveForm(),
|
||||
// Q2 and Q3 should have fa-fork icons. Assumes that the Trigger widget's column is 2 places after the title's.
|
||||
{
|
||||
content: "Check that Question 2 has 'normal' trigger icon",
|
||||
trigger: "tr:contains('Question 2') button i.fa-code-fork",
|
||||
run: () => {}, // it's a check
|
||||
}, {
|
||||
content: "Check that Question 3 has 'normal' trigger icon",
|
||||
trigger: "tr:contains('Question 3') button i.fa-code-fork",
|
||||
run: () => {}, // it's a check
|
||||
}, {
|
||||
content: "Move Question 3 above its trigger (Question 2)",
|
||||
trigger: "tr.o_data_row:nth-child(3) td[name=sequence]",
|
||||
run: "drag_and_drop_native div[name=question_and_page_ids] table tbody tr:nth-child(2)",
|
||||
}, {
|
||||
content: "Check that Question 3 has 'warning' trigger icon",
|
||||
trigger: "tr:contains('Question 3') button i.fa-exclamation-triangle",
|
||||
run: () => {}, // it's a check
|
||||
}, {
|
||||
content: "Open that question to check the server's misplacement evaluation agrees",
|
||||
trigger: "tr.o_data_row td[data-tooltip='Question 3']",
|
||||
run: "click",
|
||||
}, {
|
||||
content: "Check that an alert is shown",
|
||||
trigger: ".o_form_sheet_bg div:first-child.alert-warning:contains('positioned before its trigger')",
|
||||
in_modal: true,
|
||||
},
|
||||
...changeTab("options"),
|
||||
{
|
||||
content: "Change trigger question",
|
||||
trigger: ".o_field_widget[name=triggering_question_id] input",
|
||||
run: "click",
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Set the first question as trigger instead",
|
||||
trigger: 'ul.ui-autocomplete a:contains("Question 1")',
|
||||
run: 'click',
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Check that the alert is gone",
|
||||
trigger: `.o_form_sheet_bg div:first-child:not(.alert-warning).o_form_sheet`,
|
||||
in_modal: true,
|
||||
run: () => {}, // it's a check
|
||||
}, {
|
||||
content: "Change the trigger answer as well",
|
||||
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
|
||||
run: "click",
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Set the second answer as trigger, then",
|
||||
trigger: 'ul.ui-autocomplete a:contains("Answer B")',
|
||||
run: 'click',
|
||||
},
|
||||
...tour.stepUtils.saveForm(),
|
||||
{
|
||||
content: "Check that Question 3 has its 'normal' trigger icon back",
|
||||
trigger: "tr:contains('Question 3') button i.fa-code-fork",
|
||||
run: () => {}, // it's a check
|
||||
}, {
|
||||
content: "Move Question 3 back below Question 2",
|
||||
trigger: "tr.o_data_row:nth-child(2) td[name=sequence]",
|
||||
run: "drag_and_drop_native div[name=question_and_page_ids] table tbody tr:nth-child(3)",
|
||||
}, {
|
||||
content: "Open that question again",
|
||||
trigger: "tr.o_data_row td[data-tooltip='Question 3']",
|
||||
run: "click",
|
||||
},
|
||||
...changeTab("options"),
|
||||
{
|
||||
content: "Change trigger to confirm we can now use Question 2 again",
|
||||
trigger: ".modal-content .o_field_widget[name=triggering_question_id] input",
|
||||
run: "click",
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Question 2 is allowed as trigger again",
|
||||
trigger: '.modal-content ul.ui-autocomplete a:contains("Question 2")',
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Change the trigger answer back as well",
|
||||
trigger: ".modal-content .o_field_widget[name=triggering_answer_id] input",
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Set the second answer as trigger, then",
|
||||
trigger: '.modal-content ul.ui-autocomplete a:contains("Answer B")',
|
||||
run: "click",
|
||||
},
|
||||
...tour.stepUtils.saveForm(),
|
||||
// Deleting trigger answers or whole question gracefully remove the trigger automatically
|
||||
{
|
||||
content: "Open Question 2 again",
|
||||
trigger: "tr.o_data_row td[data-tooltip='Question 2']",
|
||||
run: "click",
|
||||
}, {
|
||||
content: "Delete Answer B",
|
||||
trigger: "div[name=suggested_answer_ids] tr:contains('Answer B') button[name=delete]",
|
||||
},
|
||||
...tour.stepUtils.saveForm(),
|
||||
{
|
||||
content: "Check that Question 3 no longer has a trigger icon",
|
||||
trigger: "div[name=question_and_page_ids] tr:contains('Question 3') div.o_widget_survey_question_trigger:not(:has(button))",
|
||||
run: () => {}, // it's a check
|
||||
}, {
|
||||
content: "Check that Question 2 however still has a trigger icon",
|
||||
trigger: "tr:contains('Question 2') button i.fa-code-fork",
|
||||
run: () => {}, // it's a check
|
||||
}, {
|
||||
content: "Delete Question 1",
|
||||
trigger: "tr:contains('Question 1') button[name=delete]",
|
||||
run: "click",
|
||||
}, {
|
||||
content: "Check that now Question 2 too does no longer have a trigger icon",
|
||||
trigger: "tr:contains('Question 2') div.o_widget_survey_question_trigger:not(:has(button))",
|
||||
run: () => {}, // it's a check
|
||||
}, {
|
||||
content: 'Go back to Kanban View',
|
||||
trigger: 'a[data-menu-xmlid="survey.menu_surveys"]',
|
||||
}, {
|
||||
content: "Check that we arrived on the kanban view",
|
||||
trigger: ".o-kanban-button-new",
|
||||
run: () => {}, // it's a check
|
||||
}
|
||||
]);
|
||||
|
||||
function addTwoAnswers() {
|
||||
return [
|
||||
{
|
||||
content: "Add the first answer",
|
||||
trigger: "div[name=suggested_answer_ids] .o_field_x2many_list_row_add a",
|
||||
in_modal: true,
|
||||
}, {
|
||||
trigger: 'tr.o_selected_row div[name=value] input',
|
||||
run: 'text Answer A',
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Add the second answer",
|
||||
trigger: "div[name=suggested_answer_ids] .o_field_x2many_list_row_add a",
|
||||
in_modal: true,
|
||||
}, {
|
||||
trigger: 'tr.o_selected_row div[name=value] input',
|
||||
run: 'text Answer B',
|
||||
in_modal: true,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function saveAndNew() {
|
||||
return [
|
||||
{
|
||||
content: "Click Save & New",
|
||||
trigger: "button.o_form_button_save_new",
|
||||
in_modal: true,
|
||||
}, {
|
||||
content: "Wait for the dialog to render new question form",
|
||||
// suggested_answer_ids required even though in_modal is specified...
|
||||
trigger: "div[name=suggested_answer_ids] .o_list_table tbody tr:first-child:not(.o_data_row)", // empty answers list
|
||||
in_modal: true,
|
||||
run: () => {}, // it's a check
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function toggleIsConditional() {
|
||||
return [
|
||||
...changeTab("options"),
|
||||
{
|
||||
content: "Toggle is_conditional",
|
||||
trigger: "div[name=is_conditional] input",
|
||||
in_modal: true,
|
||||
run: "click",
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function changeTab(tabName) {
|
||||
// Currently, .modal-content is required even though "in_modal"
|
||||
return [
|
||||
{
|
||||
content: `Go to ${tabName} tab`,
|
||||
trigger: `.modal-content a[name=${tabName}].nav-link`,
|
||||
in_modal: true
|
||||
}, {
|
||||
content: `Wait for tab ${tabName} tab`,
|
||||
trigger: `.modal-content a[name=${tabName}].nav-link.active`,
|
||||
in_modal: true,
|
||||
run: () => {}, // it's a check
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -9,6 +9,7 @@ from . import test_certification_flow
|
||||
from . import test_survey_invite
|
||||
from . import test_survey_security
|
||||
from . import test_survey_randomize
|
||||
from . import test_survey_ui_backend
|
||||
from . import test_survey_ui_certification
|
||||
from . import test_survey_ui_feedback
|
||||
from . import test_survey_ui_session
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests import HttpCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestUi(HttpCase):
|
||||
|
||||
def test_tour_test_survey_form_triggers(self):
|
||||
self.start_tour('/web', 'survey_tour_test_survey_form_triggers', login='admin')
|
||||
@@ -7,12 +7,16 @@
|
||||
<field name="model">survey.question</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Survey Question" create="false" class="o_survey_question_view_form">
|
||||
<field name="is_placed_before_trigger" invisible="1"/>
|
||||
<div class="alert alert-warning mb-0 text-center" role="alert" attrs="{'invisible': [('is_placed_before_trigger', '=', False)]}">
|
||||
⚠️ This question is positioned before its trigger and will be skipped.
|
||||
</div>
|
||||
<field name="is_page" invisible="1"/>
|
||||
<field name="page_id" invisible="1" required="False"/>
|
||||
<field name="survey_id" invisible="1"/>
|
||||
<field name="sequence" invisible="1"/>
|
||||
<field name="scoring_type" invisible="1"/>
|
||||
<sheet>
|
||||
<field name="survey_id" invisible="not context.get('show_survey_field')" readonly="1"/>
|
||||
<div class="float-end d-flex flex-column text-center" attrs="{'invisible': [('is_page', '=', False)]}">
|
||||
<label for="background_image"/>
|
||||
<field name="background_image" widget="image" class="oe_avatar"/>
|
||||
@@ -222,11 +226,16 @@
|
||||
attrs="{'invisible': ['|', ('question_type', 'not in', ['simple_choice','multiple_choice', 'matrix']), ('comments_allowed', '=', False)]}"/>
|
||||
</group>
|
||||
<group string="Layout">
|
||||
<p class="text-muted" colspan="2" attrs="{'invisible': [('questions_selection', '=', 'all')]}">
|
||||
Conditional display is not available when questions are randomly picked.
|
||||
</p>
|
||||
<field name="is_conditional" attrs="{'invisible': [('questions_selection', '=', 'random')]}"/>
|
||||
<field name="triggering_question_id" options="{'no_open': True, 'no_create': True}"
|
||||
attrs="{'invisible': [('is_conditional','=', False)], 'required': [('is_conditional','=', True)]}"/>
|
||||
<field name="allowed_triggering_question_ids" invisible="1"/>
|
||||
<field name="triggering_question_id" options="{'no_create_edit': True, 'no_open': True, 'no_create': True}"
|
||||
domain="[('id', 'in', allowed_triggering_question_ids)]" placeholder="Pick a previous multiple-choice question"
|
||||
attrs="{'invisible': ['|', ('questions_selection', '=', 'random'), ('is_conditional','=', False)], 'required': [('is_conditional','=', True)]}"/>
|
||||
<field name="triggering_answer_id" options="{'no_open': True, 'no_create': True}"
|
||||
attrs="{'invisible': ['|', ('is_conditional','=', False), ('triggering_question_id','=', False)],
|
||||
attrs="{'invisible': ['|', ('questions_selection', '=', 'random'), ('triggering_question_id','=', False)],
|
||||
'required': [('is_conditional','=', True)]}"/>
|
||||
</group>
|
||||
</group>
|
||||
@@ -263,8 +272,8 @@
|
||||
<field name="survey_id"/>
|
||||
<field name="question_type"/>
|
||||
<field name="triggering_question_id" invisible="1"/>
|
||||
<button disabled="disabled" icon="fa-code-fork" attrs="{'invisible': [('triggering_question_id', '=', False)]}"
|
||||
title="This question depends on another question's answer." class="icon_rotates"/>
|
||||
<field name="triggering_answer_id" invisible="1"/>
|
||||
<widget name="survey_question_trigger"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
@@ -289,7 +298,7 @@
|
||||
<field name="res_model">survey.question</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="survey_question_search"/>
|
||||
<field name="context">{'search_default_group_by_page': True}</field>
|
||||
<field name="context">{'search_default_group_by_page': True, 'show_survey_field': True}</field>
|
||||
<field name="domain">[('is_page', '=', False)]</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_empty_folder">
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Questions" name="questions">
|
||||
<field name="question_and_page_ids" nolabel="1" widget="question_page_one2many" mode="tree,kanban" context="{'default_survey_id': active_id, 'default_questions_selection': questions_selection}">
|
||||
<field name="question_and_page_ids" nolabel="1" widget="question_page_one2many" mode="tree,kanban" context="{'default_survey_id': active_id}">
|
||||
<tree decoration-bf="is_page">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="title" widget="survey_description_page"/>
|
||||
@@ -76,11 +76,14 @@
|
||||
<field name="suggested_answer_ids" invisible="1"/>
|
||||
<field name="matrix_row_ids" invisible="1"/>
|
||||
<field name="survey_id" invisible="1"/>
|
||||
<field name="triggering_question_id" invisible="1"/>
|
||||
<field name="random_questions_count"
|
||||
attrs="{'column_invisible': [('parent.questions_selection', '=', 'all')], 'invisible': [('is_page', '=', False)]}"/>
|
||||
<button disabled="disabled" icon="fa-code-fork" attrs="{'invisible': [('triggering_question_id', '=', False)]}"
|
||||
title="This question depends on another question's answer." class="icon_rotates"/>
|
||||
<field name="is_conditional" invisible="1"/>
|
||||
<field name="triggering_question_id" invisible="1"/>
|
||||
<field name="triggering_answer_id" invisible="1"/>
|
||||
<field name="is_placed_before_trigger" invisible="1"/>
|
||||
<field name="allowed_triggering_question_ids" invisible="1"/>
|
||||
<widget name="survey_question_trigger" options="{'isSurveyForm': True}"/>
|
||||
<control>
|
||||
<create name="add_question_control" string="Add a question"/>
|
||||
<create name="add_section_control" string="Add a section" context="{'default_is_page': True, 'default_questions_selection': 'all'}"/>
|
||||
|
||||
@@ -578,6 +578,8 @@ export class Record extends DataPoint {
|
||||
* @param {boolean} [options.useSaveErrorDialog=false] displays a custom
|
||||
* dialog and await the response from this dialog when an error is
|
||||
* returned by the server.
|
||||
* @param {boolean} [options.throwOnError=false] throws the saving error if
|
||||
* applicable, allowing to catch it.
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async save(
|
||||
@@ -586,6 +588,7 @@ export class Record extends DataPoint {
|
||||
noReload: false,
|
||||
savePoint: false,
|
||||
useSaveErrorDialog: false,
|
||||
throwOnError: false,
|
||||
}
|
||||
) {
|
||||
const shouldSwitchToReadonly = !options.stayInEdition && this.isInEdition;
|
||||
@@ -635,6 +638,9 @@ export class Record extends DataPoint {
|
||||
await this.load();
|
||||
this.model.notify();
|
||||
}
|
||||
if (options.throwOnError) {
|
||||
throw _e;
|
||||
}
|
||||
return canProceed;
|
||||
}
|
||||
this.__syncData(true);
|
||||
|
||||
Reference in New Issue
Block a user