Skip to content
Merged
47 changes: 47 additions & 0 deletions packages/common/src/DynamicPfIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { ComponentType } from "react";
import { useEffect, useState } from "react";

import { getCachedPfIcon, loadPfIcon } from "./pfIconLoader.js";

/** PF icons accept at least className; keep narrow to avoid `any`. */
type PfIconComponent = ComponentType<{ className?: string }>;

interface DynamicPfIconProps {
/** PascalCase PF icon name, e.g. "CogIcon". */
name: string;
/** Optional className forwarded to the icon wrapper. */
className?: string;
}

/**
* Renders a PatternFly icon loaded on demand.
*
* Handles async dynamic-import loading internally — consumers just pass the
* PascalCase icon name and get the rendered icon (or nothing while loading).
*
* @example
* <DynamicPfIcon name="FolderOpenIcon" />
*/
export default function DynamicPfIcon({ name, className }: DynamicPfIconProps) {
const [Icon, setIcon] = useState<PfIconComponent | null>(
() => (getCachedPfIcon(name) as PfIconComponent | undefined) ?? null,
);

useEffect(() => {
let active = true;
const cached = getCachedPfIcon(name) as PfIconComponent | undefined;
if (cached) {
setIcon(() => cached);
return;
}
loadPfIcon(name).then((comp) => {
if (active && comp) setIcon(() => comp as PfIconComponent);
});
return () => {
active = false;
};
}, [name]);

if (!Icon) return null;
return <Icon {...(className ? { className } : {})} />;
}
60 changes: 60 additions & 0 deletions packages/common/src/__tests__/pfIconLoader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";

import {
iconNameToFile,
iconNameToKeywords,
iconSlugToName,
} from "../pfIconLoader";

describe("iconNameToFile", () => {
it("converts PascalCase to kebab-case", () => {
expect(iconNameToFile("CogIcon")).toBe("cog-icon");
expect(iconNameToFile("FolderOpenIcon")).toBe("folder-open-icon");
expect(iconNameToFile("ShieldAltIcon")).toBe("shield-alt-icon");
});
});

describe("iconNameToKeywords", () => {
it("extracts search keywords from icon name", () => {
expect(iconNameToKeywords("FolderOpenIcon")).toEqual(["folder", "open"]);
expect(iconNameToKeywords("CogIcon")).toEqual(["cog"]);
expect(iconNameToKeywords("ShieldAltIcon")).toEqual(["shield", "alt"]);
});
});

describe("iconSlugToName", () => {
it("converts kebab-case slug to PascalCase icon name", () => {
expect(iconSlugToName("cog")).toBe("CogIcon");
expect(iconSlugToName("folder-open")).toBe("FolderOpenIcon");
expect(iconSlugToName("shield-alt")).toBe("ShieldAltIcon");
});

it("handles single-word slugs", () => {
expect(iconSlugToName("cubes")).toBe("CubesIcon");
expect(iconSlugToName("globe")).toBe("GlobeIcon");
expect(iconSlugToName("key")).toBe("KeyIcon");
});

it("handles multi-segment slugs", () => {
expect(iconSlugToName("layer-group")).toBe("LayerGroupIcon");
expect(iconSlugToName("puzzle-piece")).toBe("PuzzlePieceIcon");
});

it("roundtrips with iconNameToFile (minus trailing -icon)", () => {
const names = [
"CogIcon",
"FolderOpenIcon",
"CubesIcon",
"ShieldAltIcon",
"LayerGroupIcon",
];
for (const name of names) {
// iconNameToFile("CogIcon") → "cog-icon"
// strip trailing "-icon" → "cog"
// iconSlugToName("cog") → "CogIcon"
const file = iconNameToFile(name);
const slug = file.replace(/-icon$/, "");
expect(iconSlugToName(slug)).toBe(name);
}
});
});
2 changes: 2 additions & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type {
PlacementStrategy,
} from "./canonical.js";
export { buildSignedInputEnvelope, hashIntent } from "./canonical.js";
export { default as DynamicPfIcon } from "./DynamicPfIcon.js";
export type { CoreExtensionMeta, ExtensionStore } from "./extensionInstall.js";
export {
CORE_EXTENSION_DEFAULTS,
Expand Down Expand Up @@ -53,6 +54,7 @@ export {
getCachedPfIcon,
iconNameToFile,
iconNameToKeywords,
iconSlugToName,
loadPfIcon,
} from "./pfIconLoader.js";
export type { PluginLinkProps } from "./PluginLink.js";
Expand Down
13 changes: 13 additions & 0 deletions packages/common/src/pfIconLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,16 @@ export async function loadPfIcon(name: string): Promise<ComponentType | null> {
export function getCachedPfIcon(name: string): ComponentType | undefined {
return iconCache.get(name);
}

/**
* Convert a kebab-case icon slug to PascalCase icon component name.
* E.g. "folder-open" → "FolderOpenIcon", "cog" → "CogIcon"
*/
export function iconSlugToName(slug: string): string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe this function already exists somewhere. In the nav editor. Can we deduplicate? Are there any duplications introduced?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — I searched the codebase and iconSlugToName is actually new (no existing duplicate). The closest related functions are iconNameToFile and iconNameToKeywords in the same file, but they go the opposite direction (PascalCase → kebab/keywords). The nav editor (GroupFormModal, IconGalleryModal) works with PascalCase names directly.

Since the function is already in packages/common/src/pfIconLoader.ts, it's available as a shared utility across all packages.

return (
slug
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("") + "Icon"
);
}
29 changes: 23 additions & 6 deletions packages/gui/src/components/AppNav/AppNavGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { NavExpandable } from "@patternfly/react-core";
import {
DynamicPfIcon,
iconSlugToName,
isCustomGroup,
type NavLayoutGroup,
} from "@fleetshift/common";
import { Icon, NavExpandable } from "@patternfly/react-core";
import type { ComponentType } from "react";
import { useLocation } from "react-router-dom";

import type {
NavLayoutGroup,
PluginPage,
} from "../../contexts/AppConfigContext";
import type { PluginPage } from "../../contexts/AppConfigContext";
import AppNavItem from "./AppNavItem";

interface AppNavGroupProps {
Expand All @@ -17,6 +20,9 @@ interface AppNavGroupProps {
const AppNavGroup = ({ group, pageMap, iconMap }: AppNavGroupProps) => {
const location = useLocation();

const customIconName =
isCustomGroup(group) && group.icon ? iconSlugToName(group.icon) : null;

const childPages = group.children
.map((c) => ({ page: pageMap.get(c.pageId), iconOverride: c.iconOverride }))
.filter(
Expand All @@ -28,9 +34,20 @@ const AppNavGroup = ({ group, pageMap, iconMap }: AppNavGroupProps) => {
const groupBasePath = `/${group.groupId}`;
const isActive = location.pathname.startsWith(groupBasePath + "/");

const title = customIconName ? (
<>
<Icon isInline className="pf-v6-u-mr-sm">
<DynamicPfIcon name={customIconName} />
</Icon>
{group.label}
</>
) : (
group.label
);

return (
<NavExpandable
title={group.label}
title={title}
groupId={group.groupId}
isActive={isActive}
isExpanded={isActive}
Expand Down
13 changes: 8 additions & 5 deletions packages/gui/src/components/Search/FleetSearch.scss
Original file line number Diff line number Diff line change
Expand Up @@ -41,28 +41,31 @@
position: relative;
padding-left: var(--pf-t--global--spacer--lg);

// Vertical guide — runs full height, aligned to parent's indentation
&::before {
content: "";
position: absolute;
left: var(--pf-t--global--spacer--md);
top: 0;
bottom: 0;
width: 1px;
width: var(--pf-t--global--border--width--divider--default);
background: var(--pf-t--global--border--color--default);
}

// Horizontal guide — fixed offset matching PF TreeView node center
&::after {
content: "";
position: absolute;
left: var(--pf-t--global--spacer--md);
top: 50%;
width: var(--pf-t--global--spacer--sm);
height: 1px;
top: 1.125rem;
width: var(--pf-t--global--spacer--md);
height: var(--pf-t--global--border--width--divider--default);
background: var(--pf-t--global--border--color--default);
}

&--last::before {
bottom: 50%;
height: 1.125rem;
bottom: auto;
}
}

Expand Down
Loading
Loading