Skip to content

Commit b5127b7

Browse files
committed
Add type input and live error validation
1 parent c868786 commit b5127b7

File tree

5 files changed

+183
-8
lines changed

5 files changed

+183
-8
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ Install all requirements with:
4343
pip install -e .
4444
```
4545

46+
If you plan to develop the 4C-Webviewer, it is advisable to also install the pre-commit hook with:
47+
```bash
48+
pre-commit install
49+
```
50+
4651
## Using the 4C-Webviewer
4752

4853
To start the webviewer, in the conda environment run:

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ typing-extensions==4.13.2
213213
# referencing
214214
tzdata==2025.2
215215
# via pandas
216-
urllib3==2.4.0
216+
urllib3==2.5.0
217217
# via requests
218218
virtualenv==20.31.2
219219
# via pre-commit

src/fourc_webviewer/fourc_webserver.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
import numpy as np
1111
import pyvista as pv
12+
import yaml
1213
from fourcipp import CONFIG
14+
from fourcipp.fourc_input import FourCInput, ValidationError
1315
from trame.app import get_server
1416
from trame.decorators import TrameApp, change, controller
1517

@@ -25,7 +27,13 @@
2527
read_fourc_yaml_file,
2628
write_fourc_yaml_file,
2729
)
28-
from fourc_webviewer.python_utils import convert_string2number, find_value_recursively
30+
from fourc_webviewer.python_utils import (
31+
convert_string2number,
32+
dict_leaves_to_number_if_schema,
33+
find_value_recursively,
34+
parse_validation_error_text,
35+
smart_string2number_cast,
36+
)
2937

3038
# always set pyvista to plot off screen with Trame
3139
pv.OFF_SCREEN = True
@@ -68,6 +76,9 @@ def __init__(
6876
# create temporary directory
6977
self._server_vars["temp_dir_object"] = tempfile.TemporaryDirectory()
7078

79+
# Register on_field_blur function, which is called when the user leaves a field
80+
self.server.controller.on_leave_edit_field = self.on_leave_edit_field
81+
7182
# initialize state variables for the different modes and
7283
# statuses of the client (e.g. view mode versus edit mode,
7384
# read-in and export status, ...)
@@ -151,6 +162,9 @@ def init_state_and_server_vars(self):
151162
Path(self._server_vars["temp_dir_object"].name)
152163
/ f"new_{self.state.fourc_yaml_file['name']}"
153164
)
165+
# dict to store input errors for the input validation
166+
# imitates structure of self.state.general_sections
167+
self.state.input_error_dict = {}
154168

155169
# get state variables of the general sections
156170
self.init_general_sections_state_and_server_vars()
@@ -854,7 +868,6 @@ def change_fourc_yaml_file(self, fourc_yaml_file, **kwargs):
854868
self._server_vars["fourc_yaml_last_modified"],
855869
self._server_vars["fourc_yaml_read_in_status"],
856870
) = read_fourc_yaml_file(temp_fourc_yaml_file)
857-
858871
self._server_vars["fourc_yaml_name"] = Path(temp_fourc_yaml_file).name
859872

860873
# set vtu file path empty to make the convert button visible
@@ -1105,6 +1118,33 @@ def click_save_button(self, **kwargs):
11051118
else:
11061119
self.state.export_status = self.state.all_export_statuses["error"]
11071120

1121+
@change("general_sections")
1122+
def on_sections_change(self, general_sections, **kwargs):
1123+
"""Reaction to change of state.general_sections."""
1124+
1125+
self.sync_server_vars_from_state()
1126+
try:
1127+
fourcinput = FourCInput(self._server_vars["fourc_yaml_content"])
1128+
1129+
dict_leaves_to_number_if_schema(fourcinput._sections)
1130+
1131+
fourcinput.validate()
1132+
self.state.input_error_dict = {}
1133+
except ValidationError as exc:
1134+
self.state.input_error_dict = parse_validation_error_text(
1135+
str(exc.args[0])
1136+
) # exc.args[0] is the error message
1137+
return False
1138+
1139+
def on_leave_edit_field(self):
1140+
"""Reaction to user leaving the field.
1141+
1142+
Currently only supported for the general sections.
1143+
"""
1144+
# also gets called when a new file is loaded
1145+
# basically just sets the state based on server_vars
1146+
self.init_general_sections_state_and_server_vars()
1147+
11081148
""" --- Other helper functions"""
11091149

11101150
def convert_string2num_all_sections(self):

src/fourc_webviewer/gui_utils.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,8 +437,9 @@ def _functions_panel(server):
437437
)
438438

439439

440-
def _prop_value_table():
440+
def _prop_value_table(server):
441441
"""Table (property - value) layout (for general sections)."""
442+
442443
with vuetify.VTable(
443444
v_if=(
444445
"section_names[selected_main_section_name]['content_mode'] == all_content_modes['general_section']",
@@ -487,14 +488,73 @@ def _prop_value_table():
487488
v_if="edit_mode == all_edit_modes['edit_mode']",
488489
classes="text-center w-50",
489490
):
491+
item_error = (
492+
"input_error_dict[selected_main_section_name]?.[item_key]"
493+
)
494+
# if item is a string, number or integer -> use VTextField
490495
vuetify.VTextField(
491496
v_model=(
492497
"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
493498
),
499+
v_if=(
500+
"(json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] == 'string' "
501+
"|| json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] == 'number' "
502+
"|| json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] == 'integer')"
503+
"&& !json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['enum']"
504+
),
505+
blur=server.controller.on_leave_edit_field,
494506
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
495-
classes="w-80",
507+
classes="w-80 pb-1",
496508
dense=True,
497-
hide_details=True,
509+
color=f"{item_error} && error",
510+
bg_color=(f"{item_error} ? 'rgba(255, 0, 0, 0.2)' : ''",),
511+
error_messages=(
512+
f"{item_error}?.length > 100 ? {item_error}?.slice(0, 97)+' ...' : {item_error}",
513+
),
514+
)
515+
# if item is a boolean -> use VSwitch
516+
with html.Div(
517+
v_if=(
518+
"json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] === 'boolean'"
519+
),
520+
classes="d-flex align-center justify-center",
521+
):
522+
vuetify.VSwitch(
523+
v_model=(
524+
"general_sections[selected_main_section_name][selected_section_name][item_key]"
525+
),
526+
classes="mt-4",
527+
update_modelValue="flushState('general_sections')",
528+
class_="mx-100",
529+
dense=True,
530+
color="primary",
531+
)
532+
# if item is an enum -> use VAutocomplete
533+
(
534+
vuetify.VAutocomplete(
535+
v_model=(
536+
"general_sections[selected_main_section_name]"
537+
"[selected_section_name][item_key]"
538+
),
539+
v_if=(
540+
"json_schema['properties']?.[selected_section_name]"
541+
"?.['properties']?.[item_key]?.['enum']"
542+
),
543+
update_modelValue="flushState('general_sections')",
544+
# bind the enum array as items
545+
items=(
546+
"json_schema['properties'][selected_section_name]['properties'][item_key]['enum']",
547+
),
548+
dense=True,
549+
solo=True,
550+
filterable=True,
551+
classes="w-80 pb-1",
552+
color=f"{item_error} && error",
553+
bg_color=(f"{item_error} ? 'rgba(255, 0, 0, 0.2)' : ''",),
554+
error_messages=(
555+
f"{item_error}?.length > 100 ? {item_error}?.slice(0, 97)+' ...' : {item_error}",
556+
),
557+
),
498558
)
499559

500560

@@ -1120,7 +1180,7 @@ def create_gui(server, render_window):
11201180

11211181
# Further elements with conditional rendering (see above)
11221182
_sections_dropdown()
1123-
_prop_value_table()
1183+
_prop_value_table(server)
11241184
_materials_panel()
11251185
_functions_panel(server)
11261186
_design_conditions_panel()

src/fourc_webviewer/python_utils.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""Module for python utils."""
22

3+
import re
4+
5+
from fourcipp import CONFIG
6+
37

48
def flatten_list(input_list):
59
"""Flattens a given (multi-level) list into a single list.
@@ -48,6 +52,69 @@ def find_value_recursively(input_dict, target_key):
4852
return None
4953

5054

55+
def get_by_path(dct, path):
56+
"""Retrieve the value at the nested path from dct.
57+
58+
Raises KeyError if any key is missing.
59+
"""
60+
current = dct
61+
for key in path:
62+
current = current[key]
63+
return current
64+
65+
66+
def dict_leaves_to_number_if_schema(value, schema_path=[]):
67+
"""Convert all leaves of a dict to numbers if possible."""
68+
if isinstance(value, dict):
69+
for k, v in value.items():
70+
value[k] = dict_leaves_to_number_if_schema(
71+
v, schema_path + ["properties", k]
72+
)
73+
return value
74+
if isinstance(value, str) and get_by_path(
75+
CONFIG["json_schema"], schema_path + ["type"]
76+
) in ["number", "integer"]:
77+
return smart_string2number_cast(value)
78+
return value
79+
80+
81+
def parse_validation_error_text(text):
82+
"""Parse a ValidationError message string (with multiple "- Parameter in
83+
[...]" blocks) into a nested dict.
84+
85+
Args:
86+
text (str): <fill in your definition>
87+
Returns:
88+
dict: <fill in your definition>
89+
"""
90+
error_dict = {}
91+
# Match "- Parameter in [...]" blocks up until the next one or end of string
92+
block_re = re.compile(
93+
r"- Parameter in (?P<path>(?:\[[^\]]+\])+)\n"
94+
r"(?P<body>.*?)(?=(?:- Parameter in )|\Z)",
95+
re.DOTALL,
96+
)
97+
for m in block_re.finditer(text):
98+
path_str = m.group("path")
99+
body = m.group("body")
100+
101+
# extract the Error: line
102+
err_m = re.search(r"Error:\s*(.+)", body)
103+
if not err_m:
104+
continue
105+
err_msg = err_m.group(1).strip()
106+
107+
keys = re.findall(r'\["([^"]+)"\]', path_str)
108+
109+
# walk/create nested dicts, then assign the message at the leaf
110+
cur = error_dict
111+
for key in keys[:-1]:
112+
cur = cur.setdefault(key, {})
113+
cur[keys[-1]] = err_msg
114+
115+
return error_dict
116+
117+
51118
def smart_string2number_cast(input_string):
52119
"""Casts an input_string to float / int if possible. Helpful when dealing
53120
with automatic to-string conversions from vuetify.VTextField input
@@ -56,8 +123,11 @@ def smart_string2number_cast(input_string):
56123
Args:
57124
input_string (str): input string to be converted.
58125
Returns:
59-
int | float | str: converted value.
126+
int | float | str | object: converted value.
60127
"""
128+
# otherwise boolean values are converted to 0/1
129+
if not isinstance(input_string, str):
130+
return input_string
61131
try:
62132
# first convert to float
63133
input_float = float(input_string)

0 commit comments

Comments
 (0)