Skip to content

Commit ee1b056

Browse files
Merge pull request #5211 from springfall2008/feat/apps-tab-filter-box-5210
Add a filter box to the Apps tab of the web console
2 parents acc04b8 + 6c521c4 commit ee1b056

4 files changed

Lines changed: 254 additions & 12 deletions

File tree

‎apps/predbat/tests/test_web_apps_edit.py‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
"""
1515

1616
import asyncio
17+
import html
1718
import json
1819
import os
20+
import re
1921
import shutil
2022
import tempfile
2123

@@ -507,3 +509,146 @@ def run_web_apps_edit_tests(my_predbat):
507509
if failed:
508510
print("**** ERROR: {} apps.yaml editor add/delete test(s) failed ****".format(failed))
509511
return failed
512+
513+
514+
def _parent_row_path(path):
515+
"""Return the path of the row a nested path sits under, or '' when it sits at the top level."""
516+
# A dict key may itself end in ']' (giving "parent.weird]", which has no '[' to split on),
517+
# so index rather than assume - the helper must report a parent, never raise
518+
if path.endswith("]"):
519+
bracket = path.rfind("[")
520+
if bracket != -1:
521+
return path[:bracket]
522+
if "." in path:
523+
return path.rsplit(".", 1)[0]
524+
return ""
525+
526+
527+
def _attribute_values(text, pattern):
528+
"""Return the set of values an attribute pattern matches, as the browser would decode them."""
529+
return set(html.unescape(value) for value in re.findall(pattern, text))
530+
531+
532+
def _render_apps_page(my_predbat, args=None):
533+
"""Render the /apps page against the given args, or the nested fixture's, and return its HTML."""
534+
# Built the way _reset_fixture() does: the full constructor would alias .args to the live
535+
# shared args and build AnnualPage/WebChat before the rebinding below, which rendering
536+
# needs none of - and which would leave this test sensitive to whatever initialize() picks up
537+
web_interface = WebInterface.__new__(WebInterface)
538+
web_interface.base = my_predbat
539+
web_interface.log = my_predbat.log
540+
web_interface.prefix = my_predbat.prefix
541+
if args is None:
542+
yaml = YAML()
543+
yaml.preserve_quotes = True
544+
# Render the fixture's nested structures rather than whatever the live test args hold
545+
args = yaml.load(APPS_YAML_FIXTURE)["pred_bat"]
546+
web_interface.args = args
547+
return asyncio.run(web_interface.html_apps(None)).text
548+
549+
550+
def run_web_apps_filter_tests(my_predbat):
551+
"""Unit tests for the apps.yaml page filter box (issue #5210)."""
552+
failed = 0
553+
print("**** Running apps.yaml page filter tests ****")
554+
555+
text = _render_apps_page(my_predbat)
556+
557+
# -------------------------------------------------------------------------
558+
print("Test: the apps page carries a filter box wired to filterApps()")
559+
for expected in ('id="appsFilter"', 'class="filter-input"', 'oninput="filterApps()"'):
560+
if expected not in text:
561+
print(" ERROR: the apps page should carry {} so the long settings list can be filtered".format(expected))
562+
failed += 1
563+
if "function filterApps(" not in text:
564+
print(" ERROR: the apps page should define filterApps()")
565+
failed += 1
566+
if "document.getElementById('appsFilter').value=''; filterApps();" not in text:
567+
print(" ERROR: the filter box should have a Clear button, as the Config page does")
568+
failed += 1
569+
570+
# -------------------------------------------------------------------------
571+
print("Test: the filter box is styled on the apps page, not only on the config page")
572+
# .filter-container / .filter-input were defined only in get_html_config_css(), which the
573+
# apps page does not load - an unstyled box would be the whole of the bug here
574+
for style in (".filter-container {", ".filter-input {"):
575+
if style not in text:
576+
print(" ERROR: the apps page is missing the {} styling for its filter box".format(style))
577+
failed += 1
578+
579+
# -------------------------------------------------------------------------
580+
print("Test: the filter survives the apps page's own auto-refresh")
581+
if "localStorage.setItem('appsFilterValue'" not in text or "localStorage.getItem('appsFilterValue')" not in text:
582+
print(" ERROR: the filter value should persist in localStorage, as the apps page refreshes every 5 minutes")
583+
failed += 1
584+
if "document.addEventListener('DOMContentLoaded', restoreAppsFilterValue)" not in text:
585+
print(" ERROR: the saved filter should be re-applied once the page has loaded")
586+
failed += 1
587+
588+
# -------------------------------------------------------------------------
589+
print("Test: the rows the filter selects on are the rows the page renders")
590+
if "tr[data-arg-name], tr[data-nested-path]" not in text:
591+
print(" ERROR: filterApps() should select rows by the data-arg-name / data-nested-path attributes the page renders")
592+
failed += 1
593+
594+
arg_rows = _attribute_values(text, r'data-arg-name="([^"]+)"')
595+
nested_rows = _attribute_values(text, r"data-nested-path='([^']+)'")
596+
for expected in ("compare_list", "chat", "forecast_solar", "nested_matrix"):
597+
if expected not in arg_rows:
598+
print(" ERROR: expected a top-level row for {}, got: {}".format(expected, sorted(arg_rows)))
599+
failed += 1
600+
# The deeply nested rows are exactly the ones scrolling makes hardest to find
601+
for expected in ("chat.providers.openrouter.api_key", "forecast_solar[0].declination", "nested_matrix[0][1]"):
602+
if expected not in nested_rows:
603+
print(" ERROR: expected a nested row for {}, got: {}".format(expected, sorted(nested_rows)))
604+
failed += 1
605+
606+
# -------------------------------------------------------------------------
607+
print("Test: every nested row sits under a row of its own prefix")
608+
# filterApps() leaves the subtree of a matching row alone and only walks back up to re-show
609+
# parents, which holds because a nested path always extends the path of the row above it
610+
for path in sorted(nested_rows):
611+
parent = _parent_row_path(path)
612+
if parent and parent not in nested_rows and parent not in arg_rows:
613+
print(" ERROR: nested row {} has no row for its parent {} - filtering on a parent name would hide it".format(path, parent))
614+
failed += 1
615+
if not path.startswith(parent):
616+
print(" ERROR: nested row {} does not extend its parent path {}".format(path, parent))
617+
failed += 1
618+
619+
# -------------------------------------------------------------------------
620+
print("Test: a key holding a quote cannot truncate the attributes the filter reads")
621+
# data-nested-path is delimited with apostrophes and data-arg-name/data-path with quotes, so
622+
# an unescaped key such as "it's" ends its attribute early: the browser then reports a path
623+
# that is not the row's, and filtering on a parent name no longer keeps the row visible
624+
quoted_args = {"chat": {"providers": {"it's": {"api_key": "sk-quoted-credential"}}}, 'say "hi"': 1}
625+
quoted_text = _render_apps_page(my_predbat, quoted_args)
626+
quoted_nested = _attribute_values(quoted_text, r"data-nested-path='([^']+)'")
627+
quoted_top = _attribute_values(quoted_text, r'data-arg-name="([^"]+)"')
628+
quoted_edit_paths = _attribute_values(quoted_text, r'data-path="([^"]+)"')
629+
for expected, found, attribute in (
630+
("chat.providers.it's", quoted_nested, "data-nested-path"),
631+
("chat.providers.it's.api_key", quoted_nested, "data-nested-path"),
632+
('say "hi"', quoted_top, "data-arg-name"),
633+
("chat.providers.it's.api_key", quoted_edit_paths, "data-path"),
634+
):
635+
if expected not in found:
636+
print(" ERROR: {} should carry the whole path {}, got: {}".format(attribute, expected, sorted(found)))
637+
failed += 1
638+
# The truncated attribute the unescaped form produced, named directly so the test still fails
639+
# if the escaping is dropped in favour of something that only looks right after unescaping
640+
if "data-nested-path='chat.providers.it'" in quoted_text:
641+
print(" ERROR: an apostrophe in a key truncated data-nested-path to its prefix")
642+
failed += 1
643+
644+
# -------------------------------------------------------------------------
645+
print("Test: the parent-path helper handles a key that ends in a bracket")
646+
# "parent.weird]" ends with ']' but holds no '[' - the helper must still report a parent
647+
for path, expected_parent in (("parent.weird]", "parent"), ("nested_matrix[0][1]", "nested_matrix[0]"), ("chat", "")):
648+
if _parent_row_path(path) != expected_parent:
649+
print(" ERROR: the parent of {} should be '{}', got '{}'".format(path, expected_parent, _parent_row_path(path)))
650+
failed += 1
651+
652+
if failed:
653+
print("**** ERROR: {} apps.yaml page filter test(s) failed ****".format(failed))
654+
return failed

‎apps/predbat/unit_test.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@
112112
from tests.test_hainterface_websocket import run_hainterface_websocket_tests
113113
from tests.test_history_chunking import run_history_chunking_tests
114114
from tests.test_web_if import run_test_web_if
115-
from tests.test_web_apps_edit import run_web_apps_edit_tests
115+
from tests.test_web_apps_edit import run_web_apps_edit_tests, run_web_apps_filter_tests
116116
from tests.test_web_chart_currency import test_rates_chart_series_names_use_currency_symbol, test_pv_chart_forecast_history_is_uncalibrated
117117
from tests.test_web_debug_history_routes import test_web_debug_history_routes
118118
from tests.test_agent_tools import run_agent_tools_tests
@@ -523,6 +523,7 @@ def main():
523523
("manual_select", run_test_manual_select, "Manual select tests", False),
524524
("web_if", run_test_web_if, "Web interface tests", False),
525525
("web_apps_edit", run_web_apps_edit_tests, "Apps.yaml editor add/delete tests (issue #4714)", False),
526+
("web_apps_filter", run_web_apps_filter_tests, "Apps.yaml page filter box tests (issue #5210)", False),
526527
("web_chart_currency", test_rates_chart_series_names_use_currency_symbol, "Rates chart series names follow currency_symbols tests", False),
527528
("web_chart_pv_forecast", test_pv_chart_forecast_history_is_uncalibrated, "PV chart plots the uncalibrated forecast history tests", False),
528529
("web_debug_history_routes", test_web_debug_history_routes, "Debug-history web routes tests (#4438 review items 4, 6, 21)", False),

‎apps/predbat/web.py‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
get_apps_css,
5151
get_html_config_css,
5252
get_apps_js,
53+
get_apps_filter_js,
54+
get_filter_css,
5355
get_components_css,
5456
get_discovery_css,
5557
get_entity_modal_css,
@@ -2878,6 +2880,10 @@ def render_type(self, arg, value, parent_path="", row_counter=None):
28782880
text += "<table>"
28792881
for idx, item in enumerate(value):
28802882
nested_path = f"{list_path}[{idx}]"
2883+
# An apps.yaml key can hold any character, including the quotes the attributes
2884+
# below are delimited with - escape once here so a key such as "it's" cannot
2885+
# truncate data-nested-path/data-path and hand the browser a wrong path
2886+
nested_path_attr = html_module.escape(nested_path, quote=True)
28812887

28822888
# Check if this list item is editable
28832889
can_edit = self.is_editable_value(item)
@@ -2891,9 +2897,9 @@ def render_type(self, arg, value, parent_path="", row_counter=None):
28912897
if can_edit:
28922898
if isinstance(item, bool):
28932899
toggle_class = "toggle-button active" if item else "toggle-button"
2894-
actions_cell = f'<button class="{toggle_class}" onclick="toggleNestedValue({nested_row_id})" data-value="{str(item).lower()}" data-path="{nested_path}"></button>'
2900+
actions_cell = f'<button class="{toggle_class}" onclick="toggleNestedValue({nested_row_id})" data-value="{str(item).lower()}" data-path="{nested_path_attr}"></button>'
28952901
else:
2896-
actions_cell = f'<button class="edit-button" onclick="editNestedValue({nested_row_id})" data-path="{nested_path}">Edit</button>'
2902+
actions_cell = f'<button class="edit-button" onclick="editNestedValue({nested_row_id})" data-path="{nested_path_attr}">Edit</button>'
28972903

28982904
# Store the nested value info for later processing
28992905
if not hasattr(self, "_nested_values"):
@@ -2906,7 +2912,7 @@ def render_type(self, arg, value, parent_path="", row_counter=None):
29062912
raw_value = self.resolve_value_raw(arg, item)
29072913

29082914
if nested_row_id is not None:
2909-
text += f"<tr id='nested_row_{nested_row_id}' data-nested-path='{nested_path}' data-nested-original='{html_module.escape(str(raw_value))}'><td>- </td><td id='nested_value_{nested_row_id}'>{self.render_type(arg, item, nested_path, row_counter)}</td><td>{actions_cell}</td></tr>\n"
2915+
text += f"<tr id='nested_row_{nested_row_id}' data-nested-path='{nested_path_attr}' data-nested-original='{html_module.escape(str(raw_value))}'><td>- </td><td id='nested_value_{nested_row_id}'>{self.render_type(arg, item, nested_path, row_counter)}</td><td>{actions_cell}</td></tr>\n"
29102916
else:
29112917
text += "<tr><td>- {}</td></tr>\n".format(self.render_type(arg, item, nested_path, row_counter))
29122918
text += self.render_add_row("addListItem", [list_path, arg], "Add item", row_counter)
@@ -2916,6 +2922,7 @@ def render_type(self, arg, value, parent_path="", row_counter=None):
29162922
text += "<table>"
29172923
for key in value:
29182924
nested_path = f"{dict_path}.{key}"
2925+
nested_path_attr = html_module.escape(nested_path, quote=True)
29192926
nested_value = value[key]
29202927

29212928
# Check if this nested value is editable
@@ -2930,9 +2937,9 @@ def render_type(self, arg, value, parent_path="", row_counter=None):
29302937
if can_edit:
29312938
if isinstance(nested_value, bool):
29322939
toggle_class = "toggle-button active" if nested_value else "toggle-button"
2933-
actions_cell = f'<button class="{toggle_class}" onclick="toggleNestedValue({nested_row_id})" data-value="{str(nested_value).lower()}" data-path="{nested_path}"></button>'
2940+
actions_cell = f'<button class="{toggle_class}" onclick="toggleNestedValue({nested_row_id})" data-value="{str(nested_value).lower()}" data-path="{nested_path_attr}"></button>'
29342941
else:
2935-
actions_cell = f'<button class="edit-button" onclick="editNestedValue({nested_row_id})" data-path="{nested_path}">Edit</button>'
2942+
actions_cell = f'<button class="edit-button" onclick="editNestedValue({nested_row_id})" data-path="{nested_path_attr}">Edit</button>'
29362943

29372944
# Store the nested value info for later processing
29382945
if not hasattr(self, "_nested_values"):
@@ -2945,7 +2952,7 @@ def render_type(self, arg, value, parent_path="", row_counter=None):
29452952
raw_value = self.resolve_value_raw(key, nested_value)
29462953

29472954
if nested_row_id is not None:
2948-
text += f"<tr id='nested_row_{nested_row_id}' data-nested-path='{nested_path}' data-nested-original='{html_module.escape(str(raw_value))}'><td><b>{key}: </b></td><td id='nested_value_{nested_row_id}'>{self.render_type(key, nested_value, nested_path, row_counter)}</td><td>{actions_cell}</td></tr>\n"
2955+
text += f"<tr id='nested_row_{nested_row_id}' data-nested-path='{nested_path_attr}' data-nested-original='{html_module.escape(str(raw_value))}'><td><b>{key}: </b></td><td id='nested_value_{nested_row_id}'>{self.render_type(key, nested_value, nested_path, row_counter)}</td><td>{actions_cell}</td></tr>\n"
29492956
else:
29502957
text += "<tr><td><b>{}: </b></td><td colspan='2'>{}</td></tr>\n".format(key, self.render_type(key, nested_value, nested_path, row_counter))
29512958
text += self.render_add_row("addDictKey", [dict_path], "Add setting", row_counter)
@@ -3778,13 +3785,17 @@ async def html_apps(self, request):
37783785
self.log(f"Error serializing all_states for web interface: {e}")
37793786
all_states_json = "{}"
37803787

3781-
# Add CSS styles for edit functionality
3788+
# Add CSS styles for edit functionality and for the filter box
37823789
text += get_apps_css()
3790+
text += get_filter_css()
37833791
text += "<body>\n"
37843792

37853793
# JavaScript for edit functionality
37863794
text += get_apps_js(all_states_json)
37873795

3796+
# JavaScript for the filter box
3797+
text += get_apps_filter_js()
3798+
37883799
# Add message container
37893800
text += '<div id="messageContainer" class="message-container"></div>\n'
37903801

@@ -3797,6 +3808,15 @@ async def html_apps(self, request):
37973808
<button id="saveAllButton" class="save-all-button" onclick="saveAllChanges()" disabled>Save All Changes</button>
37983809
<button id="discardAllButton" class="discard-all-button" onclick="discardAllChanges()" disabled>Discard Changes</button>
37993810
</div>
3811+
"""
3812+
3813+
# Filter box, matching the one on the Config page (issue #5210)
3814+
text += """
3815+
<div class="filter-container">
3816+
<label for="appsFilter"><strong>Filter settings:</strong></label>
3817+
<input type="text" id="appsFilter" class="filter-input" placeholder="Type to filter settings..." oninput="filterApps()" />
3818+
<button type="button" style="margin-left: 10px; padding: 8px 12px;" onclick="document.getElementById('appsFilter').value=''; filterApps();">Clear</button>
3819+
</div>
38003820
"""
38013821

38023822
warning = ""
@@ -3827,14 +3847,17 @@ async def html_apps(self, request):
38273847
value = args[arg]
38283848
raw_value = self.resolve_value_raw(arg, value)
38293849
arg_errors = self.base.arg_errors.get(arg, "")
3850+
# The filter box and the editor both read the row back through data-arg-name, so an
3851+
# apps.yaml key holding a quote must not be able to truncate the attribute
3852+
arg_attr = html_module.escape(str(arg), quote=True)
38303853

38313854
# Determine if this value can be edited
38323855
# Lists should not be editable at the top level - only their individual items
38333856
can_edit = self.is_editable_value(value) and not arg_errors and not isinstance(value, list)
38343857

38353858
if arg_errors:
38363859
text += '<tr id="row_{}" data-arg-name="{}" data-original-value="{}"><td bgcolor=#FF7777><span title="{}">&#9888;{}</span></td><td>{}</td><td></td></tr>\n'.format(
3837-
row_id, arg, html_module.escape(str(raw_value)), arg_errors, arg, self.render_type(arg, value, "", row_counter)
3860+
row_id, arg_attr, html_module.escape(str(raw_value)), arg_errors, arg, self.render_type(arg, value, "", row_counter)
38383861
)
38393862
else:
38403863
actions_cell = ""
@@ -3848,7 +3871,7 @@ async def html_apps(self, request):
38483871
actions_cell = f'<button class="edit-button" onclick="editValue({row_id})">Edit</button>'
38493872

38503873
text += '<tr id="row_{}" data-arg-name="{}" data-original-value="{}"><td>{}</td><td id="value_{}">{}</td><td>{}</td></tr>\n'.format(
3851-
row_id, arg, html_module.escape(str(raw_value)), arg, row_id, self.render_type(arg, value, "", row_counter), actions_cell
3874+
row_id, arg_attr, html_module.escape(str(raw_value)), arg, row_id, self.render_type(arg, value, "", row_counter), actions_cell
38523875
)
38533876
row_id += 1
38543877

@@ -3857,7 +3880,7 @@ async def html_apps(self, request):
38573880
value = args[arg]
38583881
raw_value = self.resolve_value_raw(arg, value)
38593882
text += '<tr id="row_{}" data-arg-name="{}" data-original-value="{}"><td>{}</td><td><span style="background-color:#FFAAAA">{}</span></td><td></td></tr>\n'.format(
3860-
row_id, arg, html_module.escape(str(raw_value)), arg, self.render_type(arg, value, "", row_counter)
3883+
row_id, html_module.escape(str(arg), quote=True), html_module.escape(str(raw_value)), arg, self.render_type(arg, value, "", row_counter)
38613884
)
38623885
row_id += 1
38633886

0 commit comments

Comments
 (0)