Skip to content

Commit 0c6a9c6

Browse files
jkemmererupgradeclaudekmcginnes
authored
Add built-in Lucide icon library for node styling (#1777)
* feat(icons): add built-in Lucide icon library for node styling Stores icon selections as symbolic lucide:<name> references rather than data URIs, so config files remain human-readable and the picker can highlight the current selection without reverse-engineering the blob. Resolution happens at render time via two paths: - React components (VertexIcon): useResolvedIconUrl hook backed by React Query (staleTime: Infinity) converts lucide:<name> to a base64 SVG data URI on first use and caches the result. - Cytoscape pipeline (renderNode): getLucideSvgString resolves directly from the lucide-react dynamic-import map, skipping any fetch round-trip. Custom-uploaded icons (data: URIs and plain URLs) are unchanged -- they pass through both resolvers untouched. The new IconPicker component (searchable popover, 8-column grid, 50-icon default window) is wired into NodeStyleDialog alongside the existing file upload button so users can choose a Lucide icon or keep uploading their own. Also fixes a pre-existing lint-staged misconfiguration where the root oxlint --fix task matched vitest.config.ts, which oxlint then ignored (matching its own ignorePatterns), causing exit code 1. The root lint-staged config now mirrors the oxlint ignorePatterns by routing *.config.* files to oxfmt only and restricting oxlint to packages/. Pre-flight: pnpm checks, pnpm test (1738/1738), pnpm coverage all pass. This is Slice 1 of the 3-slice split of #1589 per @kmcginnes review. * address PR review feedback Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * Remove dead code, consolidate Lucide icon utilities, fix edge cases - Remove unused useResolvedIconUrl hook and resolveIconUrl/lucideIconToDataUri - Rename lucideIconUrl.ts to lucideIcons.ts and export IconName type and allIconNamesSorted so consumers share a single source of truth - Make isLucideIconRef a proper type guard, eliminating non-null assertion - Fix renderNode to resolve lucide: refs regardless of iconImageType - Fix IconPicker truncation hint to show whenever results are capped - Add tests for new exports, error paths, and edge cases * Revert unrelated lint-staged config change in root package.json * Fix lint and type errors in lucideIcons utilities --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Kris McGinnes <kmcginnes@me.com>
1 parent 631a1b5 commit 0c6a9c6

11 files changed

Lines changed: 588 additions & 2 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
**/dist/
77
**/.env.local
88

9+
# IntelliJ IDEA module files
10+
*.iml
11+
912
# TypeScript build cache manifests
1013
*.tsbuildinfo
1114

docs/features/graph-view.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ Each node type can be customized in a variety of ways.
8080
- **Display label** allows you to change how the node label (or rdf:type) is represented
8181
- **Display name attribute** allows you to choose the attribute on the node that is used to uniquely label the node in the graph visualization and search
8282
- **Display description attribute** allows you to choose the attribute on the node that is used to describe the node in search
83-
- **Custom symbol** can be uploaded in the form of an SVG icon
83+
- **Icon** can be picked from the built-in Lucide library via the **Browse** button, or uploaded as a custom SVG/raster image.
8484
- **Colors and borders** can be customized to visually distinguish from other node types
8585

8686
### Edge Styling Panel
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// @vitest-environment happy-dom
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
5+
import { IconPicker } from "./IconPicker";
6+
7+
describe("IconPicker", () => {
8+
it("should render Browse button", () => {
9+
render(<IconPicker onSelect={vi.fn()} />);
10+
expect(screen.getByRole("button", { name: /browse/i })).toBeInTheDocument();
11+
});
12+
13+
it("should open popover with search input on click", async () => {
14+
const user = userEvent.setup();
15+
render(<IconPicker onSelect={vi.fn()} />);
16+
17+
await user.click(screen.getByRole("button", { name: /browse/i }));
18+
19+
expect(screen.getByPlaceholderText("Search icons...")).toBeInTheDocument();
20+
});
21+
22+
it("should show icons in the grid", async () => {
23+
const user = userEvent.setup();
24+
render(<IconPicker onSelect={vi.fn()} />);
25+
26+
await user.click(screen.getByRole("button", { name: /browse/i }));
27+
28+
// Wait for at least some icon buttons to appear in the grid
29+
await waitFor(() => {
30+
const iconButtons = screen
31+
.getAllByRole("button")
32+
.filter(btn => btn.title && btn.title !== "");
33+
expect(iconButtons.length).toBeGreaterThan(0);
34+
});
35+
});
36+
37+
it("should filter icons when searching", async () => {
38+
const user = userEvent.setup();
39+
render(<IconPicker onSelect={vi.fn()} />);
40+
41+
await user.click(screen.getByRole("button", { name: /browse/i }));
42+
const searchInput = screen.getByPlaceholderText("Search icons...");
43+
44+
await user.type(searchInput, "user");
45+
46+
await waitFor(() => {
47+
const iconButtons = screen
48+
.getAllByRole("button")
49+
.filter(btn => btn.title && btn.title.includes("user"));
50+
expect(iconButtons.length).toBeGreaterThan(0);
51+
});
52+
});
53+
54+
it("should show truncation hint when results are capped", async () => {
55+
const user = userEvent.setup();
56+
render(<IconPicker onSelect={vi.fn()} />);
57+
58+
await user.click(screen.getByRole("button", { name: /browse/i }));
59+
60+
expect(screen.getByText(/Showing 64 of/)).toBeInTheDocument();
61+
});
62+
63+
it("should hide truncation hint when fewer results than cap", async () => {
64+
const user = userEvent.setup();
65+
render(<IconPicker onSelect={vi.fn()} />);
66+
67+
await user.click(screen.getByRole("button", { name: /browse/i }));
68+
const searchInput = screen.getByPlaceholderText("Search icons...");
69+
70+
await user.type(searchInput, "airplay");
71+
72+
expect(screen.queryByText(/Showing 64 of/)).not.toBeInTheDocument();
73+
});
74+
75+
it("should show no results message for invalid search", async () => {
76+
const user = userEvent.setup();
77+
render(<IconPicker onSelect={vi.fn()} />);
78+
79+
await user.click(screen.getByRole("button", { name: /browse/i }));
80+
const searchInput = screen.getByPlaceholderText("Search icons...");
81+
82+
await user.type(searchInput, "zzzznotanicon");
83+
84+
expect(screen.getByText("No icons found")).toBeInTheDocument();
85+
});
86+
87+
it("should call onSelect with lucide:<name> reference when icon is clicked", async () => {
88+
const user = userEvent.setup();
89+
const onSelect = vi.fn();
90+
render(<IconPicker onSelect={onSelect} />);
91+
92+
await user.click(screen.getByRole("button", { name: /browse/i }));
93+
94+
await waitFor(() => {
95+
const iconButtons = screen
96+
.getAllByRole("button")
97+
.filter(btn => btn.title && btn.title !== "");
98+
expect(iconButtons.length).toBeGreaterThan(0);
99+
});
100+
101+
const firstIcon = screen
102+
.getAllByRole("button")
103+
.filter(btn => btn.title && btn.title !== "")[0];
104+
const iconName = firstIcon.getAttribute("title");
105+
await user.click(firstIcon);
106+
107+
expect(onSelect).toHaveBeenCalledWith(
108+
`lucide:${iconName}`,
109+
"image/svg+xml",
110+
);
111+
});
112+
113+
it("should highlight the icon matching currentIconUrl", async () => {
114+
const user = userEvent.setup();
115+
render(<IconPicker currentIconUrl="lucide:airplay" onSelect={vi.fn()} />);
116+
117+
await user.click(screen.getByRole("button", { name: /browse/i }));
118+
119+
await waitFor(() => {
120+
const airplayBtn = screen
121+
.getAllByRole("button")
122+
.find(btn => btn.title === "airplay");
123+
expect(airplayBtn).toBeDefined();
124+
expect(airplayBtn).toHaveAttribute("aria-pressed", "true");
125+
});
126+
});
127+
128+
it("should not highlight any icon when currentIconUrl is not a lucide ref", async () => {
129+
const user = userEvent.setup();
130+
render(
131+
<IconPicker
132+
currentIconUrl="data:image/svg+xml;base64,XXXX"
133+
onSelect={vi.fn()}
134+
/>,
135+
);
136+
137+
await user.click(screen.getByRole("button", { name: /browse/i }));
138+
139+
await waitFor(() => {
140+
const iconButtons = screen
141+
.getAllByRole("button")
142+
.filter(btn => btn.title && btn.title !== "");
143+
expect(iconButtons.length).toBeGreaterThan(0);
144+
for (const btn of iconButtons) {
145+
expect(btn).toHaveAttribute("aria-pressed", "false");
146+
}
147+
});
148+
});
149+
150+
it("should close popover after selecting an icon", async () => {
151+
const user = userEvent.setup();
152+
render(<IconPicker onSelect={vi.fn()} />);
153+
154+
await user.click(screen.getByRole("button", { name: /browse/i }));
155+
156+
await waitFor(() => {
157+
const iconButtons = screen
158+
.getAllByRole("button")
159+
.filter(btn => btn.title && btn.title !== "");
160+
expect(iconButtons.length).toBeGreaterThan(0);
161+
});
162+
163+
const firstIcon = screen
164+
.getAllByRole("button")
165+
.filter(btn => btn.title && btn.title !== "")[0];
166+
await user.click(firstIcon);
167+
168+
await waitFor(() => {
169+
expect(
170+
screen.queryByPlaceholderText("Search icons..."),
171+
).not.toBeInTheDocument();
172+
});
173+
});
174+
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { SearchIcon } from "lucide-react";
2+
import { DynamicIcon } from "lucide-react/dynamic";
3+
import { useState } from "react";
4+
5+
import {
6+
allIconNamesSorted,
7+
getLucideName,
8+
type IconName,
9+
toLucideIconRef,
10+
} from "@/utils/lucideIcons";
11+
12+
import {
13+
Button,
14+
EmptyState,
15+
EmptyStateContent,
16+
EmptyStateDescription,
17+
EmptyStateTitle,
18+
Input,
19+
Popover,
20+
PopoverContent,
21+
PopoverTrigger,
22+
} from ".";
23+
24+
const MAX_VISIBLE = 64;
25+
26+
export function IconPicker({
27+
currentIconUrl,
28+
onSelect,
29+
}: {
30+
/**
31+
* The vertex's currently stored iconUrl. When this is a `lucide:<name>`
32+
* reference, the matching grid cell is highlighted to indicate the
33+
* current selection.
34+
*/
35+
currentIconUrl?: string;
36+
/**
37+
* Called with the symbolic `lucide:<name>` reference and the SVG MIME type.
38+
* Resolution to a data URI happens at render time, not here.
39+
*/
40+
onSelect: (iconUrl: string, iconImageType: string) => void;
41+
}) {
42+
const [open, setOpen] = useState(false);
43+
const [search, setSearch] = useState("");
44+
45+
const selectedName = getLucideName(currentIconUrl);
46+
const filtered = filterIcons(search);
47+
48+
function handleSelect(iconName: IconName) {
49+
onSelect(toLucideIconRef(iconName), "image/svg+xml");
50+
setOpen(false);
51+
setSearch("");
52+
}
53+
54+
return (
55+
<Popover open={open} onOpenChange={setOpen}>
56+
<PopoverTrigger asChild>
57+
<Button variant="outline" className="rounded-full">
58+
<SearchIcon className="size-4" />
59+
Browse
60+
</Button>
61+
</PopoverTrigger>
62+
<PopoverContent
63+
side="bottom"
64+
align="start"
65+
className="flex flex-col gap-4"
66+
>
67+
<Input
68+
placeholder="Search icons..."
69+
value={search}
70+
onChange={e => setSearch(e.target.value)}
71+
/>
72+
<div className="grid size-80 grid-cols-8 grid-rows-8 gap-0.5">
73+
{filtered.map(name => (
74+
<IconButton
75+
key={name}
76+
name={name}
77+
selected={name === selectedName}
78+
onSelect={handleSelect}
79+
/>
80+
))}
81+
{filtered.length === 0 && (
82+
<EmptyState className="col-span-8 row-span-8" size="small">
83+
<EmptyStateContent>
84+
<EmptyStateTitle>No icons found</EmptyStateTitle>
85+
<EmptyStateDescription className="text-balance">
86+
No matching icons found. Try a broader search.
87+
</EmptyStateDescription>
88+
</EmptyStateContent>
89+
</EmptyState>
90+
)}
91+
</div>
92+
{filtered.length >= MAX_VISIBLE && (
93+
<p className="text-text-secondary text-xs">
94+
Showing {MAX_VISIBLE} of {allIconNamesSorted.length} icons. Type to
95+
search.
96+
</p>
97+
)}
98+
</PopoverContent>
99+
</Popover>
100+
);
101+
}
102+
103+
function IconButton({
104+
name,
105+
selected,
106+
onSelect,
107+
}: {
108+
name: IconName;
109+
selected: boolean;
110+
onSelect: (name: IconName) => void;
111+
}) {
112+
return (
113+
<Button
114+
title={name}
115+
aria-pressed={selected}
116+
size="icon"
117+
variant={selected ? "primary" : "ghost"}
118+
onClick={() => onSelect(name)}
119+
className="size-full min-w-0"
120+
>
121+
<DynamicIcon name={name} />
122+
</Button>
123+
);
124+
}
125+
126+
function filterIcons(search: string) {
127+
if (!search) return allIconNamesSorted.slice(0, MAX_VISIBLE);
128+
const lower = search.toLowerCase();
129+
const results: IconName[] = [];
130+
for (const name of allIconNamesSorted) {
131+
if (name.includes(lower)) {
132+
results.push(name);
133+
if (results.length >= MAX_VISIBLE) break;
134+
}
135+
}
136+
return results;
137+
}

packages/graph-explorer/src/components/VertexIcon.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { CSSProperties } from "react";
22

3+
import { DynamicIcon } from "lucide-react/dynamic";
34
import SVG from "react-inlinesvg";
45

56
import {
@@ -8,6 +9,7 @@ import {
89
type VertexType,
910
} from "@/core";
1011
import { cn } from "@/utils";
12+
import { getLucideName, isValidLucideIconName } from "@/utils/lucideIcons";
1113

1214
import { SearchResultSymbol } from "./SearchResult";
1315

@@ -20,6 +22,23 @@ interface Props {
2022
function VertexIcon({ vertexStyle, className, alt }: Props) {
2123
const altText = alt ?? `${vertexStyle.displayLabel ?? vertexStyle.type} icon`;
2224

25+
const lucideIconName = getLucideName(vertexStyle.iconUrl);
26+
27+
if (lucideIconName) {
28+
if (isValidLucideIconName(lucideIconName)) {
29+
return (
30+
<DynamicIcon
31+
name={lucideIconName}
32+
className={cn("size-6 shrink-0", className)}
33+
style={{ color: vertexStyle.color }}
34+
/>
35+
);
36+
} else {
37+
// Unknown lucide ref — don't fall through to img/SVG paths
38+
return null;
39+
}
40+
}
41+
2342
if (vertexStyle.iconImageType === "image/svg+xml") {
2443
return (
2544
<SVG

packages/graph-explorer/src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export * from "./Field";
3333
export * from "./FileButton";
3434
export * from "./Form";
3535

36+
export * from "./IconPicker";
37+
3638
export * from "./numberFormat";
3739

3840
export * from "./icons";

0 commit comments

Comments
 (0)