diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 1c682b71..3b13a144 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -583,35 +583,33 @@ function makeInsertReleaseCtx( indexName: string, buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, ) => { - if (indexName === "by_package") { - return { - collect: vi.fn().mockResolvedValue(priorReleases), - }; - } - if (indexName === "by_package_version") { - const filters = new Map(); - const query = { - eq(field: string, value: unknown) { - filters.set(field, value); - return query; - }, - }; - buildQuery?.(query); - return { - unique: vi - .fn() - .mockResolvedValue( - priorReleases.find( - (release) => - release.packageId === filters.get("packageId") && - release.version === filters.get("version"), - ) ?? null, - ), - }; - } + if (indexName === "by_package") { return { - unique: vi.fn().mockResolvedValue(null), + collect: vi.fn().mockResolvedValue(priorReleases), }; + } + if (indexName === "by_package_version") { + const filters = new Map(); + const query = { + eq(field: string, value: unknown) { + filters.set(field, value); + return query; + }, + }; + buildQuery?.(query); + return { + unique: vi.fn().mockResolvedValue( + priorReleases.find( + (release) => + release.packageId === filters.get("packageId") && + release.version === filters.get("version"), + ) ?? null, + ), + }; + } + return { + unique: vi.fn().mockResolvedValue(null), + }; }, ), }; diff --git a/convex/users.ts b/convex/users.ts index 27c81240..9d31b237 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -394,12 +394,11 @@ export const list = query({ }); export const listPublic = query({ - args: { limit: v.optional(v.number()), search: v.optional(v.string()) }, + args: { limit: v.optional(v.number()) }, handler: async (ctx, args) => { const limit = clampInt(args.limit ?? 40, 1, 100); const result = await queryUsersForPublicList(ctx, { limit, - search: args.search, }); return { items: result.items diff --git a/src/__tests__/header.test.tsx b/src/__tests__/header.test.tsx index 0045949e..155e671b 100644 --- a/src/__tests__/header.test.tsx +++ b/src/__tests__/header.test.tsx @@ -1,9 +1,8 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import Header from "../components/Header"; type HeaderAuthStatus = { isAuthenticated: boolean; @@ -13,6 +12,52 @@ type HeaderAuthStatus = { const siteModeMock = vi.fn(() => "souls"); const navigateMock = vi.fn(); +const { useUnifiedSearchMock } = vi.hoisted(() => ({ + useUnifiedSearchMock: vi.fn(), +})); + +const defaultUnifiedSearchResult = { + results: [], + skillResults: [ + { + type: "skill", + ownerHandle: "local", + score: 10, + skill: { + _id: "skills:weather", + slug: "weather", + displayName: "Weather Skill", + ownerUserId: "users:local", + stats: { downloads: 1, stars: 2 }, + createdAt: 1, + updatedAt: 2, + }, + }, + ], + pluginResults: [ + { + type: "plugin", + plugin: { + name: "weather-plugin", + displayName: "Weather Plugin", + family: "code-plugin", + channel: "community", + isOfficial: false, + summary: "Plugin weather tools.", + ownerHandle: "local", + createdAt: 1, + updatedAt: 2, + latestVersion: "1.0.0", + capabilityTags: [], + executesCode: true, + verificationTier: null, + }, + }, + ], + skillCount: 1, + pluginCount: 1, + isSearching: false, +}; vi.mock("@tanstack/react-router", () => ({ Link: (props: { children: ReactNode; className?: string; hash?: string; to?: string }) => ( @@ -90,6 +135,10 @@ vi.mock("../lib/gravatar", () => ({ gravatarUrl: vi.fn(), })); +vi.mock("../lib/useUnifiedSearch", () => ({ + useUnifiedSearch: () => useUnifiedSearchMock(), +})); + vi.mock("../components/ui/dropdown-menu", () => ({ DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, @@ -105,6 +154,8 @@ vi.mock("../components/ui/toggle-group", () => ({ ), })); +import Header from "../components/Header"; + describe("Header", () => { beforeEach(() => { authStatusMock.mockReturnValue({ @@ -113,6 +164,7 @@ describe("Header", () => { me: null, }); siteModeMock.mockReturnValue("souls"); + useUnifiedSearchMock.mockReturnValue(defaultUnifiedSearchResult); }); it("hides Packages navigation in soul mode on mobile and desktop", () => { @@ -136,7 +188,7 @@ describe("Header", () => { expect(screen.queryByText("Users")).toBeNull(); expect(screen.queryByText("Dashboard")).toBeNull(); expect(screen.queryByText("Manage")).toBeNull(); - expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy(); + expect(screen.getByPlaceholderText("Search skills and plugins")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /Toggle theme\. Current: system/i })); expect(setModeMock).toHaveBeenCalledWith("dark"); @@ -148,6 +200,96 @@ describe("Header", () => { expect(screen.getAllByText("Plugins")).toHaveLength(2); }); + it("shows grouped skills and plugins typeahead without users", () => { + siteModeMock.mockReturnValue("skills"); + navigateMock.mockReset(); + + render(
); + + const input = screen.getByPlaceholderText("Search skills and plugins"); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "weather" } }); + + const typeahead = screen.getByRole("listbox"); + expect(within(typeahead).getByText("Skills")).toBeTruthy(); + expect(screen.getByText("Weather Skill")).toBeTruthy(); + expect(within(typeahead).getByText("Plugins")).toBeTruthy(); + expect(screen.getByText("Weather Plugin")).toBeTruthy(); + expect(within(typeahead).queryByText("Users")).toBeNull(); + expect(within(typeahead).queryByText('See user results for "weather"')).toBeNull(); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(navigateMock).toHaveBeenCalledWith({ + to: "/search", + search: { q: "weather", type: "skills" }, + }); + }); + + it("falls back to typed skill search when a typeahead skill has no owner handle", () => { + siteModeMock.mockReturnValue("skills"); + navigateMock.mockReset(); + useUnifiedSearchMock.mockReturnValue({ + ...defaultUnifiedSearchResult, + skillResults: [ + { + ...defaultUnifiedSearchResult.skillResults[0], + ownerHandle: null, + skill: { + ...defaultUnifiedSearchResult.skillResults[0].skill, + ownerUserId: "users:opaque-id", + ownerPublisherId: "publishers:opaque-id", + }, + }, + ], + pluginResults: [], + pluginCount: 0, + }); + + render(
); + + const input = screen.getByPlaceholderText("Search skills and plugins"); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "weather" } }); + fireEvent.click(screen.getByRole("option", { name: /Weather Skill/i })); + + expect(navigateMock).toHaveBeenCalledWith({ + to: "/search", + search: { q: "weather", type: "skills" }, + }); + expect(navigateMock).not.toHaveBeenCalledWith( + expect.objectContaining({ + to: "/publishers%3Aopaque-id/weather", + }), + ); + }); + + it("shows a single no-results state without section footers", () => { + siteModeMock.mockReturnValue("skills"); + useUnifiedSearchMock.mockReturnValue({ + results: [], + skillResults: [], + pluginResults: [], + skillCount: 0, + pluginCount: 0, + isSearching: false, + }); + + render(
); + + const input = screen.getByPlaceholderText("Search skills and plugins"); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "zzzz" } }); + + const typeahead = screen.getByRole("listbox"); + expect(within(typeahead).getByText('No skills or plugins found for "zzzz"')).toBeTruthy(); + expect(within(typeahead).queryByText("Skills")).toBeNull(); + expect(within(typeahead).queryByText("Plugins")).toBeNull(); + expect(within(typeahead).queryByText('See skill results for "zzzz"')).toBeNull(); + expect(within(typeahead).queryByText('See plugin results for "zzzz"')).toBeNull(); + }); + it("shows Home above Skills in the mobile menu", () => { siteModeMock.mockReturnValue("skills"); diff --git a/src/__tests__/search-route.test.ts b/src/__tests__/search-route.test.ts index adb2dd90..e49ae879 100644 --- a/src/__tests__/search-route.test.ts +++ b/src/__tests__/search-route.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest"; process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud"; - vi.mock("../convex/client", () => ({ convex: {}, convexHttp: { query: vi.fn() }, @@ -51,10 +50,10 @@ describe("search route", () => { }); }); - it("accepts the users type filter", () => { + it("ignores the users type filter", () => { expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({ q: "vincent", - type: "users", + type: undefined, }); }); diff --git a/src/__tests__/search-route.test.tsx b/src/__tests__/search-route.test.tsx index 30f6f617..8527e552 100644 --- a/src/__tests__/search-route.test.tsx +++ b/src/__tests__/search-route.test.tsx @@ -5,24 +5,23 @@ import type { ComponentType, ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const navigateMock = vi.fn(); -let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "users" } = {}; +let searchMock: { q?: string; type?: "all" | "skills" | "plugins" } = {}; vi.mock("@tanstack/react-router", () => ({ - createFileRoute: - () => - (config: { component?: unknown; validateSearch?: unknown }) => ({ - __config: config, - useSearch: () => searchMock, - }), + createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({ + __config: config, + useSearch: () => searchMock, + }), useNavigate: () => navigateMock, })); vi.mock("../lib/useUnifiedSearch", () => ({ useUnifiedSearch: () => ({ results: [], + skillResults: [], + pluginResults: [], skillCount: 0, pluginCount: 0, - userCount: 0, isSearching: false, }), })); @@ -35,10 +34,6 @@ vi.mock("../components/SkillListItem", () => ({ SkillListItem: ({ skill }: { skill: { slug: string } }) =>
{skill.slug}
, })); -vi.mock("../components/UserListItem", () => ({ - UserListItem: ({ user }: { user: { _id: string } }) =>
{user._id}
, -})); - vi.mock("../components/ui/card", () => ({ Card: ({ children, className }: { children: ReactNode; className?: string }) => (
{children}
@@ -64,7 +59,7 @@ describe("search route", () => { const Component = route.__config.component as ComponentType; const rendered = render(); - const input = screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement; + const input = screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement; expect(input.value).toBe("first"); fireEvent.change(input, { target: { value: "draft" } }); @@ -74,7 +69,16 @@ describe("search route", () => { rendered.rerender(); expect( - (screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement).value, + (screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement).value, ).toBe("second"); }); + + it("does not render a public users search tab", async () => { + const route = await loadRoute(); + const Component = route.__config.component as ComponentType; + + render(); + + expect(screen.queryByRole("button", { name: /users/i })).toBeNull(); + }); }); diff --git a/src/__tests__/users-route.test.tsx b/src/__tests__/users-route.test.tsx new file mode 100644 index 00000000..415d676f --- /dev/null +++ b/src/__tests__/users-route.test.tsx @@ -0,0 +1,56 @@ +/* @vitest-environment jsdom */ + +import { render, screen, waitFor } from "@testing-library/react"; +import type { ComponentType, ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const queryMock = vi.fn(); + +vi.mock("../convex/client", () => ({ + convexHttp: { query: (...args: unknown[]) => queryMock(...args) }, +})); + +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({ + __config: config, + }), +})); + +vi.mock("../components/UserListItem", () => ({ + UserListItem: ({ user }: { user: { _id: string } }) =>
{user._id}
, +})); + +vi.mock("../components/ui/card", () => ({ + Card: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), +})); + +async function loadRoute() { + return (await import("../routes/users/index")).Route as unknown as { + __config: { + component?: ComponentType; + validateSearch?: unknown; + }; + }; +} + +describe("users route", () => { + beforeEach(() => { + vi.resetModules(); + queryMock.mockReset(); + queryMock.mockResolvedValue({ items: [], total: 0 }); + }); + + it("does not expose public user search", async () => { + const route = await loadRoute(); + const Component = route.__config.component as ComponentType; + + render(); + + await waitFor(() => expect(queryMock).toHaveBeenCalled()); + expect(queryMock.mock.calls[0]?.[1]).toEqual({ limit: 48 }); + expect(screen.queryByPlaceholderText(/search users/i)).toBeNull(); + expect(route.__config.validateSearch).toBeUndefined(); + }); +}); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 9a3706e2..ed8b3a60 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,7 +1,7 @@ import { useAuthActions } from "@convex-dev/auth/react"; import { Link, useLocation, useNavigate } from "@tanstack/react-router"; -import { Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react"; -import { type ComponentType, useMemo, useState } from "react"; +import { ArrowRight, Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react"; +import { type ComponentType, useEffect, useMemo, useRef, useState } from "react"; import { getUserFacingAuthError } from "../lib/authErrorMessage"; import { gravatarUrl } from "../lib/gravatar"; import { filterNavItems, type NavIconName, PRIMARY_NAV_ITEMS } from "../lib/nav-items"; @@ -10,6 +10,11 @@ import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site"; import { applyTheme, useThemeMode } from "../lib/theme"; import { setAuthError, useAuthError } from "../lib/useAuthError"; import { useAuthStatus } from "../lib/useAuthStatus"; +import { + useUnifiedSearch, + type UnifiedPluginResult, + type UnifiedSkillResult, +} from "../lib/useUnifiedSearch"; import { Button } from "./ui/button"; import { DropdownMenu, @@ -33,6 +38,24 @@ const NAV_ICONS: Record(null); const ThemeModeIcon = getThemeModeIcon(mode); const nextThemeMode = getNextThemeMode(mode); + const trimmedNavSearchQuery = navSearchQuery.trim(); + const showTypeahead = !isSoulMode && typeaheadOpen && trimmedNavSearchQuery.length > 0; + const { + skillResults, + skillCount, + pluginResults, + pluginCount, + isSearching: typeaheadSearching, + } = useUnifiedSearch(navSearchQuery, "all", { + debounceMs: 180, + enabled: showTypeahead, + limits: { skills: 4, plugins: 4 }, + }); + const typeaheadItems = useMemo(() => { + if (!showTypeahead) return []; + const items: TypeaheadItem[] = []; + for (const result of skillResults) { + items.push({ kind: "skill", key: `skill-${result.skill._id}`, result }); + } + if (skillCount > 0) { + items.push({ + kind: "footer", + key: "footer-skills", + section: "skills", + label: `See skill results for "${trimmedNavSearchQuery}"`, + }); + } + for (const result of pluginResults) { + items.push({ kind: "plugin", key: `plugin-${result.plugin.name}`, result }); + } + if (pluginCount > 0) { + items.push({ + kind: "footer", + key: "footer-plugins", + section: "plugins", + label: `See plugin results for "${trimmedNavSearchQuery}"`, + }); + } + return items; + }, [pluginCount, pluginResults, showTypeahead, skillCount, skillResults, trimmedNavSearchQuery]); + + useEffect(() => { + setTypeaheadActiveIndex(0); + }, [trimmedNavSearchQuery]); + + useEffect(() => { + if (!typeaheadOpen) return () => {}; + const handlePointerDown = (event: PointerEvent) => { + if (searchWrapRef.current?.contains(event.target as Node)) return; + setTypeaheadOpen(false); + }; + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [typeaheadOpen]); const setThemeMode = (next: "system" | "light" | "dark") => { applyTheme(next, theme); @@ -85,9 +165,72 @@ export default function Header() { : { q, type: undefined }, }); setNavSearchQuery(""); + setTypeaheadOpen(false); setMobileSearchOpen(false); }; + const navigateToTypeaheadItem = (item: TypeaheadItem) => { + if (item.kind === "skill") { + const resultOwnerHandle = item.result.ownerHandle?.trim(); + if (!resultOwnerHandle) { + void navigate({ + to: "/search", + search: { q: trimmedNavSearchQuery, type: "skills" }, + }); + setNavSearchQuery(""); + setTypeaheadOpen(false); + setMobileSearchOpen(false); + return; + } + void navigate({ + to: `/${encodeURIComponent(resultOwnerHandle)}/${encodeURIComponent(item.result.skill.slug)}`, + }); + } else if (item.kind === "plugin") { + void navigate({ + to: "/plugins/$name", + params: { name: item.result.plugin.name }, + }); + } else { + void navigate({ + to: "/search", + search: { q: trimmedNavSearchQuery, type: item.section }, + }); + } + setNavSearchQuery(""); + setTypeaheadOpen(false); + setMobileSearchOpen(false); + }; + + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if (isSoulMode) return; + if (event.key === "Escape") { + setTypeaheadOpen(false); + return; + } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter") return; + if (!showTypeahead || typeaheadItems.length === 0) { + if (event.key === "ArrowDown" && trimmedNavSearchQuery) { + setTypeaheadOpen(true); + event.preventDefault(); + } + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + setTypeaheadActiveIndex((index) => (index + 1) % typeaheadItems.length); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setTypeaheadActiveIndex( + (index) => (index - 1 + typeaheadItems.length) % typeaheadItems.length, + ); + } else if (event.key === "Enter") { + const activeItem = typeaheadItems[typeaheadActiveIndex]; + if (!activeItem) return; + event.preventDefault(); + navigateToTypeaheadItem(activeItem); + } + }; + return (
@@ -172,22 +315,42 @@ export default function Header() { {siteName} -
-
diff --git a/src/routes/users/index.tsx b/src/routes/users/index.tsx index 3fbad258..b4991f4d 100644 --- a/src/routes/users/index.tsx +++ b/src/routes/users/index.tsx @@ -1,38 +1,26 @@ import { createFileRoute } from "@tanstack/react-router"; -import { Search } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { api } from "../../../convex/_generated/api"; -import { UserListItem } from "../../components/UserListItem"; import { Card } from "../../components/ui/card"; +import { UserListItem } from "../../components/UserListItem"; import { convexHttp } from "../../convex/client"; import type { PublicUser } from "../../lib/publicUser"; -type UserSearchState = { - q?: string; -}; - type UsersLoaderResult = { items: PublicUser[]; total: number }; export const Route = createFileRoute("/users/")({ - validateSearch: (search): UserSearchState => ({ - q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined, - }), component: UsersIndex, }); function UsersIndex() { - const search = Route.useSearch(); - const navigate = Route.useNavigate(); - const [query, setQuery] = useState(search.q ?? ""); const [result, setResult] = useState(undefined); const [loading, setLoading] = useState(true); - const fetchUsers = useCallback(async (q?: string) => { + const fetchUsers = useCallback(async () => { setLoading(true); try { const data = await convexHttp.query(api.users.listPublic, { limit: 48, - search: q, }); setResult(data as UsersLoaderResult); } finally { @@ -41,9 +29,8 @@ function UsersIndex() { }, []); useEffect(() => { - setQuery(search.q ?? ""); - void fetchUsers(search.q); - }, [search.q, fetchUsers]); + void fetchUsers(); + }, [fetchUsers]); const users = result?.items ?? []; @@ -57,25 +44,6 @@ function UsersIndex() { ) : null} - { - event.preventDefault(); - void navigate({ - search: { - q: query.trim() || undefined, - }, - }); - }} - > -