From 38a0ea898df071795a98691bc054f7f329de15e8 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Thu, 6 Aug 2026 18:47:58 -0400 Subject: [PATCH] feat(web): let schemas label enum dropdown options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An enum property renders as a dropdown whose option text is derived from the value — underscores replaced, title case applied. That works when the value reads as its own label and fails when it does not: "vs" renders as "Vs", and "abbrev" tells the user nothing about the "Sep 19" it produces. Schemas had no way to say otherwise, so the label was whatever the config key happened to look like. Enum dropdowns now take their option text from x-options.labels when the schema supplies it. This is not a new convention: the checkbox-group widget has read x-options.labels since it was written, with the same humanised fallback. This extends it to plain enums and to array-table columns. Display only — the option value, and so the saved config, is unchanged. The map may be partial; unlabelled values keep the humanised fallback, so every existing schema renders exactly as before. Older cores ignore x-options entirely, which means a plugin can ship labels without requiring users to upgrade first. Array-table columns get the same lookup but keep the raw value as their fallback rather than the humanised one. Those columns hold values such as ticker symbols, where "aapl" -> "Aapl" would be wrong, and they were not being humanised before this change. Verified against the running web service: with labels the hockey plugin's date dropdown reads "Sep 19 / 9/19 / 19 Sep / 19/9 / Fri Sep 19"; with the pre-change template and the same schema it falls back to "Abbrev / Numeric / Day First / ...", confirming the degradation path. --- .github/workflows/test.yml | 1 + docs/widget-guide.md | 39 ++++++++ test/test_enum_option_labels.py | 96 +++++++++++++++++++ .../templates/v3/partials/plugin_config.html | 19 +++- 4 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 test/test_enum_option_labels.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aadebc4d..074aafd8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -85,5 +85,6 @@ jobs: test/test_plugin_update_reservation.py \ test/test_template_targets.py \ test/test_widget_scripts.py \ + test/test_enum_option_labels.py \ test/test_doc_links.py \ test/web_interface/test_cache.py diff --git a/docs/widget-guide.md b/docs/widget-guide.md index 7a1ed850..2b6510a9 100644 --- a/docs/widget-guide.md +++ b/docs/widget-guide.md @@ -206,6 +206,45 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the The widget will be automatically rendered when the plugin configuration form is loaded. +## Labelling Enum Options (`x-options.labels`) + +A plain `enum` renders as a dropdown whose option text is the value with +underscores replaced and title case applied — `day_first` becomes "Day First". +That is fine for values that read as their own label, and wrong for values that +do not: `vs` becomes "Vs", and `abbrev` says nothing about the `Sep 19` it +actually produces. + +Supply `x-options.labels` to set the visible text. This is the same convention +the `checkbox-group` widget uses: + +```json +{ + "date_format": { + "type": "string", + "enum": ["abbrev", "numeric", "day_first"], + "default": "abbrev", + "x-options": { + "labels": { + "abbrev": "Sep 19", + "numeric": "9/19", + "day_first": "19 Sep" + } + } + } +} +``` + +Labels are **display only** — the stored value is still the enum value, so +adding them never changes a saved config. The map may be partial: any value +without a label keeps the humanised fallback. Older cores that predate this +support ignore `x-options` and render the fallback for every option, so a +plugin can ship labels without requiring a core upgrade. + +Array-table columns (`x-widget: array-table`) accept the same +`x-options.labels` on a column definition, but their fallback is the **raw +value** rather than the humanised one, because those columns hold values such +as ticker symbols where `aapl` → "Aapl" would be wrong. + ## Marking Fields as Advanced (`x-advanced`) Add `"x-advanced": true` to any top-level, non-object property to move it out diff --git a/test/test_enum_option_labels.py b/test/test_enum_option_labels.py new file mode 100644 index 00000000..c0a56ce6 --- /dev/null +++ b/test/test_enum_option_labels.py @@ -0,0 +1,96 @@ +"""Guard: enum dropdowns in the plugin config form honour x-options.labels. + +The form humanises a raw enum value into its option text ("day_first" -> +"Day First"), which cannot express every label a schema needs: "vs" reads +as "Vs", and "abbrev" says nothing about the "Sep 19" it produces. Schemas +can supply x-options.labels instead, the same convention the checkbox-group +widget already uses. + +These tests render the real Jinja template fragments, so they fail if the +lookup is dropped or the fallback stops matching the previous behaviour. +""" +from pathlib import Path + +import pytest +from jinja2 import DictLoader, Environment + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +CONFIG_FORM = (PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'partials' + / 'plugin_config.html') + +# The dropdown fragment, lifted from the template so the test exercises the +# real expression rather than a paraphrase of it. +SELECT_FRAGMENT = """ +{%- set enum_labels = (prop.get('x-options') or prop.get('x_options') or {}).get('labels') or {} -%} +{%- for option in prop.enum -%} + +{%- endfor -%} +""" + + +def _render(prop: dict) -> str: + env = Environment(loader=DictLoader({'f': SELECT_FRAGMENT}), autoescape=True) + return env.get_template('f').render(prop=prop) + + +def test_template_looks_up_enum_labels() -> None: + """The shipped template must resolve option text through x-options.labels.""" + source = CONFIG_FORM.read_text(encoding='utf-8') + assert "enum_labels.get(option," in source, ( + 'plugin_config.html no longer resolves enum option text through ' + 'x-options.labels; schemas that supply labels would silently show ' + 'raw values again' + ) + + +def test_labels_are_used_when_supplied() -> None: + prop = { + 'enum': ['vs', 'date_time'], + 'x-options': {'labels': {'vs': 'VS', 'date_time': 'Date and time'}}, + } + html = _render(prop) + assert '>VS<' in html + assert '>Date and time<' in html + + +def test_unlabelled_values_keep_the_humanised_fallback() -> None: + """Schemas without labels must render exactly as they did before.""" + html = _render({'enum': ['day_first', 'weekday']}) + assert '>Day First<' in html + assert '>Weekday<' in html + + +def test_partial_labels_fall_back_per_value() -> None: + """A labels map covering some values leaves the rest humanised.""" + prop = {'enum': ['vs', 'day_first'], 'x-options': {'labels': {'vs': 'VS'}}} + html = _render(prop) + assert '>VS<' in html + assert '>Day First<' in html + + +def test_option_values_are_unchanged_by_labelling() -> None: + """Labels are display-only: the submitted value stays the enum value.""" + prop = {'enum': ['abbrev'], 'x-options': {'labels': {'abbrev': 'Sep 19'}}} + html = _render(prop) + assert 'value="abbrev"' in html + assert '>Sep 19<' in html + + +@pytest.mark.parametrize('key', ['x-options', 'x_options']) +def test_both_option_key_spellings_work(key: str) -> None: + """The template accepts either spelling, as its other widgets do.""" + html = _render({'enum': ['vs'], key: {'labels': {'vs': 'VS'}}}) + assert '>VS<' in html + + +def test_table_column_enum_falls_back_to_the_raw_value() -> None: + """Array-table columns must not title-case values that were never labelled. + + Those columns hold things like ticker symbols, where "aapl" -> "Aapl" + would be wrong, so their fallback stays the raw value. + """ + source = CONFIG_FORM.read_text(encoding='utf-8') + assert 'col_labels.get(opt, opt)' in source, ( + 'array-table column options must fall back to the raw value, not the ' + 'humanised one' + ) diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index a959ba96..41651fb8 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -121,14 +121,21 @@ {% endif %} - {# Enum dropdown #} + {# Enum dropdown. Option text comes from x-options.labels when the + schema supplies it -- the same convention the checkbox-group + widget already uses -- because humanising the raw value cannot + express every label: "vs" reads as "Vs", and "abbrev" says + nothing about the "Sep 19" it produces. Values without a label + fall back to the humanised form, so existing schemas render + exactly as before. #} {% elif prop.enum %} + {% set enum_labels = (prop.get('x-options') or prop.get('x_options') or {}).get('labels') or {} %} @@ -569,10 +576,14 @@ class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center" {% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}> {% elif col_enum %} + {# Labels are opt-in here and the fallback stays the raw + value: table columns hold things like ticker symbols, + which must not be title-cased behind the user's back. #} + {% set col_labels = (col_def.get('x-options') or col_def.get('x_options') or {}).get('labels') or {} %} {% elif col_xwidget == 'date-picker' %}