mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-11 11:41:30 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe1f026fe | ||
|
|
374c8a4eb7 | ||
|
|
98c89f9a87 | ||
|
|
3cb68b661e | ||
|
|
772dacc8f7 | ||
|
|
b8f26f5f53 | ||
|
|
487efb8a34 | ||
|
|
81342a98df | ||
|
|
0073018a7a | ||
|
|
0825601c66 | ||
|
|
29ffce9405 | ||
|
|
78493e0e17 | ||
|
|
bf5415555b | ||
|
|
e69a06c2d5 |
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)`
|
||||
|
||||
|
||||
+9
-1
@@ -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)`
|
||||
|
||||
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.150"
|
||||
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.150"
|
||||
__version__ = "0.3.152"
|
||||
@@ -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"
|
||||
|
||||
@@ -373,6 +373,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 +385,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 +405,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"}
|
||||
@@ -1303,9 +1317,11 @@ async def patch_session(request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
title = body.get("title")
|
||||
title = body.get("title") if isinstance(body, dict) else None
|
||||
if title is not None:
|
||||
await _datastore.update_session(session_id, title=title)
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
return JSONResponse({"error": "title must be a non-empty string"}, status_code=400)
|
||||
await _datastore.update_session(session_id, title=title.strip()[:200])
|
||||
return JSONResponse({"status": "updated", "session_id": session_id})
|
||||
|
||||
|
||||
@@ -1723,7 +1739,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)
|
||||
@@ -3191,7 +3207,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
|
||||
|
||||
@@ -367,7 +367,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,8 +466,9 @@ 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. */
|
||||
@@ -1813,6 +1816,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 +1934,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 +1947,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);
|
||||
@@ -1979,9 +1984,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');
|
||||
@@ -2317,7 +2324,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 +2342,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 +3044,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 +3090,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 +3193,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;
|
||||
|
||||
|
||||
@@ -244,6 +244,9 @@ function injectStyles() {
|
||||
.chat-sess-item.active { background:rgba(var(--db-accent-rgb,100,100,255),.15); }
|
||||
.chat-sess-item .sess-title { font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.chat-sess-item .sess-meta { font-size:11px; color:var(--db-text-dim); }
|
||||
.chat-sess-item { position:relative; }
|
||||
.chat-sess-item .sess-rename { position:absolute; top:6px; right:6px; display:none; background:none; border:none; color:var(--db-text-dim); cursor:pointer; font-size:12px; padding:2px 4px; }
|
||||
.chat-sess-item:hover .sess-rename, .chat-sess-item .sess-rename:focus { display:block; }
|
||||
|
||||
.chat-main-panel { flex:1; display:flex; flex-direction:column; min-width:0; }
|
||||
.chat-top-bar { padding:10px 16px; border-bottom:1px solid var(--db-border); display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
@@ -1103,10 +1106,41 @@ function renderSessionList() {
|
||||
`;
|
||||
el.title = id;
|
||||
el.addEventListener('click', () => loadSession(id));
|
||||
if (!platformName && id) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'sess-rename';
|
||||
btn.title = 'Rename session';
|
||||
btn.setAttribute('aria-label', 'Rename session');
|
||||
btn.textContent = '✎';
|
||||
btn.addEventListener('click', (e) => { e.stopPropagation(); renameSession(id, title); });
|
||||
el.appendChild(btn);
|
||||
}
|
||||
list.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
async function renameSession(sessionId, currentTitle) {
|
||||
const next = (window.prompt('Rename session', currentTitle || '') || '').trim();
|
||||
if (!next || next === currentTitle) return;
|
||||
try {
|
||||
const resp = await fetch('/sessions/' + encodeURIComponent(sessionId), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: next }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const s = allSessions.find((x) => (x.id || x.session_id) === sessionId);
|
||||
if (s) { s.title = next; delete s.name; }
|
||||
renderSessionList();
|
||||
if (sessionId === currentSessionId) {
|
||||
const header = document.getElementById('chat-header-title');
|
||||
if (header) header.textContent = next;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Chat] Failed to rename session:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSession(sessionId) {
|
||||
currentSessionId = sessionId;
|
||||
refreshStreamingUI();
|
||||
@@ -1158,7 +1192,8 @@ async function loadSession(sessionId) {
|
||||
title.textContent = (sessionIcon || '📨') + ' ' + displayName;
|
||||
}
|
||||
} else if (title) {
|
||||
title.textContent = 'Session ' + sessionId.substring(0, 8);
|
||||
const cached = allSessions.find((x) => (x.id || x.session_id) === sessionId);
|
||||
title.textContent = (cached && (cached.name || cached.title)) || ('Session ' + sessionId.substring(0, 8));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Chat] Failed to load session:', e);
|
||||
|
||||
+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:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Issue #331 — rename a session from the chat rail via the existing PATCH route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import praisonaiui.server as server
|
||||
from praisonaiui.datastore import MemoryDataStore
|
||||
|
||||
CHAT = Path("src/praisonaiui/templates/frontend/plugins/views/chat.js")
|
||||
|
||||
|
||||
def _client(monkeypatch):
|
||||
ds = MemoryDataStore()
|
||||
monkeypatch.setattr(server, "_datastore", ds)
|
||||
app = Starlette(routes=[Route("/sessions/{session_id}", server.patch_session, methods=["PATCH"])])
|
||||
sid = asyncio.run(ds.create_session())["id"]
|
||||
return TestClient(app), ds, sid
|
||||
|
||||
|
||||
def test_patch_renames_and_trims(monkeypatch):
|
||||
client, ds, sid = _client(monkeypatch)
|
||||
r = client.patch(f"/sessions/{sid}", json={"title": " My chat "})
|
||||
assert r.status_code == 200
|
||||
assert asyncio.run(ds.get_session(sid))["title"] == "My chat"
|
||||
|
||||
|
||||
def test_patch_rejects_blank_or_non_string_title(monkeypatch):
|
||||
client, _, sid = _client(monkeypatch)
|
||||
assert client.patch(f"/sessions/{sid}", json={"title": " "}).status_code == 400
|
||||
assert client.patch(f"/sessions/{sid}", json={"title": 123}).status_code == 400
|
||||
|
||||
|
||||
def test_patch_unknown_session_404(monkeypatch):
|
||||
client, _, _ = _client(monkeypatch)
|
||||
assert client.patch("/sessions/nope", json={"title": "x"}).status_code == 404
|
||||
|
||||
|
||||
def test_rail_has_rename_button_wired_to_patch():
|
||||
src = CHAT.read_text()
|
||||
assert "async function renameSession(sessionId, currentTitle)" in src
|
||||
assert "method: 'PATCH'" in src
|
||||
assert "renameSession(id, title)" in src
|
||||
assert "e.stopPropagation()" 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,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
|
||||
@@ -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"
|
||||
@@ -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