diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index ee4941e..3425178 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -102,6 +102,26 @@ def root_header_sx(ctx: dict, *, oob: bool = False) -> str: ) +def mobile_nav_sx(ctx: dict) -> str: + """Build mobile navigation panel from context fragments (nav_tree, auth_menu).""" + nav_tree = ctx.get("nav_tree") or "" + auth_menu = ctx.get("auth_menu") or "" + if not nav_tree and not auth_menu: + return "" + parts: list[str] = [] + if nav_tree: + nav_tree_sx = _as_sx(nav_tree) + parts.append( + f'(div :class "flex flex-col gap-2 p-3 text-sm" {nav_tree_sx})' + ) + if auth_menu: + auth_sx = _as_sx(auth_menu) + parts.append( + f'(div :class "p-3 border-t border-stone-200" {auth_sx})' + ) + return "(<> " + " ".join(parts) + ")" if parts else "" + + def search_mobile_sx(ctx: dict) -> str: """Build mobile search input as sx call string.""" return sx_call("search-mobile", @@ -279,6 +299,9 @@ def full_page_sx(ctx: dict, *, header_rows: str, meta_html: raw HTML injected into the shell (legacy). meta: sx source for meta tags — auto-hoisted to by sx.js. """ + # Auto-generate mobile nav from context when no menu provided + if not menu: + menu = mobile_nav_sx(ctx) body_sx = sx_call("app-body", header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None, filter=SxExpr(filter) if filter else None, diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index 1cb8f06..1f3f0ca 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -107,6 +107,12 @@ async def execute_io( # Individual handlers # --------------------------------------------------------------------------- +def _clean_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: + """Strip None and NIL values from kwargs for Python interop.""" + from .types import NIL + return {k: v for k, v in kwargs.items() if v is not None and v is not NIL} + + async def _io_frag( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> str: @@ -115,7 +121,7 @@ async def _io_frag( raise ValueError("frag requires service and fragment type") service = str(args[0]) frag_type = str(args[1]) - params = {k: v for k, v in kwargs.items() if v is not None} + params = _clean_kwargs(kwargs) from shared.infrastructure.fragments import fetch_fragment return await fetch_fragment(service, frag_type, params=params or None) @@ -129,7 +135,7 @@ async def _io_query( raise ValueError("query requires service and query name") service = str(args[0]) query_name = str(args[1]) - params = {k: v for k, v in kwargs.items() if v is not None} + params = _clean_kwargs(kwargs) from shared.infrastructure.data_client import fetch_data return await fetch_data(service, query_name, params=params or None) @@ -143,7 +149,7 @@ async def _io_action( raise ValueError("action requires service and action name") service = str(args[0]) action_name = str(args[1]) - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = _clean_kwargs(kwargs) from shared.infrastructure.actions import call_action return await call_action(service, action_name, payload=payload or None) @@ -195,8 +201,12 @@ async def _io_service( if method is None: raise RuntimeError(f"Service has no method: {method_name}") - # Convert kwarg keys from kebab-case to snake_case - clean_kwargs = {k.replace("-", "_"): v for k, v in kwargs.items()} + # Convert kwarg keys from kebab-case to snake_case, NIL → None + from .types import NIL + clean_kwargs = { + k.replace("-", "_"): (None if v is NIL else v) + for k, v in kwargs.items() + } from quart import g result = await method(g.s, **clean_kwargs)