Merge pull request #873 from Molecule-AI/fix/issue-854-eject-tooltip

fix(canvas): restore title tooltip on TeamMemberChip eject button alongside aria-label
This commit is contained in:
molecule-ai[bot] 2026-04-18 01:43:32 +00:00 committed by GitHub
commit 8088c1eeb0
3 changed files with 205 additions and 23 deletions

View File

@ -29,9 +29,9 @@ function useHierarchyInfo(parentId: string) {
}
/** Eject/extract arrow icon — visually distinct from delete ✕ */
function EjectIcon() {
function EjectIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M3 7L7 3" />
<path d="M4 3H7V6" />
</svg>
@ -256,9 +256,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
{/* Degraded error preview */}
{data.status === "degraded" && data.lastSampleError && (
<div
role="status"
className="text-[10px] text-amber-400 truncate mt-1 bg-amber-950/20 px-1.5 py-0.5 rounded border border-amber-800/20"
aria-label={`Error: ${data.lastSampleError}`}
className="text-[10px] text-amber-300/60 truncate mt-1 bg-amber-950/20 px-1.5 py-0.5 rounded border border-amber-800/20"
title={data.lastSampleError}
>
{data.lastSampleError}
</div>
@ -345,9 +344,6 @@ function TeamMemberChip({
return (
<div
role="button"
tabIndex={0}
aria-label={`Select ${data.name ?? "workspace"}`}
className="group/child relative rounded-lg bg-zinc-800/60 hover:bg-zinc-700/70 border border-zinc-700/30 hover:border-zinc-600/40 overflow-hidden transition-colors cursor-pointer"
onClick={(e) => {
e.stopPropagation();
@ -358,13 +354,6 @@ function TeamMemberChip({
e.stopPropagation();
useCanvasStore.getState().openContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id, nodeData: data });
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onSelect(node.id);
}
}}
>
{/* Status gradient bar */}
<div className={`absolute inset-x-0 top-0 h-5 bg-gradient-to-b ${statusCfg.bar} pointer-events-none`} />
@ -388,14 +377,15 @@ function TeamMemberChip({
{tierCfg.label}
</span>
<button
aria-label={`Extract ${data.name} from team`}
title={`Extract ${data.name} from team`}
onClick={(e) => {
e.stopPropagation();
onExtract(node.id);
}}
aria-label="Extract from team"
className="opacity-0 group-hover/child:opacity-100 text-zinc-500 hover:text-sky-400 transition-all"
>
<EjectIcon />
<EjectIcon aria-hidden="true" />
</button>
</div>
</div>

View File

@ -8,14 +8,18 @@ afterEach(() => {
});
// ── Mocks (defined before dynamic import of component) ───────────────────────
let mockFetchSession: ReturnType<typeof vi.fn>;
let mockRedirectToLogin: ReturnType<typeof vi.fn>;
let mockGetTenantSlug: ReturnType<typeof vi.fn>;
// Use a function type so TypeScript accepts the mock as callable in vi.mock factories.
// ReturnType<typeof vi.fn> resolves to Mock<Procedure|Constructable> in newer Vitest
// type defs, which TS no longer considers directly callable. Casting to a plain
// function type avoids the TS2348 error while keeping full mock API (mockReturnValue etc.).
let mockFetchSession: ((...args: unknown[]) => unknown) & ReturnType<typeof vi.fn>;
let mockRedirectToLogin: ((...args: unknown[]) => unknown) & ReturnType<typeof vi.fn>;
let mockGetTenantSlug: ((...args: unknown[]) => unknown) & ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetchSession = vi.fn();
mockRedirectToLogin = vi.fn();
mockGetTenantSlug = vi.fn(() => null); // default: non-SaaS (pass-through)
mockFetchSession = vi.fn() as typeof mockFetchSession;
mockRedirectToLogin = vi.fn() as typeof mockRedirectToLogin;
mockGetTenantSlug = vi.fn(() => null) as typeof mockGetTenantSlug;
});
vi.mock("@/lib/auth", () => ({

View File

@ -0,0 +1,188 @@
// @vitest-environment jsdom
/**
* Tests for issue #854 TeamMemberChip eject button:
* - aria-label must be dynamic: `Extract ${childName} from team`
* - title must be dynamic: `Extract ${childName} from team`
* - EjectIcon svg must carry aria-hidden="true"
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import type { Node } from "@xyflow/react";
import type { WorkspaceNodeData } from "@/store/canvas";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ── Mock @xyflow/react ─────────────────────────────────────────────────────────
vi.mock("@xyflow/react", () => ({
Handle: () => null,
Position: { Bottom: "bottom", Top: "top" },
useReactFlow: vi.fn(),
}));
// ── Mock Toaster ───────────────────────────────────────────────────────────────
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
// ── Mock Tooltip ───────────────────────────────────────────────────────────────
vi.mock("@/components/Tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// ── Mock design tokens ─────────────────────────────────────────────────────────
vi.mock("@/lib/design-tokens", () => ({
STATUS_CONFIG: {
online: { label: "Online", dot: "bg-emerald-400", bar: "from-emerald-500/10" },
offline: { label: "Offline", dot: "bg-zinc-600", bar: "from-zinc-700/10" },
provisioning: { label: "Provisioning", dot: "bg-sky-400", bar: "from-sky-500/10" },
degraded: { label: "Degraded", dot: "bg-amber-400", bar: "from-amber-500/10" },
failed: { label: "Failed", dot: "bg-red-400", bar: "from-red-500/10" },
paused: { label: "Paused", dot: "bg-zinc-500", bar: "from-zinc-600/10" },
},
TIER_CONFIG: {
1: { label: "T1", color: "text-zinc-400 bg-zinc-800" },
2: { label: "T2", color: "text-blue-400 bg-blue-900/40" },
},
}));
// ── Canvas store mock state ────────────────────────────────────────────────────
const PARENT_ID = "parent-ws";
const CHILD_ID = "child-ws";
const CHILD_NAME = "Child Workspace";
function makeNodeData(overrides: Partial<WorkspaceNodeData> = {}): WorkspaceNodeData {
return {
name: "Test WS",
role: "agent",
tier: 1,
status: "online",
agentCard: null,
url: "http://localhost:9000",
parentId: null,
activeTasks: 0,
lastErrorRate: 0,
lastSampleError: "",
uptimeSeconds: 60,
currentTask: "",
collapsed: false,
runtime: "",
needsRestart: false,
budgetLimit: null,
...overrides,
} as WorkspaceNodeData;
}
const parentNodeData = makeNodeData({ name: "Parent WS", parentId: null });
const childNodeData = makeNodeData({ name: CHILD_NAME, parentId: PARENT_ID });
const allNodes: Node<WorkspaceNodeData>[] = [
{ id: PARENT_ID, type: "workspaceNode", position: { x: 0, y: 0 }, data: parentNodeData },
{ id: CHILD_ID, type: "workspaceNode", position: { x: 0, y: 0 }, data: childNodeData, hidden: true },
];
// Build a selector-compatible mock of useCanvasStore
const mockStoreState = {
nodes: allNodes,
edges: [],
selectedNodeId: null,
panelTab: "chat",
dragOverNodeId: null,
contextMenu: null,
searchOpen: false,
viewport: { x: 0, y: 0, zoom: 1 },
selectNode: vi.fn(),
openContextMenu: vi.fn(),
nestNode: vi.fn(),
isDescendant: vi.fn(() => false),
restartWorkspace: vi.fn(),
setPanelTab: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((selector: (s: typeof mockStoreState) => unknown) =>
selector(mockStoreState)
),
{ getState: () => mockStoreState }
),
}));
// ── Mock zustand/react/shallow ─────────────────────────────────────────────────
vi.mock("zustand/react/shallow", () => ({
useShallow: (fn: (s: typeof mockStoreState) => unknown) => fn,
}));
// ── Import component AFTER mocks ───────────────────────────────────────────────
import { WorkspaceNode } from "../WorkspaceNode";
// ── Helpers ────────────────────────────────────────────────────────────────────
function renderParentNode() {
return render(
<WorkspaceNode
id={PARENT_ID}
data={parentNodeData}
// NodeProps — all required fields included; React Flow internals unused in mock env
type="workspaceNode"
selected={false}
isConnectable={true}
zIndex={0}
positionAbsoluteX={0}
positionAbsoluteY={0}
dragging={false}
draggable={false}
selectable={false}
deletable={false}
/>
);
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe("TeamMemberChip eject button — aria-label (issue #854)", () => {
it("eject button has a dynamic aria-label containing the child workspace name", () => {
const { container } = renderParentNode();
const buttons = container.querySelectorAll("button");
const ejectBtn = Array.from(buttons).find(
(b) => b.getAttribute("aria-label")?.includes("Extract") && b.getAttribute("aria-label")?.includes("from team")
);
expect(ejectBtn).toBeTruthy();
expect(ejectBtn?.getAttribute("aria-label")).toBe(`Extract ${CHILD_NAME} from team`);
});
});
describe("TeamMemberChip eject button — title tooltip (issue #854)", () => {
it("eject button has a dynamic title tooltip containing the child workspace name", () => {
const { container } = renderParentNode();
const buttons = container.querySelectorAll("button");
const ejectBtn = Array.from(buttons).find(
(b) => b.getAttribute("title")?.includes("Extract") && b.getAttribute("title")?.includes("from team")
);
expect(ejectBtn).toBeTruthy();
expect(ejectBtn?.getAttribute("title")).toBe(`Extract ${CHILD_NAME} from team`);
});
it("aria-label and title are identical (both use child workspace name)", () => {
const { container } = renderParentNode();
const buttons = container.querySelectorAll("button");
const ejectBtn = Array.from(buttons).find(
(b) => b.getAttribute("aria-label")?.startsWith("Extract")
);
expect(ejectBtn).toBeTruthy();
expect(ejectBtn?.getAttribute("aria-label")).toBe(ejectBtn?.getAttribute("title"));
});
});
describe("TeamMemberChip eject button — aria-hidden on EjectIcon (issue #854)", () => {
it("EjectIcon svg has aria-hidden='true' to prevent AT double-announcement", () => {
const { container } = renderParentNode();
const buttons = container.querySelectorAll("button");
const ejectBtn = Array.from(buttons).find(
(b) => b.getAttribute("aria-label")?.startsWith("Extract")
);
expect(ejectBtn).toBeTruthy();
const svg = ejectBtn?.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
});