Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
**/dist/
**/.env.local

# IntelliJ IDEA module files
*.iml

# TypeScript build cache manifests
*.tsbuildinfo

Expand Down
2 changes: 1 addition & 1 deletion docs/features/graph-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Each node type can be customized in a variety of ways.
- **Display label** allows you to change how the node label (or rdf:type) is represented
- **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
- **Display description attribute** allows you to choose the attribute on the node that is used to describe the node in search
- **Custom symbol** can be uploaded in the form of an SVG icon
- **Icon** can be picked from the built-in Lucide library via the **Browse** button, or uploaded as a custom SVG/raster image.
- **Colors and borders** can be customized to visually distinguish from other node types

### Edge Styling Panel
Expand Down
174 changes: 174 additions & 0 deletions packages/graph-explorer/src/components/IconPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// @vitest-environment happy-dom
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { IconPicker } from "./IconPicker";

describe("IconPicker", () => {
it("should render Browse button", () => {
render(<IconPicker onSelect={vi.fn()} />);
expect(screen.getByRole("button", { name: /browse/i })).toBeInTheDocument();
});

it("should open popover with search input on click", async () => {
const user = userEvent.setup();
render(<IconPicker onSelect={vi.fn()} />);

await user.click(screen.getByRole("button", { name: /browse/i }));

expect(screen.getByPlaceholderText("Search icons...")).toBeInTheDocument();
});

it("should show icons in the grid", async () => {
const user = userEvent.setup();
render(<IconPicker onSelect={vi.fn()} />);

await user.click(screen.getByRole("button", { name: /browse/i }));

// Wait for at least some icon buttons to appear in the grid
await waitFor(() => {
const iconButtons = screen
.getAllByRole("button")
.filter(btn => btn.title && btn.title !== "");
expect(iconButtons.length).toBeGreaterThan(0);
});
});

it("should filter icons when searching", async () => {
const user = userEvent.setup();
render(<IconPicker onSelect={vi.fn()} />);

await user.click(screen.getByRole("button", { name: /browse/i }));
const searchInput = screen.getByPlaceholderText("Search icons...");

await user.type(searchInput, "user");

await waitFor(() => {
const iconButtons = screen
.getAllByRole("button")
.filter(btn => btn.title && btn.title.includes("user"));
expect(iconButtons.length).toBeGreaterThan(0);
});
});

it("should show truncation hint when results are capped", async () => {
const user = userEvent.setup();
render(<IconPicker onSelect={vi.fn()} />);

await user.click(screen.getByRole("button", { name: /browse/i }));

expect(screen.getByText(/Showing 64 of/)).toBeInTheDocument();
});

it("should hide truncation hint when fewer results than cap", async () => {
const user = userEvent.setup();
render(<IconPicker onSelect={vi.fn()} />);

await user.click(screen.getByRole("button", { name: /browse/i }));
const searchInput = screen.getByPlaceholderText("Search icons...");

await user.type(searchInput, "airplay");

expect(screen.queryByText(/Showing 64 of/)).not.toBeInTheDocument();
});

it("should show no results message for invalid search", async () => {
const user = userEvent.setup();
render(<IconPicker onSelect={vi.fn()} />);

await user.click(screen.getByRole("button", { name: /browse/i }));
const searchInput = screen.getByPlaceholderText("Search icons...");

await user.type(searchInput, "zzzznotanicon");

expect(screen.getByText("No icons found")).toBeInTheDocument();
});

it("should call onSelect with lucide:<name> reference when icon is clicked", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<IconPicker onSelect={onSelect} />);

await user.click(screen.getByRole("button", { name: /browse/i }));

await waitFor(() => {
const iconButtons = screen
.getAllByRole("button")
.filter(btn => btn.title && btn.title !== "");
expect(iconButtons.length).toBeGreaterThan(0);
});

const firstIcon = screen
.getAllByRole("button")
.filter(btn => btn.title && btn.title !== "")[0];
const iconName = firstIcon.getAttribute("title");
await user.click(firstIcon);

expect(onSelect).toHaveBeenCalledWith(
`lucide:${iconName}`,
"image/svg+xml",
);
});

it("should highlight the icon matching currentIconUrl", async () => {
const user = userEvent.setup();
render(<IconPicker currentIconUrl="lucide:airplay" onSelect={vi.fn()} />);

await user.click(screen.getByRole("button", { name: /browse/i }));

await waitFor(() => {
const airplayBtn = screen
.getAllByRole("button")
.find(btn => btn.title === "airplay");
expect(airplayBtn).toBeDefined();
expect(airplayBtn).toHaveAttribute("aria-pressed", "true");
});
});

it("should not highlight any icon when currentIconUrl is not a lucide ref", async () => {
const user = userEvent.setup();
render(
<IconPicker
currentIconUrl="data:image/svg+xml;base64,XXXX"
onSelect={vi.fn()}
/>,
);

await user.click(screen.getByRole("button", { name: /browse/i }));

await waitFor(() => {
const iconButtons = screen
.getAllByRole("button")
.filter(btn => btn.title && btn.title !== "");
expect(iconButtons.length).toBeGreaterThan(0);
for (const btn of iconButtons) {
expect(btn).toHaveAttribute("aria-pressed", "false");
}
});
});

it("should close popover after selecting an icon", async () => {
const user = userEvent.setup();
render(<IconPicker onSelect={vi.fn()} />);

await user.click(screen.getByRole("button", { name: /browse/i }));

await waitFor(() => {
const iconButtons = screen
.getAllByRole("button")
.filter(btn => btn.title && btn.title !== "");
expect(iconButtons.length).toBeGreaterThan(0);
});

const firstIcon = screen
.getAllByRole("button")
.filter(btn => btn.title && btn.title !== "")[0];
await user.click(firstIcon);

await waitFor(() => {
expect(
screen.queryByPlaceholderText("Search icons..."),
).not.toBeInTheDocument();
});
});
});
137 changes: 137 additions & 0 deletions packages/graph-explorer/src/components/IconPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { SearchIcon } from "lucide-react";
import { DynamicIcon } from "lucide-react/dynamic";
import { useState } from "react";

import {
allIconNamesSorted,
getLucideName,
type IconName,
toLucideIconRef,
} from "@/utils/lucideIcons";

import {
Button,
EmptyState,
EmptyStateContent,
EmptyStateDescription,
EmptyStateTitle,
Input,
Popover,
PopoverContent,
PopoverTrigger,
} from ".";

const MAX_VISIBLE = 64;

export function IconPicker({
currentIconUrl,
onSelect,
}: {
/**
* The vertex's currently stored iconUrl. When this is a `lucide:<name>`
* reference, the matching grid cell is highlighted to indicate the
* current selection.
*/
currentIconUrl?: string;
/**
* Called with the symbolic `lucide:<name>` reference and the SVG MIME type.
* Resolution to a data URI happens at render time, not here.
*/
onSelect: (iconUrl: string, iconImageType: string) => void;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");

const selectedName = getLucideName(currentIconUrl);
const filtered = filterIcons(search);

function handleSelect(iconName: IconName) {
onSelect(toLucideIconRef(iconName), "image/svg+xml");
setOpen(false);
setSearch("");
}

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="rounded-full">
<SearchIcon className="size-4" />
Browse
</Button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="flex flex-col gap-4"
>
<Input
placeholder="Search icons..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
Comment thread
kmcginnes marked this conversation as resolved.
<div className="grid size-80 grid-cols-8 grid-rows-8 gap-0.5">
{filtered.map(name => (
<IconButton
key={name}
name={name}
selected={name === selectedName}
onSelect={handleSelect}
/>
))}
{filtered.length === 0 && (
<EmptyState className="col-span-8 row-span-8" size="small">
<EmptyStateContent>
<EmptyStateTitle>No icons found</EmptyStateTitle>
<EmptyStateDescription className="text-balance">
No matching icons found. Try a broader search.
</EmptyStateDescription>
</EmptyStateContent>
</EmptyState>
)}
Comment thread
kmcginnes marked this conversation as resolved.
</div>
{filtered.length >= MAX_VISIBLE && (
<p className="text-text-secondary text-xs">
Showing {MAX_VISIBLE} of {allIconNamesSorted.length} icons. Type to
search.
</p>
)}
</PopoverContent>
</Popover>
);
}

function IconButton({
name,
selected,
onSelect,
}: {
name: IconName;
selected: boolean;
onSelect: (name: IconName) => void;
}) {
return (
<Button
title={name}
aria-pressed={selected}
size="icon"
variant={selected ? "primary" : "ghost"}
onClick={() => onSelect(name)}
className="size-full min-w-0"
>
<DynamicIcon name={name} />
</Button>
);
}

function filterIcons(search: string) {
if (!search) return allIconNamesSorted.slice(0, MAX_VISIBLE);
const lower = search.toLowerCase();
const results: IconName[] = [];
for (const name of allIconNamesSorted) {
if (name.includes(lower)) {
results.push(name);
if (results.length >= MAX_VISIBLE) break;
}
}
return results;
}
19 changes: 19 additions & 0 deletions packages/graph-explorer/src/components/VertexIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CSSProperties } from "react";

import { DynamicIcon } from "lucide-react/dynamic";
import SVG from "react-inlinesvg";

import {
Expand All @@ -8,6 +9,7 @@ import {
type VertexType,
} from "@/core";
import { cn } from "@/utils";
import { getLucideName, isValidLucideIconName } from "@/utils/lucideIcons";

import { SearchResultSymbol } from "./SearchResult";

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

const lucideIconName = getLucideName(vertexStyle.iconUrl);

if (lucideIconName) {
if (isValidLucideIconName(lucideIconName)) {
return (
<DynamicIcon
name={lucideIconName}
className={cn("size-6 shrink-0", className)}
style={{ color: vertexStyle.color }}
/>
);
} else {
// Unknown lucide ref — don't fall through to img/SVG paths
return null;
}
}

if (vertexStyle.iconImageType === "image/svg+xml") {
return (
<SVG
Expand Down
2 changes: 2 additions & 0 deletions packages/graph-explorer/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export * from "./Field";
export * from "./FileButton";
export * from "./Form";

export * from "./IconPicker";

export * from "./numberFormat";

export * from "./icons";
Expand Down
Loading
Loading