feat(frontend): add shared DOM wiring helpers

This commit is contained in:
rookiestar28
2026-04-26 17:52:34 +08:00
parent 0a959f96aa
commit 8660ece6c1
7 changed files with 309 additions and 20 deletions
+30
View File
@@ -0,0 +1,30 @@
# Frontend Tab Wiring
OpenClaw's frontend uses modular vanilla ES modules loaded by the ComfyUI extension host. Keep tab work inside this architecture unless a future migration decision explicitly changes the runtime model.
## Tab Registration
- Register tabs through `tabManager.registerTab({ id, title, icon, render })`.
- Keep `id` stable; it is used for pane ids and active-tab storage.
- Treat `render(pane)` as the only place that mutates a tab pane.
- Return a promise from `render` only when the tab genuinely performs async work; async failures are routed through the tab error boundary.
## DOM Helpers
- Prefer shared helpers from `web/openclaw_utils.js` for new shell/tab wiring:
- `createDomElement(...)` for text-safe element construction.
- `appendChildren(...)` for optional child nodes.
- `queryRequired(...)` when a selector is mandatory for the tab to function.
- Use `textContent` semantics for user-visible text. Do not add raw HTML helper paths for convenience.
- Keep legacy class aliasing centralized through existing normalization and alias helpers.
## API Contracts
- Use `OpenClawAPI.fetch(...)` normalized results instead of direct `fetch` from tabs.
- Check `result.ok` before reading `result.data`.
- Preserve admin-token handling inside `OpenClawAPI` and shared session helpers.
## Verification
- Add Vitest coverage for new shared helpers or tab wiring behavior.
- Use Playwright harness specs for user-visible tab behavior such as active panes, rendered content, and action outcomes.
+8 -6
View File
@@ -21,10 +21,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from connector.config import ConnectorConfig, load_config
from connector.contract import CommandResponse
from connector.platforms.slack_webhook import (
SLACK_SIGNING_VERSION,
SlackWebhookServer,
)
from connector.platforms.slack_webhook import SLACK_SIGNING_VERSION, SlackWebhookServer
SIGNING_SECRET = "test_signing_secret_f59"
@@ -155,7 +152,10 @@ class TestF59SlackInteractions(unittest.IsolatedAsyncioTestCase):
self.assertEqual(routed.text, "/approvals")
self.assertTrue(routed.metadata["interactive_callback"])
self.assertEqual(routed.metadata["interaction_type"], "block_actions")
self.assertEqual(routed.metadata["response_url"], "https://hooks.slack.com/actions/T_F59/mock")
self.assertEqual(
routed.metadata["response_url"],
"https://hooks.slack.com/actions/T_F59/mock",
)
async def test_invalid_signature_rejected_without_routing(self):
server = _make_server()
@@ -171,7 +171,9 @@ class TestF59SlackInteractions(unittest.IsolatedAsyncioTestCase):
async def test_duplicate_block_action_is_acknowledged_once_without_reroute(self):
server = _make_server()
payload = _block_action_payload(value="/status", action_id="dup", trigger_id="t-dup")
payload = _block_action_payload(
value="/status", action_id="dup", trigger_id="t-dup"
)
req1 = _build_interaction_request(payload)
req2 = _build_interaction_request(payload)
+31 -3
View File
@@ -14,6 +14,35 @@ import {
withPreconnectHint,
} from "./openclaw_fetch_wrappers.js";
/**
* @typedef {Object} OpenClawFetchOptions
* @property {number=} timeout Request timeout in milliseconds.
* @property {AbortSignal=} signal Optional caller-owned cancellation signal.
* @property {string=} method HTTP method.
* @property {HeadersInit=} headers Request headers.
* @property {BodyInit|null=} body Request body.
*/
/**
* @typedef {Object} OpenClawFetchSuccess
* @property {true} ok
* @property {number} status
* @property {*} data Parsed JSON value or response text.
*/
/**
* @typedef {Object} OpenClawFetchFailure
* @property {false} ok
* @property {number} status HTTP status, or 0 for network/timeout/cancelled failures.
* @property {string} error Stable error code/message.
* @property {*=} data Parsed error payload or response text.
* @property {string=} detail Low-level error detail for diagnostics.
*/
/**
* @typedef {OpenClawFetchSuccess|OpenClawFetchFailure} OpenClawFetchResult
*/
export class OpenClawAPI {
constructor() {
this._capabilitiesCache = null;
@@ -77,9 +106,8 @@ export class OpenClawAPI {
/**
* Generic fetch wrapper with timeout and error normalization.
* @param {string} url - The URL to fetch
* @param {object} options - Fetch options
* @param {number} options.timeout - Timeout in ms (default: 10000)
* @param {AbortSignal} options.signal - Optional AbortSignal from caller (R38-Lite)
* @param {OpenClawFetchOptions} options - Fetch options
* @returns {Promise<OpenClawFetchResult>}
*/
async fetch(url, options = {}) {
const { timeout = 10000, signal: externalSignal, ...fetchOptions } = options;
+29 -11
View File
@@ -6,11 +6,23 @@ import { ErrorBoundary } from "./ErrorBoundary.js";
import { STORAGE_KEYS, getMirroredStorageValue, setMirroredStorageValue } from "./openclaw_compat.js";
import {
applyLegacyClassAliases,
appendChildren,
createDomElement,
normalizeLegacyClassNames,
} from "./openclaw_utils.js";
/**
* @typedef {Object} OpenClawTabDefinition
* @property {string} id Stable tab id used for storage and pane ids.
* @property {string} title Visible tab label and error-boundary name.
* @property {string=} icon Optional icon class for the tab button.
* @property {(pane: HTMLElement) => (void|Promise<void>)} render Render function for the tab pane.
* @property {boolean=} loaded Internal lazy-render state.
*/
export class TabManager {
constructor() {
/** @type {OpenClawTabDefinition[]} */
this.tabs = [
// { id, title, renderFn, loaded }
];
@@ -31,6 +43,10 @@ export class TabManager {
this._restoreActiveTab();
}
/**
* Register or replace a tab definition.
* @param {OpenClawTabDefinition} tabDef
*/
registerTab(tabDef) {
// Idempotent: replace existing tab definition by id
const idx = this.tabs.findIndex(t => t.id === tabDef.id);
@@ -53,16 +69,17 @@ export class TabManager {
this.tabsEl.innerHTML = "";
this.tabs.forEach(tab => {
const btn = document.createElement("div");
btn.className = "openclaw-tab";
const btn = createDomElement("div", { className: "openclaw-tab" });
if (tab.icon) {
const icon = document.createElement("i");
icon.className = `openclaw-tab-icon ${tab.icon}`;
const label = document.createElement("span");
label.className = "openclaw-tab-label";
label.textContent = tab.title;
btn.appendChild(icon);
btn.appendChild(label);
appendChildren(btn, [
createDomElement("i", {
className: `openclaw-tab-icon ${tab.icon}`,
}),
createDomElement("span", {
className: "openclaw-tab-label",
text: tab.title,
}),
]);
} else {
btn.textContent = tab.title;
}
@@ -73,9 +90,10 @@ export class TabManager {
// Create container for tab content if not exists
if (!this.contentEl.querySelector(`#openclaw-tab-${tab.id}`)) {
const pane = document.createElement("div");
const pane = createDomElement("div", {
className: "openclaw-tab-pane",
});
pane.id = `openclaw-tab-${tab.id}`;
pane.className = "openclaw-tab-pane";
this.contentEl.appendChild(pane);
}
});
+75
View File
@@ -19,6 +19,81 @@ export function makeEl(tag, className = "", text = "") {
return el;
}
/**
* Build a DOM element from a small declarative option bag.
* Text is assigned with textContent; this helper intentionally has no raw HTML path.
*
* @param {string} tag
* @param {{
* className?: string|string[],
* text?: string|number|boolean|null,
* attrs?: Record<string, string|number|boolean|null|undefined>,
* dataset?: Record<string, string|number|boolean|null|undefined>,
* children?: Array<Node|null|undefined>|Node|null|undefined,
* }} options
* @returns {HTMLElement}
*/
export function createDomElement(tag, options = {}) {
const el = document.createElement(tag);
const className = options.className;
if (Array.isArray(className)) {
el.className = className.filter(Boolean).join(" ");
} else if (className) {
el.className = String(className);
}
if (options.text !== undefined && options.text !== null) {
el.textContent = String(options.text);
}
Object.entries(options.attrs || {}).forEach(([name, value]) => {
if (value === undefined || value === null) return;
el.setAttribute(name, String(value));
});
Object.entries(options.dataset || {}).forEach(([name, value]) => {
if (value === undefined || value === null) return;
el.dataset[name] = String(value);
});
appendChildren(el, options.children || []);
return el;
}
/**
* Append only real DOM nodes, allowing callers to keep optional branches concise.
* @param {Node} parent
* @param {Array<Node|null|undefined>|Node|null|undefined} children
* @returns {Node}
*/
export function appendChildren(parent, children = []) {
const items = Array.isArray(children) ? children : [children];
items.flat().forEach((child) => {
if (child instanceof Node) {
parent.appendChild(child);
}
});
return parent;
}
/**
* Query a required descendant and fail with stable owner context when missing.
* @template {Element} T
* @param {ParentNode|null|undefined} root
* @param {string} selector
* @param {string} owner
* @returns {T}
*/
export function queryRequired(root, selector, owner = "OpenClaw UI") {
const found = root && typeof root.querySelector === "function"
? root.querySelector(selector)
: null;
if (!found) {
throw new Error(`${owner} missing required selector: ${selector}`);
}
return found;
}
/**
* F63: prefer canonical `openclaw-*` classes when both legacy and canonical
* variants are present on the same node.
+92
View File
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TabManager } from "../../openclaw_tabs.js";
function makeMount() {
const tabsEl = document.createElement("div");
const contentEl = document.createElement("div");
document.body.appendChild(tabsEl);
document.body.appendChild(contentEl);
return { tabsEl, contentEl };
}
describe("TabManager", () => {
beforeEach(() => {
document.body.innerHTML = "";
localStorage.clear();
});
it("renders canonical tab buttons and creates matching panes", () => {
const manager = new TabManager();
const { tabsEl, contentEl } = makeMount();
manager.registerTab({
id: "settings",
title: "Settings",
icon: "openclaw-icon-settings",
render(pane) {
pane.textContent = "Settings loaded";
},
});
manager.init(tabsEl, contentEl);
const button = tabsEl.querySelector(".openclaw-tab");
expect(button.querySelector(".openclaw-tab-icon").className).toContain(
"openclaw-icon-settings"
);
expect(button.querySelector(".openclaw-tab-label").textContent).toBe("Settings");
const pane = contentEl.querySelector("#openclaw-tab-settings");
expect(pane.className).toContain("openclaw-tab-pane");
expect(pane.classList.contains("active")).toBe(true);
expect(pane.textContent).toBe("Settings loaded");
});
it("switches active state without duplicating panes", () => {
const manager = new TabManager();
const { tabsEl, contentEl } = makeMount();
manager.registerTab({
id: "settings",
title: "Settings",
render(pane) {
pane.textContent = "Settings loaded";
},
});
manager.registerTab({
id: "jobs",
title: "Jobs",
render(pane) {
pane.textContent = "Jobs loaded";
},
});
manager.init(tabsEl, contentEl);
manager.activateTab("jobs");
expect(contentEl.querySelectorAll(".openclaw-tab-pane")).toHaveLength(2);
expect(contentEl.querySelector("#openclaw-tab-settings").classList.contains("active")).toBe(false);
expect(contentEl.querySelector("#openclaw-tab-jobs").classList.contains("active")).toBe(true);
expect(contentEl.querySelector("#openclaw-tab-jobs").textContent).toBe("Jobs loaded");
});
it("routes async render failures through the tab error boundary", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const manager = new TabManager();
const { tabsEl, contentEl } = makeMount();
manager.registerTab({
id: "broken",
title: "Broken",
render() {
return Promise.reject(new Error("async tab failed"));
},
});
manager.init(tabsEl, contentEl);
await Promise.resolve();
const pane = contentEl.querySelector("#openclaw-tab-broken");
expect(pane.querySelector(".openclaw-error-boundary code").textContent).toContain(
"async tab failed"
);
consoleSpy.mockRestore();
});
});
+44
View File
@@ -1,12 +1,15 @@
import { describe, expect, it } from "vitest";
import {
appendChildren,
applyLegacyClassAliases,
buildLegacyAliasClassTokens,
createDomElement,
makeEl,
normalizeLegacyClassTokens,
normalizeLegacyClassNames,
parseJsonSafe,
parseJsonOrThrow,
queryRequired,
isAbortError,
} from "../../openclaw_utils.js";
@@ -18,6 +21,47 @@ describe("openclaw_utils", () => {
expect(el.textContent).toBe("Hello");
});
it("creates declarative DOM elements without treating text as HTML", () => {
const child = createDomElement("span", {
className: "openclaw-label",
text: "<strong>Run</strong>",
dataset: { role: "primary" },
attrs: { title: "Run command", "aria-live": "polite" },
});
const root = createDomElement("div", {
className: "openclaw-row",
children: [child],
});
expect(root.className).toBe("openclaw-row");
expect(root.firstElementChild).toBe(child);
expect(child.textContent).toBe("<strong>Run</strong>");
expect(child.innerHTML).toBe("&lt;strong&gt;Run&lt;/strong&gt;");
expect(child.dataset.role).toBe("primary");
expect(child.getAttribute("aria-live")).toBe("polite");
});
it("appends only defined child nodes", () => {
const root = document.createElement("div");
const first = document.createElement("span");
const second = document.createElement("button");
appendChildren(root, [first, null, undefined, second]);
expect(Array.from(root.children)).toEqual([first, second]);
});
it("queries required elements with stable owner context", () => {
const root = createDomElement("section", {
children: [createDomElement("button", { attrs: { id: "run" }, text: "Run" })],
});
expect(queryRequired(root, "#run", "Planner tab").textContent).toBe("Run");
expect(() => queryRequired(root, "#missing", "Planner tab")).toThrow(
"Planner tab missing required selector: #missing"
);
});
it("normalizes duplicate legacy class tokens", () => {
expect(
normalizeLegacyClassTokens("openclaw-btn moltbot-btn openclaw-btn-primary moltbot-btn-primary openclaw-btn")