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' %}