mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-12 22:28:06 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cad279a7d2 | ||
|
|
fc25a70d75 | ||
|
|
003312f4ff |
@@ -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
|
||||
|
||||
@@ -41,6 +41,7 @@ from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
)
|
||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||
check_empty_claimed,
|
||||
check_scale_up,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
@@ -115,6 +116,11 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||
fill_strict = spec.get("fill_check") == "strict"
|
||||
# A mode that renders nothing without returning False is never skipped by
|
||||
# the display controller, so it holds a blank panel for its whole duration.
|
||||
# Warn-only by default: a scroll mode's first frame is legitimately its
|
||||
# blank scroll-in buffer.
|
||||
empty_strict = spec.get("empty_check") == "strict"
|
||||
|
||||
# Every run: the base config, plus one per harness.json "variant" —
|
||||
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
||||
@@ -142,6 +148,7 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
compare_to_goldens(results, golden_dir)
|
||||
|
||||
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
||||
check_empty_claimed(results, strict=empty_strict)
|
||||
|
||||
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||
if variant_name:
|
||||
@@ -178,6 +185,9 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
# warn-only underfill: big panel left mostly empty
|
||||
ex, ey = r.fill_extent
|
||||
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
||||
if r.empty_claimed and r.empty_ok is None:
|
||||
detail += (f" (empty warn: drew nothing but display() returned"
|
||||
f" {r.display_returned!r}, so the mode is not skipped)")
|
||||
else:
|
||||
everything_ok = False
|
||||
if r.error is not None:
|
||||
@@ -191,6 +201,11 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||
status = "FAIL"
|
||||
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
||||
elif r.empty_ok is False:
|
||||
status = "FAIL"
|
||||
detail = (f" drew nothing but display() returned"
|
||||
f" {r.display_returned!r}; return False so the"
|
||||
f" controller skips the mode")
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
|
||||
@@ -73,6 +73,11 @@ class RenderResult:
|
||||
golden_ok: Optional[bool] = None
|
||||
golden_diff_pixels: int = 0
|
||||
golden_max_delta: int = 0
|
||||
# what display() handed back; the controller skips a mode only on False
|
||||
display_returned: Any = None
|
||||
# empty-frame check: rendered nothing while not reporting "no content"
|
||||
empty_claimed: Optional[bool] = None # True when that happened
|
||||
empty_ok: Optional[bool] = None # False only in strict mode
|
||||
# fill / scale-up check (populated only for sizes >= 2x the design size)
|
||||
fill_checked: bool = False
|
||||
fill_ok: Optional[bool] = None # False only in strict mode
|
||||
@@ -92,6 +97,8 @@ class RenderResult:
|
||||
return False
|
||||
if self.fill_ok is False:
|
||||
return False
|
||||
if self.empty_ok is False:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -132,21 +139,25 @@ def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
||||
return plugin_instance
|
||||
|
||||
|
||||
def _render_mode(plugin_instance: Any, mode: str) -> None:
|
||||
def _render_mode(plugin_instance: Any, mode: str) -> Any:
|
||||
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
||||
drive the plugin's internal mode state machine (first display() call renders
|
||||
modes[current_mode_index] when current_display_mode is None)."""
|
||||
modes[current_mode_index] when current_display_mode is None).
|
||||
|
||||
Returns whatever display() returned. The display controller skips a mode
|
||||
whose display() returns False, so that value decides whether an empty mode
|
||||
is rotated past or sat on -- which makes it worth reporting rather than
|
||||
discarding."""
|
||||
sig = inspect.signature(plugin_instance.display)
|
||||
if "display_mode" in sig.parameters:
|
||||
plugin_instance.display(force_clear=True, display_mode=mode)
|
||||
return
|
||||
return plugin_instance.display(force_clear=True, display_mode=mode)
|
||||
|
||||
modes = getattr(plugin_instance, "modes", None)
|
||||
if modes and mode in modes:
|
||||
plugin_instance.current_mode_index = list(modes).index(mode)
|
||||
if hasattr(plugin_instance, "current_display_mode"):
|
||||
plugin_instance.current_display_mode = None
|
||||
plugin_instance.display(force_clear=False)
|
||||
return plugin_instance.display(force_clear=False)
|
||||
|
||||
|
||||
def _freeze(freeze_time: Optional[str]):
|
||||
@@ -234,7 +245,7 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
||||
plugin_id, mode, e)
|
||||
if result.error is None:
|
||||
_render_mode(inst, mode)
|
||||
result.display_returned = _render_mode(inst, mode)
|
||||
result.image = dm.get_image()
|
||||
result.overflow = dm.check_overflow()
|
||||
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||
@@ -341,6 +352,44 @@ def fill_metrics(image: Image.Image) -> Tuple[float, float, float]:
|
||||
return (extent_x, extent_y, ink)
|
||||
|
||||
|
||||
def check_empty_claimed(results: List[RenderResult],
|
||||
strict: bool = False) -> List[RenderResult]:
|
||||
"""Flag a mode that rendered nothing without reporting "no content".
|
||||
|
||||
The display controller skips a mode whose ``display()`` returns False, and
|
||||
treats anything else -- including None -- as "content was shown". A mode
|
||||
that draws nothing and does not return False therefore holds whatever is on
|
||||
the panel for its whole display duration. Since a mode switch clears first,
|
||||
that is a blank screen. Two sports plugins shipped exactly this: their
|
||||
``display()`` returned None on every path, so an out-of-season league sat
|
||||
blank for its full duration rather than being rotated past.
|
||||
|
||||
Warn-only by default, because a blank frame is not automatically wrong: a
|
||||
scroll mode whose first frame is its blank scroll-in buffer renders empty
|
||||
and is behaving correctly. ``strict=True`` sets ``empty_claimed`` such that
|
||||
``RenderResult.ok`` fails -- opt in per plugin via harness.json
|
||||
``{"empty_check": "strict"}`` once its modes are known to draw on the
|
||||
fixture data.
|
||||
|
||||
Note this can only catch what the fixtures actually render. A plugin whose
|
||||
harness fixture seeds content never exercises its empty path here; the
|
||||
source-level gate in the plugins repo covers that case.
|
||||
"""
|
||||
for r in results:
|
||||
if r.image is None or r.error is not None:
|
||||
continue
|
||||
# An explicit False is the plugin correctly saying "nothing to show".
|
||||
if r.display_returned is False:
|
||||
continue
|
||||
if r.image.convert("L").point(
|
||||
lambda p: 255 if p > _LIT_THRESHOLD else 0).getbbox() is not None:
|
||||
continue
|
||||
r.empty_claimed = True
|
||||
if strict:
|
||||
r.empty_ok = False
|
||||
return results
|
||||
|
||||
|
||||
def check_scale_up(results: List[RenderResult],
|
||||
design_size: Tuple[int, int] = (128, 32),
|
||||
min_extent: float = _MIN_FILL_EXTENT,
|
||||
|
||||
@@ -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 <select> block *out of the shipped template*
|
||||
and render that, so they exercise the production expression rather than a
|
||||
copy of it. If the fallback or the lookup changes, these tests render the
|
||||
changed code and fail — a duplicated fragment here would silently keep
|
||||
passing.
|
||||
"""
|
||||
import re
|
||||
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')
|
||||
ARRAY_TABLE_JS = (PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js'
|
||||
/ 'widgets' / 'array-table.js')
|
||||
|
||||
# The enum branch: from the `{% set enum_labels %}` line through `</select>`.
|
||||
ENUM_BLOCK_RE = re.compile(
|
||||
r"(\{%\s*set enum_labels\s*=.*?</select>)", re.S
|
||||
)
|
||||
|
||||
|
||||
def _shipped_enum_block() -> str:
|
||||
"""Return the live enum <select> block lifted from plugin_config.html."""
|
||||
source = CONFIG_FORM.read_text(encoding='utf-8')
|
||||
match = ENUM_BLOCK_RE.search(source)
|
||||
assert match, (
|
||||
'could not find the enum <select> 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'<option value="([^"]*)"[^>]*>(.*?)</option>', 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'<option value="([^"]+)"[^>]*selected', html)
|
||||
assert selected and selected.group(1) == 'numeric'
|
||||
|
||||
|
||||
@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 _option_labels(html) == {'vs': 'VS'}
|
||||
|
||||
|
||||
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 values such as 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'
|
||||
)
|
||||
|
||||
|
||||
def test_dynamically_added_table_rows_use_the_same_labels() -> None:
|
||||
"""Rows added client-side must label options like the server-rendered ones.
|
||||
|
||||
array-table.js builds new rows in the browser; if it printed the raw value
|
||||
a column would read differently before and after a page reload.
|
||||
"""
|
||||
js = ARRAY_TABLE_JS.read_text(encoding='utf-8')
|
||||
assert 'function enumOptionLabel' in js, (
|
||||
'array-table.js lost its enum label helper'
|
||||
)
|
||||
raw_option_text = re.findall(r'o\.textContent\s*=\s*opt\s*;', js)
|
||||
assert not raw_option_text, (
|
||||
'array-table.js renders an enum option as its raw value; it must go '
|
||||
'through enumOptionLabel() so dynamic rows match server-rendered ones'
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests for the harness empty-frame check (src/plugin_system/testing/harness.py).
|
||||
|
||||
The display controller skips a mode whose display() returns False and treats
|
||||
anything else -- including None -- as "content was shown". A mode that draws
|
||||
nothing without returning False is therefore never skipped, and since a mode
|
||||
switch clears the panel first, it sits on a blank screen for its whole display
|
||||
duration.
|
||||
|
||||
Two sports plugins shipped exactly that: their display() returned None on every
|
||||
path, so an out-of-season league held a blank panel instead of being rotated
|
||||
past. The harness rendered those modes and passed them, because it discarded
|
||||
the return value entirely.
|
||||
"""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.plugin_system.testing.harness import RenderResult, check_empty_claimed
|
||||
|
||||
|
||||
def _blank(w=64, h=32):
|
||||
return Image.new("RGB", (w, h), (0, 0, 0))
|
||||
|
||||
|
||||
def _drawn(w=64, h=32):
|
||||
img = _blank(w, h)
|
||||
img.paste(Image.new("RGB", (10, 10), (255, 255, 255)), (5, 5))
|
||||
return img
|
||||
|
||||
|
||||
def _result(image, returned=None, **kw):
|
||||
return RenderResult("p", 64, 32, "mode", image=image,
|
||||
display_returned=returned, **kw)
|
||||
|
||||
|
||||
class TestCheckEmptyClaimed:
|
||||
def test_blank_frame_returning_none_is_flagged(self):
|
||||
# The shape that shipped: nothing drawn, nothing reported.
|
||||
r = _result(_blank(), returned=None)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is True
|
||||
|
||||
def test_blank_frame_returning_true_is_flagged(self):
|
||||
# Just as broken, and more explicit about it.
|
||||
r = _result(_blank(), returned=True)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is True
|
||||
|
||||
def test_blank_frame_returning_false_is_fine(self):
|
||||
# The plugin correctly said "no content"; the controller will skip it.
|
||||
r = _result(_blank(), returned=False)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is None
|
||||
|
||||
def test_a_drawn_frame_is_fine_whatever_it_returns(self):
|
||||
for returned in (None, True, False):
|
||||
r = _result(_drawn(), returned=returned)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is None, returned
|
||||
|
||||
def test_near_black_still_counts_as_drawn(self):
|
||||
# Guard the threshold: content dim enough to look black to the eye is
|
||||
# still content, and flagging it would train people to ignore this.
|
||||
img = _blank()
|
||||
img.paste(Image.new("RGB", (4, 4), (60, 60, 60)), (2, 2))
|
||||
r = _result(img, returned=None)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is None
|
||||
|
||||
|
||||
class TestWarnVersusStrict:
|
||||
def test_warn_only_by_default(self):
|
||||
# A scroll mode's first frame is legitimately its blank scroll-in
|
||||
# buffer, so this must not fail a run unless opted in.
|
||||
r = _result(_blank(), returned=None)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_ok is None
|
||||
assert r.ok is True
|
||||
|
||||
def test_strict_fails_the_result(self):
|
||||
r = _result(_blank(), returned=None)
|
||||
check_empty_claimed([r], strict=True)
|
||||
assert r.empty_ok is False
|
||||
assert r.ok is False
|
||||
|
||||
def test_strict_still_allows_an_honest_false(self):
|
||||
r = _result(_blank(), returned=False)
|
||||
check_empty_claimed([r], strict=True)
|
||||
assert r.empty_ok is None
|
||||
assert r.ok is True
|
||||
|
||||
|
||||
class TestSkippedResults:
|
||||
def test_a_crashed_render_is_left_alone(self):
|
||||
# error already fails the result; adding a second reason just muddies
|
||||
# the report.
|
||||
r = _result(None, returned=None, error="boom")
|
||||
check_empty_claimed([r], strict=True)
|
||||
assert r.empty_claimed is None
|
||||
|
||||
def test_a_result_with_no_image_is_left_alone(self):
|
||||
r = _result(None, returned=None)
|
||||
check_empty_claimed([r], strict=True)
|
||||
assert r.empty_claimed is None
|
||||
@@ -160,6 +160,24 @@
|
||||
|
||||
// ─── Cell rendering ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Visible text for one enum option.
|
||||
*
|
||||
* Mirrors the server-rendered table in plugin_config.html: a schema may
|
||||
* supply x-options.labels, and anything unlabelled falls back to the raw
|
||||
* value. Rows added here must match rows rendered by the template, or the
|
||||
* same column would read differently before and after a page reload.
|
||||
*
|
||||
* @param {Object} colDef column (or property) schema
|
||||
* @param {*} opt the enum value
|
||||
* @returns {string} label to display
|
||||
*/
|
||||
function enumOptionLabel(colDef, opt) {
|
||||
const options = (colDef && (colDef['x-options'] || colDef['x_options'])) || {};
|
||||
const labels = options.labels || {};
|
||||
return Object.prototype.hasOwnProperty.call(labels, opt) ? labels[opt] : opt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one <td> for a display column.
|
||||
*/
|
||||
@@ -219,7 +237,7 @@
|
||||
if (opt === null) return;
|
||||
const o = document.createElement('option');
|
||||
o.value = opt;
|
||||
o.textContent = opt;
|
||||
o.textContent = enumOptionLabel(colDef, opt);
|
||||
if (String(colValue) === String(opt)) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
@@ -646,7 +664,7 @@
|
||||
enumVals.forEach(opt => {
|
||||
if (opt === null) return;
|
||||
const o = document.createElement('option');
|
||||
o.value = opt; o.textContent = opt;
|
||||
o.value = opt; o.textContent = enumOptionLabel(schema, opt);
|
||||
if (String(currentVal) === String(opt)) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
|
||||
@@ -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