Skip to content

Commit 98c18d8

Browse files
authored
Merge pull request #2186 from thseiler/feature/autocomplete-for-fields
UI: generalize autocompletion to work for single-choice fields
2 parents 273e0ce + b81b559 commit 98c18d8

File tree

19 files changed

+618
-25
lines changed

19 files changed

+618
-25
lines changed

docs/strictdoc_01_user_guide.sdoc

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1519,7 +1519,7 @@ The supported field types are:
15191519
:widths: 20 80
15201520
:header-rows: 1
15211521

1522-
* - **Field Type**
1522+
* - **Field type**
15231523
- **Description**
15241524

15251525
* - ``String``
@@ -1539,6 +1539,10 @@ The supported field types are:
15391539
* - ``Reference``
15401540
- **DEPRECATED:** comma-separated list with allowed reference types: ``ParentReqReference``, ``FileReference``. In the newer versions of StrictDoc (0.0.45+), a separate ``RELATIONS:`` section is used to configure the available relations.
15411541

1542+
.. note::
1543+
1544+
The **field type** also influences how the field is presented in the web editor UI. A ``SingleChoice`` field enables an auto-completion function, which is useful in cases where the same values are entered repeatedly (such as requirement owners, test case types, or status fields).
1545+
15421546
Example:
15431547

15441548
.. code-block:: text

strictdoc/backend/sdoc/models/document.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66

77
from strictdoc.backend.sdoc.document_reference import DocumentReference
88
from strictdoc.backend.sdoc.models.document_config import DocumentConfig
9-
from strictdoc.backend.sdoc.models.document_grammar import DocumentGrammar
9+
from strictdoc.backend.sdoc.models.document_grammar import (
10+
DocumentGrammar,
11+
GrammarElement,
12+
)
1013
from strictdoc.backend.sdoc.models.document_view import DocumentView
1114
from strictdoc.backend.sdoc.models.model import (
1215
SDocDocumentContentIF,
@@ -15,8 +18,12 @@
1518
SDocNodeIF,
1619
SDocSectionIF,
1720
)
21+
from strictdoc.backend.sdoc.models.type_system import (
22+
GrammarElementFieldSingleChoice,
23+
)
1824
from strictdoc.core.document_meta import DocumentMeta
1925
from strictdoc.helpers.auto_described import auto_described
26+
from strictdoc.helpers.cast import assert_cast
2027
from strictdoc.helpers.mid import MID
2128

2229

@@ -193,3 +200,21 @@ def enumerate_custom_content_field_titles(
193200
yield from self.grammar.elements[
194201
0
195202
].enumerate_custom_content_field_titles()
203+
204+
def get_options_for_singlechoice(
205+
self, element_type: str, field_name: str
206+
) -> List[str]:
207+
"""
208+
Returns the list of valid options for a SingleChoice field in this document.
209+
"""
210+
assert self.grammar is not None
211+
grammar: DocumentGrammar = self.grammar
212+
element: GrammarElement = grammar.elements_by_type[element_type]
213+
214+
field = element.fields_map[field_name]
215+
216+
choice_grammar_element_field: GrammarElementFieldSingleChoice = (
217+
assert_cast(field, GrammarElementFieldSingleChoice)
218+
)
219+
220+
return choice_grammar_element_field.options

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

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
this.autocompletable = autocompletable
2727
this.hidden = autocompletable.nextElementSibling
2828
this.results = this.hidden.nextElementSibling
29+
this.abortController = null;
2930

3031
this.close()
3132

@@ -46,6 +47,19 @@
4647
this.close()
4748
});
4849

50+
autocompletable.addEventListener("click", (event) => {
51+
/* Toggle between showing / hiding results. */
52+
if (this.resultsShown) {
53+
this.hideAndRemoveOptions();
54+
} else {
55+
/* If minLengthValue is 0, we want to get all possible options (i.e. for SingleChoice).
56+
Otherwise, we want narrow-down-as-you-type behavior, and filter on remainig options.
57+
*/
58+
const query = this.minLengthValue == 0 ? "" : this.autocompletable.innerText.trim();
59+
this.fetchResults(query);
60+
}
61+
});
62+
4963
autocompletable.addEventListener("input", (event) => {
5064
const query = autocompletable.innerText.trim()
5165
if (query && query.length >= this.minLengthValue) {
@@ -78,7 +92,6 @@
7892
}
7993

8094
sibling(next) {
81-
console.group(this.options)
8295
const options = this.options
8396
const selected = this.selectedOption
8497
const index = options.indexOf(selected)
@@ -100,6 +113,18 @@
100113
target.scrollIntoView({ behavior: "auto", block: "nearest" })
101114
}
102115

116+
selectText(text) {
117+
const normalizedText = text.trim().toLowerCase();
118+
const match = this.options.find(option => {
119+
const label = option.getAttribute("data-autocompletable-label") || option.textContent;
120+
return label.trim().toLowerCase() === normalizedText;
121+
});
122+
123+
if (match) {
124+
this.select(match);
125+
}
126+
}
127+
103128
onEscapeKeydown = (event) => {
104129
if (!this.resultsShown) return
105130

@@ -109,20 +134,28 @@
109134
}
110135

111136
onArrowDownKeydown = (event) => {
137+
if (!this.resultsShown) return
138+
112139
const item = this.sibling(true)
113140
if (item) this.select(item)
114141
event.preventDefault()
115142
}
116143

117144
onArrowUpKeydown = (event) => {
145+
if (!this.resultsShown) return
146+
118147
const item = this.sibling(false)
119148
if (item) this.select(item)
120149
event.preventDefault()
121150
}
122151

123152
onTabKeydown = (event) => {
124-
const selected = this.selectedOption
125-
if (selected) this.commit(selected)
153+
if (!this.resultsShown) return
154+
155+
/* Either use the selected options, or else select the first result. */
156+
const selected = this.selectedOption || this.sibling(true)
157+
this.commit(selected)
158+
event.preventDefault();
126159
}
127160

128161
onEnterKeydown = (event) => {
@@ -194,14 +227,26 @@
194227
fetchResults = async (query) => {
195228
if (!this.hasUrlValue) return
196229

230+
/* Abort the previous request as we are about to send a new one. */
231+
if (this.abortController) {
232+
this.abortController.abort();
233+
}
234+
this.abortController = new AbortController();
235+
const signal = this.abortController.signal;
236+
197237
const url = this.buildURL(query)
198238
try {
199239
this.element.dispatchEvent(new CustomEvent("loadstart"))
200-
const html = await this.doFetch(url)
240+
const html = await this.doFetch(url, signal)
201241
this.replaceResults(html)
242+
/* Check if an entry matches the current text and select it. */
243+
this.selectText(this.autocompletable.innerText.trim());
202244
this.element.dispatchEvent(new CustomEvent("load"))
203245
this.element.dispatchEvent(new CustomEvent("loadend"))
204246
} catch (error) {
247+
if (error.name === 'AbortError') {
248+
return;
249+
}
205250
this.element.dispatchEvent(new CustomEvent("error"))
206251
this.element.dispatchEvent(new CustomEvent("loadend"))
207252
throw error
@@ -217,8 +262,8 @@
217262
return url.toString()
218263
}
219264

220-
doFetch = async (url) => {
221-
const response = await fetch(url)
265+
doFetch = async (url, signal) => {
266+
const response = await fetch(url, {signal})
222267

223268
if (!response.ok) {
224269
throw new Error(`Server responded with status ${response.status}`)
@@ -301,3 +346,4 @@
301346
}
302347

303348
})();
349+

strictdoc/export/html/_static/form.css

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,6 @@ sdoc-form-row-main {
165165
display: flex;
166166
flex-direction: column;
167167
position: relative;
168-
z-index: 2;
169168
}
170169

171170
sdoc-form-row-aside {
@@ -322,15 +321,12 @@ sdoc-autocompletable[contenteditable="false"] {
322321

323322
.autocomplete-items {
324323
position: absolute;
325-
border: 1px solid #d4d4d4;
324+
border: 1px solid var(--color-action);
326325
background-color: var(--color-bg-contrast);
327-
border-bottom: none;
328-
border-top: none;
329-
z-index: 99;
330326
top: 100%;
331327
left: 0;
332328
right: 0;
333-
width: 40vw;
329+
width: 33vw;
334330
max-height: 60vh;
335331
overflow-y: auto;
336332
z-index: 999;
@@ -433,7 +429,8 @@ sdoc-form-field select::-ms-expand {
433429
color: rgba(242, 100, 42,.2);
434430
}
435431

436-
sdoc-contenteditable[data-field-suffix]:not(:empty)::after {
432+
sdoc-contenteditable[data-field-suffix]:not(:empty)::after,
433+
sdoc-autocompletable[data-field-suffix]:not(:empty)::after {
437434
content: attr(data-field-suffix);
438435
color: var(--color-fg-accent);
439436
margin-left: 4px;
@@ -563,8 +560,10 @@ sdoc-form-error + sdoc-form-field-group {
563560

564561
sdoc-form-field-group[errors]::before, /* Grammar -> label for group of fields */
565562
sdoc-contenteditable[errors]::before, /* inside contenteditable errors block does not affected */
563+
sdoc-autocompletable[errors]::before,
566564
sdoc-form-error + sdoc-form-field > label, /* Grammar -> relation field ; File filed */
567-
sdoc-form-error + sdoc-form-field > sdoc-contenteditable::before {
565+
sdoc-form-error + sdoc-form-field > sdoc-contenteditable::before,
566+
sdoc-form-error + sdoc-form-field > sdoc-autocompletable::before {
568567
color: var(--color-danger);
569568
}
570569

strictdoc/export/html/form_objects/requirement_form_object.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,24 @@ def __init__(
5858
field_name: str,
5959
field_type: RequirementFormFieldType,
6060
field_value: str,
61+
field_is_autocompletable: bool = False,
6162
):
6263
assert isinstance(field_value, str)
6364
self.field_mid: str = field_mid
6465
self.field_name: str = field_name
6566
self.field_value: str = field_value
6667
self.field_type = field_type
68+
self.field_is_autocompletable = field_is_autocompletable
6769

68-
def is_singleline(self):
70+
def is_singleline(self) -> bool:
6971
return self.field_type == RequirementFormFieldType.SINGLELINE
7072

71-
def is_multiline(self):
73+
def is_multiline(self) -> bool:
7274
return self.field_type == RequirementFormFieldType.MULTILINE
7375

76+
def is_autocompletable(self) -> bool:
77+
return self.field_is_autocompletable
78+
7479
def get_input_field_name(self):
7580
return f"requirement[fields][{self.field_mid}][value]"
7681

@@ -104,6 +109,9 @@ def create_from_grammar_field(
104109
else RequirementFormFieldType.SINGLELINE
105110
),
106111
field_value=value,
112+
field_is_autocompletable=(
113+
grammar_field.gef_type == RequirementFieldType.SINGLE_CHOICE
114+
),
107115
)
108116
raise NotImplementedError(grammar_field)
109117

@@ -129,6 +137,9 @@ def create_existing_from_grammar_field(
129137
else RequirementFormFieldType.SINGLELINE
130138
),
131139
field_value=field_value,
140+
field_is_autocompletable=(
141+
grammar_field.gef_type == RequirementFieldType.SINGLE_CHOICE
142+
),
132143
)
133144
raise NotImplementedError(grammar_field)
134145

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{% for value in values %}
2+
<li class="autocompletable-result-item" role="option" data-autocompletable-value="{{ value }}">{{ value }}</li>{% endfor %}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
{%- assert field_placeholder is not none, "field_placeholder is not none" -%}
1010
{%- assert field_value is defined, "field_value is defined" -%}
1111
{%- assert mid is defined, "mid is defined" -%}
12-
{%- assert requirement_mid is defined, "requirement_mid is defined" -%}
1312
{%- assert testid_postfix is defined, "testid_postfix is defined" -%}
13+
{%- assert autocomplete_url is defined, "autocomplete_url is defined" -%}
14+
{%- assert autocomplete_len is defined, "autocomplete_len is defined" -%}
1415

1516
{% if field_required is not defined -%}
1617
{% set field_required=false -%}
@@ -50,7 +51,8 @@
5051
{%- endif -%}
5152
data-testid="form-field-{{ testid_postfix }}"
5253
data-autocompletable-target="input"
53-
data-autocompletable-url-value="/autocomplete/uid?exclude_requirement_mid={{ requirement_mid }}"
54+
data-autocompletable-url-value="{{ autocomplete_url }}"
55+
data-autocompletable-min-length-value="{{ autocomplete_len }}"
5456
>
5557
{{- field_value -}}
5658
</sdoc-autocompletable>

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
{% assert relation_row_context is defined, "row_with_relation: relation_row_context must be defined." %}
2-
32
{% assert relation_row_context.errors is defined, "row_with_relation: errors must be defined." %}
43
{% assert relation_row_context.field is defined, "row_with_relation: field must be defined." %}
54
{% assert relation_row_context.relation_types is defined, "row_with_relation: relation_types must be defined." %}
@@ -56,6 +55,8 @@
5655
field_type = "singleline",
5756
field_value = relation_row_context.field.field_value,
5857
mid = relation_row_context.field.field_mid,
58+
autocomplete_url = "/autocomplete/uid?exclude_requirement_mid="~requirement_mid,
59+
autocomplete_len = "2",
5960
testid_postfix = "relation-uid"
6061
%}
6162
{%- include "components/form/field/autocompletable/index.jinja" %}

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
{%- extends "components/form/row/index.jinja" %}
22

33
{% assert text_field_row_context is defined, "row_with_text: row_context must be defined." %}
4-
54
{% assert text_field_row_context.errors is defined, "row_with_text: errors must be defined." %}
65
{% assert text_field_row_context.field is defined, "row_with_text: field must be defined." %}
76
{% assert text_field_row_context.field_type is defined, "row_with_text: field_type must be defined." %}
@@ -42,7 +41,19 @@
4241
field_value = text_field_row_context.field.field_value,
4342
testid_postfix = text_field_row_context.field.field_name
4443
%}
45-
{%- include "components/form/field/contenteditable/index.jinja" %}
44+
{%- if text_field_row_context.field.is_autocompletable() -%}
45+
{#- Setting autocomplete_len to "0" will cause the sdoc-autocompletable
46+
to act like a drop-down, (i.e. show all options on click) -#}
47+
{%- with
48+
autocomplete_url = "/autocomplete/field?document_mid="~document_mid~"&element_type="~element_type~"&field_name="~field_label,
49+
result_class_name = "requirement__link",
50+
autocomplete_len = "0"
51+
%}
52+
{%- include "components/form/field/autocompletable/index.jinja" %}
53+
{%- endwith -%}
54+
{%- else -%}
55+
{%- include "components/form/field/contenteditable/index.jinja" %}
56+
{%- endif -%}
4657
{%- endwith -%}
4758

4859
<input

strictdoc/export/html/templates/screens/document/document/frame_requirement_form.jinja

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
{# Fields #}
2626
<sdoc-tab-content id="Fields" active>
2727
{% set text_field_row_context = namespace() %}
28+
{% set requirement_mid = form_object.requirement_mid %}
29+
{% set document_mid = form_object.document_mid %}
30+
{% set element_type = form_object.element_type %}
2831

2932
{# Single-line #}
3033
{%- for field_values_ in form_object.enumerate_fields(multiline=False) -%}
@@ -68,7 +71,6 @@
6871
{% set relation_row_context.errors = field_.validation_messages %}
6972
{% set relation_row_context.relation_types = form_object.relation_types %}
7073
{% set relation_row_context.form_object = form_object %}
71-
{% set requirement_mid = form_object.requirement_mid %}
7274
{% include "components/form/row/row_with_relation.jinja" %}
7375
{%- endfor -%}
7476

0 commit comments

Comments
 (0)