Skip to content

Commit bf97e4f

Browse files
authored
Merge branch 'main' into feature/add-delete-sections
2 parents 60b6483 + f76711a commit bf97e4f

File tree

3 files changed

+194
-9
lines changed

3 files changed

+194
-9
lines changed

src/fourc_webviewer/fourc_webserver.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99

1010
import numpy as np
1111
import pyvista as pv
12+
import yaml
1213
from fourcipp import CONFIG
13-
from fourcipp.fourc_input import FourCInput
14+
from fourcipp.fourc_input import FourCInput, ValidationError
1415
from trame.app import get_server
1516
from trame.decorators import TrameApp, change, controller
1617

@@ -27,7 +28,14 @@
2728
read_fourc_yaml_file,
2829
write_fourc_yaml_file,
2930
)
30-
from fourc_webviewer.python_utils import convert_string2number, find_value_recursively
31+
from fourc_webviewer.python_utils import (
32+
convert_string2number,
33+
dict_leaves_to_number_if_schema,
34+
dict_number_leaves_to_string,
35+
find_value_recursively,
36+
parse_validation_error_text,
37+
smart_string2number_cast,
38+
)
3139
from fourc_webviewer.read_geometry_from_file import (
3240
FourCGeometry,
3341
)
@@ -73,6 +81,9 @@ def __init__(
7381
# create temporary directory
7482
self._server_vars["temp_dir_object"] = tempfile.TemporaryDirectory()
7583

84+
# Register on_field_blur function, which is called when the user leaves a field
85+
self.server.controller.on_leave_edit_field = self.on_leave_edit_field
86+
7687
# initialize state variables for the different modes and
7788
# statuses of the client (e.g. view mode versus edit mode,
7889
# read-in and export status, ...)
@@ -158,6 +169,9 @@ def init_state_and_server_vars(self):
158169
Path(self._server_vars["temp_dir_object"].name)
159170
/ f"new_{self.state.fourc_yaml_file['name']}"
160171
)
172+
# dict to store input errors for the input validation
173+
# imitates structure of self.state.general_sections
174+
self.state.input_error_dict = {}
161175

162176
# get state variables of the general sections
163177
self.init_general_sections_state_and_server_vars()
@@ -924,7 +938,6 @@ def change_fourc_yaml_file(self, fourc_yaml_file, **kwargs):
924938
self._server_vars["fourc_yaml_last_modified"],
925939
self._server_vars["fourc_yaml_read_in_status"],
926940
) = read_fourc_yaml_file(temp_fourc_yaml_file)
927-
928941
self._server_vars["fourc_yaml_name"] = Path(temp_fourc_yaml_file).name
929942

930943
# set vtu file path empty to make the convert button visible
@@ -1043,6 +1056,11 @@ def change_selected_main_section_name(self, selected_main_section_name, **kwargs
10431056
selected_main_section_name
10441057
]["subsections"][0]
10451058

1059+
@change("selected_section_name")
1060+
def change_selected_section_name(self, selected_section_name, **kwargs):
1061+
"""Reaction to change of state.selected_section_name."""
1062+
self.state.selected_subsection_name = selected_section_name.split("/")[-1]
1063+
10461064
@change("selected_material")
10471065
def change_selected_material(self, selected_material, **kwargs):
10481066
"""Reaction to change of state.selected_material."""
@@ -1267,6 +1285,33 @@ def click_save_button(self, **kwargs):
12671285
else:
12681286
self.state.export_status = self.state.all_export_statuses["error"]
12691287

1288+
@change("general_sections")
1289+
def on_sections_change(self, general_sections, **kwargs):
1290+
"""Reaction to change of state.general_sections."""
1291+
1292+
self.sync_server_vars_from_state()
1293+
try:
1294+
fourcinput = FourCInput(self._server_vars["fourc_yaml_content"])
1295+
1296+
dict_leaves_to_number_if_schema(fourcinput._sections)
1297+
1298+
fourcinput.validate()
1299+
self.state.input_error_dict = {}
1300+
except ValidationError as exc:
1301+
self.state.input_error_dict = parse_validation_error_text(
1302+
str(exc.args[0])
1303+
) # exc.args[0] is the error message
1304+
return False
1305+
1306+
def on_leave_edit_field(self):
1307+
"""Reaction to user leaving the field.
1308+
1309+
Currently only supported for the general sections.
1310+
"""
1311+
# also gets called when a new file is loaded
1312+
# basically just sets the state based on server_vars
1313+
self.init_general_sections_state_and_server_vars()
1314+
12701315
""" --- Other helper functions"""
12711316

12721317
def convert_string2num_all_sections(self):

src/fourc_webviewer/gui_utils.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -610,9 +610,9 @@ def _top_row(server):
610610
style="width: 200px;",
611611
)
612612

613-
614-
def _prop_value_table():
613+
def _prop_value_table(server):
615614
"""Table (property - value) layout (for general sections)."""
615+
616616
with vuetify.VTable(
617617
v_if=(
618618
"section_names[selected_main_section_name]['content_mode'] == all_content_modes['general_section']",
@@ -661,14 +661,71 @@ def _prop_value_table():
661661
v_if="edit_mode == all_edit_modes['edit_mode']",
662662
classes="text-center w-50",
663663
):
664+
item_error = "input_error_dict[selected_main_section_name]?.[item_key] || input_error_dict[selected_main_section_name + '~1' + selected_subsection_name]?.[item_key]"
665+
# if item is a string, number or integer -> use VTextField
664666
vuetify.VTextField(
665667
v_model=(
666668
"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
667669
),
670+
v_if=(
671+
"(json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] == 'string' "
672+
"|| json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] == 'number' "
673+
"|| json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] == 'integer')"
674+
"&& !json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['enum']"
675+
),
676+
blur=server.controller.on_leave_edit_field,
668677
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
669-
classes="w-80",
678+
classes="w-80 pb-1",
670679
dense=True,
671-
hide_details=True,
680+
color=f"{item_error} && error",
681+
bg_color=(f"{item_error} ? 'rgba(255, 0, 0, 0.2)' : ''",),
682+
error_messages=(
683+
f"{item_error}?.length > 100 ? {item_error}?.slice(0, 97)+' ...' : {item_error}",
684+
),
685+
)
686+
# if item is a boolean -> use VSwitch
687+
with html.Div(
688+
v_if=(
689+
"json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] === 'boolean'"
690+
),
691+
classes="d-flex align-center justify-center",
692+
):
693+
vuetify.VSwitch(
694+
v_model=(
695+
"general_sections[selected_main_section_name][selected_section_name][item_key]"
696+
),
697+
classes="mt-4",
698+
update_modelValue="flushState('general_sections')",
699+
class_="mx-100",
700+
dense=True,
701+
color="primary",
702+
)
703+
# if item is an enum -> use VAutocomplete
704+
(
705+
vuetify.VAutocomplete(
706+
v_model=(
707+
"general_sections[selected_main_section_name]"
708+
"[selected_section_name][item_key]"
709+
),
710+
v_if=(
711+
"json_schema['properties']?.[selected_section_name]"
712+
"?.['properties']?.[item_key]?.['enum']"
713+
),
714+
update_modelValue="flushState('general_sections')",
715+
# bind the enum array as items
716+
items=(
717+
"json_schema['properties'][selected_section_name]['properties'][item_key]['enum']",
718+
),
719+
dense=True,
720+
solo=True,
721+
filterable=True,
722+
classes="w-80 pb-1",
723+
color=f"{item_error} && error",
724+
bg_color=(f"{item_error} ? 'rgba(255, 0, 0, 0.2)' : ''",),
725+
error_messages=(
726+
f"{item_error}?.length > 100 ? {item_error}?.slice(0, 97)+' ...' : {item_error}",
727+
),
728+
),
672729
)
673730

674731

@@ -1284,7 +1341,7 @@ def create_gui(server, render_window):
12841341
# Further elements with conditional rendering (see above)
12851342
_top_row(server)
12861343
_sections_dropdown()
1287-
_prop_value_table()
1344+
_prop_value_table(server)
12881345
_materials_panel()
12891346
_functions_panel(server)
12901347
_design_conditions_panel()

src/fourc_webviewer/python_utils.py

Lines changed: 84 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,82 @@ 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.fourc_json_schema, schema_path + ["type"]
76+
) in ["number", "integer"]:
77+
return smart_string2number_cast(value)
78+
return value
79+
80+
81+
def dict_number_leaves_to_string(value):
82+
"""Convert all leaves of a dict to numbers if possible."""
83+
if isinstance(value, bool):
84+
return value # isinstance(True, int) is True
85+
if isinstance(value, dict):
86+
for k, v in value.items():
87+
value[k] = dict_number_leaves_to_string(v)
88+
return value
89+
if isinstance(value, int) or isinstance(value, float):
90+
return str(value)
91+
return value
92+
93+
94+
def parse_validation_error_text(text):
95+
"""Parse a ValidationError message string (with multiple "- Parameter in
96+
[...]" blocks) into a nested dict.
97+
98+
Args:
99+
text (str): <fill in your definition>
100+
Returns:
101+
dict: <fill in your definition>
102+
"""
103+
error_dict = {}
104+
# Match "- Parameter in [...]" blocks up until the next one or end of string
105+
block_re = re.compile(
106+
r"- Parameter in (?P<path>(?:\[[^\]]+\])+)\n"
107+
r"(?P<body>.*?)(?=(?:- Parameter in )|\Z)",
108+
re.DOTALL,
109+
)
110+
for m in block_re.finditer(text):
111+
path_str = m.group("path")
112+
body = m.group("body")
113+
114+
# extract the Error: line
115+
err_m = re.search(r"Error:\s*(.+)", body)
116+
if not err_m:
117+
continue
118+
err_msg = err_m.group(1).strip()
119+
120+
keys = re.findall(r'\["([^"]+)"\]', path_str)
121+
122+
# walk/create nested dicts, then assign the message at the leaf
123+
cur = error_dict
124+
for key in keys[:-1]:
125+
cur = cur.setdefault(key, {})
126+
cur[keys[-1]] = err_msg
127+
128+
return error_dict
129+
130+
51131
def smart_string2number_cast(input_string):
52132
"""Casts an input_string to float / int if possible. Helpful when dealing
53133
with automatic to-string conversions from vuetify.VTextField input
@@ -56,8 +136,11 @@ def smart_string2number_cast(input_string):
56136
Args:
57137
input_string (str): input string to be converted.
58138
Returns:
59-
int | float | str: converted value.
139+
int | float | str | object: converted value.
60140
"""
141+
# otherwise boolean values are converted to 0/1
142+
if not isinstance(input_string, str):
143+
return input_string
61144
try:
62145
# first convert to float
63146
input_float = float(input_string)

0 commit comments

Comments
 (0)