feat(site): add search + filter tabs for template builder modules (#26674)

ref DEVEX-491

Adds a `useFuzzySearch` hook based on the logic in IconsPage.tsx, and
Storybook stories for `ModuleSelectStep` to verify filter tab count
behavior.

c16e0d9516 and
b6ee21875c co-written with Claude Code
This commit is contained in:
Andrew Aquino
2026-06-25 13:44:42 -07:00
committed by GitHub
parent 7e509e8fd0
commit 37d8b5163d
3 changed files with 256 additions and 15 deletions
@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import type { TemplateBuilderModule } from "#/api/typesGenerated";
import { ModuleSelectStep } from "./ModuleSelectStep";
const baseId = "docker";
function makeModule(
overrides: Partial<TemplateBuilderModule> &
Pick<TemplateBuilderModule, "id" | "display_name" | "category">,
): TemplateBuilderModule {
return {
description: "",
icon: "",
version: "1.0.0",
compatible_os: ["linux"],
conflicts_with: [],
variables: [],
...overrides,
};
}
const modules: TemplateBuilderModule[] = [
{
id: "code-server",
display_name: "code-server",
description: "Run VS Code in the browser.",
category: "IDE",
icon: "/icon/code.svg",
},
{
id: "jetbrains-gateway",
display_name: "JetBrains Gateway",
description: "Connect JetBrains IDEs to your workspace.",
category: "IDE",
},
{
id: "git-clone",
display_name: "Git Clone",
description: "Clone a git repository on workspace start.",
category: "Source Control",
},
{
id: "claude-code",
display_name: "Claude Code",
description: "Run Claude Code in your workspace.",
category: "AI",
},
{
id: "jfrog-artifactory",
display_name: "JFrog Artifactory",
description: "Configure JFrog Artifactory access.",
category: "Security",
},
].map(makeModule);
const meta: Meta<typeof ModuleSelectStep> = {
title: "pages/TemplateBuilder/ModuleSelectStep",
component: ModuleSelectStep,
args: {
baseId,
selectedModuleIds: [],
onChangeModules: fn(),
},
parameters: {
queries: [
{
key: ["templateBuilder", "modules", baseId],
data: { modules },
},
],
},
};
export default meta;
type Story = StoryObj<typeof ModuleSelectStep>;
export const Default: Story = {};
export const Loading: Story = {
parameters: {
queries: [],
},
};
// Verifies that the category filter tab counts reflect the active search.
// Searching "code" matches only code-server (IDE) and Claude Code (AI), so
// those tab counts drop while non-matching categories fall to zero but stay
// visible.
export const FilterCountsUpdateOnSearch: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Counts before searching reflect every module.
await canvas.findByRole("tab", { name: /All \(5\)/ });
await canvas.findByRole("tab", { name: /IDE \(2\)/ });
await canvas.findByRole("tab", { name: /AI \(1\)/ });
const search = canvas.getByPlaceholderText("Search modules...");
await userEvent.type(search, "code");
// Counts after searching reflect only matching modules per category.
await canvas.findByRole("tab", { name: /All \(2\)/ });
await canvas.findByRole("tab", { name: /IDE \(1\)/ });
await canvas.findByRole("tab", { name: /AI \(1\)/ });
// Non-matching categories stay visible with a zero count.
await canvas.findByRole("tab", { name: /Source Control \(0\)/ });
await canvas.findByRole("tab", { name: /Security \(0\)/ });
// Only matching modules render in the grid.
await expect(canvas.getByText("code-server")).toBeInTheDocument();
await expect(canvas.getByText("Claude Code")).toBeInTheDocument();
await expect(canvas.queryByText("Git Clone")).not.toBeInTheDocument();
},
};
@@ -1,4 +1,5 @@
import { type FC, type PropsWithChildren, useMemo } from "react";
import { PackageIcon, SearchIcon } from "lucide-react";
import { type FC, type PropsWithChildren, useMemo, useState } from "react";
import { useQuery } from "react-query";
import { templateBuilderModules } from "#/api/queries/templateBuilder";
import type {
@@ -8,6 +9,10 @@ import type {
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Loader } from "#/components/Loader/Loader";
import { SearchField } from "#/components/SearchField/SearchField";
import { Tabs, TabsList, TabsTrigger } from "#/components/Tabs/Tabs";
import { useKebabMenu } from "#/components/Tabs/utils/useKebabMenu";
import { useFuzzySearch } from "#/pages/TemplateBuilder/hooks/useFuzzySearch";
import {
TemplateBuilderSubtitle,
TemplateBuilderTitle,
@@ -71,14 +76,52 @@ export const ModuleSelectStep: FC<ModuleSelectStepProps> = ({
onChangeModules,
}) => {
const { data, error, isLoading } = useQuery(templateBuilderModules(baseId));
const [moduleSearchText, setModuleSearchText] = useState("");
const modules = data?.modules ?? [];
const categories = [
...new Set(modules.map((module) => module.category)),
].sort((a, b) => a.localeCompare(b));
const [selectedFilterTab, setSelectedFilterTab] = useState("All");
const searchedModules = useFuzzySearch({
allItems: modules,
searchText: moduleSearchText,
searchProperties: ["display_name", "description"],
});
const searchedCategoryCounts = new Map<string, number>();
for (const module of searchedModules) {
searchedCategoryCounts.set(
module.category,
(searchedCategoryCounts.get(module.category) ?? 0) + 1,
);
}
const filterTabs = [
{ value: "All", count: searchedModules.length },
...categories.map((value) => ({
value,
count: searchedCategoryCounts.get(value) ?? 0,
})),
];
const { containerRef, visibleTabs: visibleFilterTabs } = useKebabMenu({
tabs: filterTabs,
enabled: true,
isActive: true,
});
const visibleModules =
selectedFilterTab === "All"
? searchedModules
: searchedModules.filter(
(module) => module.category === selectedFilterTab,
);
const selectedSet = useMemo(
() => new Set(selectedModuleIds),
[selectedModuleIds],
);
const modules = data?.modules ?? [];
const conflicts = useMemo<ModuleConflict[]>(() => {
const warnings: string[] = [];
// Loop through the selected modules and check for conflicts. We sort the
@@ -139,21 +182,53 @@ export const ModuleSelectStep: FC<ModuleSelectStepProps> = ({
<div>
<TemplateBuilderTitle>Select modules</TemplateBuilderTitle>
<TemplateBuilderSubtitle>
Add functionality to your template.
Add pre-built tools and integrations. Module versions are pinned at
selection.
</TemplateBuilderSubtitle>
<SearchField
value={moduleSearchText}
onChange={setModuleSearchText}
placeholder="Search modules..."
className="my-4"
/>
<Tabs
value={selectedFilterTab}
onValueChange={setSelectedFilterTab}
className="my-4"
>
<TabsList ref={containerRef}>
{visibleFilterTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
<PackageIcon className="size-icon-sm" />
{tab.value} ({tab.count})
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{modules.map((m) => (
<ModuleCard
key={m.id}
name={m.display_name}
description={m.description}
iconUrl={m.icon}
detailsUrl={moduleDetailsUrl(m.id)}
selected={selectedSet.has(m.id)}
onSelect={() => handleToggle(m)}
/>
))}
{visibleModules.length ? (
visibleModules.map((m) => (
<ModuleCard
key={m.id}
name={m.display_name}
description={m.description}
iconUrl={m.icon}
detailsUrl={moduleDetailsUrl(m.id)}
selected={selectedSet.has(m.id)}
onSelect={() => handleToggle(m)}
/>
))
) : (
<div className="col-span-full my-12 flex flex-col items-center gap-1 text-content-secondary">
<SearchIcon />
<p className="m-0 text-sm font-normal">
No module matched your search
</p>
</div>
)}
</div>
{conflicts.length > 0 && (
@@ -0,0 +1,51 @@
import { useMemo } from "react";
import uFuzzy from "ufuzzy";
const fuzzyFinder = new uFuzzy({
intraMode: 1,
intraIns: 1,
intraSub: 1,
intraTrn: 1,
intraDel: 1,
});
type UseFuzzySearchOptions<T> = {
allItems: Readonly<Array<T>>;
searchText: string;
/**
* The item properties to fuzzy search against
*/
searchProperties: Array<keyof T>;
};
export function useFuzzySearch<T>({
allItems,
searchText,
searchProperties,
}: UseFuzzySearchOptions<T>) {
const query = searchText.trim();
const searchedItems = useMemo(() => {
if (!query) {
return allItems;
}
// Search several string fields by concatenating them together
// https://github.com/leeoniya/uFuzzy/issues/7
const allItemsAsStrings = allItems.map((item) =>
searchProperties.map((p) => item[p]).join("|"),
);
const [map, info, sorted] = fuzzyFinder.search(allItemsAsStrings, query);
// We hit an invalid state somehow
if (!map || !info || !sorted) {
return [];
}
return sorted.map((i) => allItems[info.idx[i]]);
}, [allItems, query, searchProperties]);
return searchedItems;
}