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
This commit is contained in:
gsxdsm
2026-06-09 07:00:05 -07:00
parent 4f8bb31774
commit b846027cb9
3 changed files with 161 additions and 23 deletions

View File

@@ -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 {

View File

@@ -263,6 +263,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const [registryLoading, setRegistryLoading] = useState(true);
const [registryError, setRegistryError] = useState<string | null>(null);
const [registrySearchQuery, setRegistrySearchQuery] = useState("");
const [registryCategory, setRegistryCategory] = useState<string>("");
const [installingRegistryId, setInstallingRegistryId] = useState<string | null>(null);
const registrySearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [builtinSetupStatusById, setBuiltinSetupStatusById] = useState<Record<string, PluginSetupStatusResponse>>({});
@@ -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.")}
</p>
</div>
<label className="plugin-registry-search-label">
<span className="sr-only">{t("plugins.searchRegistry", "Search registry")}</span>
<input
className="input plugin-registry-search-input"
type="search"
value={registrySearchQuery}
onChange={(event) => setRegistrySearchQuery(event.target.value)}
placeholder={t("plugins.searchRegistryPlaceholder", "Search registry plugins")}
/>
</label>
<div className="plugin-registry-controls">
<label className="plugin-registry-category-label">
<span className="sr-only">{t("plugins.registryCategory", "Registry category")}</span>
<select
className="select plugin-registry-category-select"
value={registryCategory}
onChange={(event) => setRegistryCategory(event.target.value)}
aria-label={t("plugins.registryCategory", "Registry category")}
>
<option value="">{t("plugins.registryCategoryAll", "All Categories")}</option>
<option value="runtime">{t("plugins.registryCategoryRuntime", "Runtime")}</option>
<option value="integration">{t("plugins.registryCategoryIntegration", "Integration")}</option>
</select>
</label>
<label className="plugin-registry-search-label">
<span className="sr-only">{t("plugins.searchRegistry", "Search registry")}</span>
<input
className="input plugin-registry-search-input"
type="search"
value={registrySearchQuery}
onChange={(event) => setRegistrySearchQuery(event.target.value)}
placeholder={t("plugins.searchRegistryPlaceholder", "Search registry plugins")}
/>
</label>
</div>
</div>
{registryLoading ? (
@@ -957,7 +973,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
) : registryError ? (
<div className="plugin-registry-state plugin-registry-state--error" role="alert">
<span>{t("plugins.registryLoadFailed", "Failed to load registry: {{error}}", { error: registryError })}</span>
<button className="btn btn-secondary btn-sm plugin-registry-retry" onClick={() => void loadRegistry(registrySearchQuery)}>
<button className="btn btn-secondary btn-sm plugin-registry-retry" onClick={() => void loadRegistry(registrySearchQuery, registryCategory)}>
{t("plugins.retry", "Retry")}
</button>
</div>

View File

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