mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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.c16e0d9516andb6ee21875cco-written with Claude Code
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user