Skip to content

Commit aaece00

Browse files
committed
UI: generalize autocompletion to work for multi-choice fields
1 parent 0c674c1 commit aaece00

File tree

15 files changed

+564
-29
lines changed

15 files changed

+564
-29
lines changed

strictdoc/backend/sdoc/models/document.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@
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
2527
from strictdoc.helpers.auto_described import auto_described
26-
from strictdoc.helpers.cast import assert_cast
2728
from strictdoc.helpers.mid import MID
2829

2930

@@ -210,20 +211,30 @@ def enumerate_custom_content_field_titles(
210211
0
211212
].enumerate_custom_content_field_titles()
212213

213-
def get_options_for_singlechoice(
214+
def get_grammar_element_field_for(
214215
self, element_type: str, field_name: str
215-
) -> List[str]:
216+
) -> GrammarElementField:
216217
"""
217-
Returns the list of valid options for a SingleChoice field in this document.
218+
Returns the GrammarElementField for a field of a [element_type] in this document.
218219
"""
219220
assert self.grammar is not None
220221
grammar: DocumentGrammar = self.grammar
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+
return []

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

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
minLength: Number,
1818
delay: { type: Number, default: 300 },
1919
queryParam: { type: String, default: "q" },
20+
multipleChoice: Boolean,
2021
}
2122
static uniqOptionId = 0
2223

@@ -177,10 +178,30 @@
177178
}
178179

179180
const textValue = selected.getAttribute("data-autocompletable-label") || selected.textContent.trim()
180-
const value = selected.getAttribute("data-autocompletable-value") || textValue
181-
this.autocompletable.innerText = value
181+
let suggestion = selected.getAttribute("data-autocompletable-value") || textValue
182+
183+
if (this.multipleChoiceValue) {
184+
// Get the current text content
185+
const text = this.autocompletable.innerText || "";
186+
const parts = text.split(",");
187+
188+
// Replace the last incomplete token with the suggestion
189+
parts[parts.length - 1] = " " + suggestion;
190+
suggestion = parts.map(p => p.trim()).join(", ")
191+
}
192+
193+
this.autocompletable.innerText = suggestion
194+
this.hidden.value = suggestion
195+
196+
// some shenanigans to move the cursor to the end
197+
this.autocompletable.focus();
198+
const range = document.createRange();
199+
range.selectNodeContents(this.autocompletable);
200+
range.collapse(false);
201+
const sel = window.getSelection();
202+
sel.removeAllRanges();
203+
sel.addRange(range);
182204

183-
this.hidden.value = value
184205
this.hidden.dispatchEvent(new Event("input"))
185206
this.hidden.dispatchEvent(new Event("change"))
186207

@@ -190,7 +211,7 @@
190211
this.element.dispatchEvent(
191212
new CustomEvent("autocompletable.change", {
192213
bubbles: true,
193-
detail: { value: value, textValue: textValue, selected: selected }
214+
detail: { value: suggestion, textValue: textValue, selected: selected }
194215
})
195216
)
196217
}

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: 37 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,47 @@ 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: Split the query into parts
2797+
parts = q.lower().split(",")
2798+
2799+
# only use the last_part for lookup
2800+
last_part = parts[-1].strip()
2801+
query_words = last_part.split()
2802+
2803+
# and pre-filter: don't suggest choices that were already selected / present in the query
2804+
already_selected = [
2805+
p.strip() for p in parts[:-1] if p.strip()
2806+
]
2807+
filtered_options = [
2808+
choice
2809+
for choice in all_options
2810+
if choice.lower() not in already_selected
2811+
]
2812+
else:
2813+
# SingleChoice: use all available options
2814+
query_words = q.lower().split()
2815+
filtered_options = all_options
2816+
2817+
resulting_values = []
27882818

2789-
for option_ in all_options:
2819+
# now filter the remainig options for those that macht all words in query_words
2820+
for option_ in filtered_options:
27902821
words_ = option_.strip().lower()
27912822

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

tests/end2end/helpers/form/form.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,35 @@ def do_use_first_autocomplete_result(
173173
f"'{field_value}'."
174174
)
175175

176+
def do_append_command_and_use_autocomplete_result_again(
177+
self, test_id: str, field_value: str
178+
) -> None:
179+
assert isinstance(test_id, str)
180+
assert isinstance(field_value, str)
181+
182+
field_xpath = f"(//*[@data-testid='{test_id}'])"
183+
element = self.test_case.find_element(field_xpath)
184+
185+
for _ in range(3):
186+
element.send_keys(f",{field_value}")
187+
element.send_keys(Keys.ARROW_DOWN)
188+
element.send_keys(Keys.RETURN)
189+
190+
try:
191+
WebDriverWait(self.test_case.driver, timeout=3).until(
192+
lambda _: field_value.lower()
193+
in element.text.lower().split()[-1]
194+
)
195+
break
196+
except Exception:
197+
pass
198+
199+
else:
200+
raise AssertionError(
201+
f"The text field could not be filled with the value: "
202+
f"'{field_value}'."
203+
)
204+
176205
def do_use_first_autocomplete_result_mid(
177206
self, mid: MID, test_id: str, field_value: str
178207
) -> None:

tests/end2end/helpers/screens/document/form_edit_requirement.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ def do_fill_in_field_and_autocomplete(
108108
f"form-field-{field_name}", field_value
109109
)
110110

111+
def do_fill_in_field_and_autocomplete_again(
112+
self, field_name: str, field_value: str
113+
) -> None:
114+
assert isinstance(field_value, str)
115+
super().do_append_command_and_use_autocomplete_result_again(
116+
f"form-field-{field_name}", field_value
117+
)
118+
111119
def do_select_relation_role(self, mid: MID, field_value: str) -> None:
112120
assert isinstance(mid, MID)
113121
assert isinstance(field_value, str)

0 commit comments

Comments
 (0)