diff --git a/src/routes/Dashboard/DashboardComponentsV2View.test.tsx b/src/routes/Dashboard/DashboardComponentsV2View.test.tsx new file mode 100644 index 000000000..58e694209 --- /dev/null +++ b/src/routes/Dashboard/DashboardComponentsV2View.test.tsx @@ -0,0 +1,83 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { StoredLibrary } from "@/providers/ComponentLibraryProvider/libraries/storage"; + +import { + createRegisteredLibrariesFingerprint, + SourceFilterBar, + type SourceFilterOption, +} from "./DashboardComponentsV2View"; + +const options: SourceFilterOption[] = [ + { + source: { kind: "standard", label: "Standard", id: "standard" }, + count: 2, + }, + { + source: { kind: "registered", label: "GitHub", id: "github-lib" }, + count: 3, + }, + { + source: { kind: "user", label: "User", id: "user" }, + count: 1, + }, +]; + +describe("createRegisteredLibrariesFingerprint", () => { + it("excludes secret-bearing library configuration", () => { + const libraries: StoredLibrary[] = [ + { + id: "github-lib", + name: "GitHub", + type: "github", + knownDigests: ["digest-1"], + configuration: { + repo_name: "owner/repo", + last_updated_at: "2026-01-01T00:00:00Z", + access_token: "ghp_secret-token", + auto_update: true, + }, + }, + ]; + + const fingerprint = createRegisteredLibrariesFingerprint(libraries); + + expect(fingerprint).toContain("owner/repo"); + expect(fingerprint).not.toContain("ghp_secret-token"); + expect(fingerprint).not.toContain("access_token"); + }); +}); + +describe("SourceFilterBar", () => { + it("toggles source buttons and exposes active state", () => { + const onToggle = vi.fn(); + const onEnableAll = vi.fn(); + + render( + , + ); + + expect( + screen.getByRole("button", { + name: "Hide Standard source (2 components)", + }), + ).toHaveAttribute("aria-pressed", "true"); + expect( + screen.getByRole("button", { name: "Show GitHub source (3 components)" }), + ).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click( + screen.getByRole("button", { name: "Show GitHub source (3 components)" }), + ); + expect(onToggle).toHaveBeenCalledWith("registered:github-lib"); + + fireEvent.click(screen.getByRole("button", { name: "Show all" })); + expect(onEnableAll).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/routes/Dashboard/DashboardComponentsV2View.tsx b/src/routes/Dashboard/DashboardComponentsV2View.tsx index 0d46bbea0..25758ac67 100644 --- a/src/routes/Dashboard/DashboardComponentsV2View.tsx +++ b/src/routes/Dashboard/DashboardComponentsV2View.tsx @@ -1,32 +1,763 @@ -import { type ChangeEvent, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useLiveQuery } from "dexie-react-hooks"; +import { type ChangeEvent, useEffect, useState } from "react"; +import { listApiPublishedComponentsGet } from "@/api/sdk.gen"; +import { + ComponentDetail, + ComponentDetailSkeleton, +} from "@/components/shared/ComponentDetail/ComponentDetail"; +import { SuspenseWrapper } from "@/components/shared/SuspenseWrapper"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; import { Input } from "@/components/ui/input"; -import { BlockStack } from "@/components/ui/layout"; -import { Heading, Paragraph } from "@/components/ui/typography"; -import { TOP_NAV_HEIGHT } from "@/utils/constants"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Skeleton } from "@/components/ui/skeleton"; +import { QuickTooltip } from "@/components/ui/tooltip"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { getComponentQueryKey } from "@/hooks/useHydrateComponentReference"; +import { cn } from "@/lib/utils"; +import { useBackend } from "@/providers/BackendProvider"; +import { + fetchUserComponents, + flattenFolders, +} from "@/providers/ComponentLibraryProvider/componentLibrary"; +import { createLibraryObject } from "@/providers/ComponentLibraryProvider/libraries/factory"; +import { ensureLibraryFactoriesRegistered } from "@/providers/ComponentLibraryProvider/libraries/setup"; +import { + LibraryDB, + type StoredLibrary, +} from "@/providers/ComponentLibraryProvider/libraries/storage"; +import { + buildSearchIndex, + type ComponentSource, + type IndexEntry, + type LexicalMatch, + lexicalSearch, + type MatchField, + type SourcedReference, +} from "@/services/componentSearchIndex"; +import { + fetchAndStoreComponentLibrary, + hydrateComponentReference, +} from "@/services/componentService"; +import type { ComponentFolder } from "@/types/componentLibrary"; +import type { + ComponentReference, + HydratedComponentReference, +} from "@/utils/componentSpec"; +import { componentMetadata } from "@/utils/componentTracking"; +import { HOURS, TOP_NAV_HEIGHT } from "@/utils/constants"; +import { getComponentName } from "@/utils/getComponentName"; +import { tracking } from "@/utils/tracking"; +import { isRecord } from "@/utils/typeGuards"; + +import { APP_ROUTES } from "../router"; + +// Repeated Tailwind combos extracted as named constants. +const PANEL_CLASS = "p-3 rounded-lg bg-card border border-border"; + +// Maps V2's richer ComponentSource.kind onto the analytics-tracking taxonomy +// (see analytics-tracking skill: `component_source` enum is fixed). +const TRACKING_SOURCE_BY_KIND: Record< + ComponentSource["kind"], + "library" | "published" | "user" +> = { + standard: "library", + registered: "library", + published: "published", + user: "user", +}; + +// Source identity is communicated by the package icon's colour instead of a +// text badge — cleaner card, and the same colour shows up consistently across +// the list. Hover for the human-readable source name. +const SOURCE_ICON_TONE_BY_KIND: Record = { + standard: "text-blue-500", + published: "text-emerald-500", + registered: "text-violet-500", + user: "text-amber-500", +}; + +/** How many lexical hits to display. */ +const LEXICAL_RESULT_LIMIT = 20; + +const MATCH_FIELD_LABEL: Record = { + name: "name", + description: "description", + io: "inputs/outputs", + implementation: "command", +}; + +export interface SourceFilterOption { + source: ComponentSource; + count: number; +} + +function sourceFilterKey(source: ComponentSource): string { + return `${source.kind}:${source.id}`; +} + +function createSourceFilterOptions(index: IndexEntry[]): SourceFilterOption[] { + const optionsByKey = new Map(); + + for (const entry of index) { + const key = sourceFilterKey(entry.source); + const option = optionsByKey.get(key); + if (option) { + option.count += 1; + } else { + optionsByKey.set(key, { source: entry.source, count: 1 }); + } + } + + return Array.from(optionsByKey.values()); +} + +interface SourceFilterBarProps { + options: SourceFilterOption[]; + disabledSourceKeys: string[]; + onToggle: (sourceKey: string) => void; + onEnableAll: () => void; +} + +export const SourceFilterBar = ({ + options, + disabledSourceKeys, + onToggle, + onEnableAll, +}: SourceFilterBarProps) => { + if (options.length <= 1) return null; + + const disabled = new Set(disabledSourceKeys); + const activeCount = options.filter( + (option) => !disabled.has(sourceFilterKey(option.source)), + ).length; + + return ( + + + + Sources + + {options.map(({ source, count }) => { + const key = sourceFilterKey(source); + const active = !disabled.has(key); + return ( + + ); + })} + {activeCount < options.length && ( + + )} + + {activeCount === 0 && ( + + No sources selected. Turn on at least one source to show components. + + )} + + ); +}; + +// Built-in sources are constants — only registered libraries vary per row. +const STANDARD_SOURCE: ComponentSource = { + kind: "standard", + label: "Standard", + id: "standard", +}; +const PUBLISHED_SOURCE: ComponentSource = { + kind: "published", + label: "Published", + id: "published", +}; +const USER_SOURCE: ComponentSource = { + kind: "user", + label: "User", + id: "user", +}; + +function registeredSource(library: StoredLibrary): ComponentSource { + return { kind: "registered", label: library.name, id: library.id }; +} + +function readSelectedComponentDigest(search: unknown): string | undefined { + if (!isRecord(search)) return undefined; + return typeof search.component === "string" ? search.component : undefined; +} + +function registeredLibraryConfigurationFingerprint( + configuration: StoredLibrary["configuration"], +): string { + if (!configuration) return ""; + + const repoName = configuration.repo_name; + const lastUpdatedAt = configuration.last_updated_at; + const autoUpdate = configuration.auto_update; + + return JSON.stringify({ + repoName: typeof repoName === "string" ? repoName : "", + lastUpdatedAt: typeof lastUpdatedAt === "string" ? lastUpdatedAt : "", + autoUpdate: typeof autoUpdate === "boolean" ? autoUpdate : "", + }); +} + +export function createRegisteredLibrariesFingerprint( + libraries: StoredLibrary[] | undefined, +): string { + if (!libraries) return "loading"; + + return JSON.stringify( + libraries + .map((library) => ({ + id: library.id, + type: library.type, + name: library.name, + knownDigestsCount: library.knownDigests.length, + configuration: registeredLibraryConfigurationFingerprint( + library.configuration, + ), + })) + .sort((a, b) => a.id.localeCompare(b.id)), + ); +} + +type ComponentLibraryFolder = Parameters[0]; +type UserFolder = { components?: ComponentReference[] }; + +interface ComponentCardProps { + reference: ComponentReference; + source?: ComponentSource; + matchedFields?: MatchField[]; + reason?: string; + isSelected?: boolean; + /** Position within the current result list — passed to analytics. */ + position?: number; + /** Whether the user had typed a query when this card was rendered. */ + hadQuery?: boolean; + onSelect: (reference: ComponentReference) => void; +} + +const ComponentCard = ({ + reference, + source, + matchedFields, + reason, + isSelected, + position, + hadQuery, + onSelect, +}: ComponentCardProps) => { + const name = getComponentName(reference); + const description = reference.spec?.description; + const publishedBy = reference.published_by; + const trackingSource = source + ? TRACKING_SOURCE_BY_KIND[source.kind] + : "unknown"; + + return ( + + ); +}; /** - * Experimental Components V2 route shell. Search data sources and result - * ranking land in the follow-up PRs in this stack. + * Merge every component source the rest of the app knows about into a single + * deduped, source-attributed list. + * + * Order matters: the first occurrence of a digest wins. Priority is + * `standard > published > registered > user` so the most canonical label + * sticks when the same component appears in multiple places. */ +function collectAllSourcedReferences({ + standardLibrary, + publishedRefs, + registeredSourced, + userFolder, +}: { + standardLibrary: ComponentLibraryFolder | undefined; + publishedRefs: ComponentReference[]; + registeredSourced: SourcedReference[]; + userFolder: UserFolder | undefined; +}): SourcedReference[] { + const all: SourcedReference[] = []; + + if (standardLibrary) { + for (const ref of flattenFolders(standardLibrary)) { + all.push({ reference: ref, source: STANDARD_SOURCE }); + } + } + for (const ref of publishedRefs) { + all.push({ reference: ref, source: PUBLISHED_SOURCE }); + } + for (const sr of registeredSourced) { + all.push(sr); + } + for (const ref of userFolder?.components ?? []) { + all.push({ reference: ref, source: USER_SOURCE }); + } + + // Dedupe by digest, preserving the first occurrence (which carries the + // higher-priority source label). Refs without digests are dropped — the + // search index requires them for LLM round-trip anyway. + const seen = new Set(); + const out: SourcedReference[] = []; + for (const item of all) { + const digest = item.reference.digest; + if (!digest || seen.has(digest)) continue; + seen.add(digest); + out.push(item); + } + return out; +} + export const DashboardComponentsV2View = () => { + const queryClient = useQueryClient(); + const { backendUrl, configured, available } = useBackend(); const [query, setQuery] = useState(""); + const [disabledSourceKeys, setDisabledSourceKeys] = useState([]); + + // Detail-pane selection lives in the URL so refreshes preserve it and the + // selection can be linked-to. The V2 route has no validateSearch defined. + const navigate = useNavigate(); + const selectedDigest = readSelectedComponentDigest( + useSearch({ strict: false }), + ); + const selectComponent = (reference: ComponentReference) => { + navigate({ + to: APP_ROUTES.DASHBOARD_COMPONENTS_V2, + search: { component: reference.digest }, + }); + }; + const closeDetail = () => { + navigate({ to: APP_ROUTES.DASHBOARD_COMPONENTS_V2, search: {} }); + }; + + // Close detail on Escape — only when something is open, so we don't fight + // other Esc handlers (e.g. inside Inputs). Navigate inline so the effect has + // no callback dep that would re-bind on every render. + useEffect(() => { + if (!selectedDigest) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + navigate({ to: APP_ROUTES.DASHBOARD_COMPONENTS_V2, search: {} }); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [selectedDigest, navigate]); + + // The dashboard search page doesn't mount `ComponentLibraryProvider` (which + // is editor-scoped), so the GitHub library factory isn't auto-registered. + // This runs once and is idempotent. + useEffect(() => { + ensureLibraryFactoriesRegistered(); + }, []); + + const { data: componentLibrary, isLoading: libraryLoading } = useQuery({ + queryKey: ["componentLibrary"], + queryFn: fetchAndStoreComponentLibrary, + staleTime: HOURS, + }); + + const { data: userFolder, isLoading: userLoading } = useQuery({ + queryKey: ["userComponents"], + queryFn: fetchUserComponents, + staleTime: 0, + refetchOnMount: "always", + }); + + // Published components (backend). Gated on the backend being reachable; if + // it isn't, we silently search without published rather than erroring — + // matches the V1 dashboard behaviour. + const { data: publishedRefs = [], isLoading: publishedLoading } = useQuery({ + queryKey: ["component-search-v2", "published", backendUrl], + enabled: configured && available, + staleTime: HOURS, + queryFn: async (): Promise => { + const result = await listApiPublishedComponentsGet({}); + if (result.response.status !== 200 || !result.data) return []; + const list = result.data.published_components ?? []; + return list + .filter((c) => !c.deprecated) + .map((c) => ({ + digest: c.digest, + // Backend may return null; normalize to undefined to fit ComponentReference. + name: c.name ?? undefined, + url: c.url ?? `${backendUrl}/api/components/${c.digest}`, + published_by: c.published_by, + })); + }, + }); + + // Dexie is only the source of which libraries are registered. Fetching + // remote/GitHub library contents stays in TanStack Query so loading, errors, + // and cache lifetime follow the rest of the app's server-state conventions. + const registeredLibraries = useLiveQuery( + async () => { + ensureLibraryFactoriesRegistered(); + return LibraryDB.component_libraries.toArray(); + }, + [], + [], + ); + + const registeredLibrariesFingerprint = + createRegisteredLibrariesFingerprint(registeredLibraries); + + const { data: registeredSourced = [], isLoading: registeredQueryLoading } = + useQuery({ + queryKey: [ + "component-search-v2", + "registered-libraries", + registeredLibrariesFingerprint, + ], + enabled: registeredLibraries !== undefined, + staleTime: HOURS, + queryFn: async (): Promise => { + if (!registeredLibraries || registeredLibraries.length === 0) return []; + + const results = await Promise.allSettled( + registeredLibraries.map(async (storage) => { + const lib = createLibraryObject(storage); + const folder: ComponentFolder = await lib.getComponents({}); + return { storage, folder }; + }), + ); + + const out: SourcedReference[] = []; + for (const result of results) { + if (result.status !== "fulfilled") { + // One broken library shouldn't kill the whole search. + console.warn( + "Components V2: registered library failed to load", + result.reason, + ); + continue; + } + const source = registeredSource(result.value.storage); + for (const ref of flattenFolders(result.value.folder)) { + out.push({ reference: ref, source }); + } + } + return out; + }, + }); + + const registeredLoading = + registeredLibraries === undefined || registeredQueryLoading; + + const allSourced = collectAllSourcedReferences({ + standardLibrary: componentLibrary, + publishedRefs, + registeredSourced, + userFolder, + }); + + // Fingerprint of which refs are in play. Changes when the library set + // changes, so the hydration cache invalidates appropriately. + const referencesFingerprint = allSourced + .map((s) => s.reference.digest ?? s.reference.url ?? "") + .sort() + .join("|"); + + // Use `isLoading` (first fetch only), not `isFetching` (any fetch). A + // background refetch shouldn't flip the page back to a skeleton state. + const { data: hydratedReferences, isLoading: hydrating } = useQuery({ + queryKey: ["component-search-v2", "hydrate-library", referencesFingerprint], + enabled: allSourced.length > 0, + staleTime: HOURS, + queryFn: async () => { + const results = await Promise.all( + allSourced.map((sourced) => + // Reuse the same cache key as useHydrateComponentReference so + // individual component cards elsewhere in the app share hydration. + queryClient + .ensureQueryData({ + queryKey: [ + "component", + "hydrate", + getComponentQueryKey(sourced.reference), + ], + staleTime: HOURS, + queryFn: () => hydrateComponentReference(sourced.reference), + }) + .catch(() => null), + ), + ); + return results.filter((r): r is HydratedComponentReference => r !== null); + }, + }); + + // Pair hydrated refs back with their source by digest. Hydration preserves + // digests, so this is a straightforward join. + const sourceByDigest = new Map(); + for (const sourced of allSourced) { + if (sourced.reference.digest) { + sourceByDigest.set(sourced.reference.digest, sourced.source); + } + } + const sourcedHydrated: SourcedReference[] = []; + for (const reference of hydratedReferences ?? []) { + const source = sourceByDigest.get(reference.digest); + if (!source) continue; + sourcedHydrated.push({ reference, source }); + } + + // The search index is a pure derivation. React Compiler will memoize this. + const index: IndexEntry[] = buildSearchIndex(sourcedHydrated); + const sourceFilterOptions = createSourceFilterOptions(index); + const disabledSourceKeySet = new Set(disabledSourceKeys); + const filteredIndex = index.filter( + (entry) => !disabledSourceKeySet.has(sourceFilterKey(entry.source)), + ); + const total = filteredIndex.length; + const totalAcrossSources = index.length; + + // Alphabetical order for the browse-all view. Predictable scrolling beats + // "whatever order the library happened to load in." + const sortedIndex = [...filteredIndex].sort((a, b) => + a.name.localeCompare(b.name), + ); + + const lexicalMatches: LexicalMatch[] = lexicalSearch(filteredIndex, query, { + limit: LEXICAL_RESULT_LIMIT, + }); const handleQueryChange = (event: ChangeEvent) => { setQuery(event.target.value); }; + const handleSourceToggle = (sourceKey: string) => { + setDisabledSourceKeys((current) => + current.includes(sourceKey) + ? current.filter((key) => key !== sourceKey) + : [...current, sourceKey], + ); + }; + + const handleEnableAllSources = () => { + setDisabledSourceKeys([]); + }; + + const isLoadingLibrary = + libraryLoading || + userLoading || + publishedLoading || + registeredLoading || + hydrating; + const noLibraryData = !isLoadingLibrary && totalAcrossSources === 0; + const trimmedQuery = query.trim(); + const isEmpty = trimmedQuery.length === 0; + // Resolve the full reference for the selected digest. Prefer the already- + // hydrated copy (no extra network), fall back to the un-hydrated index + // entry, then to a backend stub. The shared ComponentDetail will suspend on + // hydration as needed and shares cache with the rest of the app. + const selectedReference: ComponentReference | undefined = (() => { + if (!selectedDigest) return undefined; + const hydrated = sourcedHydrated.find( + (s) => s.reference.digest === selectedDigest, + ); + if (hydrated) return hydrated.reference; + const indexed = allSourced.find( + (s) => s.reference.digest === selectedDigest, + ); + if (indexed) return indexed.reference; + return { + digest: selectedDigest, + url: `${backendUrl}/api/components/${selectedDigest}`, + }; + })(); + const isDetailOpen = Boolean(selectedDigest); + + // Render helpers — keeps the JSX below tidy. These read the closed-over + // state from the surrounding component; React Compiler memoises them. + const renderResults = () => { + if (isLoadingLibrary) { + return ( + + + + + + ); + } + if (noLibraryData) { + return ( + + No components found in your library. + + ); + } + if (total === 0) { + return ( + + No components in the selected sources. + + ); + } + if (isEmpty) { + return ( + + + {total} component{total === 1 ? "" : "s"} in selected sources. Start + typing to search. + + {sortedIndex.map((entry, idx) => ( + + ))} + + ); + } + if (lexicalMatches.length === 0) { + return ( + + No components matched “{trimmedQuery}”. Try different terms or check + for typos. + + ); + } + return ( + + + {lexicalMatches.length} result{lexicalMatches.length === 1 ? "" : "s"}{" "} + for “{trimmedQuery}” + + {lexicalMatches.map((result, idx) => ( + + ))} + + ); + }; + return ( + // App-shell layout: escape the dashboard's outer padding (`-mt-4 -mb-6 + // -mx-8`) so we can paint a fixed-height shell with our own internal + // padding per zone. Header is always visible; the body below has two + // independent scroll columns.
+ {/* Header zone: page title, description, search input. shrink-0 so it + never gets squeezed by the body below. */}
Components V2 - Search across component sources from one experimental dashboard. + Type to search across every component source — standard library, + your published components, registered libraries, and local user + components. Results match on name, description, inputs/outputs, + and container command. { value={query} onChange={handleQueryChange} aria-label="Search components" + disabled={isLoadingLibrary || noLibraryData} className="flex-1" /> +
-
- - Component results will appear here. - + + {/* Body zone: two scroll columns. `min-h-0` is required so the flex + children can shrink and scroll instead of growing the parent. */} +
+ {/* Results column — own scroll. When detail is open, narrows to a + fixed width with a divider; otherwise fills the whole body. */} +
+ {renderResults()} +
+ + {/* Detail column — own scroll. Close button sticky to the top of + this column's scroll viewport so it stays reachable. */} + {isDetailOpen && selectedReference && ( +
+ + }> + + +
+ )}
); diff --git a/src/routes/Dashboard/DashboardComponentsView.test.tsx b/src/routes/Dashboard/DashboardComponentsView.test.tsx new file mode 100644 index 000000000..67c0580c0 --- /dev/null +++ b/src/routes/Dashboard/DashboardComponentsView.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { SourceFilterBar } from "./DashboardComponentsView"; + +describe("SourceFilterBar", () => { + it("toggles component source buttons", () => { + const onToggle = vi.fn(); + const onEnableAll = vi.fn(); + + render( + , + ); + + expect( + screen.getByRole("button", { name: "Hide User generated source" }), + ).toHaveAttribute("aria-pressed", "true"); + expect( + screen.getByRole("button", { name: "Show Library / GitHub source" }), + ).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click( + screen.getByRole("button", { name: "Show Library / GitHub source" }), + ); + expect(onToggle).toHaveBeenCalledWith("library"); + + fireEvent.click(screen.getByRole("button", { name: "Show all" })); + expect(onEnableAll).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/routes/Dashboard/DashboardComponentsView.tsx b/src/routes/Dashboard/DashboardComponentsView.tsx index 070e719f0..15df68e19 100644 --- a/src/routes/Dashboard/DashboardComponentsView.tsx +++ b/src/routes/Dashboard/DashboardComponentsView.tsx @@ -8,6 +8,7 @@ import { ComponentDetailSkeleton, } from "@/components/shared/ComponentDetail/ComponentDetail"; import { SuspenseWrapper } from "@/components/shared/SuspenseWrapper"; +import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, @@ -45,6 +46,69 @@ function readSelectedComponentDigest(search: unknown): string | undefined { return typeof search.component === "string" ? search.component : undefined; } +interface SourceFilterOption { + source: ComponentRowSection; + label: string; +} + +const SOURCE_FILTER_OPTIONS: SourceFilterOption[] = [ + { source: "user", label: "User generated" }, + { source: "library", label: "Library / GitHub" }, + { source: "published", label: "Published" }, +]; + +export const SourceFilterBar = ({ + disabledSources, + onToggle, + onEnableAll, +}: { + disabledSources: ComponentRowSection[]; + onToggle: (source: ComponentRowSection) => void; + onEnableAll: () => void; +}) => { + const disabled = new Set(disabledSources); + const activeCount = SOURCE_FILTER_OPTIONS.filter( + (option) => !disabled.has(option.source), + ).length; + + return ( + + + + Sources + + {SOURCE_FILTER_OPTIONS.map(({ source, label }) => { + const active = !disabled.has(source); + return ( + + ); + })} + {activeCount < SOURCE_FILTER_OPTIONS.length && ( + + )} + + {activeCount === 0 && ( + + No sources selected. Turn on at least one source to show components. + + )} + + ); +}; + // ─── Collapsible section header ────────────────────────────────────────────── const CollapsibleSection = ({ diff --git a/src/services/componentSearchIndex.ts b/src/services/componentSearchIndex.ts index 30fb54c73..b3d6b84d1 100644 --- a/src/services/componentSearchIndex.ts +++ b/src/services/componentSearchIndex.ts @@ -16,7 +16,7 @@ import type { ComponentReference } from "@/utils/componentSpec"; import { getComponentName } from "@/utils/getComponentName"; /** Which field of a component matched the query. Surfaced in the UI. */ -type MatchField = "name" | "description" | "io" | "implementation"; +export type MatchField = "name" | "description" | "io" | "implementation"; /** * Where a component came from. Attached to every index entry and threaded