Skip to content
Open
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. 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.
- **Colors and borders** can be customized to visually distinguish from other node types

### Edge Styling Panel
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
},
"lint-staged": {
"!(**/*.{js,ts,tsx})": "oxfmt --no-error-on-unmatched-pattern",
"**/*.{js,ts,tsx}": [
"**/*.config.{js,ts,mjs}": "oxfmt",
"packages/**/*.{js,ts,tsx}": [
"oxlint --fix",
"oxfmt"
]
Expand Down
1 change: 0 additions & 1 deletion packages/graph-explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"oxfmt"
]
},
Expand Down
153 changes: 153 additions & 0 deletions packages/graph-explorer/src/components/IconPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// @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 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();
});
});
});
157 changes: 157 additions & 0 deletions packages/graph-explorer/src/components/IconPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { SearchIcon } from "lucide-react";
import dynamicIconImports from "lucide-react/dynamicIconImports";
import { useEffect, useRef, useState } from "react";

import { cn } from "@/utils";
import {
getLucideName,
lucideIconToDataUri,
toLucideIconRef,
} from "@/utils/lucideIconUrl";

import { Button, Input, Popover, PopoverContent, PopoverTrigger } from ".";

const allIconNames = Object.keys(dynamicIconImports).sort();

const MAX_VISIBLE = 50;

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 inputRef = useRef<HTMLInputElement>(null);

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

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

useEffect(() => {
if (open) {
const timer = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(timer);
}
}, [open]);

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 w-80 flex-col gap-2 p-3"
>
<Input
ref={inputRef}
placeholder="Search icons..."
value={search}
onChange={e => setSearch(e.target.value)}
className="h-8 text-sm"
/>
<div className="grid max-h-60 grid-cols-8 gap-1 overflow-y-auto">
{filtered.map(name => (
<IconButton
key={name}
name={name}
selected={name === selectedName}
onSelect={handleSelect}
/>
))}
{filtered.length === 0 && (
<p className="text-text-secondary col-span-8 py-4 text-center text-sm">
No icons found
</p>
)}
</div>
{!search && (
<p className="text-text-secondary text-xs">
Showing {MAX_VISIBLE} of {allIconNames.length} icons. Type to
search.
</p>
)}
</PopoverContent>
</Popover>
);
}

function IconButton({
name,
selected,
onSelect,
}: {
name: string;
selected: boolean;
onSelect: (name: string) => void;
}) {
const [src, setSrc] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
lucideIconToDataUri(name).then(
uri => {
if (!cancelled && uri) setSrc(uri);
},
() => {
// Icon failed to load, leave as placeholder
},
);
return () => {
cancelled = true;
};
}, [name]);

return (
<button
type="button"
title={name}
aria-pressed={selected}
className={cn(
"hover:bg-background-contrast-secondary flex size-8 items-center justify-center rounded",
selected && "bg-primary-main/20 ring-primary-main ring-2",
)}
onClick={() => onSelect(name)}
>
{src ? (
<img src={src} alt={name} className="size-5" />
) : (
<div className="bg-background-contrast-secondary size-5 animate-pulse rounded" />
)}
</button>
);
}

function filterIcons(search: string): string[] {
if (!search) return allIconNames.slice(0, MAX_VISIBLE);
const lower = search.toLowerCase();
const results: string[] = [];
for (const name of allIconNames) {
if (name.includes(lower)) {
results.push(name);
if (results.length >= MAX_VISIBLE) break;
}
}
return results;
}
6 changes: 4 additions & 2 deletions packages/graph-explorer/src/components/VertexIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type VertexType,
} from "@/core";
import { cn } from "@/utils";
import { useResolvedIconUrl } from "@/utils/useResolvedIconUrl";

import { SearchResultSymbol } from "./SearchResult";

Expand All @@ -19,11 +20,12 @@ interface Props {

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

if (vertexStyle.iconImageType === "image/svg+xml") {
return (
<SVG
src={vertexStyle.iconUrl}
src={resolvedSrc}
className={cn("size-6 shrink-0", className)}
style={{ color: vertexStyle.color }}
title={altText}
Expand All @@ -33,7 +35,7 @@ function VertexIcon({ vertexStyle, className, alt }: Props) {

return (
<img
src={vertexStyle.iconUrl}
src={resolvedSrc}
alt={altText}
className={cn("size-6 shrink-0", className)}
style={{ color: vertexStyle.color }}
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