Skip to content

Commit ddb455f

Browse files
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 aws#1589 per @kmcginnes review.
1 parent 8056216 commit ddb455f

16 files changed

Lines changed: 755 additions & 6 deletions

.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. Picked Lucide icons are stored as `lucide:<name>` references and resolved at render time, so the picker highlights the currently selected icon when you reopen the dialog.
8484
- **Colors and borders** can be customized to visually distinguish from other node types
8585

8686
### Edge Styling Panel

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
},
4141
"lint-staged": {
4242
"!(**/*.{js,ts,tsx})": "oxfmt --no-error-on-unmatched-pattern",
43-
"**/*.{js,ts,tsx}": [
43+
"**/*.config.{js,ts,mjs}": "oxfmt",
44+
"packages/**/*.{js,ts,tsx}": [
4445
"oxlint --fix",
4546
"oxfmt"
4647
]

packages/graph-explorer/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@
107107
},
108108
"lint-staged": {
109109
"*.{ts,tsx}": [
110-
"eslint --fix",
111110
"oxfmt"
112111
]
113112
},
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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 no results message for invalid search", async () => {
55+
const user = userEvent.setup();
56+
render(<IconPicker onSelect={vi.fn()} />);
57+
58+
await user.click(screen.getByRole("button", { name: /browse/i }));
59+
const searchInput = screen.getByPlaceholderText("Search icons...");
60+
61+
await user.type(searchInput, "zzzznotanicon");
62+
63+
expect(screen.getByText("No icons found")).toBeInTheDocument();
64+
});
65+
66+
it("should call onSelect with lucide:<name> reference when icon is clicked", async () => {
67+
const user = userEvent.setup();
68+
const onSelect = vi.fn();
69+
render(<IconPicker onSelect={onSelect} />);
70+
71+
await user.click(screen.getByRole("button", { name: /browse/i }));
72+
73+
await waitFor(() => {
74+
const iconButtons = screen
75+
.getAllByRole("button")
76+
.filter(btn => btn.title && btn.title !== "");
77+
expect(iconButtons.length).toBeGreaterThan(0);
78+
});
79+
80+
const firstIcon = screen
81+
.getAllByRole("button")
82+
.filter(btn => btn.title && btn.title !== "")[0];
83+
const iconName = firstIcon.getAttribute("title");
84+
await user.click(firstIcon);
85+
86+
expect(onSelect).toHaveBeenCalledWith(
87+
`lucide:${iconName}`,
88+
"image/svg+xml",
89+
);
90+
});
91+
92+
it("should highlight the icon matching currentIconUrl", async () => {
93+
const user = userEvent.setup();
94+
render(<IconPicker currentIconUrl="lucide:airplay" onSelect={vi.fn()} />);
95+
96+
await user.click(screen.getByRole("button", { name: /browse/i }));
97+
98+
await waitFor(() => {
99+
const airplayBtn = screen
100+
.getAllByRole("button")
101+
.find(btn => btn.title === "airplay");
102+
expect(airplayBtn).toBeDefined();
103+
expect(airplayBtn).toHaveAttribute("aria-pressed", "true");
104+
});
105+
});
106+
107+
it("should not highlight any icon when currentIconUrl is not a lucide ref", async () => {
108+
const user = userEvent.setup();
109+
render(
110+
<IconPicker
111+
currentIconUrl="data:image/svg+xml;base64,XXXX"
112+
onSelect={vi.fn()}
113+
/>,
114+
);
115+
116+
await user.click(screen.getByRole("button", { name: /browse/i }));
117+
118+
await waitFor(() => {
119+
const iconButtons = screen
120+
.getAllByRole("button")
121+
.filter(btn => btn.title && btn.title !== "");
122+
expect(iconButtons.length).toBeGreaterThan(0);
123+
for (const btn of iconButtons) {
124+
expect(btn).toHaveAttribute("aria-pressed", "false");
125+
}
126+
});
127+
});
128+
129+
it("should close popover after selecting an icon", async () => {
130+
const user = userEvent.setup();
131+
render(<IconPicker onSelect={vi.fn()} />);
132+
133+
await user.click(screen.getByRole("button", { name: /browse/i }));
134+
135+
await waitFor(() => {
136+
const iconButtons = screen
137+
.getAllByRole("button")
138+
.filter(btn => btn.title && btn.title !== "");
139+
expect(iconButtons.length).toBeGreaterThan(0);
140+
});
141+
142+
const firstIcon = screen
143+
.getAllByRole("button")
144+
.filter(btn => btn.title && btn.title !== "")[0];
145+
await user.click(firstIcon);
146+
147+
await waitFor(() => {
148+
expect(
149+
screen.queryByPlaceholderText("Search icons..."),
150+
).not.toBeInTheDocument();
151+
});
152+
});
153+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { SearchIcon } from "lucide-react";
2+
import dynamicIconImports from "lucide-react/dynamicIconImports";
3+
import { useEffect, useRef, useState } from "react";
4+
5+
import { cn } from "@/utils";
6+
import {
7+
getLucideName,
8+
lucideIconToDataUri,
9+
toLucideIconRef,
10+
} from "@/utils/lucideIconUrl";
11+
12+
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from ".";
13+
14+
const allIconNames = Object.keys(dynamicIconImports).sort();
15+
16+
const MAX_VISIBLE = 50;
17+
18+
export function IconPicker({
19+
currentIconUrl,
20+
onSelect,
21+
}: {
22+
/**
23+
* The vertex's currently stored iconUrl. When this is a `lucide:<name>`
24+
* reference, the matching grid cell is highlighted to indicate the
25+
* current selection.
26+
*/
27+
currentIconUrl?: string;
28+
/**
29+
* Called with the symbolic `lucide:<name>` reference and the SVG MIME type.
30+
* Resolution to a data URI happens at render time, not here.
31+
*/
32+
onSelect: (iconUrl: string, iconImageType: string) => void;
33+
}) {
34+
const [open, setOpen] = useState(false);
35+
const [search, setSearch] = useState("");
36+
const inputRef = useRef<HTMLInputElement>(null);
37+
38+
const selectedName = getLucideName(currentIconUrl);
39+
const filtered = filterIcons(search);
40+
41+
function handleSelect(iconName: string) {
42+
onSelect(toLucideIconRef(iconName), "image/svg+xml");
43+
setOpen(false);
44+
setSearch("");
45+
}
46+
47+
useEffect(() => {
48+
if (open) {
49+
const timer = setTimeout(() => inputRef.current?.focus(), 100);
50+
return () => clearTimeout(timer);
51+
}
52+
}, [open]);
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 w-80 flex-col gap-2 p-3"
66+
>
67+
<Input
68+
ref={inputRef}
69+
placeholder="Search icons..."
70+
value={search}
71+
onChange={e => setSearch(e.target.value)}
72+
className="h-8 text-sm"
73+
/>
74+
<div className="grid max-h-60 grid-cols-8 gap-1 overflow-y-auto">
75+
{filtered.map(name => (
76+
<IconButton
77+
key={name}
78+
name={name}
79+
selected={name === selectedName}
80+
onSelect={handleSelect}
81+
/>
82+
))}
83+
{filtered.length === 0 && (
84+
<p className="text-text-secondary col-span-8 py-4 text-center text-sm">
85+
No icons found
86+
</p>
87+
)}
88+
</div>
89+
{!search && (
90+
<p className="text-text-secondary text-xs">
91+
Showing {MAX_VISIBLE} of {allIconNames.length} icons. Type to
92+
search.
93+
</p>
94+
)}
95+
</PopoverContent>
96+
</Popover>
97+
);
98+
}
99+
100+
function IconButton({
101+
name,
102+
selected,
103+
onSelect,
104+
}: {
105+
name: string;
106+
selected: boolean;
107+
onSelect: (name: string) => void;
108+
}) {
109+
const [src, setSrc] = useState<string | null>(null);
110+
111+
useEffect(() => {
112+
let cancelled = false;
113+
lucideIconToDataUri(name).then(
114+
uri => {
115+
if (!cancelled && uri) setSrc(uri);
116+
},
117+
() => {
118+
// Icon failed to load, leave as placeholder
119+
},
120+
);
121+
return () => {
122+
cancelled = true;
123+
};
124+
}, [name]);
125+
126+
return (
127+
<button
128+
type="button"
129+
title={name}
130+
aria-pressed={selected}
131+
className={cn(
132+
"hover:bg-background-contrast-secondary flex size-8 items-center justify-center rounded",
133+
selected && "bg-primary-main/20 ring-primary-main ring-2",
134+
)}
135+
onClick={() => onSelect(name)}
136+
>
137+
{src ? (
138+
<img src={src} alt={name} className="size-5" />
139+
) : (
140+
<div className="bg-background-contrast-secondary size-5 animate-pulse rounded" />
141+
)}
142+
</button>
143+
);
144+
}
145+
146+
function filterIcons(search: string): string[] {
147+
if (!search) return allIconNames.slice(0, MAX_VISIBLE);
148+
const lower = search.toLowerCase();
149+
const results: string[] = [];
150+
for (const name of allIconNames) {
151+
if (name.includes(lower)) {
152+
results.push(name);
153+
if (results.length >= MAX_VISIBLE) break;
154+
}
155+
}
156+
return results;
157+
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type VertexType,
99
} from "@/core";
1010
import { cn } from "@/utils";
11+
import { useResolvedIconUrl } from "@/utils/useResolvedIconUrl";
1112

1213
import { SearchResultSymbol } from "./SearchResult";
1314

@@ -19,11 +20,12 @@ interface Props {
1920

2021
function VertexIcon({ vertexStyle, className, alt }: Props) {
2122
const altText = alt ?? `${vertexStyle.displayLabel ?? vertexStyle.type} icon`;
23+
const resolvedSrc = useResolvedIconUrl(vertexStyle.iconUrl);
2224

2325
if (vertexStyle.iconImageType === "image/svg+xml") {
2426
return (
2527
<SVG
26-
src={vertexStyle.iconUrl}
28+
src={resolvedSrc}
2729
className={cn("size-6 shrink-0", className)}
2830
style={{ color: vertexStyle.color }}
2931
title={altText}
@@ -33,7 +35,7 @@ function VertexIcon({ vertexStyle, className, alt }: Props) {
3335

3436
return (
3537
<img
36-
src={vertexStyle.iconUrl}
38+
src={resolvedSrc}
3739
alt={altText}
3840
className={cn("size-6 shrink-0", className)}
3941
style={{ color: vertexStyle.color }}

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)