Skip to content

Commit deaf3c9

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

File tree

3 files changed

+194
-8
lines changed

3 files changed

+194
-8
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 & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -611,8 +611,9 @@ def _top_row(server):
611611
)
612612

613613

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

674732

@@ -1284,7 +1342,7 @@ def create_gui(server, render_window):
12841342
# Further elements with conditional rendering (see above)
12851343
_top_row(server)
12861344
_sections_dropdown()
1287-
_prop_value_table()
1345+
_prop_value_table(server)
12881346
_materials_panel()
12891347
_functions_panel(server)
12901348
_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)