Convert test/cart/blog/market layouts to use _ctx_to_env + render_to_sx_with_env

Phase 4 (Test): Update ~test-layout-full and ~test-detail-layout-full defcomps
to use ~root-header with env free variables. Switch render functions to
render_to_sx_with_env.

Phase 5 (Cart): Convert cart-page, cart-admin, and order render functions.
Update cart .sx layout defcomps to use ~root-header from free variables.

Phase 6 (Blog): Convert all 7 blog layouts (blog, settings, sub-settings x5).
Remove all root_header_sx calls from blog.

Phase 7 (Market): Convert market and market-admin layouts plus browse/product
render functions. Remove root_header_sx import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 15:02:59 +00:00
parent a30e7228d8
commit 1dbf600af2
8 changed files with 507 additions and 350 deletions

50
blog/sx/layouts.sx Normal file
View File

@@ -0,0 +1,50 @@
;; Blog layout defcomps — root header from env free variables,
;; blog-specific headers passed as &key params.
;; --- Blog layout (root + invisible blog header) ---
(defcomp ~blog-layout-full (&key blog-header)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
blog-header))
;; --- Settings layout (root + settings header) ---
(defcomp ~settings-layout-full (&key settings-header)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
settings-header))
;; --- Sub-settings layout (root + settings + sub row) ---
(defcomp ~sub-settings-layout-full (&key settings-header sub-header)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
settings-header sub-header))
(defcomp ~sub-settings-layout-oob (&key settings-header-oob sub-header-oob)
(<> settings-header-oob sub-header-oob))
;; --- Settings nav links (replaces Python _settings_nav_sx loop) ---
(defcomp ~blog-settings-nav (&key select-colours)
(let* ((links (list
(dict :endpoint "menu_items.defpage_menu_items_page" :icon "fa fa-bars" :label "Menu Items")
(dict :endpoint "snippets.defpage_snippets_page" :icon "fa fa-puzzle-piece" :label "Snippets")
(dict :endpoint "blog.tag_groups_admin.defpage_tag_groups_page" :icon "fa fa-tags" :label "Tag Groups")
(dict :endpoint "settings.defpage_cache_page" :icon "fa fa-refresh" :label "Cache"))))
(<> (map (lambda (lnk)
(~nav-link
:href (url-for (get lnk "endpoint"))
:icon (get lnk "icon")
:label (get lnk "label")
:select-colours (or select-colours "")))
links))))
;; --- Editor panel wrapper ---
(defcomp ~blog-editor-panel (&key parts)
(<> parts))

View File

@@ -144,21 +144,8 @@ async def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str:
async def _settings_nav_sx(ctx: dict) -> str:
from shared.sx.helpers import render_to_sx
from quart import url_for as qurl
select_colours = ctx.get("select_colours", "")
parts = []
for endpoint, icon, label in [
("menu_items.defpage_menu_items_page", "bars", "Menu Items"),
("snippets.defpage_snippets_page", "puzzle-piece", "Snippets"),
("blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups"),
("settings.defpage_cache_page", "refresh", "Cache"),
]:
href = qurl(endpoint)
parts.append(await render_to_sx("nav-link",
href=href, icon=f"fa fa-{icon}", label=label,
select_colours=select_colours))
return "(<> " + " ".join(parts) + ")" if parts else ""
return await render_to_sx("blog-settings-nav",
select_colours=ctx.get("select_colours", ""))
async def _sub_settings_header_sx(row_id: str, child_id: str, href: str,
@@ -197,34 +184,34 @@ def _register_blog_layouts() -> None:
# --- Blog layout (root + blog header) ---
async def _blog_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
root_hdr = await root_header_sx(ctx)
blog_hdr = await _blog_header_sx(ctx)
return "(<> " + root_hdr + " " + blog_hdr + ")"
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
from shared.sx.parser import SxExpr
return await render_to_sx_with_env("blog-layout-full", _ctx_to_env(ctx),
blog_header=SxExpr(await _blog_header_sx(ctx)))
async def _blog_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, oob_header_sx
root_hdr = await root_header_sx(ctx)
blog_hdr = await _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx
from shared.sx.parser import SxExpr
rows = await render_to_sx_with_env("blog-layout-full", _ctx_to_env(ctx),
blog_header=SxExpr(await _blog_header_sx(ctx)))
return await oob_header_sx("root-header-child", "blog-header-child", rows)
# --- Settings layout (root + settings header) ---
async def _settings_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
root_hdr = await root_header_sx(ctx)
settings_hdr = await _settings_header_sx(ctx)
return "(<> " + root_hdr + " " + settings_hdr + ")"
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
from shared.sx.parser import SxExpr
return await render_to_sx_with_env("settings-layout-full", _ctx_to_env(ctx),
settings_header=SxExpr(await _settings_header_sx(ctx)))
async def _settings_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, oob_header_sx
root_hdr = await root_header_sx(ctx)
settings_hdr = await _settings_header_sx(ctx)
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx
from shared.sx.parser import SxExpr
rows = await render_to_sx_with_env("settings-layout-full", _ctx_to_env(ctx),
settings_header=SxExpr(await _settings_header_sx(ctx)))
return await oob_header_sx("root-header-child", "root-settings-header-child", rows)
@@ -236,24 +223,27 @@ async def _settings_mobile(ctx: dict, **kw: Any) -> str:
async def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
endpoint: str, icon: str, label: str) -> str:
from shared.sx.helpers import root_header_sx
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
from shared.sx.parser import SxExpr
from quart import url_for as qurl
root_hdr = await root_header_sx(ctx)
settings_hdr = await _settings_header_sx(ctx)
sub_hdr = await _sub_settings_header_sx(row_id, child_id,
qurl(endpoint), icon, label, ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
return await render_to_sx_with_env("sub-settings-layout-full", _ctx_to_env(ctx),
settings_header=SxExpr(await _settings_header_sx(ctx)),
sub_header=SxExpr(await _sub_settings_header_sx(
row_id, child_id, qurl(endpoint), icon, label, ctx)))
async def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
endpoint: str, icon: str, label: str) -> str:
from shared.sx.helpers import oob_header_sx
from shared.sx.helpers import oob_header_sx, render_to_sx
from shared.sx.parser import SxExpr
from quart import url_for as qurl
settings_hdr_oob = await _settings_header_sx(ctx, oob=True)
sub_hdr = await _sub_settings_header_sx(row_id, child_id,
qurl(endpoint), icon, label, ctx)
sub_hdr = await _sub_settings_header_sx(
row_id, child_id, qurl(endpoint), icon, label, ctx)
sub_oob = await oob_header_sx("root-settings-header-child", child_id, sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
return await render_to_sx("sub-settings-layout-oob",
settings_header_oob=SxExpr(settings_hdr_oob),
sub_header_oob=SxExpr(sub_oob))
# --- Cache ---
@@ -308,26 +298,31 @@ async def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
async def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
from quart import request, url_for as qurl
from shared.sx.helpers import root_header_sx
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
from shared.sx.parser import SxExpr
g_id = (request.view_args or {}).get("id")
root_hdr = await root_header_sx(ctx)
settings_hdr = await _settings_header_sx(ctx)
sub_hdr = await _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
return await render_to_sx_with_env("sub-settings-layout-full", _ctx_to_env(ctx),
settings_header=SxExpr(await _settings_header_sx(ctx)),
sub_header=SxExpr(await _sub_settings_header_sx(
"tag-groups-row", "tag-groups-header-child",
qurl("defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)))
async def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
from quart import request, url_for as qurl
from shared.sx.helpers import oob_header_sx
from shared.sx.helpers import oob_header_sx, render_to_sx
from shared.sx.parser import SxExpr
g_id = (request.view_args or {}).get("id")
settings_hdr_oob = await _settings_header_sx(ctx, oob=True)
sub_hdr = await _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)
sub_hdr = await _sub_settings_header_sx(
"tag-groups-row", "tag-groups-header-child",
qurl("defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)
sub_oob = await oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
return await render_to_sx("sub-settings-layout-oob",
settings_header_oob=SxExpr(settings_hdr_oob),
sub_header_oob=SxExpr(sub_oob))
# ---------------------------------------------------------------------------
@@ -512,7 +507,9 @@ async def render_editor_panel(save_error: str | None = None, is_page: bool = Fal
sx_editor_js_src=sx_editor_js,
init_js=init_js))
return "(<> " + " ".join(parts) + ")" if parts else ""
from shared.sx.parser import SxExpr
return await render_to_sx("blog-editor-panel",
parts=SxExpr("(<> " + " ".join(parts) + ")")) if parts else ""
# ---------------------------------------------------------------------------
@@ -1116,6 +1113,7 @@ async def _h_post_edit_content(slug=None, **kw):
sx_editor_js_src=sx_editor_js,
init_js=init_js))
return "(<> " + " ".join(parts) + ")"
return await render_to_sx("blog-editor-panel",
parts=SxExpr("(<> " + " ".join(parts) + ")"))

78
cart/sx/layouts.sx Normal file
View File

@@ -0,0 +1,78 @@
;; Cart layout defcomps — root header from env free variables,
;; cart-specific headers passed as &key params.
;; --- cart-page layout: root + cart row + page-cart row ---
(defcomp ~cart-page-layout-full (&key cart-row page-cart-row)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
(~header-child-sx
:inner (<> cart-row
(~header-child-sx :id "cart-header-child" :inner page-cart-row)))))
(defcomp ~cart-page-layout-oob (&key root-header-oob cart-row-oob page-cart-row)
(<> (~oob-header-sx :parent-id "cart-header-child" :row page-cart-row)
cart-row-oob
root-header-oob))
;; --- cart-admin layout: root + post header + admin header ---
(defcomp ~cart-admin-layout-full (&key post-header admin-header)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
post-header admin-header))
;; --- orders-within-cart: root + auth-simple + orders ---
(defcomp ~cart-orders-layout-full (&key list-url)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
(~header-child-sx
:inner (<> (~auth-header-row-simple :account-url account-url)
(~header-child-sx :id "auth-header-child"
:inner (~orders-header-row :list-url list-url))))))
(defcomp ~cart-orders-layout-oob (&key list-url)
(<> (~auth-header-row-simple :account-url account-url :oob true)
(~oob-header-sx
:parent-id "auth-header-child"
:row (~orders-header-row :list-url list-url))
(~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin
:oob true)))
;; --- order-detail-within-cart: root + auth-simple + orders + order ---
(defcomp ~cart-order-detail-layout-full (&key list-url detail-url order-label)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
(~header-child-sx
:inner (<> (~auth-header-row-simple :account-url account-url)
(~header-child-sx :id "auth-header-child"
:inner (<> (~orders-header-row :list-url list-url)
(~header-child-sx :id "orders-header-child"
:inner (~menu-row-sx :id "order-row" :level 3 :colour "sky"
:link-href detail-url
:link-label order-label
:icon "fa fa-gbp"))))))))
(defcomp ~cart-order-detail-layout-oob (&key detail-url order-label)
(<> (~oob-header-sx
:parent-id "orders-header-child"
:row (~menu-row-sx :id "order-row" :level 3 :colour "sky"
:link-href detail-url :link-label order-label
:icon "fa fa-gbp" :oob true))
(~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin
:oob true)))
;; --- orders rows wrapper (for infinite scroll) ---
(defcomp ~cart-orders-rows (&key rows next-scroll)
(<> rows next-scroll))

View File

@@ -20,7 +20,7 @@ def _load_cart_page_files() -> None:
# ---------------------------------------------------------------------------
# Header helpers (moved from sx_components.py)
# Header helpers (still needed by layouts and render functions)
# ---------------------------------------------------------------------------
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
@@ -94,30 +94,8 @@ async def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False)
)
async def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
from shared.sx.helpers import render_to_sx, call_url
return await render_to_sx(
"auth-header-row-simple",
account_url=call_url(ctx, "account_url", ""),
oob=oob,
)
async def _orders_header_sx(ctx: dict, list_url: str) -> str:
from shared.sx.helpers import render_to_sx
return await render_to_sx("orders-header-row", list_url=list_url)
async def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
selected: str = "") -> str:
from shared.sx.helpers import post_admin_header_sx
slug = page_post.slug if page_post else ""
ctx = _ensure_post_ctx(ctx, page_post)
return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
# ---------------------------------------------------------------------------
# Order serialization helpers (used by route render functions below)
# Order serialization helpers
# ---------------------------------------------------------------------------
def _serialize_order(order: Any) -> dict:
@@ -157,11 +135,11 @@ def _serialize_calendar_entry(e: Any) -> dict:
# ---------------------------------------------------------------------------
# Render functions (called by routes)
# Render functions (called by routes) — delegate header composition to .sx
# ---------------------------------------------------------------------------
async def render_orders_page(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
from shared.sx.helpers import render_to_sx, root_header_sx, search_desktop_sx, search_mobile_sx, full_page_sx
from shared.sx.helpers import render_to_sx, render_to_sx_with_env, _ctx_to_env, search_desktop_sx, search_mobile_sx, full_page_sx
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
@@ -171,12 +149,9 @@ async def render_orders_page(ctx, orders, page, total_pages, search, search_coun
order_dicts = [_serialize_order(o) for o in orders]
content = await render_to_sx("orders-list-content", orders=order_dicts,
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
hdr = await root_header_sx(ctx)
auth = await _auth_header_sx(ctx)
orders_hdr = await _orders_header_sx(ctx, list_url)
auth_child_inner = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr))
auth_child = await render_to_sx("header-child-sx", inner=SxExpr("(<> " + auth + " " + auth_child_inner + ")"))
header_rows = "(<> " + hdr + " " + auth_child + ")"
header_rows = await render_to_sx_with_env("cart-orders-layout-full", _ctx_to_env(ctx),
list_url=list_url,
)
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
return await full_page_sx(ctx, header_rows=header_rows, filter=filt,
aside=await search_desktop_sx(ctx), content=content)
@@ -192,17 +167,21 @@ async def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn):
parts = []
for od in order_dicts:
parts.append(await render_to_sx("order-row-pair", order=od, detail_url_prefix=detail_url_prefix))
next_scroll = ""
if page < total_pages:
next_url = list_url + qs_fn(page=page + 1)
parts.append(await render_to_sx("infinite-scroll", url=next_url, page=page,
total_pages=total_pages, id_prefix="orders", colspan=5))
next_scroll = await render_to_sx("infinite-scroll", url=next_url, page=page,
total_pages=total_pages, id_prefix="orders", colspan=5)
else:
parts.append(await render_to_sx("order-end-row"))
return "(<> " + " ".join(parts) + ")"
next_scroll = await render_to_sx("order-end-row")
return await render_to_sx("cart-orders-rows",
rows=SxExpr("(<> " + " ".join(parts) + ")"),
next_scroll=SxExpr(next_scroll),
)
async def render_orders_oob(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
from shared.sx.helpers import render_to_sx, root_header_sx, search_desktop_sx, search_mobile_sx, oob_page_sx
from shared.sx.helpers import render_to_sx, render_to_sx_with_env, _ctx_to_env, search_desktop_sx, search_mobile_sx, oob_page_sx
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
@@ -212,17 +191,15 @@ async def render_orders_oob(ctx, orders, page, total_pages, search, search_count
order_dicts = [_serialize_order(o) for o in orders]
content = await render_to_sx("orders-list-content", orders=order_dicts,
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
auth_oob = await _auth_header_sx(ctx, oob=True)
orders_hdr = await _orders_header_sx(ctx, list_url)
auth_child_oob = await render_to_sx("oob-header-sx", parent_id="auth-header-child", row=SxExpr(orders_hdr))
root_oob = await root_header_sx(ctx, oob=True)
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
oobs = await render_to_sx_with_env("cart-orders-layout-oob", _ctx_to_env(ctx, oob=True),
list_url=list_url,
)
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
return await oob_page_sx(oobs=oobs, filter=filt, aside=await search_desktop_sx(ctx), content=content)
async def render_order_page(ctx, order, calendar_entries, url_for_fn):
from shared.sx.helpers import render_to_sx, root_header_sx, full_page_sx
from shared.sx.helpers import render_to_sx, render_to_sx_with_env, _ctx_to_env, full_page_sx
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
@@ -235,20 +212,15 @@ async def render_order_page(ctx, order, calendar_entries, url_for_fn):
main = await render_to_sx("order-detail-content", order=order_data, calendar_entries=cal_data)
filt = await render_to_sx("order-detail-filter-content", order=order_data,
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
hdr = await root_header_sx(ctx)
order_row = await render_to_sx("menu-row-sx", id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp")
auth = await _auth_header_sx(ctx)
orders_hdr = await _orders_header_sx(ctx, list_url)
orders_child = await render_to_sx("header-child-sx", id="orders-header-child", inner=SxExpr(order_row))
auth_inner = "(<> " + orders_hdr + " " + orders_child + ")"
auth_child = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(auth_inner))
order_child = await render_to_sx("header-child-sx", inner=SxExpr("(<> " + auth + " " + auth_child + ")"))
return await full_page_sx(ctx, header_rows="(<> " + hdr + " " + order_child + ")", filter=filt, content=main)
header_rows = await render_to_sx_with_env("cart-order-detail-layout-full", _ctx_to_env(ctx),
list_url=list_url, detail_url=detail_url,
order_label=f"Order {order.id}",
)
return await full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
async def render_order_oob(ctx, order, calendar_entries, url_for_fn):
from shared.sx.helpers import render_to_sx, root_header_sx, oob_page_sx
from shared.sx.helpers import render_to_sx, render_to_sx_with_env, _ctx_to_env, oob_page_sx
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
@@ -261,19 +233,19 @@ async def render_order_oob(ctx, order, calendar_entries, url_for_fn):
main = await render_to_sx("order-detail-content", order=order_data, calendar_entries=cal_data)
filt = await render_to_sx("order-detail-filter-content", order=order_data,
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
order_row_oob = await render_to_sx("menu-row-sx", id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp", oob=True)
orders_child_oob = await render_to_sx("oob-header-sx", parent_id="orders-header-child", row=SxExpr(order_row_oob))
root_oob = await root_header_sx(ctx, oob=True)
return await oob_page_sx(oobs="(<> " + orders_child_oob + " " + root_oob + ")", filter=filt, content=main)
oobs = await render_to_sx_with_env("cart-order-detail-layout-oob", _ctx_to_env(ctx, oob=True),
detail_url=detail_url,
order_label=f"Order {order.id}",
)
return await oob_page_sx(oobs=oobs, filter=filt, content=main)
async def render_checkout_error_page(ctx, error=None, order=None):
from shared.sx.helpers import render_to_sx, root_header_sx, full_page_sx
from shared.sx.helpers import render_to_sx, render_to_sx_with_env, _ctx_to_env, full_page_sx
from shared.infrastructure.urls import cart_url
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}") if order else None
hdr = await root_header_sx(ctx)
hdr = await render_to_sx_with_env("layout-root-full", _ctx_to_env(ctx))
filt = await render_to_sx("checkout-error-header")
content = await render_to_sx("checkout-error-content", msg=err_msg,
order=SxExpr(order_sx) if order_sx else None, back_url=cart_url("/"))
@@ -294,7 +266,7 @@ async def render_cart_payments_panel(ctx):
# ---------------------------------------------------------------------------
# Layouts
# Layouts — thin wrappers delegating to .sx defcomps in cart/sx/layouts.sx
# ---------------------------------------------------------------------------
def _register_cart_layouts() -> None:
@@ -304,50 +276,46 @@ def _register_cart_layouts() -> None:
async def _cart_page_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import SxExpr
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
page_post = ctx.get("page_post")
root_hdr = await root_header_sx(ctx)
child = await _cart_header_sx(ctx)
page_hdr = await _page_cart_header_sx(ctx, page_post)
inner_child = await render_to_sx("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr))
nested = await render_to_sx(
"header-child-sx",
inner=SxExpr("(<> " + child + " " + inner_child + ")"),
env = _ctx_to_env(ctx)
return await render_to_sx_with_env("cart-page-layout-full", env,
cart_row=SxExpr(await _cart_header_sx(ctx)),
page_cart_row=SxExpr(await _page_cart_header_sx(ctx, page_post)),
)
return "(<> " + root_hdr + " " + nested + ")"
async def _cart_page_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import SxExpr
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, root_header_sx
page_post = ctx.get("page_post")
page_hdr = await _page_cart_header_sx(ctx, page_post)
child_oob = await render_to_sx("oob-header-sx",
parent_id="cart-header-child",
row=SxExpr(page_hdr))
cart_hdr_oob = await _cart_header_sx(ctx, oob=True)
root_hdr_oob = await root_header_sx(ctx, oob=True)
return "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
env = _ctx_to_env(ctx, oob=True)
return await render_to_sx_with_env("cart-page-layout-oob", env,
root_header_oob=SxExpr(await root_header_sx(ctx, oob=True)),
cart_row_oob=SxExpr(await _cart_header_sx(ctx, oob=True)),
page_cart_row=SxExpr(await _page_cart_header_sx(ctx, page_post)),
)
async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
root_hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx, page_post)
admin_hdr = await _cart_page_admin_header_sx(ctx, page_post, selected=selected)
return "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
env = _ctx_to_env(ctx)
return await render_to_sx_with_env("cart-admin-layout-full", env,
post_header=SxExpr(await _post_header_sx(ctx, page_post)),
admin_header=SxExpr(await _cart_page_admin_header_sx(ctx, page_post, selected=selected)),
)
async def _cart_admin_oob(ctx: dict, **kw: Any) -> str:
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
return await _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
async def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
selected: str = "") -> str:
from shared.sx.helpers import post_admin_header_sx
slug = page_post.slug if page_post else ""
ctx = _ensure_post_ctx(ctx, page_post)
return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)

50
market/sx/layouts.sx Normal file
View File

@@ -0,0 +1,50 @@
;; Market layout defcomps — root header from env free variables,
;; market-specific headers passed as &key params.
;; --- Browse layout: root + post header + market header ---
(defcomp ~market-browse-layout-full (&key post-header market-header)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
(~header-child-sx :inner (<> post-header market-header))))
(defcomp ~market-browse-layout-oob (&key oob-header post-header-oob clear-oob)
(<> oob-header post-header-oob clear-oob))
;; --- Product layout: root + post + market + product ---
(defcomp ~market-product-layout-full (&key post-header market-header product-header)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
(~header-child-sx :inner (<> post-header market-header product-header))))
;; --- Product admin layout: root + post + market + product + admin ---
(defcomp ~market-product-admin-layout-full (&key post-header market-header product-header admin-header)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
(~header-child-sx :inner (<> post-header market-header product-header admin-header))))
;; --- Market admin layout: root + post + market + market-admin ---
(defcomp ~market-admin-layout-full (&key post-header market-header admin-header)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
(~header-child-sx :inner (<> post-header market-header admin-header))))
(defcomp ~market-admin-layout-oob (&key market-header-oob admin-oob-header clear-oob)
(<> market-header-oob admin-oob-header clear-oob))
;; --- OOB wrappers ---
(defcomp ~market-oob-wrap (&key parts)
(<> parts))
;; --- Content wrappers ---
(defcomp ~market-content-padded (&key content)
(<> content (div :class "pb-8")))

View File

@@ -6,7 +6,6 @@ from typing import Any
from shared.sx.parser import serialize, SxExpr
from shared.sx.helpers import (
render_to_sx,
root_header_sx,
post_header_sx as _post_header_sx,
post_admin_header_sx,
oob_header_sx as _oob_header_sx,
@@ -1227,9 +1226,10 @@ async def render_browse_page(ctx: dict) -> str:
cards = await _product_cards_sx(ctx)
content = await _product_grid(cards)
hdr = await root_header_sx(ctx)
child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + ")"
hdr = "(<> " + hdr + " " + await header_child_sx(child) + ")"
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
hdr = await render_to_sx_with_env("market-browse-layout-full", _ctx_to_env(ctx),
post_header=SxExpr(await _post_header_sx(ctx)),
market_header=SxExpr(await _market_header_sx(ctx)))
menu = await _mobile_nav_panel_sx(ctx)
filter_sx = await _mobile_filter_summary_sx(ctx)
aside_sx = await _desktop_filter_sx(ctx)
@@ -1243,12 +1243,13 @@ async def render_browse_oob(ctx: dict) -> str:
cards = await _product_cards_sx(ctx)
content = await _product_grid(cards)
oobs = await _oob_header_sx("post-header-child", "market-header-child",
oob_hdr = await _oob_header_sx("post-header-child", "market-header-child",
await _market_header_sx(ctx))
post_hdr = await _post_header_sx(ctx, oob=True)
oobs = "(<> " + oobs + " " + post_hdr + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child") + ")"
oobs = await render_to_sx("market-browse-layout-oob",
oob_header=SxExpr(oob_hdr),
post_header_oob=SxExpr(await _post_header_sx(ctx, oob=True)),
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child")))
menu = await _mobile_nav_panel_sx(ctx)
filter_sx = await _mobile_filter_summary_sx(ctx)
aside_sx = await _desktop_filter_sx(ctx)
@@ -1271,11 +1272,11 @@ async def render_product_page(ctx: dict, d: dict) -> str:
content = await _product_detail_sx(d, ctx)
meta = await _product_meta_sx(d, ctx)
hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx)
child = "(<> " + post_hdr + " " + await _market_header_sx(ctx) + " " + await _product_header_sx(ctx, d) + ")"
hdr_child = await header_child_sx(child)
hdr = "(<> " + hdr + " " + hdr_child + ")"
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
hdr = await render_to_sx_with_env("market-product-layout-full", _ctx_to_env(ctx),
post_header=SxExpr(await _post_header_sx(ctx)),
market_header=SxExpr(await _market_header_sx(ctx)),
product_header=SxExpr(await _product_header_sx(ctx, d)))
return await full_page_sx(ctx, header_rows=hdr, content=content, meta=meta)
@@ -1283,13 +1284,13 @@ async def render_product_oob(ctx: dict, d: dict) -> str:
"""OOB response: product detail."""
content = await _product_detail_sx(d, ctx)
oobs = "(<> " + await _market_header_sx(ctx, oob=True) + " "
oob_hdr = await _oob_header_sx("market-header-child", "product-header-child",
await _product_header_sx(ctx, d))
oobs += oob_hdr + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
oobs = await render_to_sx("market-oob-wrap",
parts=SxExpr("(<> " + await _market_header_sx(ctx, oob=True) + " "
+ await _oob_header_sx("market-header-child", "product-header-child",
await _product_header_sx(ctx, d)) + " "
+ _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child",
"product-row", "product-header-child") + ")"
"product-row", "product-header-child") + ")"))
menu = await _mobile_nav_panel_sx(ctx)
return await oob_page_sx(oobs=oobs, content=content, menu=menu)
@@ -1302,12 +1303,12 @@ async def render_product_admin_page(ctx: dict, d: dict) -> str:
"""Full page: product admin."""
content = await _product_detail_sx(d, ctx)
hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx)
child = "(<> " + post_hdr + " " + await _market_header_sx(ctx)
child += " " + await _product_header_sx(ctx, d) + " " + await _product_admin_header_sx(ctx, d) + ")"
hdr_child = await header_child_sx(child)
hdr = "(<> " + hdr + " " + hdr_child + ")"
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
hdr = await render_to_sx_with_env("market-product-admin-layout-full", _ctx_to_env(ctx),
post_header=SxExpr(await _post_header_sx(ctx)),
market_header=SxExpr(await _market_header_sx(ctx)),
product_header=SxExpr(await _product_header_sx(ctx, d)),
admin_header=SxExpr(await _product_admin_header_sx(ctx, d)))
return await full_page_sx(ctx, header_rows=hdr, content=content)
@@ -1315,14 +1316,14 @@ async def render_product_admin_oob(ctx: dict, d: dict) -> str:
"""OOB response: product admin."""
content = await _product_detail_sx(d, ctx)
oobs = "(<> " + await _product_header_sx(ctx, d, oob=True) + " "
oob_hdr = await _oob_header_sx("product-header-child", "product-admin-header-child",
await _product_admin_header_sx(ctx, d))
oobs += oob_hdr + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
oobs = await render_to_sx("market-oob-wrap",
parts=SxExpr("(<> " + await _product_header_sx(ctx, d, oob=True) + " "
+ await _oob_header_sx("product-header-child", "product-admin-header-child",
await _product_admin_header_sx(ctx, d)) + " "
+ _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child",
"product-row", "product-header-child",
"product-admin-row", "product-admin-header-child") + ")"
"product-admin-row", "product-admin-header-child") + ")"))
return await oob_page_sx(oobs=oobs, content=content)
@@ -1536,18 +1537,20 @@ def _register_market_layouts() -> None:
async def _market_full(ctx: dict, **kw: Any) -> str:
hdr = await root_header_sx(ctx)
child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + ")"
return "(<> " + hdr + " " + await header_child_sx(child) + ")"
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
return await render_to_sx_with_env("market-browse-layout-full", _ctx_to_env(ctx),
post_header=SxExpr(await _post_header_sx(ctx)),
market_header=SxExpr(await _market_header_sx(ctx)))
async def _market_oob(ctx: dict, **kw: Any) -> str:
oobs = await _oob_header_sx("post-header-child", "market-header-child",
oob_hdr = await _oob_header_sx("post-header-child", "market-header-child",
await _market_header_sx(ctx))
oobs = "(<> " + oobs + " " + await _post_header_sx(ctx, oob=True) + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child") + ")"
return oobs
return await render_to_sx("market-browse-layout-oob",
oob_header=SxExpr(oob_hdr),
post_header_oob=SxExpr(await _post_header_sx(ctx, oob=True)),
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child")))
async def _market_mobile(ctx: dict, **kw: Any) -> str:
@@ -1555,22 +1558,23 @@ async def _market_mobile(ctx: dict, **kw: Any) -> str:
async def _market_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env
selected = kw.get("selected", "")
hdr = await root_header_sx(ctx)
child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + " "
child += await _market_admin_header_sx(ctx, selected=selected) + ")"
return "(<> " + hdr + " " + await header_child_sx(child) + ")"
return await render_to_sx_with_env("market-admin-layout-full", _ctx_to_env(ctx),
post_header=SxExpr(await _post_header_sx(ctx)),
market_header=SxExpr(await _market_header_sx(ctx)),
admin_header=SxExpr(await _market_admin_header_sx(ctx, selected=selected)))
async def _market_admin_oob(ctx: dict, **kw: Any) -> str:
selected = kw.get("selected", "")
oobs = "(<> " + await _market_header_sx(ctx, oob=True) + " "
oobs += await _oob_header_sx("market-header-child", "market-admin-header-child",
await _market_admin_header_sx(ctx, selected=selected)) + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
return await render_to_sx("market-admin-layout-oob",
market_header_oob=SxExpr(await _market_header_sx(ctx, oob=True)),
admin_oob_header=SxExpr(await _oob_header_sx("market-header-child", "market-admin-header-child",
await _market_admin_header_sx(ctx, selected=selected))),
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child",
"market-admin-row", "market-admin-header-child") + ")"
return oobs
"market-admin-row", "market-admin-header-child")))
# ===========================================================================

104
test/sx/components.sx Normal file
View File

@@ -0,0 +1,104 @@
;; Test service composition defcomps — replaces Python string concatenation
;; in test/sxc/pages/__init__.py.
;; Service filter nav links
(defcomp ~test-service-nav (&key services active-service)
(<>
(~nav-link :href "/" :label "all"
:is-selected (if (not active-service) "true" nil)
:select-colours "aria-selected:bg-sky-200 aria-selected:text-sky-900")
(map (lambda (svc)
(~nav-link :href (str "/?service=" svc) :label svc
:is-selected (if (= active-service svc) "true" nil)
:select-colours "aria-selected:bg-sky-200 aria-selected:text-sky-900"))
services)))
;; Test header menu row
(defcomp ~test-header-row (&key services active-service)
(~menu-row-sx :id "test-row" :level 1 :colour "sky"
:link-href "/" :link-label "Tests" :icon "fa fa-flask"
:nav (~test-service-nav :services services :active-service active-service)
:child-id "test-header-child"))
;; Layout: full page header stack (reads root header values from env free variables)
(defcomp ~test-layout-full (&key services active-service)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
(~header-child-sx
:inner (~test-header-row :services services :active-service active-service))))
;; Map test dicts to test-row components
(defcomp ~test-rows (&key tests)
(<> (map (lambda (t)
(~test-row
:nodeid (get t "nodeid")
:outcome (get t "outcome")
:duration (str (get t "duration"))
:longrepr (or (get t "longrepr") "")))
tests)))
;; Grouped test rows with service headers
(defcomp ~test-grouped-rows (&key sections)
(<> (map (lambda (sec)
(<> (~test-service-header
:service (get sec "service")
:total (str (get sec "total"))
:passed (str (get sec "passed"))
:failed (str (get sec "failed")))
(~test-rows :tests (get sec "tests"))))
sections)))
;; Results partial: conditional rendering based on running/result state
(defcomp ~test-results-partial (&key status summary-data tests sections has-failures)
(let* ((state (get summary-data "state")))
(<>
(~test-summary
:status (get summary-data "status")
:passed (get summary-data "passed")
:failed (get summary-data "failed")
:errors (get summary-data "errors")
:skipped (get summary-data "skipped")
:total (get summary-data "total")
:duration (get summary-data "duration")
:last-run (get summary-data "last_run")
:running (get summary-data "running")
:csrf (get summary-data "csrf")
:active-filter (get summary-data "active_filter"))
(cond
((= state "running") (~test-running-indicator))
((= state "no-results") (~test-no-results))
((= state "empty-filtered") (~test-no-results))
(true (~test-results-table
:rows (~test-grouped-rows :sections sections)
:has-failures has-failures))))))
;; Wrap results in a div with optional HTMX polling
(defcomp ~test-results-wrap (&key running inner)
(div :id "test-results" :class "space-y-6 p-4"
:sx-get (when running "/results")
:sx-trigger (when running "every 2s")
:sx-swap (when running "outerHTML")
inner))
;; Test detail section wrapper
(defcomp ~test-detail-section (&key test)
(section :id "main-panel"
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
(~test-detail
:nodeid (get test "nodeid")
:outcome (get test "outcome")
:duration (str (get test "duration"))
:longrepr (or (get test "longrepr") ""))))
;; Detail page header stack (reads root header values from env free variables)
(defcomp ~test-detail-layout-full (&key services test-nodeid test-label)
(<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)
(~header-child-sx
:inner (<> (~test-header-row :services services)
(~header-child-sx :id "test-header-child"
:inner (~menu-row-sx :id "test-detail-row" :level 2 :colour "sky"
:link-href (str "/test/" test-nodeid)
:link-label test-label))))))

View File

@@ -5,10 +5,7 @@ import os
from datetime import datetime
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
render_to_sx, SxExpr,
root_header_sx, full_page_sx, header_child_sx,
)
from shared.sx.helpers import render_to_sx, SxExpr, render_to_sx_with_env, _ctx_to_env, full_page_sx
# Load test-specific .sx components at import time
load_service_components(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
@@ -21,10 +18,6 @@ def _format_time(ts: float | None) -> str:
return datetime.fromtimestamp(ts).strftime("%-d %b %Y, %H:%M:%S")
# ---------------------------------------------------------------------------
# Menu / header
# ---------------------------------------------------------------------------
_FILTER_MAP = {
"passed": "passed",
"failed": "failed",
@@ -46,120 +39,27 @@ def _filter_tests(tests: list[dict], active_filter: str | None,
return filtered
# ---------------------------------------------------------------------------
# Results partial
# ---------------------------------------------------------------------------
async def test_detail_sx(test: dict) -> str:
"""Return s-expression wire format for a test detail view."""
inner = await render_to_sx(
"test-detail",
nodeid=test["nodeid"],
outcome=test["outcome"],
duration=str(test["duration"]),
longrepr=test.get("longrepr", ""),
)
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
f' {inner})'
)
# ---------------------------------------------------------------------------
# Sx-native versions — return sx source (not HTML)
# ---------------------------------------------------------------------------
async def _test_header_sx(ctx: dict, active_service: str | None = None) -> str:
"""Build the Tests menu-row as sx call."""
nav = await _service_nav_sx(ctx, active_service)
return await render_to_sx("menu-row-sx",
id="test-row", level=1, colour="sky",
link_href="/", link_label="Tests", icon="fa fa-flask",
nav=SxExpr(nav),
child_id="test-header-child",
)
async def _service_nav_sx(ctx: dict, active_service: str | None = None) -> str:
"""Service filter nav as sx."""
def _service_list() -> list[str]:
from runner import _SERVICE_ORDER
parts = []
parts.append(await render_to_sx("nav-link",
href="/", label="all",
is_selected="true" if not active_service else None,
select_colours="aria-selected:bg-sky-200 aria-selected:text-sky-900",
))
for svc in _SERVICE_ORDER:
parts.append(await render_to_sx("nav-link",
href=f"/?service={svc}", label=svc,
is_selected="true" if active_service == svc else None,
select_colours="aria-selected:bg-sky-200 aria-selected:text-sky-900",
))
return "(<> " + " ".join(parts) + ")"
return list(_SERVICE_ORDER)
async def _header_stack_sx(ctx: dict, active_service: str | None = None) -> str:
"""Full header stack as sx."""
hdr = await root_header_sx(ctx)
inner = await _test_header_sx(ctx, active_service)
child = await header_child_sx(inner)
return "(<> " + hdr + " " + child + ")"
async def _test_rows_sx(tests: list[dict]) -> str:
"""Render all test result rows as sx."""
parts = []
for t in tests:
parts.append(await render_to_sx("test-row",
nodeid=t["nodeid"],
outcome=t["outcome"],
duration=str(t["duration"]),
longrepr=t.get("longrepr", ""),
))
return "(<> " + " ".join(parts) + ")"
async def _grouped_rows_sx(tests: list[dict]) -> str:
"""Test rows grouped by service as sx."""
from runner import group_tests_by_service
sections = group_tests_by_service(tests)
parts = []
for sec in sections:
parts.append(await render_to_sx("test-service-header",
service=sec["service"],
total=str(sec["total"]),
passed=str(sec["passed"]),
failed=str(sec["failed"]),
))
parts.append(await _test_rows_sx(sec["tests"]))
return "(<> " + " ".join(parts) + ")"
async def _results_partial_sx(result: dict | None, running: bool, csrf: str,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""Results section as sx."""
def _build_summary_data(result: dict | None, running: bool, csrf: str,
active_filter: str | None) -> dict:
"""Prepare summary data dict for the ~test-results-partial defcomp."""
if running and not result:
summary = await render_to_sx("test-summary",
status="running", passed="0", failed="0", errors="0",
skipped="0", total="0", duration="...",
last_run="in progress", running=True, csrf=csrf,
active_filter=active_filter,
)
return "(<> " + summary + " " + await render_to_sx("test-running-indicator") + ")"
return dict(state="running", status="running", passed="0", failed="0",
errors="0", skipped="0", total="0", duration="...",
last_run="in progress", running=True, csrf=csrf,
active_filter=active_filter)
if not result:
summary = await render_to_sx("test-summary",
status=None, passed="0", failed="0", errors="0",
skipped="0", total="0", duration="0",
last_run="never", running=running, csrf=csrf,
active_filter=active_filter,
)
return "(<> " + summary + " " + await render_to_sx("test-no-results") + ")"
return dict(state="no-results", status=None, passed="0", failed="0",
errors="0", skipped="0", total="0", duration="0",
last_run="never", running=running, csrf=csrf,
active_filter=active_filter)
status = "running" if running else result["status"]
summary = await render_to_sx("test-summary",
return dict(
state="running" if running else "has-results",
status=status,
passed=str(result["passed"]),
failed=str(result["failed"]),
@@ -168,34 +68,14 @@ async def _results_partial_sx(result: dict | None, running: bool, csrf: str,
total=str(result["total"]),
duration=str(result["duration"]),
last_run=_format_time(result["finished_at"]) if not running else "in progress",
running=running,
csrf=csrf,
running=running, csrf=csrf,
active_filter=active_filter,
)
if running:
return "(<> " + summary + " " + await render_to_sx("test-running-indicator") + ")"
tests = result.get("tests", [])
tests = _filter_tests(tests, active_filter, active_service)
if not tests:
return "(<> " + summary + " " + await render_to_sx("test-no-results") + ")"
has_failures = result["failed"] > 0 or result["errors"] > 0
rows = await _grouped_rows_sx(tests)
table = await render_to_sx("test-results-table",
rows=SxExpr(rows),
has_failures=str(has_failures).lower(),
)
return "(<> " + summary + " " + table + ")"
def _wrap_results_div_sx(inner: str, running: bool) -> str:
"""Wrap results in a div with HTMX polling (sx)."""
attrs = ':id "test-results" :class "space-y-6 p-4"'
if running:
attrs += ' :sx-get "/results" :sx-trigger "every 2s" :sx-swap "outerHTML"'
return f'(div {attrs} {inner})'
async def test_detail_sx(test: dict) -> str:
"""Return s-expression wire format for a test detail view."""
return await render_to_sx("test-detail-section", test=test)
async def render_dashboard_page_sx(ctx: dict, result: dict | None,
@@ -203,9 +83,26 @@ async def render_dashboard_page_sx(ctx: dict, result: dict | None,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""Full page: test dashboard (sx wire format)."""
hdr = await _header_stack_sx(ctx, active_service)
inner = await _results_partial_sx(result, running, csrf, active_filter, active_service)
content = _wrap_results_div_sx(inner, running)
from runner import group_tests_by_service
summary_data = _build_summary_data(result, running, csrf, active_filter)
sections = []
has_failures = "false"
if result and not running:
tests = _filter_tests(result.get("tests", []), active_filter, active_service)
if tests:
sections = group_tests_by_service(tests)
has_failures = str(result["failed"] > 0 or result["errors"] > 0).lower()
else:
summary_data["state"] = "empty-filtered"
inner = await render_to_sx("test-results-partial",
summary_data=summary_data, sections=sections, has_failures=has_failures)
content = await render_to_sx("test-results-wrap", running=running, inner=SxExpr(inner))
hdr = await render_to_sx_with_env("test-layout-full", _ctx_to_env(ctx),
services=_service_list(),
active_service=active_service,
)
return await full_page_sx(ctx, header_rows=hdr, content=content)
@@ -214,23 +111,31 @@ async def render_results_partial_sx(result: dict | None, running: bool,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""HTMX partial: results section (sx wire format)."""
inner = await _results_partial_sx(result, running, csrf, active_filter, active_service)
return _wrap_results_div_sx(inner, running)
from runner import group_tests_by_service
summary_data = _build_summary_data(result, running, csrf, active_filter)
sections = []
has_failures = "false"
if result and not running:
tests = _filter_tests(result.get("tests", []), active_filter, active_service)
if tests:
sections = group_tests_by_service(tests)
has_failures = str(result["failed"] > 0 or result["errors"] > 0).lower()
else:
summary_data["state"] = "empty-filtered"
inner = await render_to_sx("test-results-partial",
summary_data=summary_data, sections=sections, has_failures=has_failures)
return await render_to_sx("test-results-wrap", running=running, inner=SxExpr(inner))
async def render_test_detail_page_sx(ctx: dict, test: dict) -> str:
"""Full page: test detail (sx wire format)."""
root_hdr = await root_header_sx(ctx)
test_row = await _test_header_sx(ctx)
detail_row = await render_to_sx("menu-row-sx",
id="test-detail-row", level=2, colour="sky",
link_href=f"/test/{test['nodeid']}",
link_label=test["nodeid"].rsplit("::", 1)[-1],
hdr = await render_to_sx_with_env("test-detail-layout-full", _ctx_to_env(ctx),
services=_service_list(),
test_nodeid=test["nodeid"],
test_label=test["nodeid"].rsplit("::", 1)[-1],
)
hdr_child_detail = await header_child_sx(detail_row, id="test-header-child")
inner = "(<> " + test_row + " " + hdr_child_detail + ")"
hdr_child_inner = await header_child_sx(inner)
hdr = "(<> " + root_hdr + " " + hdr_child_inner + ")"
content = await render_to_sx("test-detail",
nodeid=test["nodeid"],
outcome=test["outcome"],