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:
commit
8088c1eeb0
@ -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>
|
||||
|
||||
@ -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", () => ({
|
||||
|
||||
188
canvas/src/components/__tests__/WorkspaceNode.eject.test.tsx
Normal file
188
canvas/src/components/__tests__/WorkspaceNode.eject.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user