Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 102 additions & 30 deletions apps/app/src/components/plugin/PluginsOverview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ describe("PluginsOverview", () => {

fireEvent.click(screen.getByRole("tab", { name: "Browse" }));
expect(await screen.findByText("GitHub")).toBeTruthy();
expect(screen.getByRole("radio", { name: "Developer tools" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Category" })).toBeTruthy();
expect(screen.queryByText("BB Official plugins")).toBeNull();
expect(screen.getByRole("button", { name: "New plugin" })).toBeTruthy();
});
Expand Down Expand Up @@ -233,29 +233,28 @@ describe("PluginsOverview", () => {

expect(await screen.findByText("Automations")).toBeTruthy();
expect(screen.getByText("Docs")).toBeTruthy();
expect(
screen.queryByRole("radiogroup", {
name: "Filter plugins by category",
}),
).toBeNull();
expect(screen.queryByText("Category")).toBeNull();
// Installed offers Type, not Category.
expect(screen.queryByRole("button", { name: "Category" })).toBeNull();
expect(screen.getByRole("button", { name: "Type" })).toBeTruthy();

fireEvent.click(screen.getByRole("tab", { name: "Browse" }));
expect(
await screen.findByRole("radiogroup", {
name: "Filter plugins by category",
}),
).toBeTruthy();
expect(screen.queryByText("Category")).toBeNull();
fireEvent.click(screen.getByRole("radio", { name: "Context & knowledge" }));
// Wait for the catalog so the Category menu has options to offer.
await screen.findByText("GitHub");
const categoryTrigger = screen.getByRole("button", { name: "Category" });
expect(screen.queryByRole("button", { name: "Type" })).toBeNull();
fireEvent.pointerDown(categoryTrigger);
fireEvent.click(
screen.getByRole("menuitemcheckbox", { name: "Context & knowledge" }),
);
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.getByText("Docs")).toBeTruthy();
expect(screen.queryByText("Automations")).toBeNull();
expect(screen.queryByText("GitHub")).toBeNull();
});

it("keeps category pills visually secondary to the collection tabs", async () => {
it("keeps Browse filters in the toolbar rather than a separate pill band", async () => {
installFetch();
const { wrapper: QueryClientWrapper } = createQueryClientTestHarness();
render(
const { container } = render(
<MemoryRouter initialEntries={["/tools/plugins?view=browse"]}>
<QueryClientWrapper>
<PluginsOverview />
Expand All @@ -264,19 +263,19 @@ describe("PluginsOverview", () => {
);

await screen.findByText("GitHub");
const filters = screen.getByRole("radiogroup", {
name: "Filter plugins by category",
});
const all = screen.getByRole("radio", {
name: "Show all plugin categories",
});
expect(filters.className).toContain("py-2");
expect(filters.className).toContain("gap-2");
expect(all.className).toContain("cursor-pointer");
expect(all.className).toContain("hover:border-foreground/20");
expect(all.className).toContain("hover:shadow-xs");
expect(all.className).toContain("data-[state=on]:bg-secondary/70");
expect(all.className).not.toContain("data-[state=on]:bg-state-active");
// The old pill row is gone, so Browse keeps one flush content band.
expect(
screen.queryByRole("radiogroup", {
name: "Filter plugins by category",
}),
).toBeNull();
const controls = container.querySelector(
"[data-resource-collection-viewport] > .shrink-0",
) as HTMLElement;
const category = screen.getByRole("button", { name: "Category" });
const sort = screen.getByRole("button", { name: /^Sort:/ });
expect(controls.contains(category)).toBe(true);
expect(controls.contains(sort)).toBe(true);
expect(screen.getByRole("tab", { name: "Browse" }).className).toContain(
"bg-accent",
);
Expand Down Expand Up @@ -594,6 +593,79 @@ describe("PluginsOverview", () => {
]);
});

it("filters installed plugins by type, treating builtin and catalog as bb Official", async () => {
installFetch([
{ ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" },
{
...AUTOMATIONS_PLUGIN,
id: "catalog-one",
name: "Catalog One",
provenance: "catalog",
catalogEntryId: "catalog-one",
},
{
...AUTOMATIONS_PLUGIN,
id: "direct-one",
name: "Direct One",
provenance: "direct",
},
]);
const { wrapper: QueryClientWrapper } = createQueryClientTestHarness();
render(
<MemoryRouter initialEntries={["/tools/plugins"]}>
<QueryClientWrapper>
<PluginsOverview />
</QueryClientWrapper>
</MemoryRouter>,
);

await screen.findByText("Direct One");
const rowIds = () =>
[...document.querySelectorAll('[data-testid^="plugin-row-"]')].map(
(row) => row.getAttribute("data-testid"),
);

// Nothing selected is the default and shows every type.
const typeTrigger = screen.getByRole("button", { name: "Type" });
expect(rowIds()).toEqual([
"plugin-row-builtin-one",
"plugin-row-catalog-one",
"plugin-row-direct-one",
]);
fireEvent.pointerDown(typeTrigger);
// There is no explicit "All" row: an empty selection means all types.
expect(screen.queryByRole("menuitemcheckbox", { name: "All" })).toBeNull();

fireEvent.click(
screen.getByRole("menuitemcheckbox", { name: "bb Official" }),
);
await waitFor(() => {
expect(rowIds()).toEqual([
"plugin-row-builtin-one",
"plugin-row-catalog-one",
]);
});

fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "User" }));
fireEvent.click(
screen.getByRole("menuitemcheckbox", { name: "bb Official" }),
);
await waitFor(() => {
expect(rowIds()).toEqual(["plugin-row-direct-one"]);
});

// Clearing the last selection returns to unfiltered, not to empty.
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "User" }));
await waitFor(() => {
expect(rowIds()).toEqual([
"plugin-row-builtin-one",
"plugin-row-catalog-one",
"plugin-row-direct-one",
]);
});
expect(screen.queryByText("No plugins match these filters.")).toBeNull();
});

it("keeps disabled plugins installed regardless of provenance", async () => {
installFetch([
AUTOMATIONS_PLUGIN,
Expand Down
86 changes: 71 additions & 15 deletions apps/app/src/components/plugin/PluginsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ResourceCollectionPage,
ResourceCollectionViewport,
ResourceListState,
ResourceMultiSelectMenu,
ResourceSortMenu,
ResourceToolbar,
type ResourceCollectionMode,
Expand All @@ -23,14 +24,40 @@ import {
} from "@/components/plugin/management/AddPluginDialog";
import { BrowsePluginsTab } from "@/components/plugin/management/BrowsePluginsTab";
import { InstalledPluginsTab } from "@/components/plugin/management/InstalledPluginsTab";
import { usePluginList } from "@/hooks/queries/plugin-settings-queries";
import {
usePluginList,
type PluginProvenance,
} from "@/hooks/queries/plugin-settings-queries";
import {
getPluginDetailRoutePath,
getRootComposeRoutePath,
} from "@/lib/route-paths";

type PluginsCollectionMode = "installed" | "browse";

/** Where an installed plugin came from, as the collection filter presents it. */
type PluginTypeFilter = "bb-official" | "user";

const PLUGIN_TYPE_FILTERS: readonly PluginTypeFilter[] = [
"bb-official",
"user",
];

const PLUGIN_TYPE_FILTER_OPTIONS = PLUGIN_TYPE_FILTERS.map((type) => ({
id: type,
label: type === "bb-official" ? "bb Official" : "User",
}));

function pluginTypeFilterId(provenance: PluginProvenance): PluginTypeFilter {
return provenance === "builtin" || provenance === "catalog"
? "bb-official"
: "user";
}

function isPluginTypeFilter(value: string): value is PluginTypeFilter {
return value === "bb-official" || value === "user";
}

function modeFromSearchParams(value: string | null): PluginsCollectionMode {
if (value === "browse") return value;
return "installed";
Expand Down Expand Up @@ -58,6 +85,8 @@ export function PluginsOverview() {
const [installedSortDirection, setInstalledSortDirection] = useState<
"asc" | "desc"
>("asc");
// Empty means unfiltered: the menu has no explicit "All" row.
const [typeFilters, setTypeFilters] = useState<PluginTypeFilter[]>([]);
const [addDialog, setAddDialog] = useState<{
open: boolean;
initial: AddPluginInitial | null;
Expand All @@ -79,6 +108,12 @@ export function PluginsOverview() {
() =>
plugins
.filter((plugin) => {
if (
typeFilters.length > 0 &&
!typeFilters.includes(pluginTypeFilterId(plugin.provenance))
) {
return false;
}
if (normalizedInstalledQuery.length === 0) return true;
return [
plugin.id,
Expand Down Expand Up @@ -111,11 +146,15 @@ export function PluginsOverview() {
}
return left.id.localeCompare(right.id);
}),
[installedSortDirection, normalizedInstalledQuery, plugins],
[installedSortDirection, normalizedInstalledQuery, plugins, typeFilters],
);
const installedPagination = useResourcePagination(visiblePlugins, {
pageSize: installedPageSize,
resetKey: [normalizedInstalledQuery, installedSortDirection].join("\u0000"),
resetKey: [
normalizedInstalledQuery,
installedSortDirection,
[...typeFilters].sort().join(","),
].join("\u0000"),
});
const hasInstalledPagination =
!listQuery.isError &&
Expand Down Expand Up @@ -178,18 +217,31 @@ export function PluginsOverview() {
searchValue={installedQuery}
searchPlaceholder="Search installed plugins"
onSearchChange={setInstalledQuery}
containedControls
controls={
<ResourceSortMenu
value="alpha"
direction={installedSortDirection}
options={[{ id: "alpha", label: "Plugin name" }]}
onChange={() =>
setInstalledSortDirection((current) =>
current === "asc" ? "desc" : "asc",
)
}
/>
<>
<ResourceMultiSelectMenu
label="Type"
icon="PackageReceive"
selectedValues={typeFilters}
options={PLUGIN_TYPE_FILTER_OPTIONS}
selectedLabel={(options) =>
options.map((option) => option.label).join(", ")
}
onChange={(values) =>
setTypeFilters(values.filter(isPluginTypeFilter))
}
/>
<ResourceSortMenu
value="alpha"
direction={installedSortDirection}
options={[{ id: "alpha", label: "Plugin name" }]}
onChange={() =>
setInstalledSortDirection((current) =>
current === "asc" ? "desc" : "asc",
)
}
/>
</>
}
/>
}
Expand Down Expand Up @@ -218,7 +270,11 @@ export function PluginsOverview() {
) : plugins.length > 0 && visiblePlugins.length === 0 ? (
<ResourceListState
state="empty"
message={`No plugins match "${installedQuery}"`}
message={
normalizedInstalledQuery !== ""
? `No plugins match "${installedQuery}"`
: "No plugins match these filters."
}
/>
) : (
<InstalledPluginsTab plugins={installedPagination.items} />
Expand Down
Loading
Loading