FN-6733: render dependency graph edge styling
Render dependency graph connectors with CSS-resolvable styling so task dependencies are visible. - Move dependency edge stroke, width, opacity, and arrowhead fill from SVG presentation attributes into inline styles. - Cover highlighted, dimmed, empty, filtered, desktop, and mobile dependency graph edge rendering. - Document the SVG custom-property requirement with an FNXC dependency graph comment. Files changed: .../DependencyGraph.highlighting.test.tsx | 39 ++++++++++++++++++++++ .../src/__tests__/edges.test.tsx | 34 ++++++++++++++++--- .../fusion-plugin-dependency-graph/src/edges.tsx | 17 +++++++--- 3 files changed, 81 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-6733 Fusion-Task-Lineage: 167e7b56-f0eb-4b32-9f66-3b372ef27d60
This commit is contained in:
@@ -41,8 +41,22 @@ function createTask(id: string, dependencies: string[] = []): Task {
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function setViewportDimensions(width: number, height: number) {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: height });
|
||||
}
|
||||
|
||||
function expectEdgePaintsViaResolvableStyle(edge: SVGElement, expectedStroke = "var(--border)", expectedStrokeWidth = "var(--btn-border-width)") {
|
||||
expect(edge.getAttribute("stroke")).not.toBe(expectedStroke);
|
||||
expect(edge.getAttribute("stroke-width")).not.toBe(expectedStrokeWidth);
|
||||
expect(edge.getAttribute("strokeWidth")).not.toBe(expectedStrokeWidth);
|
||||
expect(edge.style.stroke).toBe(expectedStroke);
|
||||
expect(edge.style.strokeWidth).toBe(expectedStrokeWidth);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setViewportDimensions(1024, 768);
|
||||
});
|
||||
|
||||
describe("DependencyGraph highlighting", () => {
|
||||
@@ -97,14 +111,39 @@ describe("DependencyGraph highlighting", () => {
|
||||
const edgeAB = edges.find((edge) => edge.getAttribute("data-edge-id") === "B->A");
|
||||
const edgeCB = edges.find((edge) => edge.getAttribute("data-edge-id") === "C->B");
|
||||
|
||||
expect(edgeAB).toBeDefined();
|
||||
expect(edgeAB?.className.baseVal || edgeAB?.className).toContain("graph-edge--highlighted");
|
||||
expectEdgePaintsViaResolvableStyle(edgeAB as SVGElement, "var(--todo)", "var(--space-xs)");
|
||||
expect(edgeCB).toBeDefined();
|
||||
expect(edgeCB?.className.baseVal || edgeCB?.className).toContain("graph-edge--highlighted");
|
||||
expectEdgePaintsViaResolvableStyle(edgeCB as SVGElement, "var(--todo)", "var(--space-xs)");
|
||||
|
||||
fireEvent.doubleClick(screen.getByTestId("graph-task-node-C"));
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "C" }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["vertical desktop", 1200, 800],
|
||||
["horizontal mobile", 390, 844],
|
||||
] as const)("renders dependency chain edges with resolvable stroke in %s orientation", (_label, width, height) => {
|
||||
setViewportDimensions(width, height);
|
||||
render(<DependencyGraph tasks={tasks} onOpenDetail={vi.fn()} />);
|
||||
|
||||
const edges = screen.getAllByTestId("dependency-edge");
|
||||
expect(edges.map((edge) => edge.getAttribute("data-edge-id")).sort()).toEqual(["B->A", "C->B"]);
|
||||
for (const edge of edges) {
|
||||
expectEdgePaintsViaResolvableStyle(edge);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not render an edge when a dependency target is filtered out", () => {
|
||||
const doneDependency = { ...createTask("Done"), column: "done" as const };
|
||||
render(<DependencyGraph tasks={[createTask("Visible", ["Done"]), doneDependency]} onOpenDetail={vi.fn()} />);
|
||||
|
||||
expect(screen.queryAllByTestId("dependency-edge")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("highlights only isolated node with no dependencies", () => {
|
||||
render(<DependencyGraph tasks={[createTask("X")]} onOpenDetail={vi.fn()} />);
|
||||
fireEvent.mouseEnter(screen.getByTestId("graph-task-node-X"));
|
||||
|
||||
@@ -19,6 +19,14 @@ function renderEdges(edges: GraphEdge[], highlightedEdgeIds?: Set<string>) {
|
||||
);
|
||||
}
|
||||
|
||||
function expectEdgePaintsViaResolvableStyle(edge: SVGElement, expectedStroke: string, expectedStrokeWidth: string) {
|
||||
expect(edge.getAttribute("stroke")).not.toBe(expectedStroke);
|
||||
expect(edge.getAttribute("stroke-width")).not.toBe(expectedStrokeWidth);
|
||||
expect(edge.getAttribute("strokeWidth")).not.toBe(expectedStrokeWidth);
|
||||
expect(edge.style.stroke).toBe(expectedStroke);
|
||||
expect(edge.style.strokeWidth).toBe(expectedStrokeWidth);
|
||||
}
|
||||
|
||||
describe("GraphEdges", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -26,8 +34,22 @@ describe("GraphEdges", () => {
|
||||
it("renders single edge", () => {
|
||||
renderEdges([{ source: "A", target: "B" }]);
|
||||
const edge = screen.getAllByTestId("dependency-edge")[0];
|
||||
expect(edge.getAttribute("opacity")).toBe("1");
|
||||
expect(edge.getAttribute("stroke")).toBe("var(--border)");
|
||||
expect(edge.getAttribute("opacity")).toBeNull();
|
||||
expectEdgePaintsViaResolvableStyle(edge, "var(--border)", "var(--btn-border-width)");
|
||||
});
|
||||
|
||||
it("renders no edge paths when there are zero edges", () => {
|
||||
renderEdges([]);
|
||||
expect(screen.queryAllByTestId("dependency-edge")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders arrowhead fill through resolvable style instead of a presentation attribute", () => {
|
||||
const { container } = renderEdges([{ source: "A", target: "B" }]);
|
||||
const arrowheadPath = container.querySelector("#dependency-graph-arrowhead path") as SVGPathElement | null;
|
||||
|
||||
expect(arrowheadPath).not.toBeNull();
|
||||
expect(arrowheadPath?.getAttribute("fill")).not.toBe("var(--border)");
|
||||
expect(arrowheadPath?.style.fill).toBe("var(--border)");
|
||||
});
|
||||
|
||||
it("renders multiple edges", () => {
|
||||
@@ -67,9 +89,13 @@ describe("GraphEdges", () => {
|
||||
const highlighted = all.find((edge) => edge.getAttribute("data-edge-id") === "A->B");
|
||||
const dimmed = all.find((edge) => edge.getAttribute("data-edge-id") === "A->C");
|
||||
|
||||
expect(highlighted?.getAttribute("opacity")).toBe("1");
|
||||
expect(highlighted).toBeDefined();
|
||||
expect(highlighted?.style.opacity).toBe("1");
|
||||
expectEdgePaintsViaResolvableStyle(highlighted as SVGElement, "var(--todo)", "var(--space-xs)");
|
||||
expect(highlighted?.getAttribute("class") ?? "").toContain("graph-edge--highlighted");
|
||||
expect(dimmed?.getAttribute("opacity")).toBe("0.15");
|
||||
expect(dimmed).toBeDefined();
|
||||
expect(dimmed?.style.opacity).toBe("0.15");
|
||||
expectEdgePaintsViaResolvableStyle(dimmed as SVGElement, "var(--border)", "var(--btn-border-width)");
|
||||
expect(dimmed?.getAttribute("class") ?? "").toContain("graph-edge--dimmed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@ interface GraphEdgesProps {
|
||||
const DEFAULT_NODE_WIDTH = 280;
|
||||
const DEFAULT_NODE_HEIGHT = 100;
|
||||
|
||||
/**
|
||||
* FNXC:DependencyGraphEdges 2026-06-19-08:59:
|
||||
* Browser SVG presentation attributes do not resolve CSS custom properties, so dependency edge theme paint and widths must travel through real CSS via inline style or classes.
|
||||
* Keep only literal SVG-safe values such as fill="none" as presentation attributes so dependency connector strokes and arrowheads remain visible across default, highlighted, and dimmed states.
|
||||
*/
|
||||
export function GraphEdges({
|
||||
edges,
|
||||
positions,
|
||||
@@ -35,7 +40,7 @@ export function GraphEdges({
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="var(--border)" />
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" style={{ fill: "var(--border)" }} />
|
||||
</marker>
|
||||
</defs>
|
||||
{edges.map((edge) => {
|
||||
@@ -59,11 +64,13 @@ export function GraphEdges({
|
||||
className={`dependency-graph-edge${isActiveHighlight ? " graph-edge--highlighted" : ""}${hasHighlights && !isActiveHighlight ? " graph-edge--dimmed" : ""}`}
|
||||
d={`M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`}
|
||||
fill="none"
|
||||
stroke={isActiveHighlight ? "var(--todo)" : "var(--border)"}
|
||||
strokeWidth={isActiveHighlight ? "var(--space-xs)" : "var(--btn-border-width)"}
|
||||
opacity={hasHighlights && !isActiveHighlight ? 0.15 : 1}
|
||||
markerEnd="url(#dependency-graph-arrowhead)"
|
||||
style={{ transition: "opacity var(--transition-fast), stroke var(--transition-fast), stroke-width var(--transition-fast)" }}
|
||||
style={{
|
||||
opacity: hasHighlights && !isActiveHighlight ? 0.15 : 1,
|
||||
stroke: isActiveHighlight ? "var(--todo)" : "var(--border)",
|
||||
strokeWidth: isActiveHighlight ? "var(--space-xs)" : "var(--btn-border-width)",
|
||||
transition: "opacity var(--transition-fast), stroke var(--transition-fast), stroke-width var(--transition-fast)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user