feat(canvas): highlight favorites without hiding nodes
This commit is contained in:
184
components/canvas/__tests__/canvas-favorites-visibility.test.ts
Normal file
184
components/canvas/__tests__/canvas-favorites-visibility.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { projectCanvasFavoritesVisibility } from "../canvas-favorites-visibility";
|
||||
|
||||
function createNode(
|
||||
id: string,
|
||||
data: Record<string, unknown>,
|
||||
options?: {
|
||||
style?: RFNode<Record<string, unknown>>["style"];
|
||||
className?: string;
|
||||
},
|
||||
): RFNode<Record<string, unknown>> {
|
||||
return {
|
||||
id,
|
||||
position: { x: 0, y: 0 },
|
||||
data,
|
||||
style: options?.style,
|
||||
className: options?.className,
|
||||
type: "note",
|
||||
};
|
||||
}
|
||||
|
||||
function createEdge(
|
||||
id: string,
|
||||
source: string,
|
||||
target: string,
|
||||
options?: {
|
||||
style?: RFEdge<Record<string, unknown>>["style"];
|
||||
className?: string;
|
||||
},
|
||||
): RFEdge<Record<string, unknown>> {
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
style: options?.style,
|
||||
className: options?.className,
|
||||
type: "default",
|
||||
};
|
||||
}
|
||||
|
||||
describe("projectCanvasFavoritesVisibility", () => {
|
||||
it("keeps nodes and edges unchanged when favorites focus mode is inactive", () => {
|
||||
const nodes = [
|
||||
createNode("node-a", { isFavorite: true }),
|
||||
createNode("node-b", { label: "normal" }, { style: { width: 280, height: 200 } }),
|
||||
];
|
||||
const edges = [
|
||||
createEdge("edge-a", "node-a", "node-b", {
|
||||
style: { stroke: "rgb(0, 0, 0)", strokeWidth: 2 },
|
||||
}),
|
||||
];
|
||||
|
||||
const result = projectCanvasFavoritesVisibility({
|
||||
nodes,
|
||||
edges,
|
||||
favoritesOnly: false,
|
||||
});
|
||||
|
||||
expect(result.nodes[0]).toBe(nodes[0]);
|
||||
expect(result.nodes[1]).toBe(nodes[1]);
|
||||
expect(result.edges[0]).toBe(edges[0]);
|
||||
expect(result.favoriteCount).toBe(1);
|
||||
expect(Array.from(result.favoriteNodeIds)).toEqual(["node-a"]);
|
||||
expect(result.nodes[1]?.style).toEqual({ width: 280, height: 200 });
|
||||
expect(result.edges[0]?.style).toEqual({ stroke: "rgb(0, 0, 0)", strokeWidth: 2 });
|
||||
});
|
||||
|
||||
it("dims non-favorite nodes when favorites focus mode is active", () => {
|
||||
const nodes = [
|
||||
createNode("node-a", { isFavorite: true }),
|
||||
createNode("node-b", { label: "normal" }, { className: "custom-node" }),
|
||||
createNode("node-c", { isFavorite: true }),
|
||||
];
|
||||
const edges: RFEdge<Record<string, unknown>>[] = [];
|
||||
|
||||
const result = projectCanvasFavoritesVisibility({
|
||||
nodes,
|
||||
edges,
|
||||
favoritesOnly: true,
|
||||
});
|
||||
|
||||
expect(result.nodes[0]).toBe(nodes[0]);
|
||||
expect(result.nodes[2]).toBe(nodes[2]);
|
||||
expect(result.nodes[1]).not.toBe(nodes[1]);
|
||||
expect(result.nodes[1]?.style).toMatchObject({
|
||||
opacity: 0.28,
|
||||
filter: "saturate(0.55)",
|
||||
});
|
||||
expect(result.nodes[1]?.className).toContain("custom-node");
|
||||
expect(result.favoriteCount).toBe(2);
|
||||
expect(Array.from(result.favoriteNodeIds)).toEqual(["node-a", "node-c"]);
|
||||
});
|
||||
|
||||
it("dims edges when source and target are not both favorite", () => {
|
||||
const nodes = [
|
||||
createNode("node-a", { isFavorite: true }),
|
||||
createNode("node-b", { label: "normal" }),
|
||||
createNode("node-c", { isFavorite: true }),
|
||||
];
|
||||
const edges = [
|
||||
createEdge("edge-aa", "node-a", "node-c", {
|
||||
style: { stroke: "rgb(10, 10, 10)", strokeWidth: 2 },
|
||||
}),
|
||||
createEdge("edge-ab", "node-a", "node-b", {
|
||||
style: { stroke: "rgb(20, 20, 20)", strokeWidth: 2 },
|
||||
}),
|
||||
createEdge("edge-bc", "node-b", "node-c", {
|
||||
style: { stroke: "rgb(30, 30, 30)", strokeWidth: 2 },
|
||||
}),
|
||||
];
|
||||
|
||||
const result = projectCanvasFavoritesVisibility({
|
||||
nodes,
|
||||
edges,
|
||||
favoritesOnly: true,
|
||||
});
|
||||
|
||||
expect(result.edges[0]).toBe(edges[0]);
|
||||
expect(result.edges[1]).not.toBe(edges[1]);
|
||||
expect(result.edges[2]).not.toBe(edges[2]);
|
||||
expect(result.edges[0]?.style).toEqual({ stroke: "rgb(10, 10, 10)", strokeWidth: 2 });
|
||||
expect(result.edges[1]?.style).toMatchObject({
|
||||
stroke: "rgb(20, 20, 20)",
|
||||
strokeWidth: 2,
|
||||
opacity: 0.18,
|
||||
});
|
||||
expect(result.edges[2]?.style).toMatchObject({
|
||||
stroke: "rgb(30, 30, 30)",
|
||||
strokeWidth: 2,
|
||||
opacity: 0.18,
|
||||
});
|
||||
expect(result.edges[0]).toBe(edges[0]);
|
||||
});
|
||||
|
||||
it("does not mutate input nodes or edges and only changes affected items", () => {
|
||||
const nodes = [
|
||||
createNode("node-a", { isFavorite: true }),
|
||||
createNode("node-b", { label: "normal" }, { style: { width: 240 } }),
|
||||
createNode("node-c", { isFavorite: true }, { style: { width: 180 } }),
|
||||
];
|
||||
const edges = [
|
||||
createEdge("edge-ab", "node-a", "node-b", { style: { stroke: "red" } }),
|
||||
createEdge("edge-ac", "node-a", "node-c", { style: { stroke: "green" } }),
|
||||
createEdge("edge-bc", "node-b", "node-c", { style: { stroke: "blue" } }),
|
||||
];
|
||||
|
||||
const nodesBefore = structuredClone(nodes);
|
||||
const edgesBefore = structuredClone(edges);
|
||||
|
||||
const result = projectCanvasFavoritesVisibility({
|
||||
nodes,
|
||||
edges,
|
||||
favoritesOnly: true,
|
||||
});
|
||||
|
||||
expect(nodes).toEqual(nodesBefore);
|
||||
expect(edges).toEqual(edgesBefore);
|
||||
expect(result.nodes[0]).toBe(nodes[0]);
|
||||
expect(result.nodes[1]).not.toBe(nodes[1]);
|
||||
expect(result.nodes[2]).toBe(nodes[2]);
|
||||
expect(result.edges[0]).not.toBe(edges[0]);
|
||||
expect(result.edges[1]).toBe(edges[1]);
|
||||
expect(result.edges[2]).not.toBe(edges[2]);
|
||||
});
|
||||
|
||||
it("dims all nodes and edges when focus mode is active with zero favorites", () => {
|
||||
const nodes = [createNode("node-a", { label: "first" }), createNode("node-b", { label: "second" })];
|
||||
const edges = [createEdge("edge-ab", "node-a", "node-b")];
|
||||
|
||||
const result = projectCanvasFavoritesVisibility({
|
||||
nodes,
|
||||
edges,
|
||||
favoritesOnly: true,
|
||||
});
|
||||
|
||||
expect(result.favoriteCount).toBe(0);
|
||||
expect(result.favoriteNodeIds.size).toBe(0);
|
||||
expect(result.nodes[0]?.style).toMatchObject({ opacity: 0.28, filter: "saturate(0.55)" });
|
||||
expect(result.nodes[1]?.style).toMatchObject({ opacity: 0.28, filter: "saturate(0.55)" });
|
||||
expect(result.edges[0]?.style).toMatchObject({ opacity: 0.18 });
|
||||
});
|
||||
});
|
||||
173
components/canvas/__tests__/canvas-toolbar.test.tsx
Normal file
173
components/canvas/__tests__/canvas-toolbar.test.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createNodeWithIntersection: vi.fn(async () => undefined),
|
||||
getCenteredPosition: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-placement-context", () => ({
|
||||
useCanvasPlacement: () => ({
|
||||
createNodeWithIntersection: mocks.createNodeWithIntersection,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-centered-flow-node-position", () => ({
|
||||
useCenteredFlowNodePosition: () => mocks.getCenteredPosition,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/credit-display", () => ({
|
||||
CreditDisplay: () => <div data-testid="credit-display" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/export-button", () => ({
|
||||
ExportButton: ({ canvasName }: { canvasName: string }) => (
|
||||
<button type="button">Export {canvasName}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/canvas-node-catalog", () => ({
|
||||
NODE_CATEGORIES_ORDERED: [],
|
||||
NODE_CATEGORY_META: {},
|
||||
catalogEntriesByCategory: () => new Map(),
|
||||
getTemplateForCatalogType: () => null,
|
||||
isNodePaletteEnabled: () => false,
|
||||
}));
|
||||
|
||||
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("CanvasToolbar", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.createNodeWithIntersection.mockClear();
|
||||
mocks.getCenteredPosition.mockClear();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
it("renders the favorites filter button", async () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<CanvasToolbar
|
||||
activeTool="select"
|
||||
onToolChange={vi.fn()}
|
||||
onFavoriteFilterChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const favoriteButton = container?.querySelector('button[title="Favoriten hervorheben"]');
|
||||
expect(favoriteButton).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reflects active state via aria-pressed", async () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<CanvasToolbar
|
||||
activeTool="select"
|
||||
onToolChange={vi.fn()}
|
||||
favoriteFilterActive={false}
|
||||
onFavoriteFilterChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
let favoriteButton = container?.querySelector('button[title="Favoriten hervorheben"]');
|
||||
expect(favoriteButton?.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<CanvasToolbar
|
||||
activeTool="select"
|
||||
onToolChange={vi.fn()}
|
||||
favoriteFilterActive
|
||||
onFavoriteFilterChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
favoriteButton = container?.querySelector('button[title="Favoriten hervorheben"]');
|
||||
expect(favoriteButton?.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("toggles and calls onFavoriteFilterChange", async () => {
|
||||
const onFavoriteFilterChange = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<CanvasToolbar
|
||||
activeTool="select"
|
||||
onToolChange={vi.fn()}
|
||||
favoriteFilterActive={false}
|
||||
onFavoriteFilterChange={onFavoriteFilterChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const favoriteButton = container?.querySelector('button[title="Favoriten hervorheben"]');
|
||||
if (!(favoriteButton instanceof HTMLButtonElement)) {
|
||||
throw new Error("Favorite filter button not found");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
favoriteButton.click();
|
||||
});
|
||||
|
||||
expect(onFavoriteFilterChange).toHaveBeenCalledTimes(1);
|
||||
expect(onFavoriteFilterChange).toHaveBeenCalledWith(true);
|
||||
|
||||
onFavoriteFilterChange.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<CanvasToolbar
|
||||
activeTool="select"
|
||||
onToolChange={vi.fn()}
|
||||
favoriteFilterActive
|
||||
onFavoriteFilterChange={onFavoriteFilterChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const activeFavoriteButton = container?.querySelector('button[title="Favoriten hervorheben"]');
|
||||
if (!(activeFavoriteButton instanceof HTMLButtonElement)) {
|
||||
throw new Error("Active favorite filter button not found");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
activeFavoriteButton.click();
|
||||
});
|
||||
|
||||
expect(onFavoriteFilterChange).toHaveBeenCalledTimes(1);
|
||||
expect(onFavoriteFilterChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user