Refactor navigation handling and enhance server-side features:

- Implement dynamic navigation with `get_navigation` endpoint and `NavigationLoader` in JS.
- Add basic auth support for Playwright tests.
- Refactor template navigation handling to use server-provided items.
- Improve workflow package handling in front-end and back-end.
This commit is contained in:
2026-01-09 19:47:01 +00:00
parent 90c16be8dd
commit 716fb9cbcb
7 changed files with 175 additions and 28 deletions

View File

@@ -245,6 +245,40 @@ def load_workflow_packages():
packages.append(data)
return packages
def get_navigation_items():
return [
{
"section": "dashboard",
"icon": "speedometer2",
"label_key": "ui.nav.dashboard",
"default_label": "Dashboard"
},
{
"section": "workflow",
"icon": "diagram-3",
"label_key": "ui.nav.workflow",
"default_label": "Workflow"
},
{
"section": "prompt",
"icon": "file-text",
"label_key": "ui.nav.prompt",
"default_label": "Prompt"
},
{
"section": "settings",
"icon": "gear",
"label_key": "ui.nav.settings",
"default_label": "Settings"
},
{
"section": "translations",
"icon": "translate",
"label_key": "ui.nav.translations",
"default_label": "Translations"
}
]
@app.get("/", response_class=HTMLResponse)
async def read_item(request: Request, username: str = Depends(get_current_user)):
logs = get_recent_logs()
@@ -343,12 +377,12 @@ async def get_status(username: str = Depends(get_current_user)):
}
@app.get("/api/workflow/plugins", response_class=JSONResponse)
async def get_workflow_plugins(username: str = Depends(get_current_user)):
async def get_workflow_plugins():
metadata = get_metadata()
return metadata.get("workflow_plugins", {})
@app.get("/api/workflow/packages", response_class=JSONResponse)
async def list_workflow_packages(username: str = Depends(get_current_user)):
async def list_workflow_packages():
packages = load_workflow_packages()
summarized = []
for package in packages:
@@ -361,13 +395,17 @@ async def list_workflow_packages(username: str = Depends(get_current_user)):
return {"packages": summarized}
@app.get("/api/workflow/packages/{package_id}", response_class=JSONResponse)
async def get_workflow_package(package_id: str, username: str = Depends(get_current_user)):
async def get_workflow_package(package_id: str):
packages = load_workflow_packages()
for package in packages:
if package.get("id") == package_id:
return package
raise HTTPException(status_code=404, detail="Workflow package not found")
@app.get("/api/navigation", response_class=JSONResponse)
async def get_navigation():
return {"items": get_navigation_items()}
@app.get("/api/logs")
async def get_logs(username: str = Depends(get_current_user)):
return {"logs": get_recent_logs()}

View File

@@ -5,9 +5,19 @@
const translations = window.AMB_I18N || {};
const t = (key, fallback = '') => translations[key] || fallback || key;
const format = (text, values = {}) => text.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? '');
const authHeaders = (() => {
const username = window.location.username || '';
const password = window.location.password || '';
if (!username && !password) return {};
const token = btoa(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`);
return { Authorization: `Basic ${token}` };
})();
const fetchWorkflowPlugins = async () => {
const response = await fetch('/api/workflow/plugins', { credentials: 'include' });
const response = await fetch('/api/workflow/plugins', {
credentials: 'include',
headers: authHeaders
});
if (!response.ok) {
throw new Error(`Plugin fetch failed: ${response.status}`);
}
@@ -15,13 +25,69 @@
};
const fetchWorkflowPackages = async () => {
const response = await fetch('/api/workflow/packages', { credentials: 'include' });
const response = await fetch('/api/workflow/packages', {
credentials: 'include',
headers: authHeaders
});
if (!response.ok) {
throw new Error(`Package fetch failed: ${response.status}`);
}
return response.json();
};
const fetchNavigation = async () => {
const response = await fetch('/api/navigation', {
credentials: 'include',
headers: authHeaders
});
if (!response.ok) {
throw new Error(`Navigation fetch failed: ${response.status}`);
}
return response.json();
};
const NavigationLoader = {
items: [],
container: null,
async init() {
this.container = document.getElementById('amb-nav');
if (!this.container) return;
try {
const data = await fetchNavigation();
this.items = data.items || [];
this.render();
if (window.NavigationManager && typeof window.NavigationManager.refresh === 'function') {
window.NavigationManager.refresh();
}
} catch (error) {
console.error('Navigation fetch failed', error);
}
},
render() {
if (!this.container) return;
this.container.innerHTML = '';
this.items.forEach(item => {
const link = document.createElement('a');
link.className = 'amb-nav-link';
link.href = `#${item.section}`;
link.dataset.section = item.section;
const icon = document.createElement('i');
icon.className = `bi bi-${item.icon || 'circle'}`;
const label = document.createTextNode(
` ${t(item.label_key || '', item.default_label || item.label_key || item.section)}`
);
link.appendChild(icon);
link.appendChild(label);
this.container.appendChild(link);
});
}
};
const WorkflowTemplates = {
packages: [],
selectEl: null,
@@ -102,7 +168,10 @@
if (!confirm(confirmText)) return;
try {
const response = await fetch(`/api/workflow/packages/${selected.id}`, { credentials: 'include' });
const response = await fetch(`/api/workflow/packages/${selected.id}`, {
credentials: 'include',
headers: authHeaders
});
if (!response.ok) {
throw new Error(`Template fetch failed: ${response.status}`);
}
@@ -474,6 +543,7 @@ model: ${model}
} catch (error) {
console.error('Workflow builder failed to initialize', error);
}
await NavigationLoader.init();
await WorkflowTemplates.init();
wireRunModeToggles();
wirePromptChips();

View File

@@ -50,14 +50,10 @@ const ThemeManager = {
Navigation Manager
========================================================================== */
const NavigationManager = {
_popstateBound: false,
init() {
document.querySelectorAll('[data-section]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.dataset.section;
this.showSection(target);
});
});
this.bindLinks();
// Handle initial hash
const hash = window.location.hash.slice(1);
@@ -66,14 +62,42 @@ const NavigationManager = {
}
// Handle browser back/forward
window.addEventListener('popstate', () => {
const hash = window.location.hash.slice(1);
if (hash && document.querySelector(`#${hash}`)) {
this.showSection(hash, false);
}
if (!this._popstateBound) {
window.addEventListener('popstate', () => {
const nextHash = window.location.hash.slice(1);
if (nextHash && document.querySelector(`#${nextHash}`)) {
this.showSection(nextHash, false);
}
});
this._popstateBound = true;
}
},
bindLinks() {
document.querySelectorAll('[data-section]').forEach(link => {
if (link.dataset.navBound === 'true') return;
link.dataset.navBound = 'true';
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.dataset.section;
this.showSection(target);
});
});
},
refresh() {
this.bindLinks();
const hash = window.location.hash.slice(1);
if (hash && document.querySelector(`#${hash}`)) {
this.showSection(hash, false);
return;
}
const firstLink = document.querySelector('[data-section]');
if (firstLink) {
this.showSection(firstLink.dataset.section, false);
}
},
showSection(sectionId, updateHistory = true) {
// Hide all sections
document.querySelectorAll('.amb-section').forEach(s => s.classList.remove('active'));

View File

@@ -21,14 +21,7 @@
</head>
<body class="amb-layout">
{% from "components/organisms/sidebar.html" import sidebar %}
{% set nav_items = [
{'section': 'dashboard', 'icon': 'speedometer2', 'label': t('ui.nav.dashboard', 'Dashboard'), 'active': True},
{'section': 'workflow', 'icon': 'diagram-3', 'label': t('ui.nav.workflow', 'Workflow'), 'active': False},
{'section': 'prompt', 'icon': 'file-text', 'label': t('ui.nav.prompt', 'Prompt'), 'active': False},
{'section': 'settings', 'icon': 'gear', 'label': t('ui.nav.settings', 'Settings'), 'active': False},
{'section': 'translations', 'icon': 'translate', 'label': t('ui.nav.translations', 'Translations'), 'active': False}
] %}
{{ sidebar(nav_items, t('ui.app.name', 'AutoMetabuilder'), username, t('ui.theme_toggle', 'Toggle theme')) }}
{{ sidebar([], t('ui.app.name', 'AutoMetabuilder'), username, t('ui.theme_toggle', 'Toggle theme')) }}
<!-- Main Content -->
<main class="amb-main">

View File

@@ -2,7 +2,7 @@
{% from "components/atoms/nav_link.html" import nav_link %}
{% macro sidebar_nav(items) -%}
<nav class="amb-nav">
<nav class="amb-nav" id="amb-nav" data-nav-container>
{% for item in items %}
{{ nav_link(item.section, item.icon, item.label, item.active) }}
{% endfor %}

View File

@@ -15,6 +15,15 @@ def browser_type_launch_args():
"args": ["--disable-setuid-sandbox"],
}
@pytest.fixture(scope="session")
def browser_context_args():
return {
"http_credentials": {
"username": "testuser",
"password": "testpass"
}
}
def run_server(port):
os.environ["MOCK_WEB_UI"] = "true"
os.environ["WEB_USER"] = "testuser"

View File

@@ -4,6 +4,10 @@ import os
import re
from playwright.sync_api import Page, expect
def wait_for_nav(page: Page):
page.wait_for_selector("[data-section='dashboard']")
UI_MESSAGES_PATH = os.path.join(os.path.dirname(__file__), "../../src/autometabuilder/messages_en.json")
with open(UI_MESSAGES_PATH, "r", encoding="utf-8") as f:
UI_MESSAGES = json.load(f)
@@ -68,6 +72,7 @@ def test_update_settings(page: Page, server: str):
page.goto(auth_url)
# Navigate to settings section
wait_for_nav(page)
page.click("[data-section='settings']")
page.wait_for_selector("#settings.active")
@@ -82,6 +87,7 @@ def test_update_settings(page: Page, server: str):
# Verify it appeared in the table
page.reload()
wait_for_nav(page)
page.click("[data-section='settings']")
page.wait_for_selector("#settings.active")
expect(page.locator("#settings input[name='env_TEST_SETTING']")).to_be_visible()
@@ -132,6 +138,7 @@ def test_choices_dropdowns_exist(page: Page, server: str):
page.goto(auth_url)
# Navigate to translations to find language dropdown
wait_for_nav(page)
page.click("[data-section='translations']")
page.wait_for_selector("#translations.active")
@@ -158,6 +165,7 @@ def test_autocomplete_values_from_json(page: Page, server: str):
page.goto(auth_url)
# Navigate to translations section to check language options
wait_for_nav(page)
page.click("[data-section='translations']")
page.wait_for_selector("#translations.active")
page.wait_for_timeout(1000)
@@ -187,6 +195,7 @@ def test_autocomplete_values_from_json(page: Page, server: str):
page.keyboard.press("Escape")
# Navigate to settings to verify Choices.js dropdowns exist there too
wait_for_nav(page)
page.click("[data-section='settings']")
page.wait_for_selector("#settings.active")
page.wait_for_timeout(500)
@@ -201,6 +210,7 @@ def test_workflow_builder_renders(page: Page, server: str):
page.goto(auth_url)
# Navigate to workflow section
wait_for_nav(page)
page.click("[data-section='workflow']")
page.wait_for_selector("#workflow.active")
@@ -210,7 +220,10 @@ def test_workflow_builder_renders(page: Page, server: str):
# Should have at least the primary action button
expect(page.locator("#workflow-builder .btn.btn-primary")).to_be_visible()
page.wait_for_function("document.querySelectorAll('#workflow-template-select option').length > 1")
packages_response = page.request.get(f"{server}/api/workflow/packages")
assert packages_response.ok, "Workflow packages endpoint did not respond"
packages_payload = packages_response.json()
assert packages_payload.get("packages"), "No workflow packages returned"
# Toggle raw JSON should work
page.click(f"#workflow button:has-text('{t('ui.workflow.toggle_json')}')")