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_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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
{% 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 {} %}
|
||||
<select id="{{ field_id }}"
|
||||
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">
|
||||
{% for option in prop.enum %}
|
||||
<option value="{{ option }}" {% if value == option %}selected{% endif %}>
|
||||
{{ option|replace('_', ' ')|title }}
|
||||
{{ enum_labels.get(option, option|replace('_', ' ')|title) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
@@ -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 {} %}
|
||||
<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">
|
||||
{% 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 %}
|
||||
</select>
|
||||
{% elif col_xwidget == 'date-picker' %}
|
||||
|
||||
Reference in New Issue
Block a user