From 15b3173d3bd023795a14860d65c71667ebf30ad4 Mon Sep 17 00:00:00 2001 From: Lasse Date: Mon, 29 Sep 2025 16:18:24 +0200 Subject: [PATCH 1/2] Add Custom Input and Validation --- src/fourc_webviewer/fourc_webserver.py | 51 +++++++++++++++- src/fourc_webviewer/gui_utils.py | 66 ++++++++++++++++++-- src/fourc_webviewer/python_utils.py | 85 +++++++++++++++++++++++++- 3 files changed, 195 insertions(+), 7 deletions(-) diff --git a/src/fourc_webviewer/fourc_webserver.py b/src/fourc_webviewer/fourc_webserver.py index b230a4b..0217d15 100644 --- a/src/fourc_webviewer/fourc_webserver.py +++ b/src/fourc_webviewer/fourc_webserver.py @@ -9,7 +9,9 @@ import numpy as np import pyvista as pv +import yaml from fourcipp import CONFIG +from fourcipp.fourc_input import FourCInput, ValidationError from trame.app import get_server from trame.decorators import TrameApp, change, controller @@ -25,7 +27,14 @@ read_fourc_yaml_file, write_fourc_yaml_file, ) -from fourc_webviewer.python_utils import convert_string2number, find_value_recursively +from fourc_webviewer.python_utils import ( + convert_string2number, + dict_leaves_to_number_if_schema, + dict_number_leaves_to_string, + find_value_recursively, + parse_validation_error_text, + smart_string2number_cast, +) # always set pyvista to plot off screen with Trame pv.OFF_SCREEN = True @@ -68,6 +77,9 @@ def __init__( # create temporary directory self._server_vars["temp_dir_object"] = tempfile.TemporaryDirectory() + # Register on_field_blur function, which is called when the user leaves a field + self.server.controller.on_leave_edit_field = self.on_leave_edit_field + # initialize state variables for the different modes and # statuses of the client (e.g. view mode versus edit mode, # read-in and export status, ...) @@ -151,6 +163,9 @@ def init_state_and_server_vars(self): Path(self._server_vars["temp_dir_object"].name) / f"new_{self.state.fourc_yaml_file['name']}" ) + # dict to store input errors for the input validation + # imitates structure of self.state.general_sections + self.state.input_error_dict = {} # get state variables of the general sections self.init_general_sections_state_and_server_vars() @@ -340,6 +355,7 @@ def init_general_sections_state_and_server_vars(self): self.state.general_sections[main_section_name][section_name] = ( section_data ) + dict_number_leaves_to_string(self.state.general_sections) def sync_general_sections_from_state(self): """Syncs the server-side general sections based on the current values @@ -854,7 +870,6 @@ def change_fourc_yaml_file(self, fourc_yaml_file, **kwargs): self._server_vars["fourc_yaml_last_modified"], self._server_vars["fourc_yaml_read_in_status"], ) = read_fourc_yaml_file(temp_fourc_yaml_file) - self._server_vars["fourc_yaml_name"] = Path(temp_fourc_yaml_file).name # set vtu file path empty to make the convert button visible @@ -882,6 +897,11 @@ def change_selected_main_section_name(self, selected_main_section_name, **kwargs selected_main_section_name ]["subsections"][0] + @change("selected_section_name") + def change_selected_section_name(self, selected_section_name, **kwargs): + """Reaction to change of state.selected_section_name.""" + self.state.selected_subsection_name = selected_section_name.split("/")[-1] + @change("selected_material") def change_selected_material(self, selected_material, **kwargs): """Reaction to change of state.selected_material.""" @@ -1105,6 +1125,33 @@ def click_save_button(self, **kwargs): else: self.state.export_status = self.state.all_export_statuses["error"] + @change("general_sections") + def on_sections_change(self, general_sections, **kwargs): + """Reaction to change of state.general_sections.""" + + self.sync_server_vars_from_state() + try: + fourcinput = FourCInput(self._server_vars["fourc_yaml_content"]) + + dict_leaves_to_number_if_schema(fourcinput._sections) + + fourcinput.validate() + self.state.input_error_dict = {} + except ValidationError as exc: + self.state.input_error_dict = parse_validation_error_text( + str(exc.args[0]) + ) # exc.args[0] is the error message + return False + + def on_leave_edit_field(self): + """Reaction to user leaving the field. + + Currently only supported for the general sections. + """ + # also gets called when a new file is loaded + # basically just sets the state based on server_vars + self.init_general_sections_state_and_server_vars() + """ --- Other helper functions""" def convert_string2num_all_sections(self): diff --git a/src/fourc_webviewer/gui_utils.py b/src/fourc_webviewer/gui_utils.py index b83f201..1e4de33 100644 --- a/src/fourc_webviewer/gui_utils.py +++ b/src/fourc_webviewer/gui_utils.py @@ -437,8 +437,9 @@ def _functions_panel(server): ) -def _prop_value_table(): +def _prop_value_table(server): """Table (property - value) layout (for general sections).""" + with vuetify.VTable( v_if=( "section_names[selected_main_section_name]['content_mode'] == all_content_modes['general_section']", @@ -487,14 +488,71 @@ def _prop_value_table(): v_if="edit_mode == all_edit_modes['edit_mode']", classes="text-center w-50", ): + item_error = "input_error_dict[selected_main_section_name]?.[item_key] || input_error_dict[selected_main_section_name + '~1' + selected_subsection_name]?.[item_key]" + # if item is a string, number or integer -> use VTextField vuetify.VTextField( v_model=( "general_sections[selected_main_section_name][selected_section_name][item_key]", # binding item_val directly does not work, since Object.entries(...) creates copies for the mutable objects ), + v_if=( + "(json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] == 'string' " + "|| json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] == 'number' " + "|| json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] == 'integer')" + "&& !json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['enum']" + ), + blur=server.controller.on_leave_edit_field, update_modelValue="flushState('general_sections')", # this is required in order to flush the state changes correctly to the server, as our passed on v-model is a nested variable - classes="w-80", + classes="w-80 pb-1", dense=True, - hide_details=True, + color=f"{item_error} && error", + bg_color=(f"{item_error} ? 'rgba(255, 0, 0, 0.2)' : ''",), + error_messages=( + f"{item_error}?.length > 100 ? {item_error}?.slice(0, 97)+' ...' : {item_error}", + ), + ) + # if item is a boolean -> use VSwitch + with html.Div( + v_if=( + "json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] === 'boolean'" + ), + classes="d-flex align-center justify-center", + ): + vuetify.VSwitch( + v_model=( + "general_sections[selected_main_section_name][selected_section_name][item_key]" + ), + classes="mt-4", + update_modelValue="flushState('general_sections')", + class_="mx-100", + dense=True, + color="primary", + ) + # if item is an enum -> use VAutocomplete + ( + vuetify.VAutocomplete( + v_model=( + "general_sections[selected_main_section_name]" + "[selected_section_name][item_key]" + ), + v_if=( + "json_schema['properties']?.[selected_section_name]" + "?.['properties']?.[item_key]?.['enum']" + ), + update_modelValue="flushState('general_sections')", + # bind the enum array as items + items=( + "json_schema['properties'][selected_section_name]['properties'][item_key]['enum']", + ), + dense=True, + solo=True, + filterable=True, + classes="w-80 pb-1", + color=f"{item_error} && error", + bg_color=(f"{item_error} ? 'rgba(255, 0, 0, 0.2)' : ''",), + error_messages=( + f"{item_error}?.length > 100 ? {item_error}?.slice(0, 97)+' ...' : {item_error}", + ), + ), ) @@ -1120,7 +1178,7 @@ def create_gui(server, render_window): # Further elements with conditional rendering (see above) _sections_dropdown() - _prop_value_table() + _prop_value_table(server) _materials_panel() _functions_panel(server) _design_conditions_panel() diff --git a/src/fourc_webviewer/python_utils.py b/src/fourc_webviewer/python_utils.py index 3b1482d..da3f387 100644 --- a/src/fourc_webviewer/python_utils.py +++ b/src/fourc_webviewer/python_utils.py @@ -1,5 +1,9 @@ """Module for python utils.""" +import re + +from fourcipp import CONFIG + def flatten_list(input_list): """Flattens a given (multi-level) list into a single list. @@ -48,6 +52,82 @@ def find_value_recursively(input_dict, target_key): return None +def get_by_path(dct, path): + """Retrieve the value at the nested path from dct. + + Raises KeyError if any key is missing. + """ + current = dct + for key in path: + current = current[key] + return current + + +def dict_leaves_to_number_if_schema(value, schema_path=[]): + """Convert all leaves of a dict to numbers if possible.""" + if isinstance(value, dict): + for k, v in value.items(): + value[k] = dict_leaves_to_number_if_schema( + v, schema_path + ["properties", k] + ) + return value + if isinstance(value, str) and get_by_path( + CONFIG.fourc_json_schema, schema_path + ["type"] + ) in ["number", "integer"]: + return smart_string2number_cast(value) + return value + + +def dict_number_leaves_to_string(value): + """Convert all leaves of a dict to numbers if possible.""" + if isinstance(value, bool): + return value # isinstance(True, int) is True + if isinstance(value, dict): + for k, v in value.items(): + value[k] = dict_number_leaves_to_string(v) + return value + if isinstance(value, int) or isinstance(value, float): + return str(value) + return value + + +def parse_validation_error_text(text): + """Parse a ValidationError message string (with multiple "- Parameter in + [...]" blocks) into a nested dict. + + Args: + text (str): + Returns: + dict: + """ + error_dict = {} + # Match "- Parameter in [...]" blocks up until the next one or end of string + block_re = re.compile( + r"- Parameter in (?P(?:\[[^\]]+\])+)\n" + r"(?P.*?)(?=(?:- Parameter in )|\Z)", + re.DOTALL, + ) + for m in block_re.finditer(text): + path_str = m.group("path") + body = m.group("body") + + # extract the Error: line + err_m = re.search(r"Error:\s*(.+)", body) + if not err_m: + continue + err_msg = err_m.group(1).strip() + + keys = re.findall(r'\["([^"]+)"\]', path_str) + + # walk/create nested dicts, then assign the message at the leaf + cur = error_dict + for key in keys[:-1]: + cur = cur.setdefault(key, {}) + cur[keys[-1]] = err_msg + + return error_dict + + def smart_string2number_cast(input_string): """Casts an input_string to float / int if possible. Helpful when dealing with automatic to-string conversions from vuetify.VTextField input @@ -56,8 +136,11 @@ def smart_string2number_cast(input_string): Args: input_string (str): input string to be converted. Returns: - int | float | str: converted value. + int | float | str | object: converted value. """ + # otherwise boolean values are converted to 0/1 + if not isinstance(input_string, str): + return input_string try: # first convert to float input_float = float(input_string) From ca61a6bdc55ab427b1dea06725325021b751f3d4 Mon Sep 17 00:00:00 2001 From: Dragos Ana Date: Sat, 4 Oct 2025 15:23:10 +0200 Subject: [PATCH 2/2] merge fixes --- src/fourc_webviewer/fourc_webserver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fourc_webviewer/fourc_webserver.py b/src/fourc_webviewer/fourc_webserver.py index f9c20cc..f44f273 100644 --- a/src/fourc_webviewer/fourc_webserver.py +++ b/src/fourc_webviewer/fourc_webserver.py @@ -35,6 +35,7 @@ find_value_recursively, parse_validation_error_text, smart_string2number_cast, +) from fourc_webviewer.read_geometry_from_file import ( FourCGeometry, ) @@ -367,7 +368,6 @@ def init_general_sections_state_and_server_vars(self): self.state.general_sections[main_section_name][section_name] = ( section_data ) - dict_number_leaves_to_string(self.state.general_sections) def sync_general_sections_from_state(self): """Syncs the server-side general sections based on the current values