diff --git a/CHANGES.rst b/CHANGES.rst index 0af5a44226..23a9a03ac3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ Changelog 2.7.0 (unreleased) ------------------ +- #2898 Add AllowManualEntry for select result options - #2928 Fix ASTM consumer boundary bugs (sender shape, instrument cascade, sample fallback) - #2925 Add 'reattach' workflow transition to re-link detached partitions to their primary - #2926 Strip surrounding whitespace from analysis unit choices so configuration typos don't break the round-trip diff --git a/src/bika/lims/content/abstractbaseanalysis.py b/src/bika/lims/content/abstractbaseanalysis.py index 06ebecf4e6..6c58a7e197 100644 --- a/src/bika/lims/content/abstractbaseanalysis.py +++ b/src/bika/lims/content/abstractbaseanalysis.py @@ -721,14 +721,17 @@ def getViewFor(self, instance, idx, subfield, 'ResultOptions', schemata="Result Options", type='resultsoptions', - subfields=('ResultValue', 'ResultText'), + subfields=('ResultValue', 'ResultText', 'AllowManualEntry'), required_subfields=('ResultValue', 'ResultText'), subfield_labels={'ResultValue': _('Result Value'), - 'ResultText': _('Display Value'), }, + 'ResultText': _('Display Value'), + 'AllowManualEntry': _('Allow Manual Entry'), }, + subfield_types={'AllowManualEntry': 'boolean'}, subfield_validators={'ResultValue': 'result_options_value_validator', 'ResultText': 'result_options_text_validator'}, subfield_sizes={'ResultValue': 5, - 'ResultText': 25,}, + 'ResultText': 25, + 'AllowManualEntry': 8,}, subfield_maxlength={'ResultValue': 5, 'ResultText': 255,}, widget=RecordsWidget( diff --git a/src/senaite/core/browser/modals/analysis/form.py b/src/senaite/core/browser/modals/analysis/form.py index 3a1a2dc939..bedaa0cd48 100644 --- a/src/senaite/core/browser/modals/analysis/form.py +++ b/src/senaite/core/browser/modals/analysis/form.py @@ -180,6 +180,64 @@ def get_result_options(self): ) return options + def is_result_option_manual(self, option): + """Returns whether the option should allow manual text input + """ + if not option.get("AllowManualEntry"): + value = str(option.get("ResultValue", "")).strip().lower() + text = str(option.get("ResultText", "")).strip().lower() + return value == "other" or text == "other" + return option.get("AllowManualEntry") + + def get_result_selected_value(self): + """Returns the selected option value for select-type results + + If the current result is custom free text and a manual-entry option + exists, the first manual-entry option is selected. + """ + result = self.get_result() + if not result: + return "" + + options = self.get_result_options() + manual_value = "" + for option in options: + value = option.get("ResultValue", "") + + if value == result: + return result + + if not manual_value and self.is_result_option_manual(option): + manual_value = value + + return manual_value + + def get_result_other_text(self): + """Returns the manual free-text result for select-type results + """ + result = self.get_result() + if not result: + return "" + options = self.get_result_options() + for option in options: + value = option.get("ResultValue", "") + if value == result: + return "" + return result + + def is_result_selected_option_manual(self): + """Returns whether currently selected option allows manual entry + """ + selected = self.get_result_selected_value() + if not selected: + return False + options = self.get_result_options() + for option in options: + value = option.get("ResultValue", "") + if value == selected: + return self.is_result_option_manual(option) + return False + def get_result_values(self): """Returns the current result as a list of values @@ -474,6 +532,19 @@ def handle_submit(self): form_data = self.request.form + # Normalize select + manual-entry option before persisting: + # if selected option allows manual text, persist the free text. + result_value = form_data.get("Result") + if result_value is not None: + result_value = str(result_value) + options = self.get_result_options() + selected_opt = filter( + lambda o: o.get("ResultValue", "") == result_value, + options + ) + if selected_opt and self.is_result_option_manual(selected_opt[0]): + form_data["Result"] = form_data.get("ResultOther", "") + # Map form field names to AT field names field_map = [ ("DetectionLimitOperand", "DetectionLimitOperand"), diff --git a/src/senaite/core/browser/modals/analysis/static/css/edit_analysis.css b/src/senaite/core/browser/modals/analysis/static/css/edit_analysis.css index e988e03641..f4923afa3f 100644 --- a/src/senaite/core/browser/modals/analysis/static/css/edit_analysis.css +++ b/src/senaite/core/browser/modals/analysis/static/css/edit_analysis.css @@ -39,3 +39,6 @@ border-bottom-right-radius: 0; border-right: 0; } +.edit-analysis-form .result-other-input { + max-width: 100%; +} diff --git a/src/senaite/core/browser/modals/analysis/static/js/edit_analysis.js b/src/senaite/core/browser/modals/analysis/static/js/edit_analysis.js index 1bfe69d89b..eb9333e2d0 100644 --- a/src/senaite/core/browser/modals/analysis/static/js/edit_analysis.js +++ b/src/senaite/core/browser/modals/analysis/static/js/edit_analysis.js @@ -172,6 +172,47 @@ filterInstruments(); } + /** + * Toggle manual result text input for select result options. + * + * The selected option controls the visibility by the + * "data-manual-entry" option attribute. + */ + function bindResultManualEntry() { + var selects = document.querySelectorAll( + "#edit-analysis-result-select" + ); + if (!selects.length) { + return; + } + + selects.forEach(function(select) { + var form = select.closest("form"); + if (!form) { + return; + } + var input = form.querySelector( + "#edit-analysis-result-other" + ); + if (!input) { + return; + } + + function toggle() { + var opt = select.options[select.selectedIndex]; + var show = !!opt + && opt.getAttribute("data-manual-entry") === "1"; + input.style.display = show ? "" : "none"; + if (!show) { + input.value = ""; + } + } + + select.addEventListener("change", toggle); + toggle(); + }); + } + // Initialize multiselect-duplicates document.querySelectorAll(".multiselect-duplicates") .forEach(function(el) { @@ -192,4 +233,7 @@ // Initialize method-instrument filtering bindMethodInstrumentFilter(); + + // Initialize manual-entry toggle for select results + bindResultManualEntry(); })(); diff --git a/src/senaite/core/browser/modals/analysis/templates/edit_analysis.pt b/src/senaite/core/browser/modals/analysis/templates/edit_analysis.pt index 7bf8a0925d..bbbdc49527 100644 --- a/src/senaite/core/browser/modals/analysis/templates/edit_analysis.pt +++ b/src/senaite/core/browser/modals/analysis/templates/edit_analysis.pt @@ -80,16 +80,19 @@ + diff --git a/src/senaite/core/exportimport/setupdata/__init__.py b/src/senaite/core/exportimport/setupdata/__init__.py index 326aba43a0..1f4ee8bcd0 100644 --- a/src/senaite/core/exportimport/setupdata/__init__.py +++ b/src/senaite/core/exportimport/setupdata/__init__.py @@ -1495,7 +1495,8 @@ def load_result_options(self): return sro = service.getResultOptions() sro.append({'ResultValue': row['ResultValue'], - 'ResultText': row['ResultText']}) + 'ResultText': row['ResultText'], + 'AllowManualEntry': row['AllowManualEntry']}) service.setResultOptions(sro) def load_service_uncertainties(self):