From 1dbf600af2e91f6aeeb34e8c60bb304856a1846c Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 15:02:59 +0000 Subject: [PATCH] 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 --- blog/sx/layouts.sx | 50 ++++++++ blog/sxc/pages/__init__.py | 108 +++++++++--------- cart/sx/layouts.sx | 78 +++++++++++++ cart/sxc/pages/__init__.py | 148 ++++++++++-------------- market/sx/layouts.sx | 50 ++++++++ market/sxc/pages/__init__.py | 104 +++++++++-------- test/sx/components.sx | 104 +++++++++++++++++ test/sxc/pages/__init__.py | 215 ++++++++++------------------------- 8 files changed, 507 insertions(+), 350 deletions(-) create mode 100644 blog/sx/layouts.sx create mode 100644 cart/sx/layouts.sx create mode 100644 market/sx/layouts.sx create mode 100644 test/sx/components.sx diff --git a/blog/sx/layouts.sx b/blog/sx/layouts.sx new file mode 100644 index 0000000..9058011 --- /dev/null +++ b/blog/sx/layouts.sx @@ -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)) diff --git a/blog/sxc/pages/__init__.py b/blog/sxc/pages/__init__.py index 173b69c..d4077bb 100644 --- a/blog/sxc/pages/__init__.py +++ b/blog/sxc/pages/__init__.py @@ -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) + ")")) diff --git a/cart/sx/layouts.sx b/cart/sx/layouts.sx new file mode 100644 index 0000000..bb34148 --- /dev/null +++ b/cart/sx/layouts.sx @@ -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)) diff --git a/cart/sxc/pages/__init__.py b/cart/sxc/pages/__init__.py index 46894a1..06a36f5 100644 --- a/cart/sxc/pages/__init__.py +++ b/cart/sxc/pages/__init__.py @@ -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) diff --git a/market/sx/layouts.sx b/market/sx/layouts.sx new file mode 100644 index 0000000..3fe0d5f --- /dev/null +++ b/market/sx/layouts.sx @@ -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"))) diff --git a/market/sxc/pages/__init__.py b/market/sxc/pages/__init__.py index 90202b9..20f3666 100644 --- a/market/sxc/pages/__init__.py +++ b/market/sxc/pages/__init__.py @@ -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"))) # =========================================================================== diff --git a/test/sx/components.sx b/test/sx/components.sx new file mode 100644 index 0000000..9e9ac41 --- /dev/null +++ b/test/sx/components.sx @@ -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)))))) diff --git a/test/sxc/pages/__init__.py b/test/sxc/pages/__init__.py index 2a98e5d..299d9db 100644 --- a/test/sxc/pages/__init__.py +++ b/test/sxc/pages/__init__.py @@ -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"],