Skip to content

Commit eae99ca

Browse files
authored
Merge pull request #2252 from thseiler/feature/2230-autocomplete-for-multiplechoice
UI: generalize autocompletion to work for MultipleChoice fields
2 parents 0c674c1 + bbc25cb commit eae99ca

File tree

15 files changed

+630
-61
lines changed

15 files changed

+630
-61
lines changed

strictdoc/backend/sdoc/models/document.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
SDocSectionIF,
2020
)
2121
from strictdoc.backend.sdoc.models.type_system import (
22+
GrammarElementField,
23+
GrammarElementFieldMultipleChoice,
2224
GrammarElementFieldSingleChoice,
2325
)
2426
from strictdoc.core.document_meta import DocumentMeta
@@ -210,20 +212,29 @@ def enumerate_custom_content_field_titles(
210212
0
211213
].enumerate_custom_content_field_titles()
212214

213-
def get_options_for_singlechoice(
215+
def get_grammar_element_field_for(
214216
self, element_type: str, field_name: str
215-
) -> List[str]:
217+
) -> GrammarElementField:
216218
"""
217-
Returns the list of valid options for a SingleChoice field in this document.
219+
Returns the GrammarElementField for a field of a [element_type] in this document.
218220
"""
219-
assert self.grammar is not None
220-
grammar: DocumentGrammar = self.grammar
221+
grammar: DocumentGrammar = assert_cast(self.grammar, DocumentGrammar)
221222
element: GrammarElement = grammar.elements_by_type[element_type]
223+
field: GrammarElementField = element.fields_map[field_name]
224+
return field
222225

223-
field = element.fields_map[field_name]
224-
225-
choice_grammar_element_field: GrammarElementFieldSingleChoice = (
226-
assert_cast(field, GrammarElementFieldSingleChoice)
226+
def get_options_for_choice(
227+
self, element_type: str, field_name: str
228+
) -> List[str]:
229+
"""
230+
Returns the list of valid options for a Single/MultiChoice field in this document.
231+
"""
232+
field: GrammarElementField = self.get_grammar_element_field_for(
233+
element_type, field_name
227234
)
228235

229-
return choice_grammar_element_field.options
236+
if isinstance(field, GrammarElementFieldSingleChoice) or isinstance(
237+
field, GrammarElementFieldMultipleChoice
238+
):
239+
return field.options
240+
raise AssertionError(f"Must not reach here: {field}")

strictdoc/export/html/_static/controllers/autocompletable_field_controller.js

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
ready: Boolean,
1616
url: String,
1717
minLength: Number,
18-
delay: { type: Number, default: 300 },
18+
delay: { type: Number, default: 10 },
1919
queryParam: { type: String, default: "q" },
20+
multipleChoice: Boolean,
2021
}
2122
static uniqOptionId = 0
2223

@@ -37,6 +38,8 @@
3738

3839
this.onInputChange = debounce(this.onInputChange, this.delayValue)
3940

41+
this.autocompletable.addEventListener("input", this.onInputChange)
42+
4043
autocompletable.addEventListener("keydown", (event) => {
4144
const handler = this[`on${event.key}Keydown`]
4245
if (handler) handler(event)
@@ -60,18 +63,6 @@
6063
}
6164
});
6265

63-
autocompletable.addEventListener("input", (event) => {
64-
const query = autocompletable.innerText.trim()
65-
if (query && query.length >= this.minLengthValue) {
66-
this.fetchResults(query)
67-
} else {
68-
this.hideAndRemoveOptions()
69-
}
70-
71-
const text = filterSingleLine(this.autocompletable.innerText)
72-
this.hidden.value = text
73-
});
74-
7566
this.results.addEventListener("mousedown", this.onResultsMouseDown)
7667
this.results.addEventListener("click", this.onResultsClick)
7768

@@ -177,10 +168,30 @@
177168
}
178169

179170
const textValue = selected.getAttribute("data-autocompletable-label") || selected.textContent.trim()
180-
const value = selected.getAttribute("data-autocompletable-value") || textValue
181-
this.autocompletable.innerText = value
171+
let suggestion = selected.getAttribute("data-autocompletable-value") || textValue
172+
173+
if (this.multipleChoiceValue) {
174+
// Get the current text content
175+
const text = this.autocompletable.innerText || "";
176+
const parts = text.split(",");
177+
178+
// Replace the last incomplete token with the suggestion.
179+
parts[parts.length - 1] = " " + suggestion;
180+
suggestion = parts.map(p => p.trim()).join(", ")
181+
}
182+
183+
this.autocompletable.innerText = suggestion
184+
this.hidden.value = suggestion
185+
186+
// Move the cursor to the end of the input.
187+
this.autocompletable.focus();
188+
const range = document.createRange();
189+
range.selectNodeContents(this.autocompletable);
190+
range.collapse(false);
191+
const sel = window.getSelection();
192+
sel.removeAllRanges();
193+
sel.addRange(range);
182194

183-
this.hidden.value = value
184195
this.hidden.dispatchEvent(new Event("input"))
185196
this.hidden.dispatchEvent(new Event("change"))
186197

@@ -190,7 +201,7 @@
190201
this.element.dispatchEvent(
191202
new CustomEvent("autocompletable.change", {
192203
bubbles: true,
193-
detail: { value: value, textValue: textValue, selected: selected }
204+
detail: { value: suggestion, textValue: textValue, selected: selected }
194205
})
195206
)
196207
}
@@ -213,6 +224,18 @@
213224
}, { once: true })
214225
}
215226

227+
onInputChange = () => {
228+
const query = this.autocompletable.innerText.trim()
229+
if (query && query.length >= this.minLengthValue) {
230+
this.fetchResults(query)
231+
} else {
232+
this.hideAndRemoveOptions()
233+
}
234+
235+
const text = filterSingleLine(this.autocompletable.innerText)
236+
this.hidden.value = text
237+
}
238+
216239
identifyOptions() {
217240
const prefix = this.results.id || "autocompletable"
218241
const optionsWithoutId = this.results.querySelectorAll(`${optionSelector}:not([id])`)

strictdoc/export/html/form_objects/requirement_form_object.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,14 @@ def __init__(
5858
field_name: str,
5959
field_type: RequirementFormFieldType,
6060
field_value: str,
61-
field_is_autocompletable: bool = False,
61+
field_gef_type: str = RequirementFieldType.STRING,
6262
):
6363
assert isinstance(field_value, str)
6464
self.field_mid: str = field_mid
6565
self.field_name: str = field_name
6666
self.field_value: str = field_value
6767
self.field_type = field_type
68-
self.field_is_autocompletable = field_is_autocompletable
68+
self.field_gef_type: str = field_gef_type
6969

7070
def is_singleline(self) -> bool:
7171
return self.field_type == RequirementFormFieldType.SINGLELINE
@@ -74,7 +74,13 @@ def is_multiline(self) -> bool:
7474
return self.field_type == RequirementFormFieldType.MULTILINE
7575

7676
def is_autocompletable(self) -> bool:
77-
return self.field_is_autocompletable
77+
return self.field_gef_type in (
78+
RequirementFieldType.SINGLE_CHOICE,
79+
RequirementFieldType.MULTIPLE_CHOICE,
80+
)
81+
82+
def is_multiplechoice(self) -> bool:
83+
return self.field_gef_type == RequirementFieldType.MULTIPLE_CHOICE
7884

7985
def get_input_field_name(self):
8086
return f"requirement[fields][{self.field_mid}][value]"
@@ -109,9 +115,7 @@ def create_from_grammar_field(
109115
else RequirementFormFieldType.SINGLELINE
110116
),
111117
field_value=value,
112-
field_is_autocompletable=(
113-
grammar_field.gef_type == RequirementFieldType.SINGLE_CHOICE
114-
),
118+
field_gef_type=grammar_field.gef_type,
115119
)
116120
raise NotImplementedError(grammar_field)
117121

@@ -137,9 +141,7 @@ def create_existing_from_grammar_field(
137141
else RequirementFormFieldType.SINGLELINE
138142
),
139143
field_value=field_value,
140-
field_is_autocompletable=(
141-
grammar_field.gef_type == RequirementFieldType.SINGLE_CHOICE
142-
),
144+
field_gef_type=grammar_field.gef_type,
143145
)
144146
raise NotImplementedError(grammar_field)
145147

@@ -150,6 +152,7 @@ def create_mid_field(mid: MID) -> "RequirementFormField":
150152
field_name="MID",
151153
field_type=RequirementFormFieldType.SINGLELINE,
152154
field_value=mid,
155+
field_gef_type=RequirementFieldType.STRING,
153156
)
154157

155158

strictdoc/export/html/templates/components/form/field/autocompletable/index.jinja

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
{%- assert testid_postfix is defined, "testid_postfix is defined" -%}
1313
{%- assert autocomplete_url is defined, "autocomplete_url is defined" -%}
1414
{%- assert autocomplete_len is defined, "autocomplete_len is defined" -%}
15+
{%- assert autocomplete_multiplechoice is defined, "autocomplete_multiplechoice is defined" -%}
1516

1617
{% if field_required is not defined -%}
1718
{% set field_required=false -%}
@@ -49,6 +50,9 @@
4950
{%- if field_class_name is not none -%}
5051
class="{{ field_class_name }}"
5152
{%- endif -%}
53+
{%- if autocomplete_multiplechoice -%}
54+
data-autocompletable-multiple-choice-value="true"
55+
{%- endif-%}
5256
data-testid="form-field-{{ testid_postfix }}"
5357
data-autocompletable-target="input"
5458
data-autocompletable-url-value="{{ autocomplete_url }}"

strictdoc/export/html/templates/components/form/row/row_with_relation.jinja

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
mid = relation_row_context.field.field_mid,
5858
autocomplete_url = "/autocomplete/uid?exclude_requirement_mid="~requirement_mid,
5959
autocomplete_len = "2",
60+
autocomplete_multiplechoice = False,
6061
testid_postfix = "relation-uid"
6162
%}
6263
{%- include "components/form/field/autocompletable/index.jinja" %}

strictdoc/export/html/templates/components/form/row/row_with_text_field.jinja

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@
4747
{%- with
4848
autocomplete_url = "/autocomplete/field?document_mid="~document_mid~"&element_type="~element_type~"&field_name="~field_label,
4949
result_class_name = "requirement__link",
50-
autocomplete_len = "0"
50+
autocomplete_len = "0",
51+
autocomplete_multiplechoice = text_field_row_context.field.is_multiplechoice()
5152
%}
5253
{%- include "components/form/field/autocompletable/index.jinja" %}
5354
{%- endwith -%}

strictdoc/server/routers/main_router.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
SDocNode,
3535
)
3636
from strictdoc.backend.sdoc.models.section import SDocSection
37+
from strictdoc.backend.sdoc.models.type_system import (
38+
GrammarElementField,
39+
RequirementFieldType,
40+
)
3741
from strictdoc.backend.sdoc.writer import SDWriter
3842
from strictdoc.core.actions.export_action import ExportAction
3943
from strictdoc.core.analyzers.document_stats import DocumentTreeStats
@@ -2764,7 +2768,7 @@ def get_autocomplete_field_results(
27642768
field_name: Optional[str] = None,
27652769
):
27662770
"""
2767-
Returns matches of possible SingleChoice values of a field.
2771+
Returns matches of possible values of a SingleChoice or MultiChoice field.
27682772
The field is identified by the document_mid, the element_type, and the field_name.
27692773
"""
27702774
output = ""
@@ -2773,20 +2777,59 @@ def get_autocomplete_field_results(
27732777
and document_mid is not None
27742778
and element_type is not None
27752779
):
2776-
query_words = q.lower().split()
2777-
resulting_values = []
2778-
27792780
document: SDocDocument = (
27802781
export_action.traceability_index.get_node_by_mid(
27812782
MID(document_mid)
27822783
)
27832784
)
27842785
if document:
2785-
all_options = document.get_options_for_singlechoice(
2786+
all_options = document.get_options_for_choice(
27862787
element_type, field_name
27872788
)
2789+
field: GrammarElementField = (
2790+
document.get_grammar_element_field_for(
2791+
element_type, field_name
2792+
)
2793+
)
2794+
2795+
if field.gef_type == RequirementFieldType.MULTIPLE_CHOICE:
2796+
# MultipleChoice: We split the query into its parts:
2797+
#
2798+
# Example User input: "Some Value, Another Value, Yet ano|".
2799+
# parts = ['some value', 'another value', 'yet ano'] # noqa: ERA001
2800+
parts = q.lower().split(",")
2801+
2802+
# For the lookup, we want to use the only the last, still
2803+
# incomplete part, not the full query:
2804+
#
2805+
# last_part = "yet ano" # noqa: ERA001
2806+
# query_words = ['yet', 'ano'] # noqa: ERA001
2807+
last_part = parts[-1].strip()
2808+
query_words = last_part.split()
2809+
2810+
# We also filter the already selected choices from the
2811+
# options we are going to be send to the user,
2812+
# as MultipleChoices is a Set, so options shall be
2813+
# selectable at most once.
2814+
#
2815+
# In the example, we would remove 'some value' and 'another value'.
2816+
already_selected = [
2817+
p.strip() for p in parts[:-1] if p.strip()
2818+
]
2819+
filtered_options = [
2820+
choice
2821+
for choice in all_options
2822+
if choice.lower() not in already_selected
2823+
]
2824+
else:
2825+
# SingleChoice: we use the full query and all available options.
2826+
query_words = q.lower().split()
2827+
filtered_options = all_options
2828+
2829+
resulting_values = []
27882830

2789-
for option_ in all_options:
2831+
# Now filter the remaining options for those that match all words in query_words.
2832+
for option_ in filtered_options:
27902833
words_ = option_.strip().lower()
27912834

27922835
if all(word_ in words_ for word_ in query_words):

0 commit comments

Comments
 (0)