feat(ui): status_badge, status_dot, stat_tile, empty_state, page_header, icon_button and form classes (#5094)
#5094 "Status and layout macros, and form classes". - formatting.STATES: one state -> (tone, label) map for up, down, stale, firing, pending, acknowledged, no_data, ok, warn, crit, resolved and inactive; status_info() reads it (an unmapped state is drawn dim under its own name). required() lets a macro refuse a missing argument. - _macros.html: status_badge and status_dot (a dot always carries its label, sr-only unless shown), stat_tile, empty_state, page_header (actions via a call block) and icon_button, which raises without a label. - steward.css: tone classes (badge-/dot-/stat- ok, warn, crit, info, dim), .sr-only, .page-header, .stat-tile, .empty-state, .btn-icon and the form classes .field, .field-label, .field-hint and .input. Info text is a 40% mix (--blue): the house --fs-info-fg falls to 4.18:1 on its tint over a hovered row, 40% holds 5.09:1. - First users: the Alerts list, history and maintenance pages (headers, state badges, empty states) and the alert forms' hints; the inline inp/lbl style sets on the monitor and host forms become .input / .field-label. - tests/core/test_macros.py renders each macro through the app environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
"""The status and layout macros in _macros.html (#5094), rendered through the
|
||||
app's own Jinja environment so its globals (status_info, required) are the
|
||||
real ones."""
|
||||
import enum
|
||||
|
||||
import pytest
|
||||
|
||||
from steward.core.formatting import STATES, status_info
|
||||
|
||||
|
||||
async def _render(app, source: str, **ctx) -> str:
|
||||
async with app.app_context():
|
||||
tpl = app.jinja_env.from_string('{% from "_macros.html" import status_badge, status_dot, '
|
||||
'stat_tile, empty_state, page_header, icon_button %}' + source)
|
||||
out = await tpl.render_async(**ctx) if app.jinja_env.is_async else tpl.render(**ctx)
|
||||
return " ".join(out.split())
|
||||
|
||||
|
||||
def test_every_state_a_page_shows_has_a_tone_and_a_label():
|
||||
named = ["up", "down", "stale", "firing", "pending", "acknowledged", "no_data", "ok", "warn", "crit"]
|
||||
assert set(named) <= set(STATES)
|
||||
assert {tone for tone, _ in STATES.values()} == {"ok", "warn", "crit", "info", "dim"}
|
||||
assert status_info("down")[0] == status_info("firing")[0] == "crit"
|
||||
|
||||
|
||||
class _State(enum.Enum):
|
||||
firing = "firing"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state, tone, label", [
|
||||
("firing", "crit", "Firing"), (_State.firing, "crit", "Firing"), ("UP", "ok", "Up"),
|
||||
("no_data", "warn", "No data"), ("acknowledged", "info", "Acknowledged"),
|
||||
(None, "dim", "Unknown"), ("half_open", "dim", "Half open"), # unmapped: dim, its own name
|
||||
])
|
||||
def test_status_info(state, tone, label):
|
||||
assert status_info(state) == (tone, label)
|
||||
|
||||
|
||||
async def test_status_badge(app):
|
||||
assert await _render(app, "{{ status_badge('firing') }}") == '<span class="badge badge-crit">Firing</span>'
|
||||
out = await _render(app, "{{ status_badge(s, 'Muted', title=t) }}", s=_State.firing, t='a "b"')
|
||||
assert out == '<span class="badge badge-crit" title="a "b"">Muted</span>'
|
||||
|
||||
|
||||
async def test_a_status_dot_always_carries_its_label(app):
|
||||
out = await _render(app, "{{ status_dot('down') }}")
|
||||
assert 'class="dot dot-crit" aria-hidden="true"' in out and '<span class="sr-only">Down</span>' in out
|
||||
out = await _render(app, "{{ status_dot('up', show_label=true) }}")
|
||||
assert '<span class="dot-label">Up</span>' in out and "sr-only" not in out
|
||||
|
||||
|
||||
async def test_stat_tile(app):
|
||||
out = await _render(app, "{{ stat_tile('Firing', 3, state='crit', href='/alerts/?a=1&b=2') }}")
|
||||
assert out.startswith('<a href="/alerts/?a=1&b=2" class="stat-tile stat-crit">')
|
||||
assert '<span class="stat-tile-value">3</span>' in out and out.endswith("</a>")
|
||||
out = await _render(app, "{{ stat_tile('<Hosts>', 12, hint='2 stale') }}")
|
||||
assert out.startswith('<div class="stat-tile">') and "<Hosts>" in out and "2 stale" in out
|
||||
|
||||
|
||||
async def test_empty_state(app):
|
||||
out = await _render(app, "{{ empty_state('No rules yet.', 'New rule', '/alerts/rules/new') }}")
|
||||
assert "<p>No rules yet.</p>" in out and 'href="/alerts/rules/new">New rule</a>' in out
|
||||
assert "<a" not in await _render(app, "{{ empty_state('Nothing.') }}")
|
||||
|
||||
|
||||
async def test_page_header_with_and_without_actions(app):
|
||||
out = await _render(app, "{% call page_header('Alerts', 'All rules') %}<a class=\"btn\">New</a>{% endcall %}")
|
||||
assert '<h1 class="page-title">Alerts</h1>' in out and '<p class="page-subtitle">All rules</p>' in out
|
||||
assert '<div class="page-actions"><a class="btn">New</a></div>' in out
|
||||
assert "page-actions" not in await _render(app, "{{ page_header('Alerts') }}")
|
||||
|
||||
|
||||
async def test_icon_button_names_itself_and_refuses_to_render_without_a_label(app):
|
||||
out = await _render(app, "{{ icon_button('×', 'Delete rule web01', type='submit', cls='btn-danger', confirm='Delete?') }}")
|
||||
assert 'type="submit"' in out and 'aria-label="Delete rule web01"' in out and 'data-confirm="Delete?"' in out
|
||||
out = await _render(app, "{{ icon_button('✎', 'Edit', href='/x') }}")
|
||||
assert out.startswith('<a class="btn btn-sm btn-icon btn-ghost" href="/x" aria-label="Edit"')
|
||||
for source in ("{{ icon_button('×') }}", "{{ icon_button('×', '') }}", "{{ icon_button('×', ' ') }}"):
|
||||
with pytest.raises(ValueError, match="icon_button label is required"):
|
||||
await _render(app, source)
|
||||
Reference in New Issue
Block a user