mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-07 11:48:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a0ea898d |
@@ -85,5 +85,6 @@ jobs:
|
|||||||
test/test_plugin_update_reservation.py \
|
test/test_plugin_update_reservation.py \
|
||||||
test/test_template_targets.py \
|
test/test_template_targets.py \
|
||||||
test/test_widget_scripts.py \
|
test/test_widget_scripts.py \
|
||||||
|
test/test_enum_option_labels.py \
|
||||||
test/test_doc_links.py \
|
test/test_doc_links.py \
|
||||||
test/web_interface/test_cache.py
|
test/web_interface/test_cache.py
|
||||||
|
|||||||
@@ -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.
|
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`)
|
## Marking Fields as Advanced (`x-advanced`)
|
||||||
|
|
||||||
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
||||||
|
|||||||
@@ -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 -%}
|
||||||
|
<option value="{{ option }}">{{ enum_labels.get(option, option|replace('_', ' ')|title) }}</option>
|
||||||
|
{%- 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'
|
||||||
|
)
|
||||||
@@ -121,14 +121,21 @@
|
|||||||
</label>
|
</label>
|
||||||
{% endif %}
|
{% 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 %}
|
{% elif prop.enum %}
|
||||||
|
{% set enum_labels = (prop.get('x-options') or prop.get('x_options') or {}).get('labels') or {} %}
|
||||||
<select id="{{ field_id }}"
|
<select id="{{ field_id }}"
|
||||||
name="{{ full_key }}"
|
name="{{ full_key }}"
|
||||||
class="form-select w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black">
|
class="form-select w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black">
|
||||||
{% for option in prop.enum %}
|
{% for option in prop.enum %}
|
||||||
<option value="{{ option }}" {% if value == option %}selected{% endif %}>
|
<option value="{{ option }}" {% if value == option %}selected{% endif %}>
|
||||||
{{ option|replace('_', ' ')|title }}
|
{{ enum_labels.get(option, option|replace('_', ' ')|title) }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
@@ -569,10 +576,14 @@
|
|||||||
class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
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 %}>
|
{% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}>
|
||||||
{% elif col_enum %}
|
{% 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 {} %}
|
||||||
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
||||||
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
|
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
|
||||||
{% for opt in col_enum %}{% if opt is not none %}
|
{% for opt in col_enum %}{% if opt is not none %}
|
||||||
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ opt }}</option>
|
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ col_labels.get(opt, opt) }}</option>
|
||||||
{% endif %}{% endfor %}
|
{% endif %}{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
{% elif col_xwidget == 'date-picker' %}
|
{% elif col_xwidget == 'date-picker' %}
|
||||||
|
|||||||
Reference in New Issue
Block a user