Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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