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/); });