[WIP]survey_dropdown_choice
This commit is contained in:
1
survey_dropdown_choice/__init__.py
Normal file
1
survey_dropdown_choice/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
29
survey_dropdown_choice/__manifest__.py
Normal file
29
survey_dropdown_choice/__manifest__.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Survey dropdown choice",
|
||||||
|
"summary": "Display a 'simple choice' question as a searchable dropdown instead of radio buttons",
|
||||||
|
"description": """
|
||||||
|
Adds an option on 'Multiple choice: only one answer' questions to render the
|
||||||
|
suggested answers as a searchable dropdown. While taking the survey, the
|
||||||
|
respondent can type in the input to filter the visible options, which is much
|
||||||
|
more convenient than scrolling through a long radio-button list.
|
||||||
|
""",
|
||||||
|
"version": "18.0.1.0.0",
|
||||||
|
"license": "AGPL-3",
|
||||||
|
"author": "Elabore",
|
||||||
|
"website": "https://www.elabore.coop",
|
||||||
|
"category": "Marketing/Surveys",
|
||||||
|
"depends": ["survey"],
|
||||||
|
"data": [
|
||||||
|
"views/survey_question_views.xml",
|
||||||
|
"views/survey_templates.xml",
|
||||||
|
],
|
||||||
|
"assets": {
|
||||||
|
"survey.survey_assets": [
|
||||||
|
"survey_dropdown_choice/static/src/scss/survey_dropdown_choice.scss",
|
||||||
|
"survey_dropdown_choice/static/src/js/survey_dropdown_choice.js",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"installable": True,
|
||||||
|
}
|
||||||
1
survey_dropdown_choice/models/__init__.py
Normal file
1
survey_dropdown_choice/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import survey_question
|
||||||
13
survey_dropdown_choice/models/survey_question.py
Normal file
13
survey_dropdown_choice/models/survey_question.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class SurveyQuestion(models.Model):
|
||||||
|
_inherit = "survey.question"
|
||||||
|
|
||||||
|
display_dropdown = fields.Boolean(
|
||||||
|
string="Display as searchable dropdown",
|
||||||
|
help="Render the suggested answers as a searchable dropdown instead of "
|
||||||
|
"a list of radio buttons. Only relevant for 'Multiple choice: only "
|
||||||
|
"one answer' questions. The respondent can type in the dropdown to "
|
||||||
|
"filter the available options.",
|
||||||
|
)
|
||||||
185
survey_dropdown_choice/static/src/js/survey_dropdown_choice.js
Normal file
185
survey_dropdown_choice/static/src/js/survey_dropdown_choice.js
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import publicWidget from "@web/legacy/js/public/public_widget";
|
||||||
|
|
||||||
|
// Strip combining diacritical marks (U+0300..U+036F) so "Élève" matches "eleve".
|
||||||
|
const STRIP_DIACRITICS = (str) =>
|
||||||
|
(str || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
||||||
|
|
||||||
|
publicWidget.registry.SurveyDropdownChoice = publicWidget.Widget.extend({
|
||||||
|
selector: ".o_survey_dropdown_choice",
|
||||||
|
events: {
|
||||||
|
"input .o_survey_dropdown_search": "_onSearchInput",
|
||||||
|
"focus .o_survey_dropdown_search": "_onSearchFocus",
|
||||||
|
"click .o_survey_dropdown_search_wrapper": "_onWrapperClick",
|
||||||
|
"keydown .o_survey_dropdown_search": "_onSearchKeydown",
|
||||||
|
"mousedown .o_survey_dropdown_option": "_onOptionMousedown",
|
||||||
|
"click .o_survey_dropdown_option": "_onOptionClick",
|
||||||
|
"click input[type='radio']": "_onRadioInputClick",
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
start() {
|
||||||
|
this._panelEl = this.el.querySelector(".o_survey_dropdown_panel");
|
||||||
|
this._searchEl = this.el.querySelector(".o_survey_dropdown_search");
|
||||||
|
this._noMatchEl = this.el.querySelector(".o_survey_dropdown_no_match");
|
||||||
|
this._options = Array.from(this.el.querySelectorAll(".o_survey_dropdown_option"));
|
||||||
|
this._onDocumentMousedown = this._onDocumentMousedown.bind(this);
|
||||||
|
document.addEventListener("mousedown", this._onDocumentMousedown);
|
||||||
|
return this._super(...arguments);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
document.removeEventListener("mousedown", this._onDocumentMousedown);
|
||||||
|
return this._super(...arguments);
|
||||||
|
},
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_onWrapperClick() {
|
||||||
|
// Make sure clicking anywhere in the search row (chevron, padding, ...)
|
||||||
|
// focuses the input, which in turn opens the panel.
|
||||||
|
if (document.activeElement !== this._searchEl) {
|
||||||
|
this._searchEl.focus();
|
||||||
|
} else {
|
||||||
|
this._openPanel();
|
||||||
|
this._filter("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_onSearchFocus() {
|
||||||
|
this._openPanel();
|
||||||
|
this._filter("");
|
||||||
|
},
|
||||||
|
|
||||||
|
_onSearchInput(ev) {
|
||||||
|
this._openPanel();
|
||||||
|
this._filter(ev.currentTarget.value);
|
||||||
|
},
|
||||||
|
|
||||||
|
_onSearchKeydown(ev) {
|
||||||
|
if (ev.key === "Escape") {
|
||||||
|
this._closePanel();
|
||||||
|
ev.currentTarget.blur();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent the search input's `blur` from firing before `click` resolves
|
||||||
|
* (otherwise the panel would close and swallow the click).
|
||||||
|
*/
|
||||||
|
_onOptionMousedown(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wrapping <label> forwards clicks to the radio input as its
|
||||||
|
* default action. Stop that bubbled click from reaching
|
||||||
|
* SurveyFormWidget._onRadioChoiceClick at the .o_survey_form level —
|
||||||
|
* it would see the o_survey_form_choice_item_selected class we just
|
||||||
|
* added and toggle the radio back off, leaving the question with no
|
||||||
|
* selected answer at submission time.
|
||||||
|
*/
|
||||||
|
_onRadioInputClick(ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
},
|
||||||
|
|
||||||
|
_onOptionClick(ev) {
|
||||||
|
const option = ev.currentTarget;
|
||||||
|
const radio = option.querySelector('input[type="radio"]');
|
||||||
|
if (!radio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manage the radio state ourselves and skip the survey form's
|
||||||
|
// "click-radio-to-untick" handler — picking the same item from a
|
||||||
|
// dropdown should re-affirm the selection, not clear it.
|
||||||
|
const radioName = radio.getAttribute("name");
|
||||||
|
this.el
|
||||||
|
.querySelectorAll(`input[type="radio"][name="${radioName}"]`)
|
||||||
|
.forEach((other) => {
|
||||||
|
other.checked = false;
|
||||||
|
other.classList.remove("o_survey_form_choice_item_selected");
|
||||||
|
});
|
||||||
|
radio.checked = true;
|
||||||
|
radio.classList.add("o_survey_form_choice_item_selected");
|
||||||
|
|
||||||
|
// Trigger `change` so survey_form.js handles "Other" textarea
|
||||||
|
// visibility, conditional questions and auto-submit-on-pick.
|
||||||
|
radio.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
|
||||||
|
this._searchEl.value = option.dataset.labelValue || "";
|
||||||
|
|
||||||
|
this._options.forEach((opt) => {
|
||||||
|
const isSelected = opt === option;
|
||||||
|
opt.classList.toggle("o_survey_selected", isSelected);
|
||||||
|
opt.classList.toggle("bg-light", isSelected);
|
||||||
|
const check = opt.querySelector(".o_survey_dropdown_check");
|
||||||
|
if (check) {
|
||||||
|
check.classList.toggle("invisible", !isSelected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._closePanel();
|
||||||
|
this._searchEl.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
_onDocumentMousedown(ev) {
|
||||||
|
if (!this.el.contains(ev.target)) {
|
||||||
|
this._closePanel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Internal
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_openPanel() {
|
||||||
|
this._panelEl.classList.remove("d-none");
|
||||||
|
},
|
||||||
|
|
||||||
|
_closePanel() {
|
||||||
|
this._panelEl.classList.add("d-none");
|
||||||
|
},
|
||||||
|
|
||||||
|
_filter(query) {
|
||||||
|
const needle = STRIP_DIACRITICS(query.trim());
|
||||||
|
let visibleCount = 0;
|
||||||
|
this._options.forEach((option) => {
|
||||||
|
const haystack = STRIP_DIACRITICS(option.dataset.labelValue || option.textContent);
|
||||||
|
const matches = !needle || haystack.includes(needle);
|
||||||
|
option.classList.toggle("d-none", !matches);
|
||||||
|
if (matches) {
|
||||||
|
visibleCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this._noMatchEl) {
|
||||||
|
this._noMatchEl.classList.toggle("d-none", visibleCount > 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// In `page_per_question` layout, survey_form.js loads the next question's HTML
|
||||||
|
// via AJAX and only re-bootstraps date pickers (see _onNextScreenDone). Extend
|
||||||
|
// it so our dropdown widget is also instantiated on each navigation.
|
||||||
|
if (publicWidget.registry.SurveyFormWidget) {
|
||||||
|
publicWidget.registry.SurveyFormWidget.include({
|
||||||
|
async _onNextScreenDone() {
|
||||||
|
const result = await this._super(...arguments);
|
||||||
|
const $dropdowns = this.$el.find(".o_survey_dropdown_choice");
|
||||||
|
if ($dropdowns.length) {
|
||||||
|
this.trigger_up("widgets_start_request", { $target: $dropdowns });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default publicWidget.registry.SurveyDropdownChoice;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
.o_survey_dropdown_choice {
|
||||||
|
.o_survey_dropdown_search_wrapper {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_survey_dropdown_search {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chevron sits above the input — let clicks pass through so focusing
|
||||||
|
// the input (which opens the panel) works when clicking near the arrow.
|
||||||
|
.o_survey_dropdown_caret {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_survey_dropdown_panel {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1050;
|
||||||
|
max-height: 18rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_survey_dropdown_option {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bs-light, #f8f9fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.o_survey_selected {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_survey_dropdown_check {
|
||||||
|
width: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
survey_dropdown_choice/views/survey_question_views.xml
Normal file
15
survey_dropdown_choice/views/survey_question_views.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="survey_question_form_dropdown" model="ir.ui.view">
|
||||||
|
<field name="name">survey.question.form.dropdown</field>
|
||||||
|
<field name="model">survey.question</field>
|
||||||
|
<field name="inherit_id" ref="survey.survey_question_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='comments_allowed']" position="before">
|
||||||
|
<field name="display_dropdown" invisible="question_type != 'simple_choice'"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
110
survey_dropdown_choice/views/survey_templates.xml
Normal file
110
survey_dropdown_choice/views/survey_templates.xml
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Inherit the standard "simple_choice" template:
|
||||||
|
- when display_dropdown is set on the question AND we are not in
|
||||||
|
read-only/print mode, render our dropdown variant instead
|
||||||
|
- otherwise keep the standard radio-button rendering untouched
|
||||||
|
-->
|
||||||
|
<template id="question_simple_choice_inherit"
|
||||||
|
inherit_id="survey.question_simple_choice"
|
||||||
|
name="Question: simple choice (dropdown variant)">
|
||||||
|
|
||||||
|
<xpath expr="//div[@data-question-type='simple_choice_radio']" position="before">
|
||||||
|
<t t-if="question.display_dropdown and not survey_form_readonly"
|
||||||
|
t-call="survey_dropdown_choice.question_simple_choice_dropdown"/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<xpath expr="//div[@data-question-type='simple_choice_radio']" position="attributes">
|
||||||
|
<attribute name="t-if">not question.display_dropdown or survey_form_readonly</attribute>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Searchable dropdown variant.
|
||||||
|
Keeps `data-question-type="simple_choice_radio"` and a real <input
|
||||||
|
type="radio"> per option so that the existing survey_form.js
|
||||||
|
validation/serialization logic (_validateForm, _prepareSubmitChoices)
|
||||||
|
works without any change.
|
||||||
|
-->
|
||||||
|
<template id="question_simple_choice_dropdown" name="Question: simple choice (dropdown)">
|
||||||
|
<t t-set="answer_line" t-value="answer_lines.filtered(lambda line: line.suggested_answer_id)"/>
|
||||||
|
<t t-set="comment_line" t-value="answer_lines.filtered(lambda line: line.value_char_box)"/>
|
||||||
|
<t t-set="selected_label" t-value="answer_line.suggested_answer_id"/>
|
||||||
|
<div class="o_survey_answer_wrapper o_survey_form_choice o_survey_dropdown_choice position-relative"
|
||||||
|
t-att-data-name="question.id"
|
||||||
|
t-att-data-is-skipped-question="is_skipped_question or None"
|
||||||
|
data-question-type="simple_choice_radio">
|
||||||
|
|
||||||
|
<div class="o_survey_dropdown_search_wrapper position-relative">
|
||||||
|
<input type="text"
|
||||||
|
class="o_survey_dropdown_search form-control pe-5"
|
||||||
|
autocomplete="off"
|
||||||
|
t-att-placeholder="question.question_placeholder or 'Type to filter...'"
|
||||||
|
t-att-value="selected_label.value if selected_label else ''"/>
|
||||||
|
<i class="o_survey_dropdown_caret fa fa-chevron-down position-absolute end-0 top-50 translate-middle-y me-3 text-muted"
|
||||||
|
aria-hidden="true"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_survey_dropdown_panel d-none mt-1 border rounded shadow-sm bg-white">
|
||||||
|
<ul class="o_survey_dropdown_options list-unstyled m-0 p-0">
|
||||||
|
<t t-foreach="question.suggested_answer_ids" t-as="label">
|
||||||
|
<t t-set="answer_selected" t-value="answer_line and answer_line.suggested_answer_id.id == label.id"/>
|
||||||
|
<li t-attf-class="o_survey_dropdown_option px-3 py-2 #{'o_survey_selected bg-light' if answer_selected else ''}"
|
||||||
|
t-att-data-label-value="label.value"
|
||||||
|
t-att-data-answer-id="label.id">
|
||||||
|
<label t-att-for="str(question.id) + '_' + str(label.id)"
|
||||||
|
class="o_survey_choice_btn w-100 m-0 d-flex align-items-center"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<input t-att-id="str(question.id) + '_' + str(label.id)"
|
||||||
|
type="radio"
|
||||||
|
t-att-value="label.id"
|
||||||
|
t-attf-class="o_survey_form_choice_item invisible position-absolute #{'o_survey_form_choice_item_selected' if answer_selected else ''}"
|
||||||
|
t-att-name="question.id"
|
||||||
|
t-att-checked="'checked' if answer_selected else None"/>
|
||||||
|
<i t-attf-class="o_survey_dropdown_check fa fa-check me-2 #{'' if answer_selected else 'invisible'}"
|
||||||
|
aria-hidden="true"/>
|
||||||
|
<span class="text-break" t-field="label.value"/>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
<li class="o_survey_dropdown_no_match d-none px-3 py-2 text-muted fst-italic">
|
||||||
|
No match found.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<t t-if="question.comments_allowed and question.comment_count_as_answer">
|
||||||
|
<li t-attf-class="o_survey_dropdown_option o_survey_dropdown_option_other px-3 py-2 #{'o_survey_selected bg-light' if comment_line else ''}"
|
||||||
|
data-label-value="">
|
||||||
|
<label class="o_survey_choice_btn w-100 m-0 d-flex align-items-center"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<input type="radio"
|
||||||
|
class="o_survey_form_choice_item o_survey_js_form_other_comment invisible position-absolute"
|
||||||
|
value="-1"
|
||||||
|
t-att-name="question.id"
|
||||||
|
t-att-checked="comment_line and 'checked' or None"/>
|
||||||
|
<i t-attf-class="o_survey_dropdown_check fa fa-check me-2 #{'' if comment_line else 'invisible'}"
|
||||||
|
aria-hidden="true"/>
|
||||||
|
<span t-out="question.comments_message or default_comments_message"/>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="question.comments_allowed and question.comment_count_as_answer">
|
||||||
|
<div t-attf-class="o_survey_comment_container mt-3 py-0 px-1 h-auto #{'d-none' if not comment_line else ''}">
|
||||||
|
<textarea type="text" class="form-control o_survey_question_text_box bg-transparent rounded-0 p-0"
|
||||||
|
t-att-disabled="None if comment_line else 'disabled'"><t t-esc="comment_line.value_char_box if comment_line else ''"/></textarea>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<div t-if="question.comments_allowed and not question.comment_count_as_answer"
|
||||||
|
class="mb-2 o_survey_comment_container mt-3">
|
||||||
|
<textarea type="text"
|
||||||
|
class="col form-control o_survey_comment o_survey_question_text_box bg-transparent rounded-0 p-0"
|
||||||
|
t-att-placeholder="question.comments_message or default_comments_message"><t t-esc="comment_line.value_char_box if comment_line else ''"/></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user