Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot] a1723b5432 Merge pull request #353 from MervinPraison/claude/issue-341-20261111-footer
fix: dashboard footer connection indicator uses liveness instead of deep health
2026-11-11 19:35:43 +00:00
praisonai-triage-agent[bot] a8ae997fb0 Merge pull request #352 from MervinPraison/claude/issue-339-20261111
fix: shell header branding as a content-sized row, not a flex column
2026-11-11 19:35:41 +00:00
praisonai-triage-agent[bot] f1ace7a3ae Merge pull request #350 from MervinPraison/claude/issue-338-20261111
fix: set_custom_js registrations before dashboard is ready (fixes #338)
2026-11-11 19:35:27 +00:00
praisonai-triage-agent[bot] 3d20f78529 Merge pull request #351 from MervinPraison/claude/issue-341-20261111
fix: bound provider health check in /health/ready and cold /api/health
2026-11-11 19:35:26 +00:00
praisonai-triage-agent[bot] f34aac552a Merge pull request #349 from MervinPraison/claude/issue-334-20261111
fix: align extensibility example docs with set_custom_js
2026-11-11 17:39:00 +00:00
MervinPraison afe1f026fe Release v0.3.152 2026-11-11 12:14:53 +00:00
praisonai-triage-agent[bot] 374c8a4eb7 Merge pull request #343 from MervinPraison/claude/issue-335-20261111
fix: make feature-module doc example instantiable
2026-11-11 12:12:22 +00:00
praisonai-triage-agent[bot] 98c89f9a87 Merge pull request #346 from MervinPraison/claude/issue-340-20261111
fix: explicit dashboard plugin scope for pages and script discovery
2026-11-11 12:12:08 +00:00
praisonai-triage-agent[bot] 3cb68b661e Merge pull request #345 from MervinPraison/claude/issue-337-20261111
fix: pagination exposes a page-change event to the host
2026-11-11 12:12:04 +00:00
praisonai-triage-agent[bot] 772dacc8f7 Merge pull request #347 from MervinPraison/claude/issue-336-20261111
feat: opt-in stable row IDs, selection and row actions for table()
2026-11-11 12:11:51 +00:00
praisonai-triage-agent[bot] b8f26f5f53 Merge pull request #348 from MervinPraison/claude/issue-342-20261111
fix: keyboard-accessible dashboard sidebar navigation
2026-11-11 12:11:48 +00:00
MervinPraisonandClaude Opus 5.5 05ea62bf1c fix: run cleanup of superseded view renders in selectPage (#338)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:48:02 +00:00
MervinPraisonandClaude Opus 5.5 d3a7116225 fix: drop DevTools-paste instruction from example 29 fallback page (fixes #334)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:27:12 +00:00
MervinPraisonandClaude Opus 5.5 341aed5ab8 fix: dashboard footer connection indicator uses /api/health/live (fixes #341)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:21:41 +00:00
MervinPraisonandClaude Opus 5.5 1be805fef6 fix: lay out shell header as a content-sized row above the page (fixes #339)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:16:36 +00:00
MervinPraisonandClaude Opus 5.5 0939339b11 fix: queue early set_custom_js registrations and re-render active page on late registerView (fixes #338)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:15:18 +00:00
MervinPraisonandClaude Opus 5.5 2add8a7687 fix: align extensibility example loading docs with set_custom_js (fixes #334)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:14:58 +00:00
MervinPraisonandClaude Opus 5.5 487efb8a34 fix: keyboard-accessible dashboard sidebar navigation (fixes #342)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:14:14 +00:00
MervinPraisonandClaude Opus 5.5 81342a98df feat: opt-in stable row IDs, selection and row actions for table() (fixes #336)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:14:05 +00:00
MervinPraisonandClaude Opus 5.5 0073018a7a fix: explicit dashboard plugin allowlist for pages and manifests (fixes #340)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:13:22 +00:00
MervinPraisonandClaude Opus 5.5 0825601c66 fix: pagination dispatches aiui:pagination page-change event (fixes #337)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:12:49 +00:00
MervinPraisonandClaude Opus 5.5 29ffce9405 fix: make feature-module doc example instantiable (fixes #335)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:11:15 +00:00
21 changed files with 803 additions and 108 deletions

No files matched your search

+54 -1
View File
@@ -73,6 +73,47 @@ aiui.table(
|-----------|------|----------|-------------|
| `headers` | `Sequence[str]` | ✅ | Column header labels |
| `rows` | `Sequence[Sequence[Any]]` | ✅ | Row data arrays |
| `row_ids` | `Sequence[str]` | — | Stable unique ID per row. Makes rows selectable by click, Enter or Space |
| `actions` | `Sequence[str]` | — | Row action button labels (requires `row_ids`) |
With `row_ids`, the dashboard renderer emits bubbling DOM events from the table, so
a custom view can own query state (search, filters, page, sort) and react to rows:
| Event | `detail` |
|-------|----------|
| `aiui:table-select` | `{rowId, index}` |
| `aiui:table-action` | `{action, rowId}` |
Because events carry the stable `rowId`, selection stays correct after the server
re-sorts or filters rows. Loading, empty and error states compose from existing
components (`aiui.skeleton()`, `aiui.empty()`, `aiui.alert()`):
```js
// Custom view: server-paged, searchable table using only documented APIs.
window.aiui.registerView('companies', (container) => {
const state = { q: '', page: 1 };
const show = (comps) => { container.replaceChildren(...comps.map(window.aiui.renderComponent)); };
async function load() {
show([{ type: 'skeleton', variant: 'card' }]);
try {
const res = await fetch(`/api/companies?q=${encodeURIComponent(state.q)}&page=${state.page}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { rows, total } = await res.json(); // [{id, name, status}], count
if (!rows.length) return show([{ type: 'empty', text: 'No results' }]);
show([{ type: 'table', headers: ['Name', 'Status'],
rows: rows.map(r => [r.name, r.status]),
row_ids: rows.map(r => r.id), actions: ['Edit'] },
{ type: 'caption', text: `${total} companies` }]);
} catch (e) {
show([{ type: 'alert', variant: 'error', message: 'Could not load companies. Try again.' }]);
}
}
container.addEventListener('aiui:table-select', (e) => openDetail(e.detail.rowId));
container.addEventListener('aiui:table-action', (e) => editRow(e.detail.rowId));
// On search/filter change: state.q = value; state.page = 1; load();
load();
});
```
### `aiui.text(content)`
@@ -705,7 +746,7 @@ aiui.breadcrumb([
|-----------|------|----------|-------------|
| `items` | `Sequence[dict]` | ✅ | List of `{"label": str, "href": str\|None}` |
### `aiui.pagination(*, total, page, per_page)`
### `aiui.pagination(*, total, page, per_page, id)`
Pagination controls.
@@ -718,6 +759,18 @@ aiui.pagination(total=250, page=3, per_page=25)
| `total` | `int` | ✅ | — | Total items |
| `page` | `int` | ❌ | `1` | Current page (1-based) |
| `per_page` | `int` | ❌ | `10` | Items per page |
| `id` | `str` | ❌ | `None` | Identifier echoed in the page-change event |
The control is optimistic: clicking ←, →, or a page number updates the active
page and dispatches a bubbling `aiui:pagination` DOM event. Buttons that would
go out of bounds are disabled and emit nothing. `page` is clamped to the valid range.
```javascript
container.addEventListener('aiui:pagination', (e) => {
const { id, page, per_page, total } = e.detail;
renderRows(rows.slice((page - 1) * per_page, page * per_page)); // or fetch()
});
```
### `aiui.key_value_list(items, *, title)`
+9 -1
View File
@@ -208,7 +208,7 @@ aiui.set_chat_features(
---
### `aiui.set_dashboard(*, sidebar, page_header)`
### `aiui.set_dashboard(*, sidebar, page_header, modules, plugin_dirs, plugins)`
Configure dashboard layout options.
@@ -216,6 +216,9 @@ Configure dashboard layout options.
|-----------|------|---------|-------------|
| `sidebar` | `bool` | `True` | Show the left sidebar navigation |
| `page_header` | `bool` | `True` | Show the page title/description header |
| `modules` | `list[str] \| None` | `None` | Optional dashboard JS modules (`jobs`, `auth`, `api`) |
| `plugin_dirs` | `list[str] \| None` | `None` | Extra directories to scan for dashboard plugins |
| `plugins` | `list[str] \| None` | `None` | Allowlist of dashboard plugin names. `None` = all discovered (bundled, `~/.praisonai/dashboard-plugins`, `plugin_dirs`); `[]` = none |
```python
import praisonaiui as aiui
@@ -223,8 +226,13 @@ import praisonaiui as aiui
aiui.set_style("dashboard")
aiui.set_dashboard(sidebar=False) # Chat-only dashboard, no sidebar
aiui.set_dashboard(page_header=False) # Hide page titles
aiui.set_dashboard(plugins=[]) # No auto-discovered dashboard plugins
```
`plugins` filters both the sidebar pages (`/api/pages`) and the browser plugin
manifest (`/api/dashboard/plugins`), so disabled plugin scripts are never loaded.
It scopes navigation only — it is **not** an authorization boundary for API routes.
---
### `aiui.set_chat_preview(enabled, *, surface_id, width)`
+16 -4
View File
@@ -259,19 +259,31 @@ from starlette.responses import JSONResponse
class MyFeature(BaseFeatureProtocol):
name = "my-feature"
feature_name = "my-feature"
description = "Example feature with GET and PATCH routes"
def routes(self):
return [
Route("/api/my-feature", self._handler, methods=["GET"]),
Route("/api/my-feature", self._get, methods=["GET"]),
Route("/api/my-feature/{item_id}", self._patch, methods=["PATCH"]),
]
async def _handler(self, request):
return JSONResponse({"data": "hello"})
async def _get(self, request):
# Query parameters: GET /api/my-feature?q=hello
return JSONResponse({"data": request.query_params.get("q", "hello")})
async def _patch(self, request):
# Path parameter + JSON body: PATCH /api/my-feature/42
body = await request.json()
return JSONResponse({"ok": True, "id": request.path_params["item_id"], **body})
# Register before the app is created — create_app() mounts feature
# routes ahead of the SPA fallback, so no route reordering is needed.
register_feature(MyFeature())
```
`name` and `description` are abstract on `BaseFeatureProtocol`; omitting
either raises `TypeError: Can't instantiate abstract class`.
See [Protocol Architecture](protocol-architecture.md).
---
@@ -1,4 +1,4 @@
# Example 28 — Full Extensibility Showcase
# Example 29 — Full Extensibility Showcase
Demonstrates **every** extension point in PraisonAIUI.
@@ -45,6 +45,9 @@ The `timeline` component and the `custom-view` page are client-side extensions.
> **Note:** `aiui.set_custom_js(path)` injects client-side JS from a file path at page load.
> See `docs/features/how-to-add-a-feature.md` for the extension API contract.
> **Tip:** if a custom view renders its own heading, call
> `aiui.set_dashboard(page_header=False)` to hide the default page title/description header.
## Testing the form action
1. Navigate to **Contact Form**
+4 -5
View File
@@ -1,4 +1,4 @@
"""Example 28 — Full Extensibility Showcase.
"""Example 29 — Full Extensibility Showcase.
Demonstrates EVERY extension point in PraisonAIUI:
@@ -203,10 +203,9 @@ async def custom_view_fallback():
aiui.callout(
title="Plugin not loaded",
content=(
"This page is meant to be rendered client-side. "
"Open DevTools → Console and paste the contents of "
"plugin.js (in this example folder) to "
"activate the custom view."
"This page is meant to be rendered client-side by "
"plugin.js, which app.py loads via aiui.set_custom_js(). "
"If you see this, check that /custom.js loaded."
),
variant="warning",
),
@@ -5,9 +5,11 @@
* 1. window.aiui.registerComponent(type, renderFn) — adds a new component type
* 2. window.aiui.registerView(pageId, render, cleanup) — adds a custom page view
*
* To load this file, paste its contents into the browser DevTools Console
* after the page loads. (A Python-side API to inject custom JS is a planned
* enhancement — see 28-full-extensibility/README.md.)
* Loaded automatically by app.py, which serves it at /custom.js:
*
* aiui.set_custom_js(Path(__file__).with_name("plugin.js"))
*
* See 29-full-extensibility/README.md.
*/
(function () {
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.151"
version = "0.3.152"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.151"
__version__ = "0.3.152"
+31 -7
View File
@@ -32,8 +32,18 @@ def _plugin_search_dirs(extra_dirs: list[str] | None = None) -> list[Path]:
return dirs
def discover_dashboard_plugins(extra_dirs: list[str] | None = None) -> list[dict[str, Any]]:
"""Scan dashboard-plugins folders for manifest.json files."""
def discover_dashboard_plugins(
extra_dirs: list[str] | None = None,
allowed: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Scan dashboard-plugins folders for manifest.json files.
``allowed`` restricts results to the given plugin names; ``None`` keeps
every discovered plugin and ``[]`` disables all of them.
"""
if allowed is not None and not allowed:
return []
allow = set(allowed) if allowed is not None else None
manifests: list[dict[str, Any]] = []
seen: set[str] = set()
@@ -53,6 +63,8 @@ def discover_dashboard_plugins(extra_dirs: list[str] | None = None) -> list[dict
if name in seen:
continue
seen.add(name)
if allow is not None and name not in allow:
continue
tab = data.get("tab") or {}
path = tab.get("path") or f"/{name}"
page_id = path.strip("/").split("/")[0] or name
@@ -80,8 +92,13 @@ def discover_dashboard_plugins(extra_dirs: list[str] | None = None) -> list[dict
return manifests
def _resolve_plugin_file(name: str, subpath: str, extra_dirs: list[str] | None) -> Path | None:
for manifest in discover_dashboard_plugins(extra_dirs):
def _resolve_plugin_file(
name: str,
subpath: str,
extra_dirs: list[str] | None,
allowed: list[str] | None = None,
) -> Path | None:
for manifest in discover_dashboard_plugins(extra_dirs, allowed):
if manifest["name"] != name:
continue
root = Path(manifest["root"])
@@ -95,18 +112,25 @@ def _resolve_plugin_file(name: str, subpath: str, extra_dirs: list[str] | None)
def make_dashboard_plugin_routes(
get_extra_dirs,
get_allowed=None,
) -> list[Route]:
"""Factory: ``get_extra_dirs`` returns plugin dir list from server config."""
"""Factory: ``get_extra_dirs`` returns plugin dir list from server config.
``get_allowed`` optionally returns the plugin-name allowlist (``None`` = all).
"""
def _allowed() -> list[str] | None:
return get_allowed() if get_allowed else None
async def list_plugins(request: Request) -> JSONResponse:
extra = get_extra_dirs()
return JSONResponse({"plugins": discover_dashboard_plugins(extra)})
return JSONResponse({"plugins": discover_dashboard_plugins(extra, _allowed())})
async def serve_plugin_asset(request: Request) -> Response:
name = request.path_params["name"]
subpath = request.path_params["path"]
extra = get_extra_dirs()
file_path = _resolve_plugin_file(name, subpath, extra)
file_path = _resolve_plugin_file(name, subpath, extra, _allowed())
if not file_path:
return Response(status_code=404)
media = "text/javascript"
+33 -6
View File
@@ -276,9 +276,11 @@ def set_custom_js(path: str | Path) -> None:
Reads the file at ``path`` and serves it at ``/custom.js``. A
``<script src="/custom.js">`` tag is injected into the host HTML
*after* the plugin loader, so ``window.aiui`` and all registry APIs
(``registerView``, ``registerComponent``) are ready when your code
runs.
*after* the plugin loader. ``window.aiui`` registry APIs
(``registerView``, ``registerComponent``, ``registerSlot``,
``registerSurfaceRenderer``) can be called directly at load time:
calls made before the dashboard module is ready are queued and
replayed, and a view registered for the active page re-renders it.
Use this to ship client-side extensions from your Python app without
modifying the package bundle.
@@ -373,6 +375,7 @@ def set_dashboard(
page_header: bool = True,
modules: list[str] | None = None,
plugin_dirs: list[str] | None = None,
plugins: list[str] | None = None,
) -> None:
"""Configure dashboard layout options.
@@ -384,6 +387,11 @@ def set_dashboard(
page_header: Show the page title/description header (default True).
modules: Optional JS modules to load (``jobs``, ``auth``, ``api``).
plugin_dirs: Extra paths to scan for ``dashboard-plugins`` manifests.
plugins: Allowlist of dashboard plugin names to enable. ``None``
(default) enables every discovered plugin (bundled, home and
``plugin_dirs``); ``[]`` disables all. Applies to both the sidebar
pages and the browser plugin manifest. This scopes navigation only;
it is not an authorization boundary for API routes.
Example::
@@ -399,6 +407,14 @@ def set_dashboard(
"modules": list(modules) if modules else [],
"pluginDirs": list(plugin_dirs) if plugin_dirs else [],
}
if plugins is not None:
_dashboard_config["plugins"] = list(plugins)
def _dashboard_plugin_allowlist() -> list[str] | None:
"""Plugin-name allowlist from ``set_dashboard(plugins=...)`` (``None`` = all)."""
allowed = (_dashboard_config or {}).get("plugins")
return list(allowed) if allowed is not None else None
_jobs_api_config: dict[str, str] = {"apiBase": "/api/jobs", "backend": "aiui"}
@@ -792,7 +808,18 @@ def _custom_js_tag(cache_bust: int) -> str:
"""Return a <script src="/custom.js"> tag if set_custom_js() was called."""
if _custom_js is None:
return ""
return f'<script src="/custom.js?v={cache_bust}"></script>'
return _CUSTOM_JS_QUEUE + f'<script src="/custom.js?v={cache_bust}"></script>'
# Registration stubs for /custom.js: the dashboard module loads
# asynchronously, so until it defines the real registry APIs these stubs
# queue calls on ``window.aiui.__queue``; dashboard.js replays them.
_CUSTOM_JS_QUEUE = (
"<script>(function(a){a.__queue=a.__queue||[];"
"['registerView','registerComponent','registerSlot','registerSurfaceRenderer']"
".forEach(function(n){if(!a[n])a[n]=function(){a.__queue.push([n,arguments]);};});"
"})(window.aiui=window.aiui||{});</script>"
)
async def _serve_custom_js(request: Request) -> Response:
@@ -1734,7 +1761,7 @@ async def api_pages(request: Request) -> JSONResponse:
pages = list(_pages.values())
extra_dirs = (_dashboard_config or {}).get("pluginDirs") or []
known_ids = {p["id"] for p in pages}
for manifest in discover_dashboard_plugins(extra_dirs):
for manifest in discover_dashboard_plugins(extra_dirs, _dashboard_plugin_allowlist()):
page = manifest.get("page")
if page and page["id"] not in known_ids:
pages.append(page)
@@ -3202,7 +3229,7 @@ def create_app(
cfg = _dashboard_config or {}
return list(cfg.get("pluginDirs") or cfg.get("plugin_dirs") or [])
routes.extend(make_dashboard_plugin_routes(_dashboard_plugin_dirs))
routes.extend(make_dashboard_plugin_routes(_dashboard_plugin_dirs, _dashboard_plugin_allowlist))
# ── Gap S1: Auto-initialize gateway in standalone mode ──────────
# When running standalone (not via AIUIGateway.start()), we need to
@@ -241,6 +241,10 @@ const aiuiSdk = {
window.aiui = window.aiui || {};
window.aiui.registerView = function(pageId, renderFn, cleanupFn) {
VIEW_REGISTRY[pageId] = { render: renderFn, cleanup: cleanupFn || null };
// Late registration for the page already on screen: re-render it.
if (pageId === activePageId && document.getElementById('db-main-content')) {
selectPage(pageId);
}
};
window.aiui.views = VIEW_REGISTRY;
window.aiui.registerComponent = function(type, renderFn) {
@@ -291,6 +295,14 @@ const DASHBOARD_STYLE = `
/* Root becomes the flex container */
#root { display: flex; min-height: 100vh; }
/* With a shell:header slot: sidebar | (header row sized to content, page panel below). */
#root.db-active:has(> #db-shell-header-slot) {
display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
height: 100vh; min-height: 0;
}
#root.db-active:has(> #db-shell-header-slot) > .db-sidebar { grid-column: 1; grid-row: 1 / -1; }
#db-shell-header-slot { grid-column: 2; grid-row: 1; min-width: 0; padding: 8px 40px 0; }
#root.db-active:has(> #db-shell-header-slot) > .db-main { grid-column: 2; grid-row: 2; height: auto; min-height: 0; }
/* Sidebar */
.db-sidebar {
@@ -367,7 +379,9 @@ const DASHBOARD_STYLE = `
border-left: 3px solid transparent;
text-decoration: none;
}
button.db-nav-item { width: 100%; background: none; border-top: 0; border-right: 0; border-bottom: 0; font: inherit; text-align: left; }
.db-nav-item:hover { background: var(--db-hover); color: var(--db-text); }
.db-nav-item:focus-visible { outline: 2px solid var(--db-accent); outline-offset: -2px; }
.db-nav-item.active {
color: var(--db-accent); background: var(--db-accent-glow);
border-left-color: var(--db-accent); font-weight: 500;
@@ -464,13 +478,13 @@ const DASHBOARD_STYLE = `
.db-mobile-header { display: flex; }
/* !important: buildSidebar() sets an inline position:relative for its toggle,
which otherwise keeps the hidden sidebar's 260px in the flex row. */
.db-sidebar { position: fixed !important; top: 0; left: 0; z-index: 45; transform: translateX(-100%); transition: transform 0.2s ease; }
.db-sidebar.db-sidebar-open { transform: translateX(0); }
/* visibility keeps links in the closed drawer out of the tab order. */
.db-sidebar { position: fixed !important; top: 0; left: 0; z-index: 45; transform: translateX(-100%); visibility: hidden; transition: transform 0.2s ease, visibility 0s linear 0.2s; }
.db-sidebar.db-sidebar-open { transform: translateX(0); visibility: visible; transition: transform 0.2s ease; }
.db-mobile-overlay.db-visible { display: block; }
.db-main { padding-top: 48px; }
/* The shell header slot sits in the same flex row; give it its own line. */
#root.db-active { flex-wrap: wrap; }
#db-shell-header-slot { flex: 0 0 100%; margin-top: 48px; }
/* Sidebar is fixed here; the header row sits below the fixed mobile bar. */
#db-shell-header-slot { margin-top: 48px; padding: 8px 16px 0; }
#db-shell-header-slot + .db-main { padding-top: 16px; }
}
@media (max-width: 600px) {
@@ -1813,6 +1827,32 @@ function setPageIcon(span, page, fallback) {
if (svg) { span.textContent = ''; span.appendChild(svg); } else span.textContent = page.icon || fallback;
}
// Sidebar entry: a real link for page routes (keyboard reachable, open-in-new-tab
// works) or a button for actions. Plain left clicks stay in the SPA.
function navEntry(page, fallbackIcon, onAction) {
const el = document.createElement(onAction ? 'button' : 'a');
el.className = 'db-nav-item';
if (onAction) {
el.type = 'button';
el.addEventListener('click', onAction);
} else {
el.dataset.navId = page.id;
el.href = '/' + encodeURIComponent(page.id);
el.addEventListener('click', (e) => {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
selectPage(page.id);
});
}
const iconSpan = document.createElement('span');
iconSpan.className = 'db-nav-icon';
iconSpan.setAttribute('aria-hidden', 'true');
setPageIcon(iconSpan, page, fallbackIcon);
el.appendChild(iconSpan);
el.appendChild(document.createTextNode(' ' + page.title));
return el;
}
function buildSidebar(pages) {
const sidebar = document.createElement('nav');
sidebar.className = 'db-sidebar';
@@ -1905,18 +1945,7 @@ function buildSidebar(pages) {
label.textContent = groupName;
sidebar.appendChild(label);
groupPages.forEach(page => {
const item = document.createElement('div');
item.className = 'db-nav-item';
item.dataset.navId = page.id;
const iconSpan = document.createElement('span');
iconSpan.className = 'db-nav-icon';
setPageIcon(iconSpan, page, '📄');
item.appendChild(iconSpan);
item.appendChild(document.createTextNode(' ' + page.title));
item.addEventListener('click', () => selectPage(page.id));
sidebar.appendChild(item);
});
groupPages.forEach(page => sidebar.appendChild(navEntry(page, '📄')));
}
// Footer pages — render any page with position='footer' at the sidebar bottom
@@ -1929,21 +1958,8 @@ function buildSidebar(pages) {
sidebar.appendChild(spacer);
for (const page of footerPages) {
const btn = document.createElement('div');
btn.className = 'db-nav-item';
const iconSpan = document.createElement('span');
iconSpan.className = 'db-nav-icon';
setPageIcon(iconSpan, page, '⚙');
btn.appendChild(iconSpan);
btn.appendChild(document.createTextNode(' ' + page.title));
btn.addEventListener('click', () => {
// Use built-in handler if available, otherwise navigate to page
if (page.id === 'inspector') {
showTestPanel();
} else {
selectPage(page.id);
}
});
// Inspector opens a built-in panel (an action); others are page links
const btn = navEntry(page, '⚙', page.id === 'inspector' ? showTestPanel : null);
btn.style.borderTop = '1px solid var(--db-border)';
btn.style.marginTop = '8px';
sidebar.appendChild(btn);
@@ -1967,6 +1983,12 @@ function showDashboardInitError(message) {
banner.textContent = message;
}
// A render superseded by a newer selectPage never became active, so run its
// own cleanup to release intervals/listeners it set up.
function _supersededCleanup(fn) {
if (fn) { try { fn(); } catch (e) {} }
}
async function selectPage(pageId) {
// Cleanup previous view if it has a cleanup function
if (_activeCleanup) { try { _activeCleanup(); } catch(e) {} _activeCleanup = null; }
@@ -1979,9 +2001,11 @@ async function selectPage(pageId) {
}
// Update nav active state
document.querySelectorAll('.db-nav-item').forEach(el => el.classList.remove('active'));
const active = document.querySelector(`[data-nav-id="${pageId}"]`);
if (active) active.classList.add('active');
document.querySelectorAll('.db-nav-item').forEach(el => {
const on = el.dataset.navId === pageId;
el.classList.toggle('active', on);
if (on) el.setAttribute('aria-current', 'page'); else el.removeAttribute('aria-current');
});
const page = pagesData.find(p => p.id === pageId);
const main = document.getElementById('db-main-content');
@@ -2035,9 +2059,11 @@ async function selectPage(pageId) {
// Priority 1: Custom registered view
if (VIEW_REGISTRY[pageId]) {
const view = VIEW_REGISTRY[pageId];
try {
await VIEW_REGISTRY[pageId].render(container);
_activeCleanup = VIEW_REGISTRY[pageId].cleanup || null;
await view.render(container);
if (!container.isConnected) { _supersededCleanup(view.cleanup); return; } // superseded by a newer selectPage
_activeCleanup = view.cleanup || null;
rendered = true;
} catch (e) { console.warn(`View '${pageId}' render error:`, e); }
}
@@ -2048,17 +2074,21 @@ async function selectPage(pageId) {
const viewUrl = BUILTIN_VIEWS[pageId] + '?v=' + Date.now();
const mod = await import(viewUrl);
if (mod.render) {
if (!container.isConnected) return; // superseded by a newer selectPage
await mod.render(container);
if (!container.isConnected) { _supersededCleanup(mod.cleanup); return; }
_activeCleanup = mod.cleanup || null;
rendered = true;
// Cache for future use
VIEW_REGISTRY[pageId] = { render: mod.render, cleanup: mod.cleanup || null };
// Cache for future use (never overwrite a view registered meanwhile)
if (!VIEW_REGISTRY[pageId]) {
VIEW_REGISTRY[pageId] = { render: mod.render, cleanup: mod.cleanup || null };
}
}
} catch (e) { console.warn(`Built-in view '${pageId}' import error:`, e); }
}
// Priority 3: Generic JSON viewer fallback
if (!rendered) {
if (!rendered && container.isConnected) {
loadGenericViewer(page, container);
}
@@ -2317,7 +2347,17 @@ function renderTable(comp) {
table.appendChild(thead);
}
rows.forEach(row => {
// Opt-in row selection/actions (#336): only when stable row_ids are given.
const rowIds = Array.isArray(comp.row_ids) && comp.row_ids.length === rows.length ? comp.row_ids : null;
const actions = rowIds && Array.isArray(comp.actions) ? comp.actions : [];
const emit = (name, detail) => wrapper.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
if (actions.length && headers.length) {
const th = document.createElement('th');
th.style.cssText = 'padding:8px 12px;border-bottom:1px solid var(--db-border);';
table.firstChild.appendChild(th);
}
rows.forEach((row, index) => {
const tr = document.createElement('tr');
row.forEach(cell => {
const td = document.createElement('td');
@@ -2325,6 +2365,43 @@ function renderTable(comp) {
td.textContent = cell !== null && cell !== undefined ? cell : '';
tr.appendChild(td);
});
if (rowIds) {
const rowId = String(rowIds[index]);
tr.dataset.rowId = rowId;
tr.tabIndex = 0;
tr.style.cursor = 'pointer';
tr.setAttribute('aria-selected', 'false');
const select = () => {
table.querySelectorAll('tr[aria-selected="true"]').forEach(r => {
r.setAttribute('aria-selected', 'false');
r.style.background = '';
});
tr.setAttribute('aria-selected', 'true');
tr.style.background = 'var(--db-hover)';
emit('aiui:table-select', { rowId, index });
};
tr.addEventListener('click', select);
tr.addEventListener('keydown', (e) => {
if (e.target === tr && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(); }
});
if (actions.length) {
const td = document.createElement('td');
td.style.cssText = 'padding:4px 12px;border-bottom:1px solid var(--db-border);text-align:right;white-space:nowrap;';
actions.forEach(action => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'db-btn';
btn.textContent = action;
btn.setAttribute('aria-label', `${action} ${row[0] ?? rowId}`);
btn.addEventListener('click', (e) => {
e.stopPropagation();
emit('aiui:table-action', { action, rowId });
});
td.appendChild(btn);
});
tr.appendChild(td);
}
}
table.appendChild(tr);
});
@@ -2990,24 +3067,44 @@ function renderBreadcrumb(comp) {
}
function renderPagination(comp) {
const el = document.createElement('div');
// Optimistic control: the active page updates locally, then a bubbling
// `aiui:pagination` CustomEvent ({id, page, per_page, total}) notifies the
// host so it can re-slice local rows or fetch the requested page.
const el = document.createElement('nav');
el.className = 'db-pagination';
const total = comp.total || 0;
const perPage = comp.per_page || 10;
const totalPages = Math.ceil(total / perPage);
let currentPage = comp.page || 1;
el.setAttribute('aria-label', 'Pagination');
if (comp.id) el.dataset.id = comp.id;
const total = Math.max(0, comp.total || 0);
const perPage = Math.max(1, comp.per_page || 10);
const totalPages = Math.max(1, Math.ceil(total / perPage));
let currentPage = Math.min(Math.max(1, comp.page || 1), totalPages);
function go(page) {
if (page < 1 || page > totalPages || page === currentPage) return;
currentPage = page;
render();
el.dispatchEvent(new CustomEvent('aiui:pagination', {
bubbles: true,
detail: { id: comp.id || null, page, per_page: perPage, total },
}));
}
function button(label, ariaLabel, page, disabled) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = label;
btn.setAttribute('aria-label', ariaLabel);
btn.disabled = disabled;
btn.addEventListener('click', () => go(page));
return btn;
}
function render() {
el.innerHTML = '';
const prev = document.createElement('button');
prev.textContent = '←';
prev.disabled = currentPage <= 1;
prev.addEventListener('click', () => { if (currentPage > 1) { currentPage--; render(); } });
el.appendChild(prev);
el.appendChild(button('←', 'Previous page', currentPage - 1, currentPage <= 1));
for (let i = 1; i <= Math.min(totalPages, 7); i++) {
const btn = document.createElement('button');
btn.textContent = i;
if (i === currentPage) btn.classList.add('active');
btn.addEventListener('click', () => { currentPage = i; render(); });
const btn = button(String(i), `Page ${i}`, i, false);
if (i === currentPage) {
btn.classList.add('active');
btn.setAttribute('aria-current', 'page');
}
el.appendChild(btn);
}
if (totalPages > 7) {
@@ -3016,11 +3113,7 @@ function renderPagination(comp) {
dots.style.cssText = 'padding:0 4px;color:var(--db-text-dim)';
el.appendChild(dots);
}
const next = document.createElement('button');
next.textContent = '→';
next.disabled = currentPage >= totalPages;
next.addEventListener('click', () => { if (currentPage < totalPages) { currentPage++; render(); } });
el.appendChild(next);
el.appendChild(button('→', 'Next page', currentPage + 1, currentPage >= totalPages));
}
render();
return el;
@@ -3123,7 +3216,10 @@ async function showTestPanel() {
if (location.pathname !== '/inspector') {
history.pushState({ pageId: '__test__' }, '', '/inspector');
}
document.querySelectorAll('.db-nav-item').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.db-nav-item').forEach(el => {
el.classList.remove('active');
el.removeAttribute('aria-current');
});
const main = document.getElementById('db-main-content');
if (!main) return;
@@ -3444,23 +3540,21 @@ window.aiui.registerSlot('shell:header', () => {
window.aiui.registerSlot('shell:sidebar:footer', () => {
const el = document.createElement('div');
el.innerHTML = '<span class="db-health-dot" style="color:var(--db-text-dim)">●</span> <span class="db-health-label">Checking…</span>';
fetch('/api/health').then((r) => r.json()).then((d) => {
// Connection indicator uses side-effect-free liveness (#341). Deep
// feature/SDK-gap diagnostics remain at /api/health (Debug view).
fetch('/api/health/live').then((r) => r.json()).then((d) => {
const ok = d && (d.status === 'ok' || d.status === 'healthy');
const dot = el.querySelector('.db-health-dot');
const label = el.querySelector('.db-health-label');
const gaps = (d && d.sdk_gaps) || [];
if (dot) dot.style.color = ok && !gaps.length ? '#22c55e' : (gaps.length ? '#f59e0b' : '#ef4444');
if (label) {
if (gaps.length) {
label.textContent = `SDK gaps (${gaps.length})`;
label.title = gaps.map((g) => `${g.feature}: ${g.message}`).join('\n');
} else {
label.textContent = ok ? (d.integrated ? 'Integrated' : 'Connected') : 'Degraded';
label.title = '';
}
}
if (dot) dot.style.color = ok ? '#22c55e' : '#ef4444';
if (label) label.textContent = ok ? 'Connected' : 'Degraded';
}).catch(() => { const label = el.querySelector('.db-health-label'); if (label) label.textContent = 'Offline'; });
return el;
});
// Replay registrations queued by the /custom.js stubs (see server.py).
(window.aiui.__queue || []).splice(0).forEach(([name, args]) => {
try { window.aiui[name](...args); } catch (e) { console.warn(`[AIUI] queued ${name} failed:`, e); }
});
export { init, onContentChange };
+33 -3
View File
@@ -110,18 +110,38 @@ def table(
*,
headers: Sequence[str],
rows: Sequence[Sequence[Any]],
row_ids: Sequence[str] | None = None,
actions: Sequence[str] | None = None,
) -> dict:
"""A data table.
Args:
headers: Column header labels
rows: List of row arrays
row_ids: Optional stable ID per row. When given, rows are selectable
(click, Enter or Space) and emit a bubbling ``aiui:table-select``
DOM event with ``detail = {rowId, index}``.
actions: Optional row action labels (requires ``row_ids``). Each row
gets one button per action, emitting a bubbling
``aiui:table-action`` DOM event with ``detail = {action, rowId}``.
"""
return {
comp: dict[str, Any] = {
"type": "table",
"headers": list(headers),
"rows": [list(r) for r in rows],
}
if row_ids is not None:
ids = [str(i) for i in row_ids]
if len(ids) != len(comp["rows"]):
raise ValueError("row_ids must have one entry per row")
if len(set(ids)) != len(ids):
raise ValueError("row_ids must be unique")
comp["row_ids"] = ids
if actions:
if row_ids is None:
raise ValueError("actions require row_ids")
comp["actions"] = [str(a) for a in actions]
return comp
def text(content: str) -> dict:
@@ -829,15 +849,25 @@ def breadcrumb(items: Sequence[dict]) -> dict:
return {"type": "breadcrumb", "items": list(items)}
def pagination(*, total: int, page: int = 1, per_page: int = 10) -> dict:
def pagination(
*, total: int, page: int = 1, per_page: int = 10, id: str | None = None
) -> dict:
"""Pagination controls.
Clicking a page updates the control and dispatches a bubbling
``aiui:pagination`` DOM event with ``{id, page, per_page, total}`` so
the host can re-slice or fetch rows.
Args:
total: Total number of items
page: Current page number (1-based)
per_page: Items per page
id: Optional identifier echoed in the event to tell controls apart
"""
return {"type": "pagination", "total": total, "page": page, "per_page": per_page}
comp: dict[str, Any] = {"type": "pagination", "total": total, "page": page, "per_page": per_page}
if id is not None:
comp["id"] = id
return comp
def definition_list(items: Sequence[dict], *, title: str | None = None) -> dict:
+43
View File
@@ -122,3 +122,46 @@ class TestCustomJsServing:
"custom.js must be injected AFTER plugin-loader.js so window.aiui "
"APIs are available when custom.js runs"
)
# ── Registration lifecycle (issue #338) ───────────────────────────
_DASHBOARD_JS = (
Path(srv.__file__).parent / "templates" / "frontend" / "plugins" / "dashboard.js"
)
class TestCustomJsRegistrationLifecycle:
def test_queue_stub_injected_before_custom_js(self, tmp_js):
"""Registry stubs must exist before /custom.js runs (dashboard loads async)."""
srv.set_custom_js(tmp_js)
srv.set_style("dashboard")
html = TestClient(srv.create_app()).get("/").text
stub_idx = html.find("__queue")
assert stub_idx != -1, "registration queue stub must be injected"
assert stub_idx < html.find("/custom.js")
for name in ("registerView", "registerComponent", "registerSlot",
"registerSurfaceRenderer"):
assert name in html[stub_idx:html.find("/custom.js")]
def test_no_queue_stub_when_unset(self, client):
assert "__queue" not in client.get("/").text
def test_dashboard_replays_queued_registrations(self):
src = _DASHBOARD_JS.read_text(encoding="utf-8")
replay_idx = src.find("window.aiui.__queue")
assert replay_idx != -1, "dashboard.js must replay queued registrations"
# Replay must run after `let activePageId` is initialised (no TDZ error).
assert replay_idx > src.find("let activePageId")
def test_late_register_view_rerenders_active_page(self):
src = _DASHBOARD_JS.read_text(encoding="utf-8")
start = src.find("window.aiui.registerView = function")
body = src[start:src.find("\n};", start)]
assert "activePageId" in body and "selectPage(pageId)" in body
def test_superseded_render_runs_its_cleanup(self):
"""A render replaced by a newer selectPage must still be cleaned up."""
src = _DASHBOARD_JS.read_text(encoding="utf-8")
assert "_supersededCleanup(view.cleanup)" in src
assert "_supersededCleanup(mod.cleanup)" in src
+68
View File
@@ -0,0 +1,68 @@
"""Regression tests for #342 — dashboard sidebar navigation keyboard semantics.
Sidebar entries were clickable ``div``s: not focusable, no role, and the
current page was only a CSS class. They are now native links (pages) or
buttons (actions such as the Inspector) with ``aria-current`` kept in sync.
"""
from __future__ import annotations
import re
from pathlib import Path
DASHBOARD_JS = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "dashboard.js"
)
def _src() -> str:
return DASHBOARD_JS.read_text(encoding="utf-8")
def _fn(name: str) -> str:
m = re.search(rf"function {name}\([^)]*\)\s*\{{(.*?)^}}", _src(), re.DOTALL | re.MULTILINE)
assert m, f"{name} not found"
return m.group(1)
def test_nav_items_are_not_divs():
src = _src()
assert not re.search(
r"createElement\('div'\);\s*\n\s*\w+\.className = 'db-nav-item'", src
), "db-nav-item must not be a clickable div"
def test_nav_entry_uses_link_or_button():
body = _fn("navEntry")
assert "onAction ? 'button' : 'a'" in body
assert "el.href = '/' + encodeURIComponent(page.id)" in body
assert "el.type = 'button'" in body
def test_nav_link_keeps_modified_clicks_native():
body = _fn("navEntry")
assert "e.preventDefault()" in body
for mod in ("metaKey", "ctrlKey", "shiftKey", "altKey", "e.button !== 0"):
assert mod in body
def test_sidebar_and_footer_use_nav_entry():
body = _fn("buildSidebar")
assert "navEntry(page, '📄')" in body
assert "navEntry(page, '⚙', page.id === 'inspector' ? showTestPanel : null)" in body
def test_select_page_syncs_aria_current():
m = re.search(r"async function selectPage\(pageId\)\s*\{(.*?)^}", _src(), re.DOTALL | re.MULTILINE)
assert m
body = m.group(1)
assert "setAttribute('aria-current', 'page')" in body
assert "removeAttribute('aria-current')" in body
def test_focus_visible_and_closed_drawer_hidden():
src = _src()
assert ".db-nav-item:focus-visible" in src
assert re.search(r"\.db-sidebar \{ position: fixed !important;[^}]*visibility: hidden", src)
assert re.search(r"\.db-sidebar\.db-sidebar-open \{[^}]*visibility: visible", src)
+71
View File
@@ -0,0 +1,71 @@
"""Dashboard plugin scope: ``set_dashboard(plugins=...)`` applies to pages and manifests."""
import json
import pytest
from starlette.testclient import TestClient
import praisonaiui.dashboard_plugins as dp
import praisonaiui.server as srv
def _make_plugin(root, name, label=None):
d = root / name
d.mkdir(parents=True)
(d / "manifest.json").write_text(json.dumps({"name": name, "label": label or name}))
(d / "index.js").write_text("// plugin")
@pytest.fixture
def env(tmp_path, monkeypatch):
home = tmp_path / "home"
bundled = tmp_path / "bundled"
extra = tmp_path / "extra"
_make_plugin(home / ".praisonai" / "dashboard-plugins", "home-plug")
_make_plugin(home / ".praisonai" / "dashboard-plugins", "dup", label="from-home")
_make_plugin(bundled, "bundled-plug")
_make_plugin(bundled, "dup", label="from-bundled")
bad = bundled / "broken"
bad.mkdir()
(bad / "manifest.json").write_text("{not json")
_make_plugin(extra, "extra-plug")
monkeypatch.setattr(dp.Path, "home", classmethod(lambda cls: home))
monkeypatch.setattr(dp, "_BUILTIN_PLUGIN_ROOT", bundled)
srv.reset_state()
srv.set_style("dashboard")
srv.set_pages([])
srv.register_page("synthetic", title="Synthetic", handler=lambda: {"_components": []})
yield str(extra)
srv.reset_state()
def _state():
client = TestClient(srv.create_app())
pages = [p["id"] for p in client.get("/api/pages").json()["pages"]]
plugins = {p["name"]: p for p in client.get("/api/dashboard/plugins").json()["plugins"]}
return client, pages, plugins
def test_default_keeps_all_discovered(env):
srv.set_dashboard(plugin_dirs=[env])
_, pages, plugins = _state()
assert set(plugins) == {"home-plug", "dup", "bundled-plug", "extra-plug"}
assert plugins["dup"]["label"] == "from-home" # first root wins on duplicates
assert {"synthetic", "home-plug", "bundled-plug", "extra-plug", "dup"} <= set(pages)
def test_empty_disables_all_plugins(env):
srv.set_dashboard(plugin_dirs=[env], plugins=[])
client, pages, plugins = _state()
assert plugins == {}
assert pages == ["synthetic"]
assert client.get("/dashboard-plugins/bundled-plug/index.js").status_code == 404
def test_allowlist_filters_pages_and_manifest(env):
srv.set_dashboard(plugin_dirs=[env], plugins=["extra-plug", "missing"])
client, pages, plugins = _state()
assert set(plugins) == {"extra-plug"}
assert sorted(pages) == ["extra-plug", "synthetic"]
assert client.get("/dashboard-plugins/extra-plug/index.js").status_code == 200
assert client.get("/dashboard-plugins/home-plug/index.js").status_code == 404
+45
View File
@@ -0,0 +1,45 @@
"""Smoke test for the feature-module example in docs/features/how-to-add-a-feature.md (#335)."""
from starlette.responses import JSONResponse
from starlette.routing import Route
from starlette.testclient import TestClient
from praisonaiui.features import BaseFeatureProtocol, _features, register_feature
from praisonaiui.server import create_app
# Mirrors section 7 of docs/features/how-to-add-a-feature.md — keep in sync.
class MyFeature(BaseFeatureProtocol):
name = "my-feature"
description = "Example feature with GET and PATCH routes"
def routes(self):
return [
Route("/api/my-feature", self._get, methods=["GET"]),
Route("/api/my-feature/{item_id}", self._patch, methods=["PATCH"]),
]
async def _get(self, request):
return JSONResponse({"data": request.query_params.get("q", "hello")})
async def _patch(self, request):
body = await request.json()
return JSONResponse({"ok": True, "id": request.path_params["item_id"], **body})
def test_doc_feature_example_routes():
saved = dict(_features)
try:
register_feature(MyFeature())
client = TestClient(create_app())
r = client.get("/api/my-feature", params={"q": "hi"})
assert r.status_code == 200
assert r.json() == {"data": "hi"}
r = client.patch("/api/my-feature/42", json={"title": "x"})
assert r.status_code == 200
assert r.json() == {"ok": True, "id": "42", "title": "x"}
finally:
_features.clear()
_features.update(saved)
+91
View File
@@ -0,0 +1,91 @@
"""Pagination exposes a page-change event to the host (issue #337)."""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
from praisonaiui.ui import pagination
DASHBOARD_JS = (
Path(__file__).resolve().parents[2]
/ "src/praisonaiui/templates/frontend/plugins/dashboard.js"
)
def test_helper_id_optional():
assert "id" not in pagination(total=10)
assert pagination(total=10, id="users")["id"] == "users"
def _render_pagination_src() -> str:
src = DASHBOARD_JS.read_text()
m = re.search(r"function renderPagination\(comp\) \{.*?\n\}\n", src, re.S)
assert m
return m.group(0)
# Minimal DOM shim so the renderer runs under plain Node.
_HARNESS = r"""
class CustomEvent { constructor(t, o) { this.type = t; this.detail = o.detail; this.bubbles = o.bubbles; } }
class El {
constructor(tag) { this.tag = tag; this.children = []; this.attrs = {}; this.dataset = {};
this.listeners = {}; this.disabled = false; this.textContent = '';
const cls = new Set(); this.classList = { add: c => cls.add(c), has: c => cls.has(c) }; this.style = {}; }
set innerHTML(v) { this.children = []; }
appendChild(c) { this.children.push(c); return c; }
setAttribute(k, v) { this.attrs[k] = v; }
addEventListener(t, f) { (this.listeners[t] ||= []).push(f); }
dispatchEvent(e) { (this.listeners[e.type] || []).forEach(f => f(e)); }
click() { if (!this.disabled) (this.listeners.click || []).forEach(f => f()); }
}
const document = { createElement: t => new El(t) };
__SRC__
const out = {};
function mk(comp) {
const el = renderPagination(comp); const events = [];
el.addEventListener('aiui:pagination', e => events.push(e.detail));
const btn = l => el.children.find(c => c.attrs && c.attrs['aria-label'] === l);
const active = () => el.children.find(c => c.attrs && c.attrs['aria-current'] === 'page').textContent;
return { el, events, btn, active };
}
// next / prev / numbered, exactly once each
let a = mk({ total: 30, page: 1, per_page: 10, id: 'a' });
let b = mk({ total: 30, page: 1, per_page: 10, id: 'b' });
a.btn('Previous page').click(); // disabled at bound
a.btn('Next page').click();
a.btn('Page 3').click();
a.btn('Next page').click(); // disabled at bound
a.btn('Page 3').click(); // same page: no event
a.btn('Previous page').click();
out.a = a.events; out.aActive = a.active(); out.b = b.events; out.bActive = b.active();
out.tag = a.el.tag; out.label = a.el.attrs['aria-label'];
// clamping
out.clampHigh = mk({ total: 25, page: 99, per_page: 10 }).active();
out.clampLow = mk({ total: 0, page: 0, per_page: 0 }).active();
console.log(JSON.stringify(out));
"""
@pytest.mark.skipif(shutil.which("node") is None, reason="node not installed")
def test_pagination_dispatches_page_change(tmp_path):
script = tmp_path / "h.js"
script.write_text(_HARNESS.replace("__SRC__", _render_pagination_src()))
res = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True)
out = json.loads(res.stdout)
d = {"per_page": 10, "total": 30}
assert out["a"] == [
{"id": "a", "page": 2, **d},
{"id": "a", "page": 3, **d},
{"id": "a", "page": 2, **d},
]
assert out["aActive"] == "2"
assert out["b"] == [] and out["bActive"] == "1"
assert out["tag"] == "nav" and out["label"] == "Pagination"
assert out["clampHigh"] == "3"
assert out["clampLow"] == "1"
+10 -1
View File
@@ -139,10 +139,19 @@ def test_replay_page_uses_own_svg_mark_not_emoji_heading():
def test_dashboard_mobile_shell_gives_content_the_width():
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
assert ".db-sidebar { position: fixed !important;" in dash
assert "#db-shell-header-slot { flex: 0 0 100%;" in dash
assert "#db-shell-header-slot { margin-top: 48px;" in dash
assert ".db-main { padding: 64px 16px 16px; }" in dash
def test_shell_header_is_a_content_sized_row_not_a_column():
"""#339: the header slot must not take a flex column or stretch on mobile."""
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
assert "#root.db-active:has(> #db-shell-header-slot) {" in dash
assert "grid-template-rows: auto minmax(0, 1fr);" in dash
assert "#db-shell-header-slot { grid-column: 2; grid-row: 1;" in dash
assert "#root.db-active { flex-wrap: wrap; }" not in dash
def test_theme_defaults_to_system():
view = VIEW.read_text(encoding="utf-8")
assert "@media (prefers-color-scheme:dark){.rp-root[data-theme=\"system\"]" in view
+45
View File
@@ -25,6 +25,8 @@ def client():
_srv._DEEP_HEALTH_CACHE = {}
_srv._DEEP_HEALTH_CACHE_TIME = 0.0
_srv._health_cache = {}
_srv._health_cache_time = 0
# Snapshot and clear the global feature registry so a feature leaked by an
# earlier test (e.g. one that crashed before its cleanup) cannot slow down
# /health and flake the deep-health timing/cache assertions under the full
@@ -252,6 +254,49 @@ class TestHealthEndpoint:
_srv._provider = prev_provider
class TestFooterLiveness:
"""Dashboard footer uses side-effect-free liveness (#341)."""
def test_live_makes_no_provider_or_feature_calls(self, client):
import praisonaiui.server as _srv
from praisonaiui.features import _features
calls = {"provider": 0, "feature": 0}
class _Provider:
async def health(self):
calls["provider"] += 1
return {}
class _Feature:
async def health(self):
calls["feature"] += 1
return {"healthy": True}
original = _srv._provider
_srv._provider = _Provider()
_features["__count__"] = _Feature()
try:
assert client.get("/api/health/live").status_code == 200
assert calls == {"provider": 0, "feature": 0}
finally:
_srv._provider = original
_features.pop("__count__", None)
def test_sidebar_footer_uses_liveness_endpoint(self):
from pathlib import Path
import praisonaiui
js = (
Path(praisonaiui.__file__).parent
/ "templates/frontend/plugins/dashboard.js"
).read_text(encoding="utf-8")
footer = js[js.index("shell:sidebar:footer"):]
assert "fetch('/api/health/live')" in footer
assert "fetch('/api/health')" not in footer
class TestProviderHealthEndpoint:
"""Tests for the provider health JSON route (#151)."""
+71
View File
@@ -0,0 +1,71 @@
"""Tests for #336 — opt-in stable row IDs, selection and row actions on table()."""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from praisonaiui import table
DASHBOARD_JS = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "dashboard.js"
)
class TestTableHelper:
def test_static_payload_unchanged(self):
assert table(headers=["A"], rows=[["x"]]) == {
"type": "table", "headers": ["A"], "rows": [["x"]],
}
def test_row_ids_and_actions(self):
t = table(headers=["Name"], rows=[["Acme"], ["Beta"]],
row_ids=["c1", "c2"], actions=["Edit"])
assert t["row_ids"] == ["c1", "c2"]
assert t["actions"] == ["Edit"]
def test_row_ids_length_mismatch(self):
with pytest.raises(ValueError):
table(headers=["A"], rows=[["x"], ["y"]], row_ids=["1"])
def test_row_ids_unique(self):
with pytest.raises(ValueError):
table(headers=["A"], rows=[["x"], ["y"]], row_ids=["1", "1"])
def test_actions_require_row_ids(self):
with pytest.raises(ValueError):
table(headers=["A"], rows=[["x"]], actions=["Edit"])
class TestRenderTable:
def _body(self) -> str:
src = DASHBOARD_JS.read_text(encoding="utf-8")
m = re.search(r"function renderTable\(comp\)\s*\{(.*?)^}", src,
re.DOTALL | re.MULTILINE)
assert m, "renderTable not found"
return m.group(1)
def test_selection_is_opt_in_and_uses_row_id(self):
body = self._body()
assert "comp.row_ids" in body
assert "aiui:table-select" in body
assert "rowId" in body
def test_keyboard_and_pointer_selection(self):
body = self._body()
assert "tabIndex = 0" in body
assert "'click', select" in body
assert "'Enter'" in body and "' '" in body
assert "aria-selected" in body
def test_row_actions_are_buttons(self):
body = self._body()
assert "aiui:table-action" in body
assert "btn.type = 'button'" in body
assert "stopPropagation" in body
def test_events_bubble(self):
assert "bubbles: true" in self._body()
Generated
+1 -1
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.151"
version = "0.3.152"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },