diff --git a/docs/widget-guide.md b/docs/widget-guide.md index 7a1ed850..9bc271eb 100644 --- a/docs/widget-guide.md +++ b/docs/widget-guide.md @@ -206,6 +206,47 @@ 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. Rows added in the +browser use the labels too (`array-table.js`), so a column reads the same +before and after a page reload. + ## 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..1e8d248e --- /dev/null +++ b/test/test_enum_option_labels.py @@ -0,0 +1,134 @@ +"""Guard: enum dropdowns in the plugin config form honour x-options.labels. + +The form derives an option's visible text from its value — underscores +replaced, title case applied ("day_first" -> "Day First"). That 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 extract the enum `. +ENUM_BLOCK_RE = re.compile( + r"(\{%\s*set enum_labels\s*=.*?)", re.S +) + + +def _shipped_enum_block() -> str: + """Return the live enum block in plugin_config.html — the ' + 'template changed shape and this guard needs updating' + ) + return match.group(1) + + +def _render(prop: dict, value=None) -> str: + """Render the shipped enum block with a minimal fixture.""" + env = Environment(loader=DictLoader({'f': _shipped_enum_block()}), + autoescape=True) + return env.get_template('f').render( + prop=prop, value=value, field_id='fid', full_key='k' + ) + + +def _option_labels(html: str) -> dict: + """Map each rendered option's value to its visible text.""" + return { + value: text.strip() + for value, text in re.findall( + r'', html, re.S + ) + } + + +def test_labels_are_used_when_supplied() -> None: + html = _render({ + 'enum': ['vs', 'date_time'], + 'x-options': {'labels': {'vs': 'VS', 'date_time': 'Date and time'}}, + }) + assert _option_labels(html) == {'vs': 'VS', 'date_time': 'Date and time'} + + +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 _option_labels(html) == {'day_first': 'Day First', 'weekday': 'Weekday'} + + +def test_partial_labels_fall_back_per_value() -> None: + """A labels map covering some values leaves the rest humanised.""" + html = _render({'enum': ['vs', 'day_first'], + 'x-options': {'labels': {'vs': 'VS'}}}) + assert _option_labels(html) == {'vs': 'VS', 'day_first': 'Day First'} + + +def test_option_values_are_unchanged_by_labelling() -> None: + """Labels are display-only: the submitted value stays the enum value.""" + html = _render({'enum': ['abbrev'], + 'x-options': {'labels': {'abbrev': 'Sep 19'}}}) + assert _option_labels(html) == {'abbrev': 'Sep 19'} + + +def test_selected_option_still_tracks_the_current_value() -> None: + """Labelling must not disturb which option is marked selected.""" + html = _render({'enum': ['abbrev', 'numeric'], + 'x-options': {'labels': {'abbrev': 'Sep 19'}}}, + value='numeric') + selected = re.search(r'