From b846027cb94e44539b5e4016e9538caa42ce98f0 Mon Sep 17 00:00:00 2001
From: gsxdsm
Date: Tue, 9 Jun 2026 07:00:05 -0700
Subject: [PATCH] FN-6077: add plugin registry category filter
Add category filtering controls to registry browsing.
- add a registry category select and thread its value through registry loading, retry, install refresh, and live updates
- style the registry controls for desktop and stacked mobile layouts alongside the existing search input
- add registry browse tests covering category filtering, query combinations, debounce timing, and control rendering
Files changed:
packages/dashboard/app/components/PluginManager.css | 23 +++++
packages/dashboard/app/components/PluginManager.tsx | 58 +++++++-----
packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx | 103 ++++++++++++++++++++-
3 files changed, 161 insertions(+), 23 deletions(-)
Fusion-Task-Id: FN-6077
Fusion-Task-Lineage: a57da263-550e-4607-a9a8-13123ef10867
---
.../app/components/PluginManager.css | 23 ++++
.../app/components/PluginManager.tsx | 58 ++++++----
.../__tests__/PluginManager.registry.test.tsx | 103 +++++++++++++++++-
3 files changed, 161 insertions(+), 23 deletions(-)
diff --git a/packages/dashboard/app/components/PluginManager.css b/packages/dashboard/app/components/PluginManager.css
index e2383941e8..191d2391e7 100644
--- a/packages/dashboard/app/components/PluginManager.css
+++ b/packages/dashboard/app/components/PluginManager.css
@@ -511,11 +511,26 @@
color: var(--text-muted);
}
+.plugin-registry-controls {
+ display: flex;
+ gap: var(--space-sm);
+ align-items: flex-end;
+}
+
+.plugin-registry-category-label,
.plugin-registry-search-label {
display: flex;
+}
+
+.plugin-registry-category-label {
+ min-width: min(100%, 11rem);
+}
+
+.plugin-registry-search-label {
min-width: min(100%, 18rem);
}
+.plugin-registry-category-select,
.plugin-registry-search-input {
width: 100%;
}
@@ -821,8 +836,16 @@
align-items: stretch;
}
+ .plugin-registry-controls {
+ flex-direction: column;
+ align-items: stretch;
+ width: 100%;
+ }
+
+ .plugin-registry-category-label,
.plugin-registry-search-label {
min-width: 0;
+ width: 100%;
}
.plugin-registry-list {
diff --git a/packages/dashboard/app/components/PluginManager.tsx b/packages/dashboard/app/components/PluginManager.tsx
index ceb75ca74c..6ef6b7f4cb 100644
--- a/packages/dashboard/app/components/PluginManager.tsx
+++ b/packages/dashboard/app/components/PluginManager.tsx
@@ -263,6 +263,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const [registryLoading, setRegistryLoading] = useState(true);
const [registryError, setRegistryError] = useState(null);
const [registrySearchQuery, setRegistrySearchQuery] = useState("");
+ const [registryCategory, setRegistryCategory] = useState("");
const [installingRegistryId, setInstallingRegistryId] = useState(null);
const registrySearchTimerRef = useRef | null>(null);
const [builtinSetupStatusById, setBuiltinSetupStatusById] = useState>({});
@@ -282,11 +283,11 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
}
}, [projectId, addToast]);
- const loadRegistry = useCallback(async (query = registrySearchQuery) => {
+ const loadRegistry = useCallback(async (query = registrySearchQuery, category = registryCategory) => {
try {
setRegistryLoading(true);
setRegistryError(null);
- const entries = await fetchPluginRegistry(query, undefined, projectId);
+ const entries = await fetchPluginRegistry(query, category || undefined, projectId);
setRegistryEntries(entries);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -294,7 +295,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
} finally {
setRegistryLoading(false);
}
- }, [projectId, registrySearchQuery]);
+ }, [projectId, registryCategory, registrySearchQuery]);
useEffect(() => {
loadPlugins();
@@ -306,7 +307,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
}
registrySearchTimerRef.current = setTimeout(() => {
- void loadRegistry(registrySearchQuery);
+ void loadRegistry(registrySearchQuery, registryCategory);
}, 300);
return () => {
@@ -314,7 +315,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
clearTimeout(registrySearchTimerRef.current);
}
};
- }, [loadRegistry, registrySearchQuery]);
+ }, [loadRegistry, registryCategory, registrySearchQuery]);
useEffect(() => {
const installedBuiltinsWithSetup = BUILTIN_PLUGINS.filter((builtinPlugin) => (
@@ -377,7 +378,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
case "enabled":
case "disabled":
case "settings-updated":
- void loadRegistry(registrySearchQuery);
+ void loadRegistry(registrySearchQuery, registryCategory);
// Update existing plugin or add if new
setPlugins((prev) => {
const existingIndex = prev.findIndex((p) => p.id === payload.pluginId);
@@ -419,7 +420,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
case "uninstalled":
// Remove plugin from list
setPlugins((prev) => prev.filter((p) => p.id !== payload.pluginId));
- void loadRegistry(registrySearchQuery);
+ void loadRegistry(registrySearchQuery, registryCategory);
break;
case "error":
@@ -450,10 +451,10 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
// Re-sync plugin list after a forced reconnect — any events that
// occurred while disconnected would otherwise be missed.
void loadPlugins();
- void loadRegistry(registrySearchQuery);
+ void loadRegistry(registrySearchQuery, registryCategory);
},
});
- }, [projectId, loadPlugins, loadRegistry, registrySearchQuery]);
+ }, [projectId, loadPlugins, loadRegistry, registryCategory, registrySearchQuery]);
const handleInstall = async () => {
if (!installPath.trim()) {
@@ -505,7 +506,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
await installPlugin({ path: entry.path }, projectId);
addToast(t("plugins.registryInstalled", "{{name}} installed and enabled", { name: entry.name }), "success");
await loadPlugins();
- await loadRegistry(registrySearchQuery);
+ await loadRegistry(registrySearchQuery, registryCategory);
} catch (err) {
addToast(t("plugins.registryInstallFailed", "Failed to install {{name}}: {{error}}", { name: entry.name, error: err instanceof Error ? err.message : String(err) }), "error");
} finally {
@@ -937,16 +938,31 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
{t("plugins.registryDescription", "Discover curated runtimes and integrations that can be added to this Fusion workspace.")}
-
+
+
+
+
{registryLoading ? (
@@ -957,7 +973,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
) : registryError ? (
{t("plugins.registryLoadFailed", "Failed to load registry: {{error}}", { error: registryError })}
-
diff --git a/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx
index 8dd501ab76..8757cc0d4e 100644
--- a/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx
+++ b/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx
@@ -126,8 +126,6 @@ afterEach(() => {
});
describe("PluginManager registry browsing", () => {
- // GAP: The API supports ?category= filtering but the UI does not expose a category selector.
- // A future task should add category filter UI and tests.
it("renders registry entries with metadata", async () => {
await renderRegistry();
@@ -225,6 +223,105 @@ describe("PluginManager registry browsing", () => {
expect(fetchPluginRegistry).toHaveBeenCalledWith("slack", undefined, undefined);
});
+ it("filters registry results by selected category", async () => {
+ await renderRegistry();
+ vi.mocked(fetchPluginRegistry).mockImplementation(async (_query, category) => (
+ category ? registryEntries.filter((entry) => entry.category === category) : registryEntries
+ ));
+ vi.mocked(fetchPluginRegistry).mockClear();
+
+ fireEvent.change(screen.getByLabelText("Registry category"), { target: { value: "runtime" } });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const section = screen.getByRole("region", { name: "Browse Registry" });
+ expect(fetchPluginRegistry).toHaveBeenLastCalledWith("", "runtime", undefined);
+ expect(within(section).getByText("Installed Registry")).toBeInTheDocument();
+ expect(within(section).queryByText("Installable Registry")).not.toBeInTheDocument();
+ expect(within(section).queryByText("Coming Soon Registry")).not.toBeInTheDocument();
+ });
+
+ it("clears category filtering when All Categories is selected", async () => {
+ await renderRegistry();
+ vi.mocked(fetchPluginRegistry).mockImplementation(async (_query, category) => (
+ category ? registryEntries.filter((entry) => entry.category === category) : registryEntries
+ ));
+ vi.mocked(fetchPluginRegistry).mockClear();
+
+ const categorySelect = screen.getByLabelText("Registry category");
+ fireEvent.change(categorySelect, { target: { value: "runtime" } });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ fireEvent.change(categorySelect, { target: { value: "" } });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const section = screen.getByRole("region", { name: "Browse Registry" });
+ expect(fetchPluginRegistry).toHaveBeenLastCalledWith("", undefined, undefined);
+ expect(within(section).getByText("Installable Registry")).toBeInTheDocument();
+ expect(within(section).getByText("Installed Registry")).toBeInTheDocument();
+ expect(within(section).getByText("Coming Soon Registry")).toBeInTheDocument();
+ });
+
+ it("combines category filtering with the registry search query", async () => {
+ await renderRegistry();
+ vi.mocked(fetchPluginRegistry).mockClear();
+
+ const searchInput = screen.getByPlaceholderText("Search registry plugins");
+ const categorySelect = screen.getByLabelText("Registry category");
+
+ fireEvent.change(searchInput, { target: { value: "whatsapp" } });
+ fireEvent.change(categorySelect, { target: { value: "integration" } });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(fetchPluginRegistry).toHaveBeenLastCalledWith("whatsapp", "integration", undefined);
+
+ fireEvent.change(categorySelect, { target: { value: "runtime" } });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(fetchPluginRegistry).toHaveBeenLastCalledWith("whatsapp", "runtime", undefined);
+ });
+
+ it("debounces category changes before fetching registry results", async () => {
+ await renderRegistry();
+ vi.mocked(fetchPluginRegistry).mockClear();
+
+ fireEvent.change(screen.getByLabelText("Registry category"), { target: { value: "integration" } });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(299);
+ });
+ expect(fetchPluginRegistry).not.toHaveBeenCalled();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(fetchPluginRegistry).toHaveBeenLastCalledWith("", "integration", undefined);
+ });
+
it("shows loading state while registry fetch is pending", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([]);
vi.mocked(fetchPluginRegistry).mockReturnValue(new Promise(() => undefined));
@@ -286,6 +383,7 @@ describe("PluginManager registry browsing", () => {
await renderRegistry();
const section = screen.getByRole("region", { name: "Browse Registry" });
+ expect(within(section).getByLabelText("Registry category")).toBeInTheDocument();
expect(within(section).getByPlaceholderText("Search registry plugins")).toBeInTheDocument();
expect(within(section).getByLabelText("Registry plugin results")).toBeInTheDocument();
expect(within(section).getByText("Installable Registry")).toBeInTheDocument();
@@ -318,6 +416,7 @@ describe("PluginManager registry CSS", () => {
const css = loadAllAppCss();
expect(css).toContain("@media (max-width: 768px)");
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*\.plugin-registry-item[\s\S]*flex-direction: column/);
+ expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*\.plugin-registry-controls[\s\S]*flex-direction: column/);
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*\.plugin-registry-action,[\s\S]*\.plugin-registry-retry[\s\S]*min-height: 36px/);
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*\.plugin-registry-list[\s\S]*overflow-y: auto/);
});