feat(FN-3472): expand org chart regression test assertions
The merge adds expanded regression assertions for the org chart feature, covering agent CSS classes and mobile agents view components. The tests verify styling and layout behavior across both desktop and mobile contexts. Fusion-Task-Id: FN-3472
This commit is contained in:
@@ -104,6 +104,7 @@ describe("Agent CSS classes", () => {
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-100")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-125")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-150")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart--vertical")).toBe(true);
|
||||
expect(hasClass(".agent-board")).toBe(true);
|
||||
expect(hasClass(".agent-board-card")).toBe(true);
|
||||
expect(hasClass(".agent-board-card--idle")).toBe(true);
|
||||
@@ -191,10 +192,14 @@ describe("Agent CSS classes", () => {
|
||||
expect(orgChartSection).toContain("gap: var(--space-xl)");
|
||||
expect(orgChartSection).toContain("padding: var(--space-lg)");
|
||||
expect(orgChartSection).toContain("--org-chart-node-width: calc(var(--space-xl) * 9 + var(--space-xs))");
|
||||
expect(orgChartSection).toContain("--org-chart-root-gap: var(--space-xl)");
|
||||
expect(orgChartSection).toContain("--org-chart-connector-gap: var(--space-sm)");
|
||||
expect(orgChartSection).toContain("--org-chart-sibling-gap: var(--space-xl)");
|
||||
expect(orgChartSection).toContain("--org-chart-children-offset: calc(var(--space-lg) + var(--space-sm))");
|
||||
expect(orgChartSection).toContain("min-height: var(--org-chart-node-width)");
|
||||
expect(orgChartSection).toContain("touch-action: pan-x pan-y");
|
||||
expect(orgChartSection).toContain("overflow: auto");
|
||||
expect(orgChartSection).toContain("overscroll-behavior: contain");
|
||||
expect(orgChartSection).toContain("transform-origin: top left");
|
||||
expect(orgChartSection).toContain("border: 1px solid var(--border)");
|
||||
expect(orgChartSection).toContain("color: var(--text)");
|
||||
@@ -206,7 +211,14 @@ describe("Agent CSS classes", () => {
|
||||
expect(orgChartSection).not.toContain("var(--border-color)");
|
||||
expect(orgChartSection).not.toContain("var(--text-primary)");
|
||||
expect(orgChartSection).not.toContain("var(--text-secondary)");
|
||||
expect(orgChartSection).not.toMatch(/1\.5rem|0\.75rem|0\.72rem|0\.78rem|0\.65rem|120ms\s+ease|220px|10px/);
|
||||
expect(orgChartSection).toContain(".agent-org-chart--vertical .org-chart-children");
|
||||
expect(orgChartSection).toContain("gap: var(--space-sm)");
|
||||
expect(orgChartSection).toContain("padding-top: var(--space-sm)");
|
||||
expect(orgChartSection).toContain("padding-left: calc(var(--space-lg) + var(--space-sm))");
|
||||
expect(orgChartSection).toContain("--org-chart-node-width: calc(var(--space-2xl) * 5)");
|
||||
expect(orgChartSection).toContain("--org-chart-sibling-gap: var(--space-sm)");
|
||||
expect(orgChartSection).toContain("--org-chart-children-offset: var(--space-lg)");
|
||||
expect(orgChartSection).not.toMatch(/1\.5rem|0\.75rem|0\.72rem|0\.78rem|0\.65rem|120ms\s+ease|10px/);
|
||||
});
|
||||
|
||||
// Verify AgentDetailView classes
|
||||
|
||||
@@ -783,8 +783,11 @@
|
||||
/* === FN-1167: Agent Org Chart + Chain of Command === */
|
||||
.agent-org-chart-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-org-chart-controls {
|
||||
@@ -803,6 +806,8 @@
|
||||
}
|
||||
|
||||
.agent-org-chart-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
overscroll-behavior: contain;
|
||||
@@ -837,10 +842,11 @@
|
||||
--org-chart-sibling-gap: var(--space-xl);
|
||||
--org-chart-connector-gap: var(--space-sm);
|
||||
--org-chart-children-offset: calc(var(--space-lg) + var(--space-sm));
|
||||
--org-chart-root-gap: var(--space-xl);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-xl);
|
||||
gap: var(--org-chart-root-gap);
|
||||
padding: var(--space-lg);
|
||||
min-height: var(--org-chart-node-width);
|
||||
}
|
||||
@@ -867,6 +873,10 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.org-chart-node--has-children {
|
||||
padding-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.org-chart-node-card {
|
||||
width: var(--org-chart-node-width);
|
||||
max-width: 100%;
|
||||
@@ -952,7 +962,7 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: var(--org-chart-sibling-gap);
|
||||
padding-top: var(--org-chart-children-offset);
|
||||
@@ -969,6 +979,59 @@
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.agent-org-chart--vertical {
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.agent-org-chart--vertical .org-chart-node {
|
||||
width: 100%;
|
||||
min-width: var(--org-chart-node-width);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.agent-org-chart--vertical .org-chart-node--has-children {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.agent-org-chart--vertical .org-chart-node-card {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.agent-org-chart--vertical .org-chart-node--has-children > .org-chart-node-card::after {
|
||||
left: calc(var(--space-lg) + var(--space-xs));
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.agent-org-chart--vertical .org-chart-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
gap: var(--space-sm);
|
||||
padding-top: var(--space-sm);
|
||||
padding-left: calc(var(--space-lg) + var(--space-sm));
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.agent-org-chart--vertical .org-chart-children::before {
|
||||
top: 0;
|
||||
bottom: var(--space-sm);
|
||||
left: var(--space-sm);
|
||||
right: auto;
|
||||
width: 1px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.agent-org-chart--vertical .org-chart-children > .org-chart-node::before {
|
||||
top: calc(var(--space-md) * -1);
|
||||
left: calc(var(--space-sm) * -1);
|
||||
transform: none;
|
||||
width: var(--space-sm);
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.org-chart-children > .org-chart-node::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -1058,6 +1121,8 @@
|
||||
|
||||
.agent-org-chart-shell {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-org-chart-controls {
|
||||
@@ -1073,6 +1138,15 @@
|
||||
|
||||
.agent-org-chart-viewport {
|
||||
min-height: calc(var(--space-2xl) * 4);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.agent-org-chart-canvas {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.agent-org-chart {
|
||||
@@ -1084,6 +1158,10 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-org-chart--vertical {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.agents-view-loading {
|
||||
min-height: calc(var(--space-2xl) * 4);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "../utils/heartbeatIntervals";
|
||||
import { isEphemeralAgent, getErrorMessage } from "@fusion/core";
|
||||
import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
|
||||
import { resolveOrgChartLayoutMode, type OrgChartLayoutMode } from "./agentsOrgChartLayout";
|
||||
|
||||
export interface AgentsViewProps {
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
@@ -247,10 +248,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
});
|
||||
const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
|
||||
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
|
||||
const [orgChartViewportWidth, setOrgChartViewportWidth] = useState(0);
|
||||
const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false);
|
||||
const [isOverviewOpen, setIsOverviewOpen] = useState(false);
|
||||
const [orgChartZoomIndex, setOrgChartZoomIndex] = useState(1);
|
||||
const controlsPanelRef = useRef<HTMLDivElement>(null);
|
||||
const orgChartViewportRef = useRef<HTMLDivElement>(null);
|
||||
const { confirm } = useConfirm();
|
||||
const controlsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const controlsPanelId = useId();
|
||||
@@ -371,6 +374,26 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
.filter((n): n is OrgTreeNode => n !== null);
|
||||
}, [orgTree, showSystemAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agentView !== "org") return;
|
||||
|
||||
const viewport = orgChartViewportRef.current;
|
||||
if (!viewport) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
setOrgChartViewportWidth(viewport.clientWidth);
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(updateWidth) : null;
|
||||
resizeObserver?.observe(viewport);
|
||||
window.addEventListener("resize", updateWidth);
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", updateWidth);
|
||||
};
|
||||
}, [agentView, displayOrgTree.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agentView !== "org") return;
|
||||
@@ -712,6 +735,10 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
|
||||
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "◆";
|
||||
const orgChartZoom = ORG_CHART_ZOOM_LEVELS[orgChartZoomIndex];
|
||||
const orgChartLayoutMode: OrgChartLayoutMode = useMemo(() => resolveOrgChartLayoutMode({
|
||||
tree: displayOrgTree,
|
||||
availableWidth: orgChartViewportWidth,
|
||||
}), [displayOrgTree, orgChartViewportWidth]);
|
||||
|
||||
/** Get skill badges from agent metadata */
|
||||
const getSkillBadges = (agent: Agent): string[] => {
|
||||
@@ -1027,9 +1054,17 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="agent-org-chart-viewport" data-testid="agent-org-chart-viewport">
|
||||
<div
|
||||
ref={orgChartViewportRef}
|
||||
className="agent-org-chart-viewport"
|
||||
data-testid="agent-org-chart-viewport"
|
||||
>
|
||||
<div className={`agent-org-chart-canvas agent-org-chart-canvas--zoom-${Math.round(orgChartZoom * 100)}`}>
|
||||
<div className="agent-org-chart" data-testid="agent-org-chart">
|
||||
<div
|
||||
className={`agent-org-chart${orgChartLayoutMode === "vertical" ? " agent-org-chart--vertical" : ""}`}
|
||||
data-testid="agent-org-chart"
|
||||
data-layout-mode={orgChartLayoutMode}
|
||||
>
|
||||
{isOrgTreeLoading ? (
|
||||
<div className="agent-org-chart__loading" role="status" aria-live="polite">
|
||||
<RefreshCw size={18} className="spin" />
|
||||
|
||||
@@ -66,6 +66,8 @@ const mockFetchAgentStats = vi.mocked((apiModule as any).fetchAgentStats);
|
||||
const mockFetchSettings = vi.mocked((apiModule as any).fetchSettings);
|
||||
const mockUpdateSettings = vi.mocked((apiModule as any).updateSettings);
|
||||
const mockClipboardWriteText = vi.fn();
|
||||
const mockResizeObserverObserve = vi.fn();
|
||||
const mockResizeObserverDisconnect = vi.fn();
|
||||
|
||||
describe("AgentsView", () => {
|
||||
const mockAddToast = vi.fn();
|
||||
@@ -121,6 +123,10 @@ describe("AgentsView", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("ResizeObserver", class {
|
||||
observe = mockResizeObserverObserve;
|
||||
disconnect = mockResizeObserverDisconnect;
|
||||
});
|
||||
mockViewportMode.mockReturnValue("desktop");
|
||||
mockClipboardWriteText.mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
@@ -1308,6 +1314,41 @@ describe("AgentsView", () => {
|
||||
expect(container.querySelectorAll(".org-chart-node--has-children").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("switches org chart to vertical layout mode when estimated width exceeds viewport", async () => {
|
||||
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(320);
|
||||
mockFetchOrgTree.mockResolvedValue(orgTree);
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const chart = screen.getByTestId("agent-org-chart");
|
||||
expect(chart.getAttribute("data-layout-mode")).toBe("vertical");
|
||||
expect(chart.className).toContain("agent-org-chart--vertical");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Director One"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-child-1");
|
||||
});
|
||||
clientWidthSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps org chart horizontal layout mode when viewport is wide enough", async () => {
|
||||
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(1920);
|
||||
mockFetchOrgTree.mockResolvedValue(orgTree);
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const chart = screen.getByTestId("agent-org-chart");
|
||||
expect(chart.getAttribute("data-layout-mode")).toBe("horizontal");
|
||||
expect(chart.className).not.toContain("agent-org-chart--vertical");
|
||||
});
|
||||
clientWidthSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shows mobile zoom controls for org chart and keeps node selection working", async () => {
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
mockFetchOrgTree.mockResolvedValue(orgTree);
|
||||
|
||||
@@ -59,6 +59,34 @@ import {
|
||||
fetchOrgTree,
|
||||
} from "../../api";
|
||||
|
||||
const mockOrgTree = [
|
||||
{
|
||||
agent: {
|
||||
id: "agent-root-mobile",
|
||||
name: "Mobile Root",
|
||||
role: "scheduler" as AgentCapability,
|
||||
state: "active" as AgentState,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
agent: {
|
||||
id: "agent-child-mobile",
|
||||
name: "Mobile Child",
|
||||
role: "executor" as AgentCapability,
|
||||
state: "running" as AgentState,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockAgents: Agent[] = [
|
||||
{
|
||||
id: "agent-001",
|
||||
@@ -170,6 +198,7 @@ describe("AgentsView mobile adaptations", () => {
|
||||
});
|
||||
|
||||
it("switches between board, list, and org views", async () => {
|
||||
vi.mocked(fetchOrgTree).mockResolvedValue(mockOrgTree);
|
||||
const { container } = render(<AgentsView addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
|
||||
|
||||
@@ -262,12 +291,41 @@ describe("agents-view mobile CSS", () => {
|
||||
it("defines mobile org chart sizing and pan/zoom controls rules", () => {
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("display: flex");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("gap: var(--space-sm)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-viewport")).toContain("min-height: calc(var(--space-2xl) * 4)");
|
||||
const viewportBlock = extractRuleBlock(mobileMediaBlock, ".agent-org-chart-viewport");
|
||||
expect(viewportBlock).toContain("min-height: calc(var(--space-2xl) * 4)");
|
||||
expect(viewportBlock).toContain("overflow: auto");
|
||||
expect(viewportBlock).toContain("overscroll-behavior: contain");
|
||||
expect(viewportBlock).toContain("-webkit-overflow-scrolling: touch");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("gap: var(--space-sm)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-node-width: calc(var(--space-2xl) * 5)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-sibling-gap: var(--space-sm)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-children-offset: var(--space-lg)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".org-chart-node-card")).toContain("padding: var(--space-sm)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".org-chart-node__badge")).toContain("font-size: calc(var(--space-sm) + var(--space-xs) * 0.625)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-shell")).toContain("overflow: hidden");
|
||||
});
|
||||
|
||||
it("keeps org chart viewport as scroll owner while mobile zoom and selection work", async () => {
|
||||
vi.mocked(fetchOrgTree).mockResolvedValue(mockOrgTree);
|
||||
const { container } = render(<AgentsView addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||
|
||||
const shell = await screen.findByTestId("agent-org-chart-shell");
|
||||
expect(shell.className).toContain("agent-org-chart-shell");
|
||||
|
||||
const viewport = await screen.findByTestId("agent-org-chart-viewport");
|
||||
expect(viewport.className).toContain("agent-org-chart-viewport");
|
||||
|
||||
const chart = await screen.findByTestId("agent-org-chart");
|
||||
expect(chart.getAttribute("data-layout-mode")).toBeTruthy();
|
||||
|
||||
expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-100");
|
||||
|
||||
fireEvent.click(screen.getByText("Mobile Child"));
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".org-chart-node-card--running.agent-card--selected")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OrgTreeNode } from "../../api";
|
||||
import { estimateOrgChartWidth, resolveOrgChartLayoutMode } from "../agentsOrgChartLayout";
|
||||
|
||||
function makeNode(id: string, children: OrgTreeNode[] = []): OrgTreeNode {
|
||||
return {
|
||||
agent: {
|
||||
id,
|
||||
name: id,
|
||||
role: "executor",
|
||||
state: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
describe("agentsOrgChartLayout", () => {
|
||||
it("returns horizontal when estimated width fits", () => {
|
||||
const tree = [makeNode("root", [makeNode("child")])];
|
||||
const width = estimateOrgChartWidth(tree);
|
||||
expect(resolveOrgChartLayoutMode({ tree, availableWidth: width + 1 })).toBe("horizontal");
|
||||
});
|
||||
|
||||
it("returns vertical when estimated width exceeds available width", () => {
|
||||
const tree = [
|
||||
makeNode("root", [
|
||||
makeNode("a", [makeNode("a1"), makeNode("a2")]),
|
||||
makeNode("b", [makeNode("b1"), makeNode("b2")]),
|
||||
]),
|
||||
makeNode("root-2", [makeNode("c"), makeNode("d")]),
|
||||
];
|
||||
expect(resolveOrgChartLayoutMode({ tree, availableWidth: 400 })).toBe("vertical");
|
||||
});
|
||||
|
||||
it("stays horizontal for single-root trees and zero-width measurement", () => {
|
||||
const tree = [makeNode("solo", [makeNode("child-1"), makeNode("child-2")])];
|
||||
expect(resolveOrgChartLayoutMode({ tree, availableWidth: 0 })).toBe("horizontal");
|
||||
expect(resolveOrgChartLayoutMode({ tree, availableWidth: 120 })).toBe("horizontal");
|
||||
});
|
||||
});
|
||||
48
packages/dashboard/app/components/agentsOrgChartLayout.ts
Normal file
48
packages/dashboard/app/components/agentsOrgChartLayout.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { OrgTreeNode } from "../api";
|
||||
|
||||
export type OrgChartLayoutMode = "horizontal" | "vertical";
|
||||
|
||||
export interface OrgChartLayoutInput {
|
||||
tree: OrgTreeNode[];
|
||||
availableWidth: number;
|
||||
}
|
||||
|
||||
const DEFAULT_NODE_WIDTH = 220;
|
||||
const DEFAULT_SIBLING_GAP = 24;
|
||||
const DEFAULT_ROOT_GAP = 24;
|
||||
const DEFAULT_CHART_PADDING = 16;
|
||||
|
||||
function getLeafCount(node: OrgTreeNode): number {
|
||||
if (node.children.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
return node.children.reduce((sum, child) => sum + getLeafCount(child), 0);
|
||||
}
|
||||
|
||||
function estimateSubtreeWidth(node: OrgTreeNode): number {
|
||||
const leaves = getLeafCount(node);
|
||||
return leaves * DEFAULT_NODE_WIDTH + Math.max(0, leaves - 1) * DEFAULT_SIBLING_GAP;
|
||||
}
|
||||
|
||||
export function estimateOrgChartWidth(tree: OrgTreeNode[]): number {
|
||||
if (tree.length === 0) {
|
||||
return DEFAULT_NODE_WIDTH;
|
||||
}
|
||||
const rootWidths = tree.map(estimateSubtreeWidth);
|
||||
const totalRootsWidth = rootWidths.reduce((sum, width) => sum + width, 0);
|
||||
const rootGapWidth = Math.max(0, tree.length - 1) * DEFAULT_ROOT_GAP;
|
||||
return totalRootsWidth + rootGapWidth + DEFAULT_CHART_PADDING * 2;
|
||||
}
|
||||
|
||||
export function resolveOrgChartLayoutMode(input: OrgChartLayoutInput): OrgChartLayoutMode {
|
||||
const safeAvailableWidth = Number.isFinite(input.availableWidth) && input.availableWidth > 0
|
||||
? input.availableWidth
|
||||
: 0;
|
||||
|
||||
if (safeAvailableWidth === 0 || input.tree.length <= 1) {
|
||||
return "horizontal";
|
||||
}
|
||||
|
||||
const estimatedWidth = estimateOrgChartWidth(input.tree);
|
||||
return estimatedWidth > safeAvailableWidth ? "vertical" : "horizontal";
|
||||
}
|
||||
Reference in New Issue
Block a user