Add widget registry for universal UI decoupling

Introduces a widget system where domains register UI fragments into
named slots (container_nav, container_card, account_page, account_link).
Host apps iterate widgets generically without naming any domain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-19 18:04:13 +00:00
parent dfc324b1be
commit 7882644731
18 changed files with 425 additions and 73 deletions

View File

@@ -93,6 +93,11 @@ class StubCalendarService:
) -> list[CalendarEntryDTO]:
return []
async def confirmed_entries_for_posts(
self, session: AsyncSession, post_ids: list[int],
) -> dict[int, list[CalendarEntryDTO]]:
return {}
class StubMarketService:
async def marketplaces_for_container(

View File

@@ -0,0 +1,90 @@
"""Singleton widget registry for cross-domain UI composition.
Usage::
from shared.services.widget_registry import widgets
# Register at app startup (after domain services)
widgets.add_container_nav(NavWidget(...))
# Query in templates / context processors
for w in widgets.container_nav:
ctx = await w.context_fn(session, container_type="page", ...)
"""
from __future__ import annotations
from shared.contracts.widgets import (
NavWidget,
CardWidget,
AccountPageWidget,
AccountNavLink,
)
class _WidgetRegistry:
"""Central registry holding all widget descriptors.
Widgets are added at startup and read at request time.
Properties return sorted-by-order copies.
"""
def __init__(self) -> None:
self._container_nav: list[NavWidget] = []
self._container_card: list[CardWidget] = []
self._account_pages: list[AccountPageWidget] = []
self._account_nav: list[AccountNavLink] = []
# -- registration ---------------------------------------------------------
def add_container_nav(self, w: NavWidget) -> None:
self._container_nav.append(w)
def add_container_card(self, w: CardWidget) -> None:
self._container_card.append(w)
def add_account_page(self, w: AccountPageWidget) -> None:
self._account_pages.append(w)
# Auto-create a matching internal nav link
slug = w.slug
def _href(s=slug):
from shared.infrastructure.urls import coop_url
return coop_url(f"/auth/{s}/")
self._account_nav.append(AccountNavLink(
label=w.label,
order=w.order,
href_fn=_href,
external=False,
))
def add_account_link(self, link: AccountNavLink) -> None:
self._account_nav.append(link)
# -- read access (sorted copies) ------------------------------------------
@property
def container_nav(self) -> list[NavWidget]:
return sorted(self._container_nav, key=lambda w: w.order)
@property
def container_cards(self) -> list[CardWidget]:
return sorted(self._container_card, key=lambda w: w.order)
@property
def account_pages(self) -> list[AccountPageWidget]:
return sorted(self._account_pages, key=lambda w: w.order)
@property
def account_nav(self) -> list[AccountNavLink]:
return sorted(self._account_nav, key=lambda w: w.order)
def account_page_by_slug(self, slug: str) -> AccountPageWidget | None:
for w in self._account_pages:
if w.slug == slug:
return w
return None
# Module-level singleton — import this everywhere.
widgets = _WidgetRegistry()

View File

@@ -0,0 +1,22 @@
"""Per-domain widget registration.
Called once at startup after domain services are registered.
Only registers widgets for domains that are actually available.
"""
from __future__ import annotations
def register_all_widgets() -> None:
from shared.services.registry import services
if services.has("calendar"):
from .calendar_widgets import register_calendar_widgets
register_calendar_widgets()
if services.has("market"):
from .market_widgets import register_market_widgets
register_market_widgets()
if services.has("cart"):
from .cart_widgets import register_cart_widgets
register_cart_widgets()

View File

@@ -0,0 +1,91 @@
"""Calendar-domain widgets: entries nav, calendar links, card entries, account pages."""
from __future__ import annotations
from shared.contracts.widgets import NavWidget, CardWidget, AccountPageWidget
from shared.services.widget_registry import widgets
from shared.services.registry import services
# -- container_nav: associated entries ----------------------------------------
async def _nav_entries_context(
session, *, container_type, container_id, post_slug, page=1, **kw,
):
entries, has_more = await services.calendar.associated_entries(
session, container_type, container_id, page,
)
return {
"entries": entries,
"has_more": has_more,
"page": page,
"post_slug": post_slug,
}
# -- container_nav: calendar links -------------------------------------------
async def _nav_calendars_context(
session, *, container_type, container_id, post_slug, **kw,
):
calendars = await services.calendar.calendars_for_container(
session, container_type, container_id,
)
return {"calendars": calendars, "post_slug": post_slug}
# -- container_card: confirmed entries for post listings ----------------------
async def _card_entries_batch(session, post_ids):
return await services.calendar.confirmed_entries_for_posts(session, post_ids)
# -- account pages: tickets & bookings ---------------------------------------
async def _tickets_context(session, *, user_id, **kw):
tickets = await services.calendar.user_tickets(session, user_id=user_id)
return {"tickets": tickets}
async def _bookings_context(session, *, user_id, **kw):
bookings = await services.calendar.user_bookings(session, user_id=user_id)
return {"bookings": bookings}
# -- registration entry point ------------------------------------------------
def register_calendar_widgets() -> None:
widgets.add_container_nav(NavWidget(
domain="calendar",
order=10,
context_fn=_nav_entries_context,
template="_widgets/container_nav/calendar_entries.html",
))
widgets.add_container_nav(NavWidget(
domain="calendar_links",
order=20,
context_fn=_nav_calendars_context,
template="_widgets/container_nav/calendar_links.html",
))
widgets.add_container_card(CardWidget(
domain="calendar",
order=10,
batch_fn=_card_entries_batch,
context_key="associated_entries",
template="_widgets/container_card/calendar_entries.html",
))
widgets.add_account_page(AccountPageWidget(
domain="calendar",
slug="tickets",
label="tickets",
order=20,
context_fn=_tickets_context,
template="_types/auth/_tickets_panel.html",
))
widgets.add_account_page(AccountPageWidget(
domain="calendar",
slug="bookings",
label="bookings",
order=30,
context_fn=_bookings_context,
template="_types/auth/_bookings_panel.html",
))

View File

@@ -0,0 +1,15 @@
"""Cart-domain widgets: orders link on account page."""
from __future__ import annotations
from shared.contracts.widgets import AccountNavLink
from shared.services.widget_registry import widgets
from shared.infrastructure.urls import cart_url
def register_cart_widgets() -> None:
widgets.add_account_link(AccountNavLink(
label="orders",
order=100,
href_fn=lambda: cart_url("/orders/"),
external=True,
))

View File

@@ -0,0 +1,24 @@
"""Market-domain widgets: marketplace links on container pages."""
from __future__ import annotations
from shared.contracts.widgets import NavWidget
from shared.services.widget_registry import widgets
from shared.services.registry import services
async def _nav_markets_context(
session, *, container_type, container_id, post_slug, **kw,
):
markets = await services.market.marketplaces_for_container(
session, container_type, container_id,
)
return {"markets": markets, "post_slug": post_slug}
def register_market_widgets() -> None:
widgets.add_container_nav(NavWidget(
domain="market",
order=30,
context_fn=_nav_markets_context,
template="_widgets/container_nav/market_links.html",
))