Skip to content

Commit 277cec4

Browse files
committed
Merge branch 'feature/input-validation-general' of https://github.com/LaSi5002/4C-webviewer into feature/input-validation-general
2 parents ccd3992 + b5127b7 commit 277cec4

File tree

6 files changed

+232
-20
lines changed

6 files changed

+232
-20
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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ referencing==0.36.2
161161
# fourcipp
162162
# jsonschema
163163
# jsonschema-specifications
164-
requests==2.32.3
164+
requests==2.32.4
165165
# via pooch
166166
rich==14.0.0
167167
# via meshio
@@ -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: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
import numpy as np
1111
import pyvista as pv
12+
import yaml
13+
from fourcipp import CONFIG
14+
from fourcipp.fourc_input import FourCInput, ValidationError
1215
from trame.app import get_server
1316
from trame.decorators import TrameApp, change, controller
1417

@@ -24,7 +27,13 @@
2427
read_fourc_yaml_file,
2528
write_fourc_yaml_file,
2629
)
27-
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+
)
2837

2938
# always set pyvista to plot off screen with Trame
3039
pv.OFF_SCREEN = True
@@ -67,6 +76,9 @@ def __init__(
6776
# create temporary directory
6877
self._server_vars["temp_dir_object"] = tempfile.TemporaryDirectory()
6978

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+
7082
# initialize state variables for the different modes and
7183
# statuses of the client (e.g. view mode versus edit mode,
7284
# read-in and export status, ...)
@@ -150,6 +162,9 @@ def init_state_and_server_vars(self):
150162
Path(self._server_vars["temp_dir_object"].name)
151163
/ f"new_{self.state.fourc_yaml_file['name']}"
152164
)
165+
# dict to store input errors for the input validation
166+
# imitates structure of self.state.general_sections
167+
self.state.input_error_dict = {}
153168

154169
# get state variables of the general sections
155170
self.init_general_sections_state_and_server_vars()
@@ -295,6 +310,8 @@ def init_general_sections_state_and_server_vars(self):
295310
approach to add them up to the main section SOLVERS.
296311
"""
297312

313+
self.state.json_schema = CONFIG["json_schema"]
314+
298315
# define substrings of section names to exclude
299316
substr_to_exclude = ["DESIGN", "TOPOLOGY", "ELEMENTS", "NODE", "FUNCT"]
300317
# define full section names to exclude
@@ -851,7 +868,6 @@ def change_fourc_yaml_file(self, fourc_yaml_file, **kwargs):
851868
self._server_vars["fourc_yaml_last_modified"],
852869
self._server_vars["fourc_yaml_read_in_status"],
853870
) = read_fourc_yaml_file(temp_fourc_yaml_file)
854-
855871
self._server_vars["fourc_yaml_name"] = Path(temp_fourc_yaml_file).name
856872

857873
# set vtu file path empty to make the convert button visible
@@ -1102,6 +1118,33 @@ def click_save_button(self, **kwargs):
11021118
else:
11031119
self.state.export_status = self.state.all_export_statuses["error"]
11041120

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+
11051148
""" --- Other helper functions"""
11061149

11071150
def convert_string2num_all_sections(self):

src/fourc_webviewer/gui_utils.py

Lines changed: 103 additions & 9 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']",
@@ -465,7 +466,19 @@ def _prop_value_table():
465466
),
466467
key="item_key",
467468
):
468-
html.Td(v_text=("item_key",), classes="text-center")
469+
with html.Td(classes="text-center"):
470+
with vuetify.VTooltip(location="bottom"):
471+
with html.Template(v_slot_activator="{ props }"):
472+
html.P(v_text=("item_key",), v_bind="props")
473+
html.P(
474+
v_text=(
475+
"json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['description'] || 'no description'",
476+
),
477+
v_if=(
478+
"json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['description']",
479+
),
480+
style="max-width: 450px;",
481+
)
469482
html.Td(
470483
v_if="edit_mode == all_edit_modes['view_mode']",
471484
v_text=("item_val",),
@@ -475,17 +488,73 @@ def _prop_value_table():
475488
v_if="edit_mode == all_edit_modes['edit_mode']",
476489
classes="text-center w-50",
477490
):
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
478495
vuetify.VTextField(
479496
v_model=(
480497
"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
481498
),
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,
482506
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
483507
classes="w-80 pb-1",
484508
dense=True,
485-
error=True,
486-
color="error",
487-
bg_color="rgba(255, 0, 0, 0.1)",
488-
error_messages=("'This property is not modifiable!2'",),
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+
),
489558
)
490559

491560

@@ -510,7 +579,7 @@ def _materials_panel():
510579
items=("Object.keys(materials_section)",),
511580
)
512581
# show material type
513-
with html.Div(classes="d-flex align-center ga-3 mb-5 pl-5 w-full"):
582+
with html.Div(classes="d-flex align-center ga-3 mb-1 pl-5 w-full"):
514583
html.Span("TYPE: ", classes="text-h6")
515584
# view mode: text
516585
html.Span(
@@ -530,8 +599,20 @@ def _materials_panel():
530599
dense=True,
531600
hide_details=True,
532601
)
602+
html.P(
603+
classes="ga-3 mb-5 pl-5 pr-5 w-full",
604+
v_if=("edit_mode == all_edit_modes['view_mode']",),
605+
v_text=(
606+
"json_schema?.properties?.MATERIALS?.items?.oneOf?"
607+
".find(v => v.properties?.[materials_section[selected_material]?.TYPE])?.properties?"
608+
".[materials_section[selected_material]?.TYPE]?.description || 'Error on material description'",
609+
),
610+
style="color: #999;",
611+
)
612+
533613
# show relationships to other materials (linked materials
534614
# and master material) -> only in view mode
615+
535616
with html.Div(
536617
v_if=("edit_mode == all_edit_modes['view_mode']",),
537618
):
@@ -572,6 +653,7 @@ def _materials_panel():
572653
html.Th(
573654
"Value",
574655
classes="text-center font-weight-bold",
656+
style="width: 50%;",
575657
)
576658
with html.Tbody():
577659
with html.Tr(
@@ -580,7 +662,19 @@ def _materials_panel():
580662
),
581663
classes="text-center",
582664
):
583-
html.Td(v_text=("param_key",))
665+
with html.Td(classes="text-center"):
666+
with vuetify.VTooltip(location="bottom"):
667+
with html.Template(v_slot_activator="{ props }"):
668+
html.P(v_text=("param_key",), v_bind="props")
669+
html.P(
670+
v_text=(
671+
"json_schema?.properties?.MATERIALS?.items?.oneOf?"
672+
".find(v => v.properties?.[materials_section[selected_material]?.TYPE])?"
673+
".properties?.[materials_section[selected_material]?.TYPE]?.properties?"
674+
".[param_key]?.description || 'Error on parameter description'",
675+
),
676+
style="max-width: 450px;",
677+
)
584678
html.Td(
585679
v_text=("param_val",),
586680
)
@@ -1086,7 +1180,7 @@ def create_gui(server, render_window):
10861180

10871181
# Further elements with conditional rendering (see above)
10881182
_sections_dropdown()
1089-
_prop_value_table()
1183+
_prop_value_table(server)
10901184
_materials_panel()
10911185
_functions_panel(server)
10921186
_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)