diff --git a/gui/src/components/mainInput/Lump/sections/docs/DocsSection.test.tsx b/gui/src/components/mainInput/Lump/sections/docs/DocsSection.test.tsx new file mode 100644 index 0000000000..8b9731037e --- /dev/null +++ b/gui/src/components/mainInput/Lump/sections/docs/DocsSection.test.tsx @@ -0,0 +1,242 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import DocsIndexingStatuses from "./DocsSection"; +import { AuthProvider } from "../../../../../context/Auth"; +import { vi } from "vitest"; + +// Mock the dependencies +vi.mock("../ExploreBlocksButton", () => ({ + ExploreBlocksButton: () =>
Explore Blocks
, +})); + +vi.mock("./DocsIndexingStatus", () => ({ + default: ({ docConfig }: any) => ( +
{docConfig.title || docConfig.startUrl}
+ ), +})); + +vi.mock("../../../../Input", () => ({ + Input: ({ onChange, value, placeholder, ...props }: any) => ( + + ), +})); + +vi.mock("../../../../Select", () => ({ + Select: ({ children, value, onValueChange }: any) => ( +
+ {children} +
+ ), + SelectTrigger: ({ children }: any) => ( + + ), + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value }: any) => ( + + ), + SelectValue: ({ placeholder }: any) => {placeholder}, +})); + +const createMockStore = (docs: any[] = [], statuses: any = {}) => { + return configureStore({ + reducer: { + config: (state = { config: { docs } }) => state, + indexing: (state = { indexing: { statuses } }) => state, + }, + }); +}; + +const renderWithProviders = (component: JSX.Element, store: any) => { + return render( + + + {component} + + + ); +}; + +describe("DocsIndexingStatuses", () => { + it("renders the search input and filter controls", () => { + const store = createMockStore(); + renderWithProviders(, store); + + expect(screen.getByTestId("search-input")).toBeInTheDocument(); + expect(screen.getAllByTestId("select-trigger")).toHaveLength(2); // Sort and Group selects + }); + + it("filters docs based on search query", async () => { + const docs = [ + { title: "React Documentation", startUrl: "https://react.dev" }, + { title: "Vue Guide", startUrl: "https://vuejs.org" }, + { title: "Angular Docs", startUrl: "https://angular.io" }, + ]; + const store = createMockStore(docs); + renderWithProviders(, store); + + const searchInput = screen.getByTestId("search-input"); + fireEvent.change(searchInput, { target: { value: "React" } }); + + await waitFor(() => { + const docItems = screen.getAllByTestId("doc-item"); + expect(docItems).toHaveLength(1); + expect(docItems[0]).toHaveTextContent("React Documentation"); + }); + }); + + it("shows empty state when no docs match search", async () => { + const docs = [ + { title: "React Documentation", startUrl: "https://react.dev" }, + ]; + const store = createMockStore(docs); + renderWithProviders(, store); + + const searchInput = screen.getByTestId("search-input"); + fireEvent.change(searchInput, { target: { value: "NonExistent" } }); + + await waitFor(() => { + expect(screen.getByText(/No documentation found matching/)).toBeInTheDocument(); + }); + }); + + it("clears search when clear button is clicked", async () => { + const docs = [ + { title: "React Documentation", startUrl: "https://react.dev" }, + ]; + const store = createMockStore(docs); + renderWithProviders(, store); + + const searchInput = screen.getByTestId("search-input"); + fireEvent.change(searchInput, { target: { value: "test" } }); + + await waitFor(() => { + expect(screen.getByText("Clear search")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Clear search")); + expect(searchInput).toHaveValue(""); + }); + + it("sorts docs by status correctly", () => { + const docs = [ + { title: "Doc 1", startUrl: "https://doc1.com" }, + { title: "Doc 2", startUrl: "https://doc2.com" }, + { title: "Doc 3", startUrl: "https://doc3.com" }, + ]; + const statuses = { + "https://doc1.com": { status: "complete" }, + "https://doc2.com": { status: "failed" }, + "https://doc3.com": { status: "indexing" }, + }; + const store = createMockStore(docs, statuses); + renderWithProviders(, store); + + const docItems = screen.getAllByTestId("doc-item"); + // Should be sorted: indexing, failed, complete + expect(docItems[0]).toHaveTextContent("Doc 3"); + expect(docItems[1]).toHaveTextContent("Doc 2"); + expect(docItems[2]).toHaveTextContent("Doc 1"); + }); + + it("groups docs by domain", () => { + const docs = [ + { title: "React Docs", startUrl: "https://react.dev/docs" }, + { title: "React Tutorial", startUrl: "https://react.dev/tutorial" }, + { title: "Vue Guide", startUrl: "https://vuejs.org/guide" }, + ]; + const store = createMockStore(docs); + renderWithProviders(, store); + + // The component should group by domain when selected + // This test would need more sophisticated mocking to test the actual grouping + expect(screen.getAllByTestId("doc-item")).toHaveLength(3); + }); + + it("renders collapsible groups when grouping is enabled", () => { + const docs = [ + { title: "GitHub Repo 1", startUrl: "https://github.com/user/repo1" }, + { title: "GitHub Repo 2", startUrl: "https://github.com/user/repo2" }, + ]; + const store = createMockStore(docs); + renderWithProviders(, store); + + // Check that docs are rendered + expect(screen.getAllByTestId("doc-item")).toHaveLength(2); + }); + + it("auto-expands groups when searching", async () => { + const docs = [ + { title: "React Documentation", startUrl: "https://react.dev" }, + { title: "Vue Guide", startUrl: "https://vuejs.org" }, + ]; + const store = createMockStore(docs); + renderWithProviders(, store); + + const searchInput = screen.getByTestId("search-input"); + fireEvent.change(searchInput, { target: { value: "React" } }); + + await waitFor(() => { + // Groups should be automatically expanded when searching + const docItems = screen.getAllByTestId("doc-item"); + expect(docItems).toHaveLength(1); + }); + }); + + it("categorizes docs correctly", () => { + const docs = [ + { title: "GitHub Project", startUrl: "https://github.com/user/project" }, + { title: "API Reference", startUrl: "https://example.com/api/reference" }, + { title: "Tutorial", startUrl: "https://example.com/tutorial/intro" }, + { title: "Blog Post", startUrl: "https://example.com/blog/post" }, + { title: "Documentation", startUrl: "https://example.com/docs/guide" }, + { title: "Other Resource", startUrl: "https://example.com/resource" }, + ]; + const store = createMockStore(docs); + renderWithProviders(, store); + + // All docs should be rendered + expect(screen.getAllByTestId("doc-item")).toHaveLength(6); + }); + + it("handles empty docs list", () => { + const store = createMockStore([]); + renderWithProviders(, store); + + expect(screen.getByText("Explore Blocks")).toBeInTheDocument(); + expect(screen.queryByTestId("doc-item")).not.toBeInTheDocument(); + }); + + it("debounces search input", async () => { + const docs = [ + { title: "React Documentation", startUrl: "https://react.dev" }, + { title: "Vue Guide", startUrl: "https://vuejs.org" }, + ]; + const store = createMockStore(docs); + renderWithProviders(, store); + + const searchInput = screen.getByTestId("search-input"); + + // Type quickly + fireEvent.change(searchInput, { target: { value: "R" } }); + fireEvent.change(searchInput, { target: { value: "Re" } }); + fireEvent.change(searchInput, { target: { value: "Rea" } }); + fireEvent.change(searchInput, { target: { value: "Reac" } }); + fireEvent.change(searchInput, { target: { value: "React" } }); + + // Initially all docs should still be visible + expect(screen.getAllByTestId("doc-item")).toHaveLength(2); + + // Wait for debounce + await waitFor(() => { + expect(screen.getAllByTestId("doc-item")).toHaveLength(1); + expect(screen.getByTestId("doc-item")).toHaveTextContent("React Documentation"); + }, { timeout: 400 }); + }); +}); diff --git a/gui/src/components/mainInput/Lump/sections/docs/DocsSection.tsx b/gui/src/components/mainInput/Lump/sections/docs/DocsSection.tsx index e7913d1ef1..bb8ab68b05 100644 --- a/gui/src/components/mainInput/Lump/sections/docs/DocsSection.tsx +++ b/gui/src/components/mainInput/Lump/sections/docs/DocsSection.tsx @@ -1,74 +1,302 @@ import { parseConfigYaml } from "@continuedev/config-yaml"; +import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; import { IndexingStatus } from "core"; -import { useMemo } from "react"; +import { useMemo, useState, useEffect } from "react"; import { useAuth } from "../../../../../context/Auth"; import { useAppSelector } from "../../../../../redux/hooks"; import { ExploreBlocksButton } from "../ExploreBlocksButton"; import DocsIndexingStatus from "./DocsIndexingStatus"; +import { Input } from "../../../../Input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../../Select"; + +type SortOption = "status" | "name" | "url"; +type GroupOption = "none" | "domain" | "category"; + +interface CollapsibleGroupProps { + title: string; + count: number; + isExpanded: boolean; + onToggle: () = void; + children: React.ReactNode; +} + +const CollapsibleGroup: React.FCCollapsibleGroupProps = ({ + title, + count, + isExpanded, + onToggle, + children +}) = { + return ( + div className="mb-2" + button + onClick={onToggle} + className="flex items-center gap-1 w-full p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors" +  + {isExpanded ? ( + ChevronDownIcon className="h-4 w-4" / + ) : ( + ChevronRightIcon className="h-4 w-4" / + )} + span className="font-medium text-sm"{title}/span + span className="text-xs text-gray-500 ml-1"({count})/span + /button + {isExpanded  ( + div className="ml-4" + {children} + /div + )} + /div + ); +}; function DocsIndexingStatuses() { - const config = useAppSelector((store) => store.config.config); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useStateSortOption("status"); + const [groupBy, setGroupBy] = useStateGroupOption("none"); + const [expandedGroups, setExpandedGroups] = useStateSetstring(new Set()); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + + // Debounce search query + useEffect(() = { + const timer = setTimeout(() = { + setDebouncedSearchQuery(searchQuery); + }, 300); + + return () = clearTimeout(timer); + }, [searchQuery]); + + const config = useAppSelector((store) = store.config.config); const indexingStatuses = useAppSelector( - (store) => store.indexing.indexing.statuses, + (store) = store.indexing.indexing.statuses, ); const { selectedProfile } = useAuth(); - const mergedDocs = useMemo(() => { + const mergedDocs = useMemo(() = { const parsed = selectedProfile?.rawYaml ? parseConfigYaml(selectedProfile?.rawYaml ?? "") : undefined; - return (config.docs ?? []).map((doc, index) => ({ + return (config.docs ?? []).map((doc, index) = ({ doc, docFromYaml: parsed?.docs?.[index], })); }, [config.docs, selectedProfile?.rawYaml]); - const sortedConfigDocs = useMemo(() => { - const sorter = (status: IndexingStatus["status"]) => { - if (status === "complete") return 0; - if (status === "indexing" || status === "paused") return 1; - if (status === "failed") return 2; - if (status === "aborted" || status === "pending") return 3; - return 4; - }; - - const docs = [...mergedDocs]; - docs.sort((a, b) => { - const statusA = indexingStatuses[a.doc.startUrl]?.status ?? "pending"; - const statusB = indexingStatuses[b.doc.startUrl]?.status ?? "pending"; - - // First, compare by status - const statusCompare = sorter(statusA) - sorter(statusB); - if (statusCompare !== 0) return statusCompare; - - // If status is the same, sort by presence of icon - const hasIconA = !!a.doc.faviconUrl; - const hasIconB = !!b.doc.faviconUrl; - return hasIconB === hasIconA ? 0 : hasIconB ? 1 : -1; + // Filter docs based on search query + const filteredDocs = useMemo(() = { + const allDocs = (mergedDocs ?? []); + if (!debouncedSearchQuery) return allDocs; + + const query = debouncedSearchQuery.toLowerCase(); + return allDocs.filter( + ({ doc }) = + doc.title?.toLowerCase().includes(query) || + doc.startUrl?.toLowerCase().includes(query) + ); + }, [mergedDocs, debouncedSearchQuery]); + + // Sort docs + const sortedConfigDocs = useMemo(() = { + const sorted = [...filteredDocs]; + switch (sortBy) { + case "status": + const statusOrder = { indexing: 0, failed: 1, complete: 2, pending: 3, aborted: 4 }; + sorted.sort((a, b) = { + const orderA = statusOrder[indexingStatuses[a.doc.startUrl]?.status ?? "pending"] ?? 5; + const orderB = statusOrder[indexingStatuses[b.doc.startUrl]?.status ?? "pending"] ?? 5; + return orderA - orderB; + }); + break; + case "name": + sorted.sort((a, b) = (a.doc.title || "").localeCompare(b.doc.title || "")); + break; + case "url": + sorted.sort((a, b) = (a.doc.startUrl || "").localeCompare(b.doc.startUrl || "")); + break; + } + return sorted; + }, [filteredDocs, sortBy, indexingStatuses]); + + // Helper function to categorize docs + const categorizeDoc = (doc: string): string = { + const url = doc.toLowerCase() || ""; + if (url.includes("github.com")) return "GitHub"; + if (url.includes("/docs") || url.includes("documentation")) return "Documentation"; + if (url.includes("/api") || url.includes("reference")) return "API Reference"; + if (url.includes("blog") || url.includes("article")) return "Blogs"; + if (url.includes("tutorial") || url.includes("guide")) return "Tutorials  Guides"; + return "Other"; + }; + + // Group docs + const groupedConfigDocs = useMemo(() = { + if (groupBy === "none") { + return { "All Documents": sortedConfigDocs }; + } + + const groups: Recordstring, { doc: any, docFromYaml: any }[] = {}; + + sortedConfigDocs.forEach((docConfig) = { + let groupKey: string; + + if (groupBy === "domain") { + try { + const url = new URL(docConfig.doc.startUrl || ""); + groupKey = url.hostname; + } catch { + groupKey = "Unknown Domain"; + } + } else { + // groupBy === "category" + groupKey = categorizeDoc(docConfig.doc.startUrl); + } + + if (!groups[groupKey]) { + groups[groupKey] = []; + } + groups[groupKey].push(docConfig); + }); + + // Sort groups by number of docs (descending) + const sortedGroups: Recordstring, { doc: any, docFromYaml: any }[] = {}; + Object.entries(groups) + .sort(([, a], [, b]) = b.length - a.length) + .forEach(([key, value]) = { + sortedGroups[key] = value; + }); + + return sortedGroups; + }, [sortedConfigDocs, groupBy]); + + const toggleGroup = (groupName: string) = { + setExpandedGroups((prev) = { + const newSet = new Set(prev); + if (newSet.has(groupName)) { + newSet.delete(groupName); + } else { + newSet.add(groupName); + } + return newSet; }); - return docs; - }, [mergedDocs, indexingStatuses]); + }; + + // Auto-expand groups when there's only one group or when searching + useEffect(() = { + const groupNames = Object.keys(groupedConfigDocs); + if (groupNames.length === 1 || debouncedSearchQuery) { + setExpandedGroups(new Set(groupNames)); + } + }, [groupedConfigDocs, debouncedSearchQuery]); + + const totalDocs = filteredDocs.length; + const isEmpty = totalDocs === 0  debouncedSearchQuery; return ( -
-
- {sortedConfigDocs.map((docConfig) => { - return ( -
-
- -
-
- ); - })} -
- -
+ div className="space-y-4 overflow-y-auto" + {/* Search and Filter Controls */} + div className="sticky top-0 bg-white dark:bg-gray-900 z-10 pb-2 space-y-2" + Input + type="text" + placeholder="Search documentation..." + value={searchQuery} + onChange={(e) = setSearchQuery(e.target.value)} + className="w-full" + / + + div className="flex gap-2" + Select value={sortBy} onValueChange={(value) = setSortBy(value as SortOption)} + SelectTrigger className="flex-1" + SelectValue placeholder="Sort by" / + /SelectTrigger + SelectContent + SelectItem value="status"Status/SelectItem + SelectItem value="name"Name/SelectItem + SelectItem value="url"URL/SelectItem + /SelectContent + /Select + + Select value={groupBy} onValueChange={(value) = setGroupBy(value as GroupOption)} + SelectTrigger className="flex-1" + SelectValue placeholder="Group by" / + /SelectTrigger + SelectContent + SelectItem value="none"No Grouping/SelectItem + SelectItem value="domain"Domain/SelectItem + SelectItem value="category"Category/SelectItem + /SelectContent + /Select + /div + + {searchQuery  ( + button + onClick={() = setSearchQuery("")} + className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200" +  + Clear search + /button + )} + /div + + {/* Results */} + {isEmpty ? ( + div className="text-center py-8 text-gray-500" + pNo documentation found matching "{debouncedSearchQuery}"/p + button + onClick={() = setSearchQuery("")} + className="mt-2 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200" +  + Clear search + /button + /div + ) : ( + div + {Object.entries(groupedConfigDocs).map(([groupName, docs]) = { + const isExpanded = expandedGroups.has(groupName); + const showGroup = groupBy !== "none"; + + if (showGroup) { + return ( + CollapsibleGroup + key={groupName} + title={groupName} + count={docs.length} + isExpanded={isExpanded} + onToggle={() = toggleGroup(groupName)} +  + {docs.map(({doc, docFromYaml}) = ( + div key={doc.startUrl} className="flex items-center gap-2" + div className="flex-grow" + DocsIndexingStatus + docFromYaml={docFromYaml} + docConfig={doc} + / + /div + /div + ))} + /CollapsibleGroup + ); + } else { + return docs.map(({doc, docFromYaml}) = ( + div key={doc.startUrl} className="flex items-center gap-2" + div className="flex-grow" + DocsIndexingStatus + docFromYaml={docFromYaml} + docConfig={doc} + / + /div + /div + )); + } + })} + /div + )} + ExploreBlocksButton blockType="docs" / + /div ); } diff --git a/gui/src/utils/debounce.ts b/gui/src/utils/debounce.ts new file mode 100644 index 0000000000..daca217a8c --- /dev/null +++ b/gui/src/utils/debounce.ts @@ -0,0 +1,25 @@ +/** + * Creates a debounced function that delays invoking func until after wait milliseconds + * have elapsed since the last time the debounced function was invoked. + * + * @param func The function to debounce + * @param wait The number of milliseconds to delay + * @returns A debounced version of the function + */ +export function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeoutId: NodeJS.Timeout | null = null; + + return function debounced(...args: Parameters) { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + func(...args); + timeoutId = null; + }, wait); + }; +}