diff --git a/pyxform/question.py b/pyxform/question.py index 4421e630..9fa5600e 100644 --- a/pyxform/question.py +++ b/pyxform/question.py @@ -166,6 +166,12 @@ def validate(self): def xml_control(self): raise NotImplementedError() + def _translation_path(self, display_element): + choice_itext_id = self.get("_choice_itext_id") + if choice_itext_id is not None: + return choice_itext_id + return super()._translation_path(display_element=display_element) + class MultipleChoiceQuestion(Question): def __init__(self, **kwargs): diff --git a/pyxform/survey.py b/pyxform/survey.py index 058dc490..301ee74a 100644 --- a/pyxform/survey.py +++ b/pyxform/survey.py @@ -44,6 +44,7 @@ RE_PULLDATA = re.compile(r"(pulldata\s*\(\s*)(.*?),") RE_XML_OUTPUT = re.compile(r"\n.*()\n(\s\s)*") RE_XML_TEXT = re.compile(r"(>)\n\s*(\s[^<>\s].*?)\n\s*(\s None: + """ + For selects using the "search()" appearance, redirect itext for in-line items. + + External selects from a "search" appearance alone don't work in Enketo. In Collect + they must have the "item" elements in the body, rather than in an "itemset". + + The "itemset" reference is cleared below, so that the element will get in-line + items instead of an itemset reference to a secondary instance. The itext ref is + passed to the options/choices so they can use the generated translations. This + accounts for questions with and without a "search()" appearance sharing choices. + + :param element: A select type question. + :return: None, the question/children are modified in-place. + """ + try: + is_search = bool( + SEARCH_APPEARANCE_REGEX.search( + element[constants.CONTROL][constants.APPEARANCE] + ) + ) + except (KeyError, TypeError): + is_search = False + if is_search: + element[constants.ITEMSET] = "" + for i, opt in enumerate(element.get(constants.CHILDREN, [])): + opt["_choice_itext_id"] = f"{element['list_name']}-{i}" + def _setup_translations(self): """ set up the self._translations dict which will be referenced in the @@ -740,8 +770,10 @@ def get_choices(): element._itemset_has_media = itemset in itemsets_has_media element._itemset_dyn_label = itemset in itemsets_has_dyn_label + if element[constants.TYPE] in select_types: + self._redirect_is_search_itext(element=element) # Skip creation of translations for choices in selects. The creation of these - # translations is done futher below in this function. + # translations is done above in this function. parent = element.get("parent") if parent is not None and parent[constants.TYPE] not in select_types: for d in element.get_translations(self.default_language): diff --git a/pyxform/survey_element.py b/pyxform/survey_element.py index 937b26ac..9a11508e 100644 --- a/pyxform/survey_element.py +++ b/pyxform/survey_element.py @@ -272,7 +272,8 @@ def __eq__(self, y): and self.to_json_dict() == y.to_json_dict() ) - def _translation_path(self, display_element): + def _translation_path(self, display_element: str) -> str: + """Get an itextId based on the element XPath and display type.""" return self.get_xpath() + ":" + display_element def get_translations(self, default_language): diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index 6d987b61..02fe8aa2 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -31,7 +31,6 @@ from pyxform.xlsparseutils import find_sheet_misspellings, is_valid_xml_tag SMART_QUOTES = {"\u2018": "'", "\u2019": "'", "\u201c": '"', "\u201d": '"'} -SEARCH_APPEARANCE_REGEX = re.compile(r"search\(.*?\)") def print_pyobj_to_json(pyobj, path=None): @@ -362,19 +361,8 @@ def add_choices_info_to_question( choice_filter = "" if file_extension is None: file_extension = "" - try: - is_search = bool( - SEARCH_APPEARANCE_REGEX.search( - question[constants.CONTROL][constants.APPEARANCE] - ) - ) - except (KeyError, TypeError): - is_search = False - # External selects from a "search" appearance alone don't work in Enketo. In Collect - # they must have the "item" elements in the body, rather than in an "itemset". - if not is_search: - question[constants.ITEMSET] = list_name + question[constants.ITEMSET] = list_name if choice_filter: # External selects e.g. type = "select_one_external city". diff --git a/tests/test_expected_output/search_and_select.xml b/tests/test_expected_output/search_and_select.xml index be822d5e..73c67a2a 100644 --- a/tests/test_expected_output/search_and_select.xml +++ b/tests/test_expected_output/search_and_select.xml @@ -12,6 +12,14 @@ + + + + name_key + + + + diff --git a/tests/test_translations.py b/tests/test_translations.py index 6567960a..bf35983d 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -1553,6 +1553,75 @@ def test_choice_name_containing_dash_output_itext(self): ) +class TestTranslationsSearchAppearance(PyxformTestCase): + """Translations behaviour with the search() appearance.""" + + def test_shared_choice_list(self): + """Should include translation for search() items, sharing the choice list""" + md = """ + | survey | | | | | | + | | type | name | label::en | label::fr | appearance | + | | select_one c1 | q1 | Question 1 | Chose 1 | search('my_file') | + | | select_one c2 | q2 | Question 2 | Chose 2 | | + | choices | | | | | + | | list_name | name | label::en | label::fr | + | | c1 | na | la-e | la-f | + | | c1 | nb | lb-e | lb-f | + | | c2 | na | la-e | la-f | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=True, + xml__xpath_match=[ + "/h:html/h:body/x:select1/x:item[./x:value/text()='na']", + xpc.model_itext_choice_text_label_by_pos("en", "c1", ("la-e", "lb-e")), + xpc.model_itext_choice_text_label_by_pos("fr", "c1", ("la-f", "lb-f")), + xpc.model_itext_choice_text_label_by_pos("en", "c2", ("la-e",)), + xpc.model_itext_choice_text_label_by_pos("fr", "c2", ("la-f",)), + ], + ) + + def test_single_question_single_choice(self): + """Should include translation for search() items, edge case of single elements""" + md = """ + | survey | | | | | | + | | type | name | label::en | label::fr | appearance | + | | select_one c1 | q1 | Question 1 | Chose 1 | search('my_file') | + | choices | | | | | + | | list_name | name | label::en | label::fr | + | | c1 | na | la-e | la-f | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=True, + xml__xpath_match=[ + "/h:html/h:body/x:select1/x:item[./x:value/text()='na']", + xpc.model_itext_choice_text_label_by_pos("en", "c1", ("la-e",)), + xpc.model_itext_choice_text_label_by_pos("fr", "c1", ("la-f",)), + ], + ) + + def test_name_clashes(self): + """Should include translation for search() items, avoids any name clashes.""" + md = """ + | survey | | | | | | + | | type | name | label::en | label::fr | appearance | + | | select_one c1-0 | c1-0 | Question 1 | Chose 1 | search('my_file') | + | choices | | | | | + | | list_name | name | label::en | label::fr | + | | c1-0 | na | la-e | la-f | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=True, + xml__xpath_match=[ + "/h:html/h:body/x:select1/x:item[./x:value/text()='na']", + xpc.model_itext_choice_text_label_by_pos("en", "c1-0", ("la-e",)), + xpc.model_itext_choice_text_label_by_pos("fr", "c1-0", ("la-f",)), + ], + ) + + class TestTranslationsOrOther(PyxformTestCase): """Translations behaviour with or_other."""