Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
Comment thread
kmcginnes marked this conversation as resolved.
Outdated
- **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();
Comment thread
kmcginnes marked this conversation as resolved.
Outdated

const MAX_VISIBLE = 50;
Comment thread
kmcginnes marked this conversation as resolved.
Outdated

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) {
Comment thread
kmcginnes marked this conversation as resolved.
Outdated
onSelect(toLucideIconRef(iconName), "image/svg+xml");
setOpen(false);
setSearch("");
}

useEffect(() => {
if (open) {
const timer = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(timer);
}
}, [open]);
Comment thread
kmcginnes marked this conversation as resolved.
Outdated

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"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
className="flex w-80 flex-col gap-2 p-3"
className="flex flex-col gap-4"

This will better match the color picker padding.

>
<Input
ref={inputRef}
placeholder="Search icons..."
value={search}
onChange={e => setSearch(e.target.value)}
className="h-8 text-sm"
/>
Comment thread
kmcginnes marked this conversation as resolved.
<div className="grid max-h-60 grid-cols-8 gap-1 overflow-y-auto">
Comment thread
kmcginnes marked this conversation as resolved.
Outdated
{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>
)}
Comment thread
kmcginnes marked this conversation as resolved.
</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;
Comment thread
kmcginnes marked this conversation as resolved.
Outdated
selected: boolean;
onSelect: (name: string) => void;
Comment thread
kmcginnes marked this conversation as resolved.
Outdated
}) {
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)}
>
Comment thread
kmcginnes marked this conversation as resolved.
Outdated
{src ? (
<img src={src} alt={name} className="size-5" />
) : (
<div className="bg-background-contrast-secondary size-5 animate-pulse rounded" />
)}
Comment thread
kmcginnes marked this conversation as resolved.
Outdated
</button>
);
}

function filterIcons(search: string): string[] {
Comment thread
kmcginnes marked this conversation as resolved.
Outdated
if (!search) return allIconNames.slice(0, MAX_VISIBLE);
const lower = search.toLowerCase();
const results: string[] = [];
Comment thread
kmcginnes marked this conversation as resolved.
Outdated
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);
Comment thread
kmcginnes marked this conversation as resolved.
Outdated

if (vertexStyle.iconImageType === "image/svg+xml") {
return (
<SVG
src={vertexStyle.iconUrl}
src={resolvedSrc}
Comment thread
kmcginnes marked this conversation as resolved.
Outdated
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}
Comment thread
kmcginnes marked this conversation as resolved.
Outdated
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