diff --git a/site/src/pages/TemplateBuilder/ModuleSelectStep.stories.tsx b/site/src/pages/TemplateBuilder/ModuleSelectStep.stories.tsx new file mode 100644 index 0000000000..71b5405466 --- /dev/null +++ b/site/src/pages/TemplateBuilder/ModuleSelectStep.stories.tsx @@ -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 & + Pick, +): 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 = { + 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; + +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(); + }, +}; diff --git a/site/src/pages/TemplateBuilder/ModuleSelectStep.tsx b/site/src/pages/TemplateBuilder/ModuleSelectStep.tsx index 783bea49ee..993cdf3b8c 100644 --- a/site/src/pages/TemplateBuilder/ModuleSelectStep.tsx +++ b/site/src/pages/TemplateBuilder/ModuleSelectStep.tsx @@ -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 = ({ 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(); + 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(() => { const warnings: string[] = []; // Loop through the selected modules and check for conflicts. We sort the @@ -139,21 +182,53 @@ export const ModuleSelectStep: FC = ({
Select modules - Add functionality to your template. + Add pre-built tools and integrations. Module versions are pinned at + selection. + + + + + {visibleFilterTabs.map((tab) => ( + + + {tab.value} ({tab.count}) + + ))} + + +
- {modules.map((m) => ( - handleToggle(m)} - /> - ))} + {visibleModules.length ? ( + visibleModules.map((m) => ( + handleToggle(m)} + /> + )) + ) : ( +
+ +

+ No module matched your search +

+
+ )}
{conflicts.length > 0 && ( diff --git a/site/src/pages/TemplateBuilder/hooks/useFuzzySearch.ts b/site/src/pages/TemplateBuilder/hooks/useFuzzySearch.ts new file mode 100644 index 0000000000..37036b1ba2 --- /dev/null +++ b/site/src/pages/TemplateBuilder/hooks/useFuzzySearch.ts @@ -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 = { + allItems: Readonly>; + searchText: string; + + /** + * The item properties to fuzzy search against + */ + searchProperties: Array; +}; + +export function useFuzzySearch({ + allItems, + searchText, + searchProperties, +}: UseFuzzySearchOptions) { + 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; +}