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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user