diff --git a/.agent/workflows/analysis-github-issue-create.md b/.agent/workflows/analysis-github-issue-create.md index 64c37e3..7a24383 100644 --- a/.agent/workflows/analysis-github-issue-create.md +++ b/.agent/workflows/analysis-github-issue-create.md @@ -25,12 +25,10 @@ Deep Analysis → Gap Analysis → Critical Review → Plan → Create GitHub Is ## CANONICAL PATHS ``` -Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents) -Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai) -Tools: /Users/praison/PraisonAI-tools +UI Repo: /Users/praison/praisonaiui (praisonaiui) +SDK Core: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents) Docs: /Users/praison/PraisonAIDocs -TypeScript: /Users/praison/praisonai-package/src/praisonai-ts -Extension: tools/base.py, tools/decorator.py, db/* +Extension: src/praisonaiui/*, docs/*, tests/* ``` --- @@ -91,16 +89,13 @@ Key files to read: Go through ALL relevant files, understand current logic end-to-end. ```bash -# Core SDK +# UI repo +ls /Users/praison/praisonaiui/src/praisonaiui/ +ls /Users/praison/praisonaiui/tests/ +ls /Users/praison/praisonaiui/docs/ + +# Optional cross-check against SDK only when needed ls /Users/praison/praisonai-package/src/praisonai-agents/praisonaiagents/ -grep -r "class.*Protocol" praisonaiagents/ --include="*.py" | wc -l - -# Wrapper -ls /Users/praison/praisonai-package/src/praisonai/praisonai/ -ls /Users/praison/praisonai-package/src/praisonai/praisonai/cli/commands/ - -# TypeScript -ls /Users/praison/praisonai-package/src/praisonai-ts/src/ ``` **Document:** @@ -320,7 +315,7 @@ Working code example. ```bash gh issue create \ - --repo MervinPraison/PraisonAI \ + --repo MervinPraison/PraisonAIUI \ --title ": " \ --body "" ``` @@ -334,7 +329,7 @@ gh issue create \ ### 9b. Verify issue created ```bash -gh issue view --repo MervinPraison/PraisonAI | head -20 +gh issue view --repo MervinPraison/PraisonAIUI | head -20 ``` --- diff --git a/.agent/workflows/analysis.md b/.agent/workflows/analysis.md index 8345bc0..be934d6 100644 --- a/.agent/workflows/analysis.md +++ b/.agent/workflows/analysis.md @@ -17,13 +17,10 @@ PRINCIPLES (apply in all analysis/proposals): - Easy for non-developers. "Few lines of code to do the task!" CANONICAL PATHS: -- Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents) -- Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai) -- Tools: /Users/praison/PraisonAI-tools +- UI Repo: /Users/praison/praisonaiui (praisonaiui) +- SDK Core (reference): /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents) - Docs: /Users/praison/PraisonAIDocs (JS: docs/js, Rust: docs/rust) -- TypeScript: /Users/praison/praisonai-package/src/praisonai-ts -- Rust: /Users/praison/praisonai-package/src/praisonai-rust -- Extension points: tools/base.py, tools/decorator.py, db/* +- Extension points: src/praisonaiui/*, docs/*, tests/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ MANDATORY PROCESS (NO IMPLEMENTATION) @@ -54,7 +51,7 @@ STEP 4 — Detailed Review STEP 5 — Gap Analysis - Compare acceptance criteria vs current state. Checklist: - - Core SDK, Wrapper, Tools/plugins, CLI, Docs, Tests, Exports, Perf, Multi-agent + async + - UI Core, CLI, Docs, Tests, Exports, Perf, Multi-agent + async - Each gap: severity, impact, risk, recommended location (core/wrapper/tools). STEP 6 — Critical Review diff --git a/.windsurf/workflows/analysis-github-issue-create.md b/.windsurf/workflows/analysis-github-issue-create.md index 64c37e3..7a24383 100644 --- a/.windsurf/workflows/analysis-github-issue-create.md +++ b/.windsurf/workflows/analysis-github-issue-create.md @@ -25,12 +25,10 @@ Deep Analysis → Gap Analysis → Critical Review → Plan → Create GitHub Is ## CANONICAL PATHS ``` -Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents) -Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai) -Tools: /Users/praison/PraisonAI-tools +UI Repo: /Users/praison/praisonaiui (praisonaiui) +SDK Core: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents) Docs: /Users/praison/PraisonAIDocs -TypeScript: /Users/praison/praisonai-package/src/praisonai-ts -Extension: tools/base.py, tools/decorator.py, db/* +Extension: src/praisonaiui/*, docs/*, tests/* ``` --- @@ -91,16 +89,13 @@ Key files to read: Go through ALL relevant files, understand current logic end-to-end. ```bash -# Core SDK +# UI repo +ls /Users/praison/praisonaiui/src/praisonaiui/ +ls /Users/praison/praisonaiui/tests/ +ls /Users/praison/praisonaiui/docs/ + +# Optional cross-check against SDK only when needed ls /Users/praison/praisonai-package/src/praisonai-agents/praisonaiagents/ -grep -r "class.*Protocol" praisonaiagents/ --include="*.py" | wc -l - -# Wrapper -ls /Users/praison/praisonai-package/src/praisonai/praisonai/ -ls /Users/praison/praisonai-package/src/praisonai/praisonai/cli/commands/ - -# TypeScript -ls /Users/praison/praisonai-package/src/praisonai-ts/src/ ``` **Document:** @@ -320,7 +315,7 @@ Working code example. ```bash gh issue create \ - --repo MervinPraison/PraisonAI \ + --repo MervinPraison/PraisonAIUI \ --title ": " \ --body "" ``` @@ -334,7 +329,7 @@ gh issue create \ ### 9b. Verify issue created ```bash -gh issue view --repo MervinPraison/PraisonAI | head -20 +gh issue view --repo MervinPraison/PraisonAIUI | head -20 ``` --- diff --git a/.windsurf/workflows/analysis.md b/.windsurf/workflows/analysis.md index 8345bc0..be934d6 100644 --- a/.windsurf/workflows/analysis.md +++ b/.windsurf/workflows/analysis.md @@ -17,13 +17,10 @@ PRINCIPLES (apply in all analysis/proposals): - Easy for non-developers. "Few lines of code to do the task!" CANONICAL PATHS: -- Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents) -- Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai) -- Tools: /Users/praison/PraisonAI-tools +- UI Repo: /Users/praison/praisonaiui (praisonaiui) +- SDK Core (reference): /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents) - Docs: /Users/praison/PraisonAIDocs (JS: docs/js, Rust: docs/rust) -- TypeScript: /Users/praison/praisonai-package/src/praisonai-ts -- Rust: /Users/praison/praisonai-package/src/praisonai-rust -- Extension points: tools/base.py, tools/decorator.py, db/* +- Extension points: src/praisonaiui/*, docs/*, tests/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ MANDATORY PROCESS (NO IMPLEMENTATION) @@ -54,7 +51,7 @@ STEP 4 — Detailed Review STEP 5 — Gap Analysis - Compare acceptance criteria vs current state. Checklist: - - Core SDK, Wrapper, Tools/plugins, CLI, Docs, Tests, Exports, Perf, Multi-agent + async + - UI Core, CLI, Docs, Tests, Exports, Perf, Multi-agent + async - Each gap: severity, impact, risk, recommended location (core/wrapper/tools). STEP 6 — Critical Review diff --git a/docs/api/components.md b/docs/api/components.md index f968d45..0a282fb 100644 --- a/docs/api/components.md +++ b/docs/api/components.md @@ -802,3 +802,136 @@ The `dashboard.js` renderer supports any dict with a `type` key. Unknown types r # Custom component — renders as JSON until frontend support is added {"type": "timeline", "events": [{"time": "10:00", "label": "Deploy"}]} ``` + +--- + +## Component Render Protocol + +Understanding how components flow from Python to the browser. + +### Data Flow + +``` +Python Server Frontend +────── ────── ──────── +aiui.card("X", value=42) → {"type":"card",...} → renderComponent() + ↓ ↓ +@aiui.page() handler → /api/pages/{id}/data → renderCard() + ↓ ↓ +aiui.layout([...]) → {"_components":[...]} → DOM elements +``` + +### Step-by-Step + +1. **Python**: Call `aiui.card("Revenue", value="$1,500")` — returns a dict: + ```python + {"type": "card", "title": "Revenue", "value": "$1,500"} + ``` + +2. **Server**: `@aiui.page()` handler returns `aiui.layout([...])`: + ```python + {"_components": [{"type": "card", ...}, {"type": "table", ...}]} + ``` + +3. **API**: `GET /api/pages/{id}/data` returns the JSON + +4. **Frontend**: `dashboard.js` detects `_components` key and calls `renderComponents()`: + ```javascript + function renderComponents(data, container) { + if (data && data._components) { + data._components.forEach(comp => { + container.appendChild(renderComponent(comp)); + }); + } + } + ``` + +5. **Renderer**: `renderComponent(comp)` dispatches on `comp.type`: + ```javascript + function renderComponent(comp) { + switch (comp.type) { + case 'card': return renderCard(comp); + case 'table': return renderTable(comp); + // ... 48 component types + default: return renderAsJSON(comp); + } + } + ``` + +6. **DOM**: Each renderer creates HTML elements with `db-*` CSS classes + +### The `_components` Key + +The magic key that triggers structured rendering: + +```python +# With _components — renders as UI elements +return {"_components": [aiui.card("X"), aiui.table(...)]} + +# Without _components — renders as raw JSON +return {"status": "ok", "data": [...]} +``` + +`aiui.layout(children)` automatically wraps children in `{"_components": [...]}`. + +### Component Dict Protocol + +Every component function returns a dict with: + +| Key | Required | Description | +|-----|----------|-------------| +| `type` | ✅ | Component type name (e.g. `"card"`, `"table"`) | +| Other keys | Varies | Component-specific properties | + +```python +# Card component +{"type": "card", "title": "Revenue", "value": "$1,500", "footer": "+8%"} + +# Table component +{"type": "table", "headers": ["A", "B"], "rows": [["x", "y"]]} + +# Nested components +{"type": "columns", "children": [ + {"type": "card", "title": "A"}, + {"type": "card", "title": "B"}, +]} +``` + +### CSS Classes + +All components use `db-*` prefixed CSS classes: + +| Component | CSS Class | +|-----------|-----------| +| Card | `.db-card`, `.db-card-title`, `.db-card-value`, `.db-card-footer` | +| Table | `.db-viewer table` | +| Alert | `.db-alert`, `.db-alert-info`, `.db-alert-success`, etc. | +| Badge | `.db-badge`, `.db-badge-secondary`, `.db-badge-destructive` | +| Tabs | `.db-tabs`, `.db-tab-list`, `.db-tab-btn`, `.db-tab-panel` | + +### Extending with Custom Components + +To add a new component type: + +1. **Python**: Add function to `ui.py`: + ```python + def timeline(events: list[dict]) -> dict: + return {"type": "timeline", "events": events} + ``` + +2. **Frontend**: Add renderer to `dashboard.js`: + ```javascript + function renderTimeline(comp) { + const el = document.createElement('div'); + el.className = 'db-timeline'; + // ... render events + return el; + } + + // Add to switch statement in renderComponent() + case 'timeline': return renderTimeline(comp); + ``` + +3. **CSS**: Add styles for `.db-timeline` + +Until the frontend renderer is added, unknown types render as formatted JSON. diff --git a/docs/api/python.md b/docs/api/python.md index e3e4f2d..cfec5ab 100644 --- a/docs/api/python.md +++ b/docs/api/python.md @@ -110,6 +110,251 @@ aiui.remove_page("debug") --- +### `aiui.set_chat_mode(mode, *, position, size, resizable, minimized)` + +Configure chat window display mode — full page, floating window, or sidebar panel. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `mode` | `str` | `"fullpage"` | `"fullpage"`, `"floating"`, or `"sidebar"` | +| `position` | `tuple[int, int]` | `(20, 20)` | (bottom, right) pixel offset for floating mode | +| `size` | `tuple[int, int]` | `(400, 500)` | (width, height) initial size for floating mode | +| `resizable` | `bool` | `True` | Allow resizing the floating window | +| `minimized` | `bool` | `False` | Start minimized (floating mode only) | + +```python +import praisonaiui as aiui + +# Full page chat (default) +aiui.set_chat_mode("fullpage") + +# Floating chat window +aiui.set_chat_mode("floating", position=(20, 20), size=(420, 550)) + +# Sidebar panel +aiui.set_chat_mode("sidebar") +``` + +--- + +### `aiui.set_sidebar_config(*, collapsible, default_collapsed, width, min_width, max_width)` + +Configure sidebar behavior and dimensions. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `collapsible` | `bool` | `True` | Allow sidebar to be collapsed | +| `default_collapsed` | `bool` | `False` | Start with sidebar collapsed | +| `width` | `int` | `260` | Default sidebar width in pixels | +| `min_width` | `int` | `200` | Minimum width when resizing | +| `max_width` | `int` | `360` | Maximum width when resizing | + +```python +import praisonaiui as aiui + +aiui.set_sidebar_config(collapsible=True, width=280) +aiui.set_sidebar_config(default_collapsed=True) # Start collapsed +``` + +--- + +### `aiui.set_brand_color(color)` + +Set the brand/primary accent color. Overrides the theme's default accent. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `color` | `str` | Hex color (e.g. `"#6366f1"`) or CSS color value | + +```python +import praisonaiui as aiui + +aiui.set_brand_color("#818cf8") # Indigo-400 +aiui.set_brand_color("#22c55e") # Green-500 +``` + +--- + +### `aiui.set_chat_features(*, history, streaming, file_upload, audio, reasoning, tools, multimedia, feedback)` + +Configure which chat features are enabled in the UI. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `history` | `bool` | `True` | Show session history sidebar | +| `streaming` | `bool` | `True` | Enable streaming responses | +| `file_upload` | `bool` | `False` | Show file upload button | +| `audio` | `bool` | `False` | Show audio input button | +| `reasoning` | `bool` | `True` | Show reasoning/thinking steps | +| `tools` | `bool` | `True` | Show tool call displays | +| `multimedia` | `bool` | `True` | Enable multimedia rendering | +| `feedback` | `bool` | `False` | Show feedback buttons | + +```python +import praisonaiui as aiui + +# Minimal chat — no history sidebar, no file upload +aiui.set_chat_features(history=False, file_upload=False) + +# Full-featured chat +aiui.set_chat_features( + history=True, + streaming=True, + file_upload=True, + audio=True, + feedback=True, +) +``` + +--- + +### `aiui.set_dashboard(*, sidebar, page_header)` + +Configure dashboard layout options. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `sidebar` | `bool` | `True` | Show the left sidebar navigation | +| `page_header` | `bool` | `True` | Show the page title/description header | + +```python +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.register_theme(name, variables)` + +Register a custom theme preset. The theme becomes available in the theme picker UI and via `/api/theme`. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `name` | `str` | Unique theme name (e.g. `"ocean"`, `"sunset"`) | +| `variables` | `dict[str, str]` | Dict with at least `{"accent": "#hexcolor"}` | + +```python +import praisonaiui as aiui + +# Register a custom theme +aiui.register_theme("ocean", {"accent": "#0077b6"}) + +# With explicit RGB (auto-derived if omitted) +aiui.register_theme("sunset", { + "accent": "#ff6b35", + "accentRgb": "255,107,53", +}) + +# Apply the custom theme +aiui.set_theme(preset="ocean") +``` + +See [CSS Architecture](../features/css-architecture.md) for all available CSS variables. + +--- + +### `aiui.set_custom_js(path)` + +Inject a local JavaScript file into the UI. Reads the file at `path` and +serves it at `/custom.js`. A `' + ) # Server-side theme variable injection (dashboard style) # Only inject accent-related vars with !important so that: @@ -574,10 +646,28 @@ def _build_html(style: str) -> str: '
' f'{react_script}' f'' + f'{_custom_js_tag(cache_bust)}' '' ) +def _custom_js_tag(cache_bust: int) -> str: + """Return a ' + + +async def _serve_custom_js(request: Request) -> Response: + """Serve the user's custom.js set via aiui.set_custom_js().""" + if _custom_js is None: + return Response(status_code=404) + return Response( + content=_custom_js, + media_type="application/javascript", + ) + + # Shared effective style — set by create_app(), read by _plugins_config() _effective_style: str = "chat" @@ -700,6 +790,25 @@ def register_page( register_callback(f"page:{id}", handler) +def register_page_action(page_id: str): + """Decorator to register a form action handler for a page. + + The decorated async function receives the form data dict and should + return a dict that will be JSON-serialized back to the client. + + Example:: + + @aiui.register_page_action("settings") + async def handle_settings(data): + save_settings(data) + return {"status": "saved"} + """ + def decorator(fn: Callable) -> Callable: + _page_actions[page_id] = fn + return fn + return decorator + + async def health(request: Request) -> JSONResponse: """Health check endpoint.""" provider = get_provider() @@ -1139,17 +1248,32 @@ async def api_pages(request: Request) -> JSONResponse: return JSONResponse({"pages": pages}) +async def api_component_schemas(request: Request) -> JSONResponse: + """Return the merged component schema registry (built-in + user-registered). + + Use this endpoint to power client-side validators, codegen, and docs. + """ + return JSONResponse({"schemas": get_component_schemas()}) + + async def api_features(request: Request) -> JSONResponse: - """List all registered feature protocols.""" - features = get_features() - infos = [] - for f in features.values(): + """List all registered feature protocols. + + Calls ``info()`` on every registered feature concurrently (via + ``asyncio.gather``). This avoids the previous N*latency cold-start + time where features with slow imports (e.g. LiteLLM) serialized the + whole response. + """ + features = list(get_features().values()) + + async def _safe_info(f): try: - info = await f.info() - infos.append(info) + return await f.info() except Exception: - infos.append({"name": f.name, "status": "error"}) - return JSONResponse({"features": infos, "count": len(infos)}) + return {"name": f.name, "status": "error"} + + infos = await asyncio.gather(*(_safe_info(f) for f in features)) + return JSONResponse({"features": list(infos), "count": len(infos)}) async def api_gateway_status(request: Request) -> JSONResponse: @@ -1207,6 +1331,28 @@ async def api_page_data(request: Request) -> JSONResponse: return JSONResponse({"error": str(e)}, status_code=500) +async def api_page_action(request: Request) -> JSONResponse: + """Handle form action submissions for a page.""" + page_id = request.path_params["page_id"] + handler = _page_actions.get(page_id) + if not handler: + return JSONResponse( + {"error": f"No action handler for page '{page_id}'"}, + status_code=404, + ) + try: + data = await request.json() + import asyncio as _aio + result = handler(data) + if _aio.iscoroutine(result): + result = await result + if not isinstance(result, dict): + result = {"result": result} + return JSONResponse(result) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + def _get_version() -> str: """Get praisonaiui version.""" try: @@ -2004,8 +2150,11 @@ def create_app( # Page registry protocol Route("/api/pages", api_pages, methods=["GET"]), Route("/api/pages/{page_id}/data", api_page_data, methods=["GET"]), + Route("/api/pages/{page_id}/action", api_page_action, methods=["POST"]), # Feature protocol registry Route("/api/features", api_features, methods=["GET"]), + # Component schema registry (built-in + user-registered) + Route("/api/components/schemas", api_component_schemas, methods=["GET"]), # Gateway status Route("/api/gateway/status", api_gateway_status, methods=["GET"]), # Frontend config JSON (dynamic fallback — static files override if present) @@ -2181,6 +2330,10 @@ def create_app( # so the dynamic endpoint takes priority over the static file. routes.append(Route("/plugins/plugins.json", _plugins_config, methods=["GET"])) + # User-provided custom JS via aiui.set_custom_js(). Always register so that + # it returns 404 when unset (tests rely on this) and serves content otherwise. + routes.append(Route("/custom.js", _serve_custom_js, methods=["GET"])) + if _plugins_dir.exists(): routes.append(Mount("/plugins", app=StaticFiles(directory=str(_plugins_dir)))) if _assets_dir.exists(): diff --git a/src/praisonaiui/templates/frontend/plugins/dashboard.js b/src/praisonaiui/templates/frontend/plugins/dashboard.js index 39b076b..7effa5a 100644 --- a/src/praisonaiui/templates/frontend/plugins/dashboard.js +++ b/src/praisonaiui/templates/frontend/plugins/dashboard.js @@ -9,6 +9,11 @@ // Record load time for overview stats window.__aiuiLoadTime = window.__aiuiLoadTime || Date.now(); +// ── Extensible Component Registry ──────────────────────────────────── +// Maps component type names to custom render functions. +// Custom renderers take priority over the built-in switch in renderComponent(). +const COMPONENT_REGISTRY = {}; + // ── Extensible View Registry ───────────────────────────────────────── // Maps page IDs to view modules. Each module exports render(container). // Built-in views are loaded via dynamic import from ./views/ @@ -52,6 +57,10 @@ window.aiui.registerView = function(pageId, renderFn, cleanupFn) { VIEW_REGISTRY[pageId] = { render: renderFn, cleanup: cleanupFn || null }; }; window.aiui.views = VIEW_REGISTRY; +window.aiui.registerComponent = function(type, renderFn) { + COMPONENT_REGISTRY[type] = renderFn; +}; +window.aiui.components = COMPONENT_REGISTRY; // ── Sidebar State ──────────────────────────────────────────────── let sidebarCollapsed = false; @@ -605,10 +614,6 @@ async function init() { style.textContent = DASHBOARD_STYLE; document.head.appendChild(style); - // Re-append user custom CSS so it takes precedence over DASHBOARD_STYLE defaults - const customCss = document.getElementById('aiui-custom-css'); - if (customCss) document.head.appendChild(customCss); - // Apply theme from config BEFORE building dashboard try { const cfgRes = await fetch('/ui-config.json'); @@ -915,6 +920,10 @@ function renderComponents(data, container) { } function renderComponent(comp) { + // Extensible: check COMPONENT_REGISTRY first (custom renderers take priority) + if (COMPONENT_REGISTRY[comp.type]) { + try { return COMPONENT_REGISTRY[comp.type](comp); } catch (e) { console.warn(`Custom component '${comp.type}' error:`, e); } + } switch (comp.type) { case 'card': return renderCard(comp); case 'columns': return renderColumns(comp); @@ -969,6 +978,7 @@ function renderComponent(comp) { case 'pagination': return renderPagination(comp); case 'key_value_list': return renderKeyValueList(comp); case 'popover': return renderPopover(comp); + case 'form_action': return renderFormAction(comp); default: { const div = document.createElement('div'); div.className = 'db-viewer'; @@ -1219,7 +1229,7 @@ function renderTextInput(comp) { el.className = 'db-form-group'; el.innerHTML = ` ${comp.label ? `` : ''} - + `; return el; } @@ -1233,7 +1243,7 @@ function renderNumberInput(comp) { if (comp.step !== undefined) attrs.push(`step="${comp.step}"`); el.innerHTML = ` ${comp.label ? `` : ''} - + `; return el; } @@ -1249,7 +1259,7 @@ function renderSelectInput(comp) { }).join(''); el.innerHTML = ` ${comp.label ? `` : ''} - + `; return el; } @@ -1264,7 +1274,7 @@ function renderSliderInput(comp) { el.innerHTML = ` ${comp.label ? `` : ''}
- + ${val}
`; @@ -1278,7 +1288,7 @@ function renderCheckboxInput(comp) { const el = document.createElement('div'); el.className = 'db-form-group db-form-checkbox'; el.innerHTML = ` - + `; return el; @@ -1288,24 +1298,32 @@ function renderSwitchInput(comp) { const el = document.createElement('div'); el.className = 'db-form-group db-form-switch'; const on = comp.checked ? ' on' : ''; + // Hidden checkbox mirrors the switch state so form_action can collect its value. el.innerHTML = ` +
${comp.label || ''} `; const track = el.querySelector('.db-switch-track'); - track.addEventListener('click', () => { track.classList.toggle('on'); }); + const hidden = el.querySelector('input.db-switch-hidden'); + track.addEventListener('click', () => { + track.classList.toggle('on'); + hidden.checked = track.classList.contains('on'); + }); return el; } function renderRadioInput(comp) { const el = document.createElement('div'); el.className = 'db-form-group db-form-radio'; - const name = 'radio_' + Math.random().toString(36).slice(2, 8); + // Prefer explicit comp.name (needed for form_action key), fall back to + // random for the HTML radio-group semantic. + const name = comp.name || ('radio_' + Math.random().toString(36).slice(2, 8)); const options = (comp.options || []).map(opt => { const val = typeof opt === 'object' ? opt.value : opt; const label = typeof opt === 'object' ? opt.label : opt; const checked = val === comp.value ? ' checked' : ''; - return ``; + return ``; }).join(''); el.innerHTML = ` ${comp.label ? `
${comp.label}
` : ''} @@ -1320,7 +1338,7 @@ function renderTextareaInput(comp) { const rows = comp.rows || 4; el.innerHTML = ` ${comp.label ? `` : ''} - + `; return el; } @@ -1749,6 +1767,52 @@ function renderPopover(comp) { return el; } +function renderFormAction(comp) { + const form = document.createElement('form'); + form.className = 'db-form-action'; + form.dataset.action = comp.action || ''; + (comp.children || []).forEach(child => form.appendChild(renderComponent(child))); + const btn = document.createElement('button'); + btn.type = 'submit'; + btn.className = 'db-btn db-btn-primary'; + btn.textContent = comp.submit_label || 'Submit'; + form.appendChild(btn); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = {}; + form.querySelectorAll('input, select, textarea').forEach(el => { + const key = el.name || el.dataset.label || el.getAttribute('aria-label') || ''; + if (!key) return; + if (el.type === 'radio') { + // Only store the checked radio in a group; skip unchecked ones. + if (el.checked) data[key] = el.value; + else if (!(key in data)) data[key] = null; + } else if (el.type === 'checkbox') { + data[key] = el.checked; + } else { + data[key] = el.value; + } + }); + btn.disabled = true; + btn.textContent = 'Submitting...'; + try { + const pageId = form.closest('[data-page]')?.dataset?.page || comp.action; + const res = await fetch(`/api/pages/${pageId}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + const result = await res.json(); + btn.textContent = res.ok ? 'Done' : 'Error'; + setTimeout(() => { btn.textContent = comp.submit_label || 'Submit'; btn.disabled = false; }, 2000); + } catch (err) { + btn.textContent = 'Error'; + btn.disabled = false; + } + }); + return form; +} + // ── Test All Features panel ──────────────────────────────── async function showTestPanel() { diff --git a/src/praisonaiui/ui.py b/src/praisonaiui/ui.py index d7eb81a..49009fe 100644 --- a/src/praisonaiui/ui.py +++ b/src/praisonaiui/ui.py @@ -239,15 +239,28 @@ def json_view(data: Any) -> dict: # ── Tier 2: Form Input Components ─────────────────────────────────── -def text_input(label: str, *, value: str = "", placeholder: str = "") -> dict: +def text_input( + label: str, + *, + value: str = "", + placeholder: str = "", + name: str | None = None, +) -> dict: """A text input field. Args: label: Input label value: Default value placeholder: Placeholder text + name: Optional explicit submit-key for ``form_action``. When + omitted, the form collector falls back to ``label``. Use + ``name`` whenever you have duplicate labels or want a stable + machine-readable key. """ - return {"type": "text_input", "label": label, "value": value, "placeholder": placeholder} + comp: dict[str, Any] = {"type": "text_input", "label": label, "value": value, "placeholder": placeholder} + if name is not None: + comp["name"] = name + return comp def number_input( @@ -257,6 +270,7 @@ def number_input( min_val: int | float | None = None, max_val: int | float | None = None, step: int | float = 1, + name: str | None = None, ) -> dict: """A number input field. @@ -266,24 +280,37 @@ def number_input( min_val: Minimum allowed value max_val: Maximum allowed value step: Increment step + name: Optional explicit submit-key for ``form_action``. """ comp: dict[str, Any] = {"type": "number_input", "label": label, "value": value, "step": step} if min_val is not None: comp["min_val"] = min_val if max_val is not None: comp["max_val"] = max_val + if name is not None: + comp["name"] = name return comp -def select_input(label: str, *, options: Sequence[str], value: str = "") -> dict: +def select_input( + label: str, + *, + options: Sequence[str], + value: str = "", + name: str | None = None, +) -> dict: """A dropdown select input. Args: label: Input label options: List of option strings value: Default selected value + name: Optional explicit submit-key for ``form_action``. """ - return {"type": "select_input", "label": label, "options": list(options), "value": value} + comp: dict[str, Any] = {"type": "select_input", "label": label, "options": list(options), "value": value} + if name is not None: + comp["name"] = name + return comp def slider_input( @@ -293,6 +320,7 @@ def slider_input( min_val: int | float = 0, max_val: int | float = 100, step: int | float = 1, + name: str | None = None, ) -> dict: """A slider input. @@ -302,45 +330,84 @@ def slider_input( min_val: Minimum value max_val: Maximum value step: Increment step + name: Optional explicit submit-key for ``form_action``. """ - return { + comp: dict[str, Any] = { "type": "slider_input", "label": label, "value": value, "min_val": min_val, "max_val": max_val, "step": step, } + if name is not None: + comp["name"] = name + return comp -def checkbox_input(label: str, *, checked: bool = False) -> dict: +def checkbox_input( + label: str, + *, + checked: bool = False, + name: str | None = None, +) -> dict: """A checkbox input. Args: label: Checkbox label checked: Default checked state + name: Optional explicit submit-key for ``form_action``. """ - return {"type": "checkbox_input", "label": label, "checked": checked} + comp: dict[str, Any] = {"type": "checkbox_input", "label": label, "checked": checked} + if name is not None: + comp["name"] = name + return comp -def switch_input(label: str, *, checked: bool = False) -> dict: +def switch_input( + label: str, + *, + checked: bool = False, + name: str | None = None, +) -> dict: """A toggle switch input. Args: label: Switch label checked: Default checked state + name: Optional explicit submit-key for ``form_action``. """ - return {"type": "switch_input", "label": label, "checked": checked} + comp: dict[str, Any] = {"type": "switch_input", "label": label, "checked": checked} + if name is not None: + comp["name"] = name + return comp -def radio_input(label: str, *, options: Sequence[str], value: str = "") -> dict: +def radio_input( + label: str, + *, + options: Sequence[str], + value: str = "", + name: str | None = None, +) -> dict: """A radio button group. Args: label: Group label options: List of option strings value: Default selected value + name: Optional explicit submit-key for ``form_action``. """ - return {"type": "radio_input", "label": label, "options": list(options), "value": value} + comp: dict[str, Any] = {"type": "radio_input", "label": label, "options": list(options), "value": value} + if name is not None: + comp["name"] = name + return comp -def textarea_input(label: str, *, value: str = "", placeholder: str = "", rows: int = 4) -> dict: +def textarea_input( + label: str, + *, + value: str = "", + placeholder: str = "", + rows: int = 4, + name: str | None = None, +) -> dict: """A multi-line text area input. Args: @@ -348,8 +415,12 @@ def textarea_input(label: str, *, value: str = "", placeholder: str = "", rows: value: Default value placeholder: Placeholder text rows: Number of visible rows + name: Optional explicit submit-key for ``form_action``. """ - return {"type": "textarea_input", "label": label, "value": value, "placeholder": placeholder, "rows": rows} + comp: dict[str, Any] = {"type": "textarea_input", "label": label, "value": value, "placeholder": placeholder, "rows": rows} + if name is not None: + comp["name"] = name + return comp # ── Tier 3: Layout & Advanced Components ───────────────────────────── @@ -724,3 +795,27 @@ def popover(trigger: dict, *, children: Sequence[dict]) -> dict: children: Content components inside the popover """ return {"type": "popover", "trigger": trigger, "children": list(children)} + + +def form_action( + action: str, + *, + children: Sequence[dict], + submit_label: str = "Submit", +) -> dict: + """A form container that submits input values to the server. + + When the user clicks submit, the frontend collects all input values + from ``children`` and POSTs them to ``/api/pages/{page_id}/action``. + + Args: + action: Action identifier (typically matches the page_id). + children: Form input components (text_input, select_input, etc.) + submit_label: Label for the submit button. Default "Submit". + """ + return { + "type": "form_action", + "action": action, + "children": list(children), + "submit_label": submit_label, + } diff --git a/tests/unit/test_all_exports.py b/tests/unit/test_all_exports.py new file mode 100644 index 0000000..96fc708 --- /dev/null +++ b/tests/unit/test_all_exports.py @@ -0,0 +1,23 @@ +"""Test that every symbol in praisonaiui.__all__ is importable.""" + +from __future__ import annotations + +import importlib + + +def test_all_exports_importable(): + """Every name in __all__ must be accessible via getattr.""" + mod = importlib.import_module("praisonaiui") + missing = [] + for name in mod.__all__: + try: + getattr(mod, name) + except (AttributeError, ImportError) as e: + missing.append((name, str(e))) + assert not missing, f"Failed to import: {missing}" + + +def test_all_exports_count(): + """Sanity check — __all__ should have 80+ symbols.""" + mod = importlib.import_module("praisonaiui") + assert len(mod.__all__) >= 80, f"Only {len(mod.__all__)} exports" diff --git a/tests/unit/test_component_registry.py b/tests/unit/test_component_registry.py new file mode 100644 index 0000000..e2af17f --- /dev/null +++ b/tests/unit/test_component_registry.py @@ -0,0 +1,87 @@ +"""Tests for the component registry extensibility in dashboard.js. + +Verifies: + 1. COMPONENT_REGISTRY object exists + 2. window.aiui.registerComponent() API exists + 3. renderComponent() checks COMPONENT_REGISTRY before hardcoded switch + 4. All Python ui.py component types have a matching JS renderer case +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +# Path to dashboard.js +DASHBOARD_JS = ( + Path(__file__).resolve().parents[2] + / "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "dashboard.js" +) + + +@pytest.fixture +def dashboard_source(): + """Read dashboard.js source.""" + return DASHBOARD_JS.read_text() + + +class TestComponentRegistry: + """Verify the extensible component registry exists.""" + + def test_component_registry_object_exists(self, dashboard_source): + assert "COMPONENT_REGISTRY" in dashboard_source + + def test_register_component_api_exists(self, dashboard_source): + assert "registerComponent" in dashboard_source + + def test_register_component_is_on_window_aiui(self, dashboard_source): + assert "window.aiui.registerComponent" in dashboard_source + + def test_render_component_checks_registry_first(self, dashboard_source): + """renderComponent() must check COMPONENT_REGISTRY before the switch.""" + # Find the renderComponent function + match = re.search( + r"function renderComponent\(comp\)\s*\{(.*?)^}", + dashboard_source, + re.DOTALL | re.MULTILINE, + ) + assert match, "renderComponent function not found" + body = match.group(1) + + # Registry check must come before the switch + registry_pos = body.find("COMPONENT_REGISTRY") + switch_pos = body.find("switch") + assert registry_pos != -1, "COMPONENT_REGISTRY not referenced in renderComponent" + assert registry_pos < switch_pos, ( + "COMPONENT_REGISTRY check must come BEFORE the switch statement" + ) + + +class TestPythonJSComponentParity: + """Every Python component type in ui.py must have a JS renderer.""" + + def test_all_python_types_have_js_case(self, dashboard_source): + """Extract all type strings from ui.py and verify each has a case in dashboard.js.""" + ui_py = ( + Path(__file__).resolve().parents[2] + / "src" / "praisonaiui" / "ui.py" + ) + source = ui_py.read_text() + + # Extract all "type": "xxx" patterns from ui.py + py_types = set(re.findall(r'"type":\s*"(\w+)"', source)) + assert len(py_types) > 40, f"Expected 40+ component types, got {len(py_types)}" + + # Extract all case 'xxx': patterns from dashboard.js renderComponent switch + js_cases = set(re.findall(r"case\s+'(\w+)'", dashboard_source)) + + missing = py_types - js_cases + assert not missing, ( + f"Python component types missing JS renderer: {sorted(missing)}" + ) + + def test_form_action_type_has_js_case(self, dashboard_source): + """The form_action component must have a JS renderer.""" + assert "case 'form_action'" in dashboard_source diff --git a/tests/unit/test_component_schemas.py b/tests/unit/test_component_schemas.py new file mode 100644 index 0000000..22bdbf5 --- /dev/null +++ b/tests/unit/test_component_schemas.py @@ -0,0 +1,121 @@ +"""Tests for the opt-in component schema registry. + +Closes the gap: there was no typed Python <-> JS contract for custom +components, so component dicts could silently drift. + +Design: +- Users call `aiui.register_component_schema("my-type", {...})` to register + a JSON Schema describing their custom component dict. +- Schemas are published at `GET /api/components/schemas` for client-side + validators, codegen, and docs. +- Schemas for all 48 built-in components are provided out of the box. + +The registry is opt-in: unregistered types continue to work as before. +""" + +from __future__ import annotations + +import pytest +from starlette.testclient import TestClient + +from praisonaiui import server as srv +from praisonaiui.datastore import MemoryDataStore + + +@pytest.fixture(autouse=True) +def _clean(): + srv.reset_state() + srv.set_datastore(MemoryDataStore()) + yield + srv.reset_state() + + +@pytest.fixture +def client(): + return TestClient(srv.create_app()) + + +# ── Python API surface ──────────────────────────────────────────── + +class TestRegisterComponentSchema: + def test_is_exported_on_package(self): + import praisonaiui as aiui + assert callable(aiui.register_component_schema) + assert callable(aiui.get_component_schemas) + + def test_register_stores_schema(self): + schema = { + "type": "object", + "required": ["type", "events"], + "properties": { + "type": {"const": "timeline"}, + "events": {"type": "array"}, + }, + } + srv.register_component_schema("timeline", schema) + schemas = srv.get_component_schemas() + assert "timeline" in schemas + assert schemas["timeline"]["required"] == ["type", "events"] + + def test_register_multiple(self): + srv.register_component_schema("a", {"type": "object"}) + srv.register_component_schema("b", {"type": "object"}) + schemas = srv.get_component_schemas() + assert {"a", "b"}.issubset(schemas) + + def test_reset_state_clears_user_schemas(self): + srv.register_component_schema("custom-x", {"type": "object"}) + srv.reset_state() + assert "custom-x" not in srv.get_component_schemas() + + def test_reset_state_preserves_builtins(self): + """Built-in schemas must remain after reset_state().""" + srv.reset_state() + schemas = srv.get_component_schemas() + # These are built-in Python components + assert "card" in schemas + assert "text_input" in schemas + + +# ── Endpoint ────────────────────────────────────────────────────── + +class TestSchemasEndpoint: + def test_endpoint_returns_schemas(self, client): + resp = client.get("/api/components/schemas") + assert resp.status_code == 200 + body = resp.json() + assert "schemas" in body + assert isinstance(body["schemas"], dict) + + def test_endpoint_includes_registered(self, client): + srv.register_component_schema("my-thing", {"type": "object"}) + resp = client.get("/api/components/schemas") + assert resp.status_code == 200 + assert "my-thing" in resp.json()["schemas"] + + def test_endpoint_includes_builtins(self, client): + resp = client.get("/api/components/schemas") + schemas = resp.json()["schemas"] + # At least a sample of the Python ui.py builders + for t in ("card", "text", "chart", "text_input"): + assert t in schemas, f"{t} schema missing" + + +# ── Built-in schema coverage ────────────────────────────────────── + +class TestBuiltinSchemaCoverage: + def test_all_public_ui_types_have_schema(self): + """Every component type emitted by praisonaiui.ui.* must have a schema.""" + import re + from pathlib import Path + ui_src = ( + Path(__file__).resolve().parents[2] + / "src" / "praisonaiui" / "ui.py" + ).read_text() + py_types = set(re.findall(r'"type":\s*"(\w+)"', ui_src)) + schemas = srv.get_component_schemas() + missing = py_types - set(schemas) + assert not missing, ( + f"Built-in component types missing from schema registry: " + f"{sorted(missing)}" + ) diff --git a/tests/unit/test_config_api_functions.py b/tests/unit/test_config_api_functions.py new file mode 100644 index 0000000..270abe0 --- /dev/null +++ b/tests/unit/test_config_api_functions.py @@ -0,0 +1,270 @@ +"""Tests for server config API functions that were missing test coverage. + +Covers: set_chat_mode, set_brand_color, set_chat_features, set_dashboard, + set_sidebar_config, register_theme, configure, remove_page. +Each function is tested for: + 1. Correct global mutation + 2. Correct JSON output via /api/config endpoint + 3. reset_state() properly clears it +""" + +from __future__ import annotations + +import pytest +from starlette.testclient import TestClient + +from praisonaiui import server as srv +from praisonaiui.datastore import MemoryDataStore + + +@pytest.fixture(autouse=True) +def _clean_state(): + """Reset all server state before and after each test.""" + srv.reset_state() + srv.set_datastore(MemoryDataStore()) + yield + srv.reset_state() + + +@pytest.fixture +def client(): + app = srv.create_app() + return TestClient(app) + + +# ── set_chat_mode ───────────────────────────────────────────────── + +class TestSetChatMode: + def test_sets_global(self): + srv.set_chat_mode("floating", position=(30, 40), size=(500, 600)) + assert srv._chat_mode is not None + assert srv._chat_mode["mode"] == "floating" + assert srv._chat_mode["position"] == [30, 40] + assert srv._chat_mode["size"] == [500, 600] + assert srv._chat_mode["resizable"] is True + assert srv._chat_mode["minimized"] is False + + def test_defaults(self): + srv.set_chat_mode() + assert srv._chat_mode["mode"] == "fullpage" + assert srv._chat_mode["position"] == [20, 20] + assert srv._chat_mode["size"] == [400, 500] + + def test_sidebar_mode(self): + srv.set_chat_mode("sidebar") + assert srv._chat_mode["mode"] == "sidebar" + + def test_minimized(self): + srv.set_chat_mode("floating", minimized=True) + assert srv._chat_mode["minimized"] is True + + def test_ui_config_returns_chat_mode(self, client): + srv.set_chat_mode("floating", position=(10, 10)) + resp = client.get("/ui-config.json") + assert resp.status_code == 200 + data = resp.json() + assert data["chat"]["mode"]["mode"] == "floating" + assert data["chat"]["mode"]["position"] == [10, 10] + + def test_reset_clears_chat_mode(self): + srv.set_chat_mode("floating") + srv.reset_state() + assert srv._chat_mode is None + + +# ── set_brand_color ─────────────────────────────────────────────── + +class TestSetBrandColor: + def test_sets_global(self): + srv.set_brand_color("#ff5733") + assert srv._brand_color == "#ff5733" + + def test_ui_config_returns_brand_color(self, client): + srv.set_brand_color("#818cf8") + resp = client.get("/ui-config.json") + assert resp.status_code == 200 + data = resp.json() + assert data["site"]["brandColor"] == "#818cf8" + + def test_ui_config_omits_when_none(self, client): + resp = client.get("/ui-config.json") + assert resp.status_code == 200 + data = resp.json() + assert "brandColor" not in data["site"] + + def test_reset_clears_brand_color(self): + srv.set_brand_color("#abc") + srv.reset_state() + assert srv._brand_color is None + + +# ── set_chat_features ───────────────────────────────────────────── + +class TestSetChatFeatures: + def test_sets_global(self): + srv.set_chat_features(history=False, file_upload=True) + assert srv._chat_features is not None + assert srv._chat_features["history"] is False + assert srv._chat_features["fileUpload"] is True + assert srv._chat_features["streaming"] is True # default + + def test_all_defaults(self): + srv.set_chat_features() + cf = srv._chat_features + assert cf["history"] is True + assert cf["streaming"] is True + assert cf["fileUpload"] is False + assert cf["audio"] is False + assert cf["reasoning"] is True + assert cf["tools"] is True + assert cf["multimedia"] is True + assert cf["feedback"] is False + + def test_ui_config_returns_features(self, client): + srv.set_chat_features(feedback=True) + resp = client.get("/ui-config.json") + data = resp.json() + assert data["chat"]["features"]["feedback"] is True + + def test_ui_config_omits_when_none(self, client): + resp = client.get("/ui-config.json") + data = resp.json() + assert "features" not in data["chat"] + + def test_reset_clears(self): + srv.set_chat_features(audio=True) + srv.reset_state() + assert srv._chat_features is None + + +# ── set_dashboard ───────────────────────────────────────────────── + +class TestSetDashboard: + def test_sets_global(self): + srv.set_dashboard(sidebar=False, page_header=False) + assert srv._dashboard_config == { + "sidebar": False, + "pageHeader": False, + } + + def test_defaults(self): + srv.set_dashboard() + assert srv._dashboard_config["sidebar"] is True + assert srv._dashboard_config["pageHeader"] is True + + def test_ui_config_returns_dashboard(self, client): + srv.set_dashboard(sidebar=False) + resp = client.get("/ui-config.json") + data = resp.json() + assert data["dashboard"]["sidebar"] is False + + def test_ui_config_omits_when_none(self, client): + resp = client.get("/ui-config.json") + data = resp.json() + assert "dashboard" not in data + + def test_reset_clears(self): + srv.set_dashboard(sidebar=False) + srv.reset_state() + assert srv._dashboard_config is None + + +# ── set_sidebar_config ──────────────────────────────────────────── + +class TestSetSidebarConfig: + def test_sets_global(self): + srv.set_sidebar_config(collapsible=True, width=300) + assert srv._dashboard_config is not None + assert srv._dashboard_config["sidebarCollapsible"] is True + assert srv._dashboard_config["sidebarWidth"] == 300 + assert srv._dashboard_config["sidebarMinWidth"] == 200 + assert srv._dashboard_config["sidebarMaxWidth"] == 360 + + def test_default_collapsed(self): + srv.set_sidebar_config(default_collapsed=True) + assert srv._dashboard_config["sidebarCollapsed"] is True + + def test_merges_with_existing_dashboard_config(self): + srv.set_dashboard(sidebar=True, page_header=False) + srv.set_sidebar_config(width=280) + # sidebar and pageHeader from set_dashboard should be preserved + assert srv._dashboard_config["sidebar"] is True + assert srv._dashboard_config["pageHeader"] is False + assert srv._dashboard_config["sidebarWidth"] == 280 + + def test_creates_dashboard_config_if_none(self): + assert srv._dashboard_config is None + srv.set_sidebar_config(collapsible=False) + assert srv._dashboard_config is not None + assert srv._dashboard_config["sidebarCollapsible"] is False + + def test_ui_config_returns_sidebar_fields(self, client): + srv.set_sidebar_config(width=300) + resp = client.get("/ui-config.json") + data = resp.json() + assert data["dashboard"]["sidebarWidth"] == 300 + + def test_reset_clears(self): + srv.set_sidebar_config(width=300) + srv.reset_state() + assert srv._dashboard_config is None + + +# ── register_theme ──────────────────────────────────────────────── + +class TestRegisterTheme: + def test_registers_via_theme_manager(self): + srv.register_theme("ocean", {"accent": "#0077b6"}) + from praisonaiui.features.theme import get_theme_manager + mgr = get_theme_manager() + assert "ocean" in mgr.list_themes() + + def test_multiple_themes(self): + srv.register_theme("sunset", {"accent": "#ff6b35"}) + srv.register_theme("forest", {"accent": "#228b22"}) + from praisonaiui.features.theme import get_theme_manager + mgr = get_theme_manager() + themes = mgr.list_themes() + assert "sunset" in themes + assert "forest" in themes + + def test_reset_clears_custom_themes(self): + srv.register_theme("temp", {"accent": "#123456"}) + srv.reset_state() + from praisonaiui.features.theme import get_theme_manager + mgr = get_theme_manager() + assert "temp" not in mgr.list_themes() + + +# ── configure ───────────────────────────────────────────────────── + +class TestConfigure: + def test_memory_datastore(self): + from praisonaiui._config import configure + configure(datastore="memory") + ds = srv.get_datastore() + assert type(ds).__name__ == "MemoryDataStore" + + def test_json_datastore(self, tmp_path): + from praisonaiui._config import configure + configure(datastore=f"json:{tmp_path}") + ds = srv.get_datastore() + assert type(ds).__name__ == "JSONFileDataStore" + + def test_invalid_datastore_raises(self): + from praisonaiui._config import configure + with pytest.raises(ValueError, match="Unknown datastore"): + configure(datastore="invalid") + + +# ── remove_page ─────────────────────────────────────────────────── + +class TestRemovePage: + def test_remove_existing_page(self): + srv._pages["test-page"] = {"id": "test-page", "title": "Test"} + srv.remove_page("test-page") + assert "test-page" not in srv._pages + + def test_remove_nonexistent_page_no_error(self): + # Should not raise + srv.remove_page("nonexistent") diff --git a/tests/unit/test_custom_js.py b/tests/unit/test_custom_js.py new file mode 100644 index 0000000..f33469e --- /dev/null +++ b/tests/unit/test_custom_js.py @@ -0,0 +1,123 @@ +"""Tests for aiui.set_custom_js(path) — user-side client extension injection. + +This closes the gap documented in how-to-add-a-feature.md: previously, +users had no way to load plugin JS (e.g. registerComponent / registerView) +from their Python app without modifying the package's frontend bundle. +""" + +from __future__ import annotations + +import pytest +from pathlib import Path +from starlette.testclient import TestClient + +from praisonaiui import server as srv +from praisonaiui.datastore import MemoryDataStore + + +@pytest.fixture(autouse=True) +def _clean_state(): + srv.reset_state() + srv.set_datastore(MemoryDataStore()) + yield + srv.reset_state() + + +@pytest.fixture +def client(): + return TestClient(srv.create_app()) + + +@pytest.fixture +def tmp_js(tmp_path: Path): + p = tmp_path / "plugin.js" + p.write_text( + "console.log('custom plugin loaded');\n" + "if (window.aiui) { window.aiui.registerComponent('x', c => " + "document.createTextNode('hi')); }\n" + ) + return p + + +# ── Python API surface ──────────────────────────────────────────── + +class TestSetCustomJsAPI: + def test_set_custom_js_is_exported(self): + import praisonaiui as aiui + assert callable(aiui.set_custom_js), ( + "aiui.set_custom_js must be publicly exported" + ) + + def test_set_custom_js_accepts_path(self, tmp_js): + srv.set_custom_js(str(tmp_js)) + # Should be read and stored + assert srv._custom_js is not None + + def test_set_custom_js_accepts_path_object(self, tmp_js): + srv.set_custom_js(tmp_js) # pathlib.Path + assert srv._custom_js is not None + + def test_reset_state_clears_custom_js(self, tmp_js): + srv.set_custom_js(tmp_js) + srv.reset_state() + assert srv._custom_js is None + + def test_missing_file_raises(self, tmp_path): + with pytest.raises((FileNotFoundError, OSError)): + srv.set_custom_js(tmp_path / "does-not-exist.js") + + +# ── Served content ──────────────────────────────────────────────── + +class TestCustomJsServing: + def test_custom_js_route_serves_content(self, tmp_js, client): + srv.set_custom_js(tmp_js) + # Recreate app to pick up the route + app = srv.create_app() + c = TestClient(app) + resp = c.get("/custom.js") + assert resp.status_code == 200 + assert "custom plugin loaded" in resp.text + assert resp.headers["content-type"].startswith("application/javascript") \ + or resp.headers["content-type"].startswith("text/javascript") + + def test_custom_js_not_served_when_unset(self, client): + resp = client.get("/custom.js") + assert resp.status_code == 404 + + def test_index_injects_script_tag_when_set(self, tmp_js): + srv.set_custom_js(tmp_js) + srv.set_style("dashboard") + app = srv.create_app() + c = TestClient(app) + resp = c.get("/") + assert resp.status_code == 200 + # Script tag with our endpoint must be present + assert "/custom.js" in resp.text, ( + "set_custom_js() must inject