mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-11 17:31:41 -10:00
Compare commits
23
Commits
No files matched your search
+54
-1
@@ -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)`
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
@@ -36,7 +36,7 @@ curl http://localhost:8082/health/live
|
||||
|
||||
### Readiness Check: `/health/ready`
|
||||
|
||||
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection.
|
||||
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection. The provider check runs concurrently and is bounded by the same 0.5s timeout, so a slow provider cannot hang the response (or a cold `/api/health`).
|
||||
|
||||
**Use for:**
|
||||
- Kubernetes readiness probes
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
@@ -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,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.151"
|
||||
__version__ = "0.3.152"
|
||||
@@ -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.
|
||||
@@ -806,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:
|
||||
@@ -1095,11 +1108,18 @@ async def health_ready(request: Request) -> JSONResponse:
|
||||
|
||||
provider = get_provider()
|
||||
provider_info = {"name": type(provider).__name__}
|
||||
try:
|
||||
provider_health = await provider.health()
|
||||
provider_info.update(provider_health)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _safe_provider() -> Dict[str, Any]:
|
||||
# Bounded like feature checks so a slow provider cannot hang readiness
|
||||
# (or the cold /api/health dashboard request that delegates here).
|
||||
try:
|
||||
result = await asyncio.wait_for(provider.health(), timeout=0.5)
|
||||
except Exception:
|
||||
return {}
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
# Start the provider check now so it runs concurrently with feature checks.
|
||||
provider_task = asyncio.ensure_future(_safe_provider())
|
||||
|
||||
features_health = {}
|
||||
sdk_gaps: list = []
|
||||
@@ -1138,6 +1158,8 @@ async def health_ready(request: Request) -> JSONResponse:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
provider_info.update(await provider_task)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"status": "ok",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -203,6 +205,97 @@ class TestHealthEndpoint:
|
||||
assert second.json().get("cached") is True
|
||||
assert duration < 0.1
|
||||
|
||||
def test_slow_provider_does_not_block_readiness(self, client):
|
||||
"""Cold /api/health and /health/ready bound a slow provider (#341)."""
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import praisonaiui.server as _srv
|
||||
|
||||
class _SlowProvider:
|
||||
async def health(self):
|
||||
await asyncio.sleep(5)
|
||||
return {"status": "ok"}
|
||||
|
||||
prev_provider = _srv._provider
|
||||
_srv._provider = _SlowProvider()
|
||||
_srv._health_cache = {}
|
||||
_srv._health_cache_time = 0
|
||||
try:
|
||||
for path in ("/api/health", "/health/ready"):
|
||||
start = time.perf_counter()
|
||||
response = client.get(path)
|
||||
duration = time.perf_counter() - start
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["provider"]["name"] == "_SlowProvider"
|
||||
assert duration < 2.0
|
||||
finally:
|
||||
_srv._provider = prev_provider
|
||||
_srv._health_cache = {}
|
||||
_srv._health_cache_time = 0
|
||||
|
||||
def test_non_dict_provider_health_does_not_500_readiness(self, client):
|
||||
"""A provider returning a non-dict from health() must not break /health/ready."""
|
||||
import praisonaiui.server as _srv
|
||||
|
||||
class _NoneProvider:
|
||||
async def health(self):
|
||||
return None
|
||||
|
||||
prev_provider = _srv._provider
|
||||
_srv._provider = _NoneProvider()
|
||||
try:
|
||||
response = client.get("/health/ready")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["provider"] == {"name": "_NoneProvider"}
|
||||
finally:
|
||||
_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)."""
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user