diff --git a/apps/web/src/app/(tilt)/custom/marketplace/page.tsx b/apps/web/src/app/(tilt)/custom/marketplace/page.tsx index 7bb1d4f5..184b097f 100644 --- a/apps/web/src/app/(tilt)/custom/marketplace/page.tsx +++ b/apps/web/src/app/(tilt)/custom/marketplace/page.tsx @@ -8,8 +8,8 @@ import { CustomizationPage } from "@/components/custom/CustomizationPage"; import { IndexStoreResults } from "@/components/tilt-pro/IndexStoreResults"; import { IndexStoreToolbar } from "@/components/tilt-pro/IndexStoreToolbar"; import { useOrganization } from "@/contexts/OrganizationContext"; -import { useAssetSearch } from "@/hooks/useAssetSearch"; import { useIndexEnabledList } from "@/hooks/useIndexEnabledList"; +import { useTokenAssetSearch } from "@/hooks/useTokenAssetSearch"; import { cn } from "@/lib/utils"; import type { IndexStoreSortOption } from "@/lib/utils/index-store-converters"; @@ -21,7 +21,7 @@ export default function MarketplacePage() { const [sortOption, setSortOption] = useState("relevance"); - const searchState = useAssetSearch({ + const searchState = useTokenAssetSearch({ limit: 50, allowedSourceTypes: ["TILT_INDEX"], }); diff --git a/apps/web/src/components/asset-search/AssetSearchInput.tsx b/apps/web/src/components/asset-search/AssetSearchInput.tsx deleted file mode 100644 index 7b54604c..00000000 --- a/apps/web/src/components/asset-search/AssetSearchInput.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import { LexicalComposer } from "@lexical/react/LexicalComposer"; -import { SearchIcon } from "@tilt/ui/icons"; -import { KeywordNode } from "@/lib/lexical/KeywordNode"; -import type { ParsedFiltersWithMatches } from "@/lib/utils/parseAssetSearchQuery"; -import { EditorContent } from "./EditorContent"; - -type AssetSearchInputProps = { - value: string; - onChange: (value: string) => void; - parsedFilters: ParsedFiltersWithMatches | null; - isSearching: boolean; - onSearch?: () => void; - placeholder?: string; - className?: string; - disabled?: boolean; -}; - -export function AssetSearchInput( - props: AssetSearchInputProps -): React.ReactElement { - const { className, disabled, isSearching } = props; - - const initialConfig = { - namespace: "AssetSearchInputLexical", - theme: { - paragraph: "m-0 text-[14px]", - text: { - base: "text-white", - }, - }, - onError: (error: Error) => { - console.error("Lexical error:", error); - }, - nodes: [KeywordNode], - editable: !disabled, - }; - - const isLoading = isSearching; - - return ( -
- {/* Left side: Search icon + Lexical editor */} -
- - -
- - - -
- - {/* Loading indicator - positioned after text */} - {isLoading && ( -
- )} -
-
- ); -} diff --git a/apps/web/src/components/asset-search/EditorContent.tsx b/apps/web/src/components/asset-search/EditorContent.tsx deleted file mode 100644 index a14adadb..00000000 --- a/apps/web/src/components/asset-search/EditorContent.tsx +++ /dev/null @@ -1,176 +0,0 @@ -"use client"; - -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { ContentEditable } from "@lexical/react/LexicalContentEditable"; -import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; -import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; -import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; -import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; -import { - $getRoot, - type EditorState, - type ElementNode, - TextNode, -} from "lexical"; -import { useEffect, useRef, useState } from "react"; -import type { KeywordVariant } from "@/lib/lexical/KeywordNode"; -import { KeywordPlugin } from "@/lib/lexical/KeywordPlugin"; -import type { ParsedFiltersWithMatches } from "@/lib/utils/parseAssetSearchQuery"; -import { SearchPlugin } from "./SearchPlugin"; - -type EditorContentProps = { - value: string; - onChange: (value: string) => void; - parsedFilters: ParsedFiltersWithMatches | null; - onSearch?: () => void; - placeholder?: string; - disabled?: boolean; -}; - -export function EditorContent({ - value, - onChange, - parsedFilters, - onSearch, - placeholder, -}: EditorContentProps): React.ReactElement { - const [editor] = useLexicalComposerContext(); - const [popup, setPopup] = useState<{ - text: string; - variant: KeywordVariant; - x: number; - y: number; - } | null>(null); - const editorRef = useRef(null); - - // Initialize editor with value - useEffect(() => { - if (!value) return; - - // Check if editor is available before updating - try { - editor.update(() => { - const root = $getRoot(); - const paragraph = root.getFirstChild(); - if (paragraph && paragraph.getType() === "paragraph") { - const paragraphElement = paragraph as ElementNode; - paragraphElement.clear(); - const textNode = new TextNode(value); - paragraphElement.append(textNode); - } - }); - } catch (error) { - // Silently handle editor not being ready - will be initialized by OnChangePlugin - console.debug("Editor not ready for initialization:", error); - } - }, []); // Only on mount - - // Listen for chip clicks - useEffect(() => { - const handleChipClick = (e: Event) => { - const customEvent = e as CustomEvent<{ - text: string; - variant: KeywordVariant; - element: HTMLElement; - }>; - const { text, variant, element } = customEvent.detail; - - // Get element position - const rect = element.getBoundingClientRect(); - setPopup({ - text, - variant, - x: rect.left, - y: rect.bottom + 4, - }); - }; - - const editorElement = editorRef.current; - if (editorElement) { - editorElement.addEventListener("keyword-chip-click", handleChipClick); - return () => { - editorElement.removeEventListener( - "keyword-chip-click", - handleChipClick - ); - }; - } - }, []); - - // Close popup on outside click - useEffect(() => { - if (!popup) return; - - const handleClickOutside = () => { - setPopup(null); - }; - - document.addEventListener("click", handleClickOutside); - return () => { - document.removeEventListener("click", handleClickOutside); - }; - }, [popup]); - - function handleChange(editorState: EditorState): void { - editorState.read(() => { - const root = $getRoot(); - const text = root.getTextContent(); - onChange(text); - }); - } - - return ( -
- - } - ErrorBoundary={LexicalErrorBoundary} - placeholder={ -
- {placeholder} -
- } - /> - - - - - - {/* Popup */} - {popup && ( -
e.stopPropagation()} - style={{ - left: `${popup.x}px`, - top: `${popup.y}px`, - }} - > -
-
Filter Details
-
-
- Type: - {popup.variant} -
-
- Value: - {popup.text} -
-
-
-
- )} -
- ); -} diff --git a/apps/web/src/components/asset-search/SearchPlugin.tsx b/apps/web/src/components/asset-search/SearchPlugin.tsx deleted file mode 100644 index f2a05d02..00000000 --- a/apps/web/src/components/asset-search/SearchPlugin.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client"; - -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND } from "lexical"; -import { useEffect } from "react"; - -type SearchPluginProps = { - onSearch?: () => void; -}; - -export function SearchPlugin({ onSearch }: SearchPluginProps): null { - const [editor] = useLexicalComposerContext(); - - useEffect( - () => - editor.registerCommand( - KEY_ENTER_COMMAND, - (event) => { - event?.preventDefault(); - onSearch?.(); - return true; - }, - COMMAND_PRIORITY_HIGH - ), - [editor, onSearch] - ); - - return null; -} diff --git a/apps/web/src/components/asset-search/asset-search.tsx b/apps/web/src/components/asset-search/asset-search.tsx deleted file mode 100644 index 14416be2..00000000 --- a/apps/web/src/components/asset-search/asset-search.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import { useAssetSearch } from "@/hooks/useAssetSearch"; -import type { SourceType } from "@/lib/utils/parseAssetSearchQuery"; -import { AssetSearchInput } from "./AssetSearchInput"; - -type AssetSearchProps = { - placeholder?: string; - className?: string; - /** - * Restrict parsing to specific source types (e.g., ["TILT", "TILT_INDEX"]) - * If not provided, all source types are allowed - */ - allowedSourceTypes?: readonly SourceType[]; -} & ( - | { - /** - * Provide search state from useAssetSearch hook for controlled usage. - * Use this when the parent needs access to search state (results, loading, etc). - */ - searchState: ReturnType; - limit?: never; - } - | { - /** - * Let the component manage its own search state internally. - * Use this for standalone search inputs that don't need to expose state. - */ - searchState?: never; - /** - * Maximum number of results to return - * @default 20 - */ - limit?: number; - } -); - -/** - * Asset search component with built-in query parsing and search execution. - * Can be used in controlled mode (with searchState from useAssetSearch) or - * uncontrolled mode (manages its own state internally). - * - * @example - * ```tsx - * // Controlled mode - parent manages state - * function MyPage() { - * const searchState = useAssetSearch(); - * return ( - * <> - * - *
Results: {searchState.results.length}
- * - * ); - * } - * - * // Uncontrolled mode - component manages state internally - * function MyPage() { - * return ; - * } - * ``` - */ -export function AssetSearch(props: AssetSearchProps): React.ReactElement { - const { - placeholder = "What are you looking for?", - className, - allowedSourceTypes, - } = props; - - // Always call the hook (rules of hooks), but only use it if not in controlled mode - const internalSearchState = useAssetSearch({ - limit: "limit" in props ? props.limit : undefined, - allowedSourceTypes, - }); - - // Use external state if provided, otherwise use internal state - const searchState = - "searchState" in props && props.searchState - ? props.searchState - : internalSearchState; - - const { query, setQuery, parsedFilters, isSearching, executeSearch } = - searchState; - - async function handleSearch(): Promise { - await executeSearch(); - } - - return ( - - ); -} diff --git a/apps/web/src/components/custom/CreateAssetClassModal.tsx b/apps/web/src/components/custom/CreateAssetClassModal.tsx index 17fe736d..563787e2 100644 --- a/apps/web/src/components/custom/CreateAssetClassModal.tsx +++ b/apps/web/src/components/custom/CreateAssetClassModal.tsx @@ -7,7 +7,7 @@ import type { AssetClassRead, AssetClassWrite } from "@tilt/tilt-backend-api"; import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { toastFactory } from "@/components/ui/toast-factory"; -import { useAssetSearch } from "@/hooks/useAssetSearch"; +import { useTokenAssetSearch } from "@/hooks/useTokenAssetSearch"; import { AlternativeEtfSearchStep, BenchmarkSearchStep, @@ -44,12 +44,12 @@ export function CreateAssetClassModal({ "select-benchmark" | "create-class" | "select-alternative" >("select-benchmark"); - const searchState = useAssetSearch({ + const searchState = useTokenAssetSearch({ limit: 100, allowedSourceTypes: ["TILT_INDEX", "ETF"] as const, }); - const alternativeEtfSearchState = useAssetSearch({ + const alternativeEtfSearchState = useTokenAssetSearch({ limit: 100, allowedSourceTypes: ["ETF"] as const, }); diff --git a/apps/web/src/components/custom/EditAssetClassModal.tsx b/apps/web/src/components/custom/EditAssetClassModal.tsx index c3003aac..7a5a82e4 100644 --- a/apps/web/src/components/custom/EditAssetClassModal.tsx +++ b/apps/web/src/components/custom/EditAssetClassModal.tsx @@ -11,7 +11,7 @@ import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { browserClient } from "@/api/browserClient"; import { toastFactory } from "@/components/ui/toast-factory"; -import { useAssetSearch } from "@/hooks/useAssetSearch"; +import { useTokenAssetSearch } from "@/hooks/useTokenAssetSearch"; import { AlternativeEtfSearchStep, type AssetClassFormValues, @@ -46,12 +46,12 @@ export function EditAssetClassModal({ "edit-class" | "select-benchmark" | "select-alternative" >("edit-class"); - const benchmarkSearchState = useAssetSearch({ + const benchmarkSearchState = useTokenAssetSearch({ limit: 100, allowedSourceTypes: ["TILT_INDEX", "ETF"] as const, }); - const alternativeEtfSearchState = useAssetSearch({ + const alternativeEtfSearchState = useTokenAssetSearch({ limit: 100, allowedSourceTypes: ["ETF"] as const, }); diff --git a/apps/web/src/components/custom/SelectOrCreateAssetClassModal.tsx b/apps/web/src/components/custom/SelectOrCreateAssetClassModal.tsx index 47f6c3e9..1bbdbeea 100644 --- a/apps/web/src/components/custom/SelectOrCreateAssetClassModal.tsx +++ b/apps/web/src/components/custom/SelectOrCreateAssetClassModal.tsx @@ -7,7 +7,7 @@ import type { AssetClassRead, AssetClassWrite } from "@tilt/tilt-backend-api"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { toastFactory } from "@/components/ui/toast-factory"; -import { useAssetSearch } from "@/hooks/useAssetSearch"; +import { useTokenAssetSearch } from "@/hooks/useTokenAssetSearch"; import { type AssetClassFormValues, assetClassFormSchema, @@ -34,7 +34,7 @@ export function SelectOrCreateAssetClassModal({ ); const [step, setStep] = useState<"form" | "select-benchmark">("form"); - const searchState = useAssetSearch({ + const searchState = useTokenAssetSearch({ limit: 100, allowedSourceTypes: ["TILT_INDEX", "ETF"] as const, }); diff --git a/apps/web/src/components/custom/SelectTiltOrIndexModal.tsx b/apps/web/src/components/custom/SelectTiltOrIndexModal.tsx index a7ac4cf7..7435ecdb 100644 --- a/apps/web/src/components/custom/SelectTiltOrIndexModal.tsx +++ b/apps/web/src/components/custom/SelectTiltOrIndexModal.tsx @@ -3,9 +3,9 @@ import type { FundResult } from "@tilt/sig-api-client/schemas"; import { Button } from "@tilt/ui/components/button"; import { useEffect, useRef, useState } from "react"; -import { AssetSearch } from "@/components/asset-search/asset-search"; +import { TokenAssetSearch } from "@/components/store-search/TokenAssetSearch"; import { BaseModal } from "@/components/ui/base-modal"; -import { useAssetSearch } from "@/hooks/useAssetSearch"; +import { useTokenAssetSearch } from "@/hooks/useTokenAssetSearch"; import { SvgLogoT } from "../SvgLogoT"; type SelectTiltOrIndexModalProps = { @@ -29,14 +29,15 @@ export function SelectTiltOrIndexModal({ onRemove, existingTiltUuids, }: SelectTiltOrIndexModalProps) { - const searchState = useAssetSearch({ + const searchState = useTokenAssetSearch({ + facetSources: ["TILT_INDEX"], limit: 100, allowedSourceTypes: ["TILT", "TILT_INDEX"] as const, additionalFilters: { tilt_portfolio_published: true, }, }); - const { results, isSearching, query, setQuery } = searchState; + const { clearResults, results, isSearching, query } = searchState; const [currentPage, setCurrentPage] = useState(1); const [initialSelectedUuids, setInitialSelectedUuids] = useState( @@ -44,8 +45,8 @@ export function SelectTiltOrIndexModal({ ); const prevIsOpenRef = useRef(isOpen); - // Capture initial selected UUIDs when modal opens (freeze on open, don't update on selection changes) - // Also clear search query when modal closes to prevent Lexical editor errors + // Capture initial selected UUIDs when modal opens (freeze on open, don't update on selection changes). + // Clear the token search when the modal closes so the next open starts fresh. useEffect(() => { // Detect transition from closed to open if (isOpen && !prevIsOpenRef.current) { @@ -53,12 +54,12 @@ export function SelectTiltOrIndexModal({ } // Detect transition from open to closed if (!isOpen && prevIsOpenRef.current) { - setQuery(""); + clearResults(); } prevIsOpenRef.current = isOpen; // existingTiltUuids is intentionally omitted - we only want to capture it on open transition, not track changes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, setQuery]); + }, [isOpen, clearResults]); // Sort results: selected items first (based on initial state), then unselected const sortedResults = [...(results ?? [])].sort((a, b) => { @@ -127,7 +128,7 @@ export function SelectTiltOrIndexModal({
{/* Search Bar */}
-
-
- ; +type AssetSearchState = ReturnType; export type CreateAssetClassFormStepProps = { isOpen: boolean; diff --git a/apps/web/src/components/custom/edit-asset-class/AlternativeEtfSearchStep.tsx b/apps/web/src/components/custom/edit-asset-class/AlternativeEtfSearchStep.tsx index c01bea68..51092c7b 100644 --- a/apps/web/src/components/custom/edit-asset-class/AlternativeEtfSearchStep.tsx +++ b/apps/web/src/components/custom/edit-asset-class/AlternativeEtfSearchStep.tsx @@ -2,8 +2,8 @@ import { Form } from "@tilt/ui/components/form"; import { ArrowLeftIcon, LoadingIcon } from "@tilt/ui/icons"; -import { AssetSearch } from "@/components/asset-search/asset-search"; import { CustomizationDataTable } from "@/components/custom/CustomizationDataTable"; +import { TokenAssetSearch } from "@/components/store-search/TokenAssetSearch"; import { BaseModal } from "@/components/ui/base-modal"; import type { AlternativeEtfSearchStepProps } from "./types"; @@ -38,7 +38,7 @@ export function AlternativeEtfSearchStep({
-
- ; -type AssetSearchState = ReturnType; +type AssetSearchState = ReturnType; export type BenchmarkSearchStepProps = { isOpen: boolean; diff --git a/apps/web/src/components/custom/select-or-create-asset-class/BenchmarkSearchStep.tsx b/apps/web/src/components/custom/select-or-create-asset-class/BenchmarkSearchStep.tsx index 6a774828..83adb31d 100644 --- a/apps/web/src/components/custom/select-or-create-asset-class/BenchmarkSearchStep.tsx +++ b/apps/web/src/components/custom/select-or-create-asset-class/BenchmarkSearchStep.tsx @@ -1,8 +1,8 @@ "use client"; import { ArrowLeftIcon, LoadingIcon } from "@tilt/ui/icons"; -import { AssetSearch } from "@/components/asset-search/asset-search"; import { CustomizationDataTable } from "@/components/custom/CustomizationDataTable"; +import { TokenAssetSearch } from "@/components/store-search/TokenAssetSearch"; import { BaseModal } from "@/components/ui/base-modal"; import type { BenchmarkSearchStepProps } from "./types"; @@ -34,7 +34,7 @@ export function BenchmarkSearchStep({ } >
- ; -type AssetSearchState = ReturnType; +type AssetSearchState = ReturnType; export type SelectOrCreateAssetClassModalProps = { isOpen: boolean; diff --git a/apps/web/src/components/store-search/TokenSearchBar.tsx b/apps/web/src/components/store-search/TokenSearchBar.tsx index d973faf8..9fa6a6b8 100644 --- a/apps/web/src/components/store-search/TokenSearchBar.tsx +++ b/apps/web/src/components/store-search/TokenSearchBar.tsx @@ -90,6 +90,7 @@ export function TokenSearchBar({ setFocusedTokenIndex, openDropdown, closeDropdown, + catalogue, } = tokenState; useEffect(() => { @@ -162,7 +163,11 @@ export function TokenSearchBar({ (tokenId: string, newDisplayText: string | null) => { stopEditingToken(); if (newDisplayText) { - const newSuggestions = getTokenSuggestions(newDisplayText, tokens); + const newSuggestions = getTokenSuggestions( + newDisplayText, + tokens, + catalogue + ); if ( newSuggestions.length === 1 && newSuggestions[0].confidence >= 0.95 @@ -183,7 +188,7 @@ export function TokenSearchBar({ } inputRefs.current.get(activeSegmentId)?.focus(); }, - [stopEditingToken, updateToken, tokens, activeSegmentId] + [stopEditingToken, updateToken, tokens, catalogue, activeSegmentId] ); /** Focus a text segment's input, placing the cursor at a given position. */ @@ -225,6 +230,7 @@ export function TokenSearchBar({ recategorizeTokenId, constructionState, isConstructing, + catalogue, focusPrevToken, focusNextToken, removeToken, diff --git a/apps/web/src/components/store-search/use-search-keyboard.ts b/apps/web/src/components/store-search/use-search-keyboard.ts index 497a291d..efd65862 100644 --- a/apps/web/src/components/store-search/use-search-keyboard.ts +++ b/apps/web/src/components/store-search/use-search-keyboard.ts @@ -5,6 +5,7 @@ import { getScopedValueSuggestions, shouldSmartSpaceFinalize, } from "./facet-construction"; +import type { FilterDefinition } from "./filter-catalogue"; import { findTextFromFocusedToken, getTokenSegmentInfo, @@ -30,6 +31,7 @@ type SearchKeyboardDeps = { recategorizeTokenId: string | null; constructionState: TokenConstructionState; isConstructing: boolean; + catalogue?: FilterDefinition[]; focusPrevToken: () => void; focusNextToken: () => void; @@ -71,6 +73,7 @@ export function useSearchKeyboard(deps: SearchKeyboardDeps) { recategorizeTokenId, constructionState, isConstructing, + catalogue, focusPrevToken, focusNextToken, removeToken, @@ -152,7 +155,8 @@ export function useSearchKeyboard(deps: SearchKeyboardDeps) { const scoped = getScopedValueSuggestions( trimmed, constructionState.category, - tokens + tokens, + catalogue ); if (shouldSmartSpaceFinalize(scoped, trimmed)) { e.preventDefault(); @@ -298,6 +302,7 @@ export function useSearchKeyboard(deps: SearchKeyboardDeps) { recategorizeTokenId, constructionState, isConstructing, + catalogue, activeSegmentId, segments, focusPrevToken, diff --git a/apps/web/src/components/store-search/use-store-facets-catalogue.ts b/apps/web/src/components/store-search/use-store-facets-catalogue.ts index 34fc2476..487c11e9 100644 --- a/apps/web/src/components/store-search/use-store-facets-catalogue.ts +++ b/apps/web/src/components/store-search/use-store-facets-catalogue.ts @@ -13,7 +13,7 @@ import { labelForStrategyCode, normalizeStrategyCode, } from "@tilt/store-search-core"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { cloneFilterValue, normalizeDefault, @@ -45,6 +45,18 @@ type StoreFacetsSnapshot = { type UseStoreFacetsCatalogueOptions = { sources?: FundsSearchFilters["source"]; minCount?: number; + /** + * `TILT` is a client-only source chip for inbox-published Tilts. Store pages + * need it, but narrower pickers such as ETF/index selectors should not offer + * a chip that their `useAssetSearch` source filter will reject. + */ + includeTiltSource?: boolean; + /** + * Existing store surfaces still read the shared singleton catalogue. Scoped + * per-instance searches should opt out and consume the returned catalogue + * instead so they do not alter unrelated search bars. + */ + registerGlobal?: boolean; }; function snapshotIsValid(value: unknown): value is StoreFacetsSnapshot { @@ -156,10 +168,10 @@ function buildCategoryDefinition( }; } -function buildDynamicCatalogue( - facets: FundSearchFacetsResponse["facets"] -): FilterDefinition[] { - const rawSourceValues = uniqueNormalizedValues(facets.source, normalizeUpper); +function buildSourceValues( + rawSourceValues: readonly string[], + { includeTiltSource }: { includeTiltSource: boolean } +): string[] { // EXTERNAL_INDEX is SIG's internal name for third-party indices — they are the // same "Index" concept as TILT_INDEX. Always include it alongside TILT_INDEX so // both codes are sent as filters when the user selects "Index", even when the @@ -168,13 +180,46 @@ function buildDynamicCatalogue( rawSourceValues.includes("TILT_INDEX") && !rawSourceValues.includes("EXTERNAL_INDEX") ? [...rawSourceValues, "EXTERNAL_INDEX"] - : rawSourceValues; + : [...rawSourceValues]; + // TILT represents inbox-published Tilts — a client-side-only concept that - // SIG's facets API will never return. Always inject it so the chip is - // available regardless of what SIG reports. - const sourceValues = sourceWithExternal.includes("TILT") - ? sourceWithExternal - : [...sourceWithExternal, "TILT"]; + // SIG's facets API will never return. Store search needs it, but narrower + // selectors should not offer a chip that their source filter will reject. + return includeTiltSource && !sourceWithExternal.includes("TILT") + ? [...sourceWithExternal, "TILT"] + : sourceWithExternal; +} + +function buildFallbackCatalogue( + sourceValues: readonly string[], + { includeTiltSource }: { includeTiltSource: boolean } +): FilterDefinition[] { + const normalizedSourceValues = uniqueNormalizedValues( + sourceValues, + normalizeUpper + ); + + return BASELINE_FILTER_CATALOGUE.map((definition) => + definition.category === "source" + ? buildCategoryDefinition( + "source", + buildSourceValues(normalizedSourceValues, { includeTiltSource }) + ) + : { + ...definition, + values: definition.values.map(cloneFilterValue), + } + ); +} + +function buildDynamicCatalogue( + facets: FundSearchFacetsResponse["facets"], + { includeTiltSource }: { includeTiltSource: boolean } +): FilterDefinition[] { + const rawSourceValues = uniqueNormalizedValues(facets.source, normalizeUpper); + const sourceValues = buildSourceValues(rawSourceValues, { + includeTiltSource, + }); const issuerValues = uniqueNormalizedValues(facets.issuer, normalizeDefault); const currencyValues = uniqueNormalizedValues( facets.fund_currency, @@ -227,29 +272,45 @@ function writeSnapshot(snapshot: StoreFacetsSnapshot): void { export function useStoreFacetsCatalogue( options: UseStoreFacetsCatalogueOptions = {} ) { - const { sources = ["ETF", "TILT_INDEX", "MF"], minCount = 1 } = options; + const { + includeTiltSource = true, + minCount = 1, + registerGlobal = true, + sources = ["ETF", "TILT_INDEX", "MF"], + } = options; - const normalizedSources = useMemo( - () => [...new Set(sources.map((source) => source.toUpperCase()))].sort(), + const sourcesKey = useMemo( + () => + [...new Set(sources.map((source) => source.toUpperCase()))] + .sort() + .join(","), [sources] ); - - const sourcesKey = useMemo( - () => normalizedSources.join(","), - [normalizedSources] + const normalizedSources = useMemo( + () => (sourcesKey ? sourcesKey.split(",") : []), + [sourcesKey] ); + const fallbackCatalogue = useMemo( + () => buildFallbackCatalogue(normalizedSources, { includeTiltSource }), + [normalizedSources, includeTiltSource] + ); + const [catalogue, setCatalogue] = + useState(fallbackCatalogue); // Hydrate from localStorage whenever sourcesKey changes (including mount). // If no matching snapshot exists, reset to baseline so the singleton never // serves stale facets from a previous source set. useEffect(() => { const snapshot = readSnapshot(); - if (snapshot && snapshot.sourcesKey === sourcesKey) { - setFilterCatalogue(buildDynamicCatalogue(snapshot.facets)); - } else { - setFilterCatalogue(BASELINE_FILTER_CATALOGUE); + const nextCatalogue = + snapshot && snapshot.sourcesKey === sourcesKey + ? buildDynamicCatalogue(snapshot.facets, { includeTiltSource }) + : fallbackCatalogue; + setCatalogue(nextCatalogue); + if (registerGlobal) { + setFilterCatalogue(nextCatalogue); } - }, [sourcesKey]); + }, [sourcesKey, fallbackCatalogue, includeTiltSource, registerGlobal]); const query = useQuery({ queryKey: ["store-search", "facets", sourcesKey, minCount], @@ -279,14 +340,20 @@ export function useStoreFacetsCatalogue( useEffect(() => { if (!query.data) return; - setFilterCatalogue(buildDynamicCatalogue(query.data.facets)); + const nextCatalogue = buildDynamicCatalogue(query.data.facets, { + includeTiltSource, + }); + setCatalogue(nextCatalogue); + if (registerGlobal) { + setFilterCatalogue(nextCatalogue); + } writeSnapshot({ version: STORE_FACETS_STORAGE_VERSION, sourcesKey, fetchedAt: new Date().toISOString(), facets: query.data.facets, }); - }, [query.data, sourcesKey]); + }, [query.data, sourcesKey, includeTiltSource, registerGlobal]); - return query; + return { ...query, catalogue }; } diff --git a/apps/web/src/components/tilt-pro/IndexStoreToolbar.tsx b/apps/web/src/components/tilt-pro/IndexStoreToolbar.tsx index 0670cf93..62740699 100644 --- a/apps/web/src/components/tilt-pro/IndexStoreToolbar.tsx +++ b/apps/web/src/components/tilt-pro/IndexStoreToolbar.tsx @@ -1,13 +1,13 @@ "use client"; -import { AssetSearch } from "@/components/asset-search/asset-search"; +import { TokenAssetSearch } from "@/components/store-search/TokenAssetSearch"; import { IndexStoreSortSelector } from "@/components/tilt-pro/IndexStoreSortSelector"; import { IndexStoreViewToggle } from "@/components/tilt-pro/IndexStoreViewToggle"; -import type { useAssetSearch } from "@/hooks/useAssetSearch"; +import type { useTokenAssetSearch } from "@/hooks/useTokenAssetSearch"; import type { IndexStoreSortOption } from "@/lib/utils/index-store-converters"; type IndexStoreToolbarProps = { - searchState: ReturnType; + searchState: ReturnType; selectedView: string; onViewChange: (view: string) => void; hideSearch?: boolean; @@ -30,7 +30,7 @@ export function IndexStoreToolbar({ return (
{!hideSearch && ( - diff --git a/apps/web/src/hooks/useSearchTokens.ts b/apps/web/src/hooks/useSearchTokens.ts index ba7a6478..9e238407 100644 --- a/apps/web/src/hooks/useSearchTokens.ts +++ b/apps/web/src/hooks/useSearchTokens.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback, useMemo, useState } from "react"; +import type { FilterDefinition } from "@/components/store-search/filter-catalogue"; import { buildParsedFilters, buildSegmentsFromItems, @@ -28,6 +29,8 @@ import { useSuggestions } from "./useSearchTokens/useSuggestions"; import { useTokenConstruction } from "./useSearchTokens/useTokenConstruction"; type UseSearchTokensReturn = { + catalogue?: FilterDefinition[]; + // Segment state segments: Segment[]; activeSegmentId: string; @@ -92,13 +95,18 @@ type UseSearchTokensReturn = { loadItems: (items: DecodedItem[]) => void; }; +type UseSearchTokensOptions = { + blockedCategories?: FilterCategory[]; + catalogue?: FilterDefinition[]; +}; + function makeInitialSegments(): Segment[] { return [{ type: "text", id: generateSegmentId(), value: "" }]; } export function useSearchTokens( initialItems?: DecodedItem[], - options?: { blockedCategories?: FilterCategory[] } + options?: UseSearchTokensOptions ): UseSearchTokensReturn { const [segments, setSegments] = useState(() => initialItems ? buildSegmentsFromItems(initialItems) : makeInitialSegments() @@ -164,6 +172,7 @@ export function useSearchTokens( dismissedTexts, constructionState: construction.constructionState, blockedCategories: options?.blockedCategories, + catalogue: options?.catalogue, setSegments, setActiveSegmentId, setDropdownOpen, @@ -177,6 +186,7 @@ export function useSearchTokens( recategorizeTokenId, constructionState: construction.constructionState, blockedCategories: options?.blockedCategories, + catalogue: options?.catalogue, }); // ─── Segment mutations ────────────────────────────────────────────────────── @@ -351,6 +361,7 @@ export function useSearchTokens( tokens, activeTextSegment, addToken, + catalogue: options?.catalogue, setSegments, setDropdownOpen, }); @@ -440,6 +451,7 @@ export function useSearchTokens( ); return { + catalogue: options?.catalogue, segments, activeSegmentId, setActiveSegmentId, diff --git a/apps/web/src/hooks/useSearchTokens/useAutoTokenizeEffect.ts b/apps/web/src/hooks/useSearchTokens/useAutoTokenizeEffect.ts index 1866001e..aa77f5e9 100644 --- a/apps/web/src/hooks/useSearchTokens/useAutoTokenizeEffect.ts +++ b/apps/web/src/hooks/useSearchTokens/useAutoTokenizeEffect.ts @@ -2,6 +2,7 @@ import { ensureDefaultPeriod } from "@tilt/store-search-core/facets-utils"; import { useEffect, useRef } from "react"; +import type { FilterDefinition } from "@/components/store-search/filter-catalogue"; import { getTokenSuggestions, shouldAutoTokenize, @@ -25,6 +26,7 @@ type UseAutoTokenizeEffectParams = { dismissedTexts: Set; constructionState: TokenConstructionState; blockedCategories?: FilterCategory[]; + catalogue?: FilterDefinition[]; setSegments: React.Dispatch>; setActiveSegmentId: (id: string) => void; setDropdownOpen: (open: boolean) => void; @@ -45,6 +47,7 @@ export function useAutoTokenizeEffect({ dismissedTexts, constructionState, blockedCategories, + catalogue, setSegments, setActiveSegmentId, setDropdownOpen, @@ -66,7 +69,7 @@ export function useAutoTokenizeEffect({ const fullSuggestions = getTokenSuggestions( trimmed, tokens, - undefined, + catalogue, blockedCategories ); const fullToken = shouldAutoTokenize(fullSuggestions, trimmed); @@ -110,7 +113,7 @@ export function useAutoTokenizeEffect({ const suggestions = getTokenSuggestions( phrase, tokens, - undefined, + catalogue, blockedCategories ); const autoToken = shouldAutoTokenize(suggestions, phrase); @@ -153,6 +156,7 @@ export function useAutoTokenizeEffect({ dismissedTexts, constructionState, blockedCategories, + catalogue, setSegments, setActiveSegmentId, setDropdownOpen, diff --git a/apps/web/src/hooks/useSearchTokens/useConstructionCallbacks.ts b/apps/web/src/hooks/useSearchTokens/useConstructionCallbacks.ts index 4d822fd6..7a17b426 100644 --- a/apps/web/src/hooks/useSearchTokens/useConstructionCallbacks.ts +++ b/apps/web/src/hooks/useSearchTokens/useConstructionCallbacks.ts @@ -12,6 +12,7 @@ import { getOperatorsForCategory, getScopedValueSuggestions, } from "@/components/store-search/facet-construction"; +import type { FilterDefinition } from "@/components/store-search/filter-catalogue"; import { canSerializeValueToken } from "@/components/store-search/token-serialization"; import { CATEGORY_TO_VARIANT, @@ -33,6 +34,7 @@ type ConstructionCallbacksDeps = { tokens: SearchToken[]; activeTextSegment: TextSegment | null; addToken: (suggestion: TokenSuggestion) => void; + catalogue?: FilterDefinition[]; setSegments: React.Dispatch>; setDropdownOpen: (open: boolean) => void; }; @@ -65,6 +67,7 @@ export function useConstructionCallbacks({ tokens, activeTextSegment, addToken, + catalogue, setSegments, setDropdownOpen, }: ConstructionCallbacksDeps) { @@ -225,7 +228,8 @@ export function useConstructionCallbacks({ const scoped = getScopedValueSuggestions( trimmed, category, - tokensRef.current + tokensRef.current, + catalogue ); if ( scoped.length > 0 && @@ -280,7 +284,7 @@ export function useConstructionCallbacks({ }; addToken(suggestion); construction.reset(); - }, [addToken, construction, activeTextSegment, setSegments]); + }, [addToken, construction, activeTextSegment, setSegments, catalogue]); return { handleDropdownItemSelect, finalizeConstruction }; } diff --git a/apps/web/src/hooks/useSearchTokens/useSuggestions.ts b/apps/web/src/hooks/useSearchTokens/useSuggestions.ts index c36ad153..f4ce29d3 100644 --- a/apps/web/src/hooks/useSearchTokens/useSuggestions.ts +++ b/apps/web/src/hooks/useSearchTokens/useSuggestions.ts @@ -1,6 +1,9 @@ import { useMemo } from "react"; import { getDropdownItems } from "@/components/store-search/facet-construction"; -import { getFilterCatalogueVersion } from "@/components/store-search/filter-catalogue"; +import { + type FilterDefinition, + getFilterCatalogueVersion, +} from "@/components/store-search/filter-catalogue"; import { getScopedValueSuggestions, getTokenSuggestions, @@ -24,17 +27,19 @@ export function useSuggestions({ recategorizeTokenId, constructionState, blockedCategories, + catalogue, }: { inputValue: string; tokens: SearchToken[]; recategorizeTokenId: string | null; constructionState: TokenConstructionState; blockedCategories?: FilterCategory[]; + catalogue?: FilterDefinition[]; }): { suggestions: TokenSuggestion[]; dropdownItems: DropdownItem[]; } { - const catalogueVersion = getFilterCatalogueVersion(); + const catalogueVersion = catalogue ? 0 : getFilterCatalogueVersion(); /** Legacy flat suggestions — used for recategorize and auto-tokenize compatibility */ const suggestions = useMemo(() => { @@ -49,14 +54,14 @@ export function useSuggestions({ "", token.category, otherTokens, - undefined, + catalogue, blockedCategories ); } return getTokenSuggestions( token.displayText, otherTokens, - undefined, + catalogue, blockedCategories ); } @@ -64,7 +69,7 @@ export function useSuggestions({ return getTokenSuggestions( inputValue, tokens, - undefined, + catalogue, blockedCategories ); }, [ @@ -72,6 +77,7 @@ export function useSuggestions({ tokens, recategorizeTokenId, catalogueVersion, + catalogue, blockedCategories, ]); @@ -83,7 +89,7 @@ export function useSuggestions({ inputValue, tokens, constructionState, - undefined, + catalogue, blockedCategories ); }, [ @@ -92,6 +98,7 @@ export function useSuggestions({ constructionState, recategorizeTokenId, catalogueVersion, + catalogue, blockedCategories, ]); diff --git a/apps/web/src/lib/lexical/KeywordNode.tsx b/apps/web/src/lib/lexical/KeywordNode.tsx deleted file mode 100644 index d2ee8a0f..00000000 --- a/apps/web/src/lib/lexical/KeywordNode.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import type { - DOMConversionMap, - DOMConversionOutput, - DOMExportOutput, - EditorConfig, - LexicalNode, - NodeKey, - SerializedTextNode, - Spread, -} from "lexical"; -import { $applyNodeReplacement, TextNode } from "lexical"; - -export type KeywordVariant = - | "type" - | "vendor" - | "currency" - | "sector" - | "theme" - | "country" - | "aum"; - -type SerializedKeywordNode = Spread< - { - variant: KeywordVariant; - }, - SerializedTextNode ->; - -const VARIANT_STYLES: Record< - KeywordVariant, - { bg: string; text: string; border: string } -> = { - type: { - bg: "rgba(99,223,21,0.08)", - text: "#c2f6a2", - border: "#63df15", - }, - vendor: { - bg: "rgba(4,114,241,0.08)", - text: "#9bc9fd", - border: "#0472f1", - }, - currency: { - bg: "rgba(118,40,205,0.08)", - text: "#caa9ef", - border: "#7628cd", - }, - sector: { - bg: "rgba(233,12,38,0.08)", - text: "#fa9ea9", - border: "#e90c26", - }, - theme: { - bg: "rgba(237,111,8,0.08)", - text: "#fcc79c", - border: "#ed6f08", - }, - country: { - bg: "rgba(6,239,215,0.08)", - text: "#9cfcf3", - border: "#06efd7", - }, - aum: { - bg: "rgba(24,220,103,0.08)", - text: "#a3f5c4", - border: "#18dc67", - }, -}; - -/** - * Custom Lexical node for keyword chips - * Extends TextNode to allow inline rendering with special styling - */ -export class KeywordNode extends TextNode { - __variant: KeywordVariant; - - static getType(): string { - return "keyword"; - } - - static clone(node: KeywordNode): KeywordNode { - return new KeywordNode(node.__text, node.__variant, node.__key); - } - - // Constructor with zero required arguments (Lexical best practice for collaboration support) - constructor(text?: string, variant?: KeywordVariant, key?: NodeKey) { - super(text || "", key); - this.__variant = variant || "type"; - } - - getVariant(): KeywordVariant { - return this.getLatest().__variant; - } - - setVariant(variant: KeywordVariant): void { - const writable = this.getWritable(); - writable.__variant = variant; - } - - createDOM(config: EditorConfig): HTMLElement { - const element = document.createElement("span"); - const styles = VARIANT_STYLES[this.__variant]; - - element.className = - "inline-flex items-center justify-center gap-[10px] px-[3px] pb-[3px] pt-px font-normal text-[14px] leading-normal whitespace-pre"; - - element.style.backgroundColor = styles.bg; - element.style.color = styles.text; - element.style.borderBottom = `0.5px solid ${styles.border}`; - - element.textContent = this.__text; - - return element; - } - - updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { - const isUpdated = super.updateDOM(prevNode, dom, config); - - if ($isKeywordNode(prevNode) && prevNode.__variant !== this.__variant) { - const styles = VARIANT_STYLES[this.__variant]; - dom.style.backgroundColor = styles.bg; - dom.style.color = styles.text; - dom.style.borderBottom = `0.5px solid ${styles.border}`; - } - - return isUpdated; - } - - exportDOM(): DOMExportOutput { - const element = document.createElement("span"); - const styles = VARIANT_STYLES[this.__variant]; - - element.className = - "inline-flex items-center justify-center gap-[10px] px-2 pb-[3px] pt-px font-normal text-[14px] leading-normal whitespace-pre mx-2"; - - element.style.backgroundColor = styles.bg; - element.style.color = styles.text; - element.style.borderBottom = `0.5px solid ${styles.border}`; - - element.textContent = this.__text; - - return { element }; - } - - static importDOM(): DOMConversionMap | null { - return { - span: (node: Node) => ({ - conversion: convertKeywordElement, - priority: 1, - }), - }; - } - - static importJSON(serializedNode: SerializedKeywordNode): KeywordNode { - const node = $createKeywordNode( - serializedNode.text, - serializedNode.variant - ); - node.setFormat(serializedNode.format); - node.setDetail(serializedNode.detail); - node.setMode(serializedNode.mode); - node.setStyle(serializedNode.style); - return node; - } - - exportJSON(): SerializedKeywordNode { - return { - ...super.exportJSON(), - variant: this.__variant, - type: "keyword", - version: 1, - }; - } - - // Make the node non-editable (user can't edit chip content directly) - isSegmented(): boolean { - return true; - } -} - -function convertKeywordElement(domNode: Node): DOMConversionOutput | null { - const element = domNode as HTMLElement; - const text = element.textContent; - if (text !== null) { - const node = $createKeywordNode(text, "type"); // Default variant - return { node }; - } - return null; -} - -export function $createKeywordNode( - text: string, - variant: KeywordVariant -): KeywordNode { - return $applyNodeReplacement(new KeywordNode(text, variant)); -} - -function $isKeywordNode( - node: LexicalNode | null | undefined -): node is KeywordNode { - return node instanceof KeywordNode; -} diff --git a/apps/web/src/lib/lexical/KeywordPlugin.tsx b/apps/web/src/lib/lexical/KeywordPlugin.tsx deleted file mode 100644 index 93d022c5..00000000 --- a/apps/web/src/lib/lexical/KeywordPlugin.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { registerLexicalTextEntity } from "@lexical/text"; -import type { TextNode } from "lexical"; -import { useEffect } from "react"; -import type { ParsedFiltersWithMatches } from "@/lib/utils/parseAssetSearchQuery"; -import type { KeywordVariant } from "./KeywordNode"; -import { $createKeywordNode, KeywordNode } from "./KeywordNode"; - -type KeywordPluginProps = { - parsedFilters: ParsedFiltersWithMatches | null; -}; - -/** - * Plugin that transforms text into KeywordNodes based on parsed filters - * Uses Lexical's registerLexicalTextEntity for efficient, automatic text-to-node conversion - */ -export function KeywordPlugin({ parsedFilters }: KeywordPluginProps) { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - if (!parsedFilters) { - return; - } - - // Build a map of all filter matches for quick lookup - const matchMap = new Map< - string, - { filterType: KeywordVariant; matchedText: string } - >(); - - // Collect all matched texts from all filter types - const allMatches: Array<{ text: string; filterType: KeywordVariant }> = []; - - if (parsedFilters.source) { - for (const source of parsedFilters.source) { - allMatches.push({ - text: source.matchedText, - filterType: "type", - }); - matchMap.set(source.matchedText.toLowerCase(), { - filterType: "type", - matchedText: source.matchedText, - }); - } - } - - if (parsedFilters.issuer) { - for (const issuer of parsedFilters.issuer) { - allMatches.push({ - text: issuer.matchedText, - filterType: "vendor", - }); - matchMap.set(issuer.matchedText.toLowerCase(), { - filterType: "vendor", - matchedText: issuer.matchedText, - }); - } - } - - if (parsedFilters.listing_currency) { - for (const currency of parsedFilters.listing_currency) { - allMatches.push({ - text: currency.matchedText, - filterType: "currency", - }); - matchMap.set(currency.matchedText.toLowerCase(), { - filterType: "currency", - matchedText: currency.matchedText, - }); - } - } - - if (parsedFilters.country) { - for (const country of parsedFilters.country) { - allMatches.push({ - text: country.matchedText, - filterType: "country", - }); - matchMap.set(country.matchedText.toLowerCase(), { - filterType: "country", - matchedText: country.matchedText, - }); - } - } - - if (parsedFilters.aum?.min) { - allMatches.push({ - text: parsedFilters.aum.min.matchedText, - filterType: "aum", - }); - matchMap.set(parsedFilters.aum.min.matchedText.toLowerCase(), { - filterType: "aum", - matchedText: parsedFilters.aum.min.matchedText, - }); - } - - if (parsedFilters.aum?.max) { - allMatches.push({ - text: parsedFilters.aum.max.matchedText, - filterType: "aum", - }); - matchMap.set(parsedFilters.aum.max.matchedText.toLowerCase(), { - filterType: "aum", - matchedText: parsedFilters.aum.max.matchedText, - }); - } - - if (allMatches.length === 0) { - return; - } - - // Create a regex pattern that matches any of the filter texts - // Use lookahead/lookbehind for boundaries to handle special characters like $ - const escapedPatterns = allMatches.map((match) => - match.text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - ); - // Match at word boundaries OR when preceded/followed by non-alphanumeric characters - const pattern = new RegExp( - `(^|\\s|[^a-zA-Z0-9])(${escapedPatterns.join("|")})(?=\\s|[^a-zA-Z0-9]|$)`, - "gi" - ); - - // Register text entity transform using Lexical's built-in utility - // This automatically handles text → KeywordNode conversion efficiently - const unregisterFns = registerLexicalTextEntity( - editor, - (text) => { - // getMatch function: find matches in the text - const matches: Array<{ start: number; end: number }> = []; - let match; - - // Reset regex lastIndex for global flag - pattern.lastIndex = 0; - - while ((match = pattern.exec(text)) !== null) { - // match[0] = full match including boundary - // match[1] = boundary character before (could be empty for ^) - // match[2] = actual matched text - const boundaryLength = match[1].length; - matches.push({ - start: match.index + boundaryLength, - end: match.index + boundaryLength + match[2].length, - }); - } - - return matches.length > 0 ? matches[0] : null; - }, - KeywordNode, - (textNode: TextNode) => { - // createNode function: create KeywordNode from matched TextNode - const text = textNode.getTextContent(); - const matchInfo = matchMap.get(text.toLowerCase()); - - if (!matchInfo) { - // Fallback to default if not found - return $createKeywordNode(text, "type"); - } - - return $createKeywordNode(matchInfo.matchedText, matchInfo.filterType); - } - ); - - // Cleanup function - unregisterFns is an array of cleanup functions - return () => { - for (const unregister of unregisterFns) { - unregister(); - } - }; - }, [editor, parsedFilters]); - - return null; -} diff --git a/package.json b/package.json index bdb8eabc..2abf64cf 100644 --- a/package.json +++ b/package.json @@ -78,8 +78,6 @@ "@knocklabs/client": "0.14.9", "@knocklabs/node": "^1.10.3", "@knocklabs/react": "0.7.15", - "@lexical/react": "^0.32.1", - "@lexical/text": "^0.32.1", "@lezer/highlight": "^1.2.3", "@mendable/firecrawl-js": "^4.13.2", "@next/bundle-analyzer": "16.2.3", @@ -164,7 +162,6 @@ "jsdom": "^26.1.0", "jsurl2": "^2.2.0", "katex": "^0.16.22", - "lexical": "^0.32.1", "lightweight-charts": "^5.1.0", "luxon": "^3.6.1", "nanoid": "^5.1.6", diff --git a/yarn.lock b/yarn.lock index a6d86c55..b1681d94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4272,7 +4272,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/react-dom@npm:^2.0.0, @floating-ui/react-dom@npm:^2.1.6": +"@floating-ui/react-dom@npm:^2.0.0": version: 2.1.6 resolution: "@floating-ui/react-dom@npm:2.1.6" dependencies: @@ -4284,20 +4284,6 @@ __metadata: languageName: node linkType: hard -"@floating-ui/react@npm:^0.27.8": - version: 0.27.16 - resolution: "@floating-ui/react@npm:0.27.16" - dependencies: - "@floating-ui/react-dom": "npm:^2.1.6" - "@floating-ui/utils": "npm:^0.2.10" - tabbable: "npm:^6.0.0" - peerDependencies: - react: ">=17.0.0" - react-dom: ">=17.0.0" - checksum: 10c0/a026266d8875e69de1ac1e1a00588660c8ee299c1e7d067c5c5fd1d69a46fd10acff5dd6cb66c3fe40a3347b443234309ba95f5b33d49059d0cda121f558f566 - languageName: node - linkType: hard - "@floating-ui/utils@npm:^0.2.10": version: 0.2.10 resolution: "@floating-ui/utils@npm:0.2.10" @@ -4914,258 +4900,6 @@ __metadata: languageName: node linkType: hard -"@lexical/clipboard@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/clipboard@npm:0.32.1" - dependencies: - "@lexical/html": "npm:0.32.1" - "@lexical/list": "npm:0.32.1" - "@lexical/selection": "npm:0.32.1" - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - checksum: 10c0/679b97ea1a605ef7432808d468b4c9ca49746208074eee8b3018ae27a3b6852643f94263219cde8781ac4cfeb8c896b26c0b9e3a1d027526883b61bb82910c11 - languageName: node - linkType: hard - -"@lexical/code@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/code@npm:0.32.1" - dependencies: - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - prismjs: "npm:^1.30.0" - checksum: 10c0/4658b03ddd7accd06e97e386b6731d861dc71bf7f84b24d816dcadad43c877f74f012d9435d00aefc7fdedc3bebe5f1e3bf50d5555efd554c855c27e829100e7 - languageName: node - linkType: hard - -"@lexical/devtools-core@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/devtools-core@npm:0.32.1" - dependencies: - "@lexical/html": "npm:0.32.1" - "@lexical/link": "npm:0.32.1" - "@lexical/mark": "npm:0.32.1" - "@lexical/table": "npm:0.32.1" - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - peerDependencies: - react: ">=17.x" - react-dom: ">=17.x" - checksum: 10c0/6950a19234bc5e7d3ad5cc8831f56b98e0d5ce8df2a5b1734140a5d72cba375b90635f28c7a19bb103a7990e67b0c6dc8aacf474780ae8e672bd94f84ff6131d - languageName: node - linkType: hard - -"@lexical/dragon@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/dragon@npm:0.32.1" - dependencies: - lexical: "npm:0.32.1" - checksum: 10c0/9d1a50d9f0a3be9fe022e65b3b64ed393213a796aaac70ffc5fa77ae112327b248267595e8c06644f0798e24062199a5523d2017367b6bd5dcb61bb332432745 - languageName: node - linkType: hard - -"@lexical/hashtag@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/hashtag@npm:0.32.1" - dependencies: - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - checksum: 10c0/928d1ccff76392e709491a41c1962298a68d0d8151f861320d7e39de553380892ec63efdd33f80c8b2d6719299143786ea01088b9a56d24bb73b070ad82798a2 - languageName: node - linkType: hard - -"@lexical/history@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/history@npm:0.32.1" - dependencies: - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - checksum: 10c0/6512db4d9d9e6cae02cd0d8c48c9770c9aa95429fa4a3e6436ae4c4b21046af7d29bfdd1ad036c0ef27442e98bb866ba6243b091464325c056389835a452adb6 - languageName: node - linkType: hard - -"@lexical/html@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/html@npm:0.32.1" - dependencies: - "@lexical/selection": "npm:0.32.1" - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - checksum: 10c0/1d6691d8a2694e6230bde714878d9e89b801af9fea305d6cf8b61eaf7edb86c8a3b0f9136e5bb3fe3855dd08d21edbc3ac13c9d70df7c52e559d968c127e64c8 - languageName: node - linkType: hard - -"@lexical/link@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/link@npm:0.32.1" - dependencies: - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - checksum: 10c0/2ba9d36b18beefd74cb60189ea6ef235eb12b342e4b0019fa77a4f0b54dc3a062e7e36fbdcc77df96c31d4de91e0c2dd603eb2d27314de4447feb14a675b077e - languageName: node - linkType: hard - -"@lexical/list@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/list@npm:0.32.1" - dependencies: - "@lexical/selection": "npm:0.32.1" - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - checksum: 10c0/05346e239af2292c1f14ffd9d0cc43e4f171a284ffd4863c7582f3e5f1c3b4a91a4e491c924a58a426f57a3791a643328f25e5c5cb98fdf5e86f3fc5538e2bdc - languageName: node - linkType: hard - -"@lexical/mark@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/mark@npm:0.32.1" - dependencies: - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - checksum: 10c0/d3b64f061acc289cefe77d45672f0d566c04054b0eec6c1380d0646d4ccb4760470d276eaded4d8e90e5ea6074eae6529b597555b9a5115e866c3d01a633c311 - languageName: node - linkType: hard - -"@lexical/markdown@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/markdown@npm:0.32.1" - dependencies: - "@lexical/code": "npm:0.32.1" - "@lexical/link": "npm:0.32.1" - "@lexical/list": "npm:0.32.1" - "@lexical/rich-text": "npm:0.32.1" - "@lexical/text": "npm:0.32.1" - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - checksum: 10c0/04e0c76a7c2ab547ef3b5a978582793b8367354bc846665d84ec986c33392c4edc9c2695803b17d1c76ab56d7b9895be41345368ed3fe5d603ccab98eceb393e - languageName: node - linkType: hard - -"@lexical/offset@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/offset@npm:0.32.1" - dependencies: - lexical: "npm:0.32.1" - checksum: 10c0/c7434a64c1df2bbd3ce9c13777ce1cbf7d75284e401b4197d810d5e7f37fb56a34d37697bbe161b6f6dd900212231044f4a7b65da8152a44fbf86a5acaeb0ddd - languageName: node - linkType: hard - -"@lexical/overflow@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/overflow@npm:0.32.1" - dependencies: - lexical: "npm:0.32.1" - checksum: 10c0/e992525edd392f185b816dbf724a03f4357ae2811eabe4d53668e4815d1a831ab3449a540d4c9c67afc407f6a1602a3cf8eb24c6c9f6908450410a7de7399827 - languageName: node - linkType: hard - -"@lexical/plain-text@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/plain-text@npm:0.32.1" - dependencies: - "@lexical/clipboard": "npm:0.32.1" - "@lexical/selection": "npm:0.32.1" - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - checksum: 10c0/0b8f9311f0fb492854273d686296f1ec36fad6e95db89cd6911408b66b56f704314b18be9f554811064d74904225833fb11d467f42a41138fcf257a0e9abcd2a - languageName: node - linkType: hard - -"@lexical/react@npm:^0.32.1": - version: 0.32.1 - resolution: "@lexical/react@npm:0.32.1" - dependencies: - "@floating-ui/react": "npm:^0.27.8" - "@lexical/devtools-core": "npm:0.32.1" - "@lexical/dragon": "npm:0.32.1" - "@lexical/hashtag": "npm:0.32.1" - "@lexical/history": "npm:0.32.1" - "@lexical/link": "npm:0.32.1" - "@lexical/list": "npm:0.32.1" - "@lexical/mark": "npm:0.32.1" - "@lexical/markdown": "npm:0.32.1" - "@lexical/overflow": "npm:0.32.1" - "@lexical/plain-text": "npm:0.32.1" - "@lexical/rich-text": "npm:0.32.1" - "@lexical/table": "npm:0.32.1" - "@lexical/text": "npm:0.32.1" - "@lexical/utils": "npm:0.32.1" - "@lexical/yjs": "npm:0.32.1" - lexical: "npm:0.32.1" - react-error-boundary: "npm:^3.1.4" - peerDependencies: - react: ">=17.x" - react-dom: ">=17.x" - checksum: 10c0/fccc637d31b6151f45895800f7211b8cd0c0300aef94e889a9f1c13cf29fc4cdf25bc921f9fca9f216044d3a83c3d2895f7200cf1507179c8af4aa7ad2a9849f - languageName: node - linkType: hard - -"@lexical/rich-text@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/rich-text@npm:0.32.1" - dependencies: - "@lexical/clipboard": "npm:0.32.1" - "@lexical/selection": "npm:0.32.1" - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - checksum: 10c0/921eb7edfd778a5e2d447c20b486fd1c389333a27add1a1ad8cf32c4d28cb8ff016463338c9bfdfa74e0f579e0e5617ec165a07f5701eec0c2ad0b15a5f35046 - languageName: node - linkType: hard - -"@lexical/selection@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/selection@npm:0.32.1" - dependencies: - lexical: "npm:0.32.1" - checksum: 10c0/43bb8b1797a95eb085db1f1ecebc4a6dc18c354cd29089bafa2830989a161fe8088c3a035caf8423b102fd2aaf1f32228604b0e4537869f3889a756a9cc9cbb7 - languageName: node - linkType: hard - -"@lexical/table@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/table@npm:0.32.1" - dependencies: - "@lexical/clipboard": "npm:0.32.1" - "@lexical/utils": "npm:0.32.1" - lexical: "npm:0.32.1" - checksum: 10c0/5d21f947b81cadc78a962c4c0703d8e771b106e2fd5f3c5f8353608c46d95fccd93e96c3870ffa3521d49e56a46d6936c5cf89b8dc149781aee3676fcddcf3d9 - languageName: node - linkType: hard - -"@lexical/text@npm:0.32.1, @lexical/text@npm:^0.32.1": - version: 0.32.1 - resolution: "@lexical/text@npm:0.32.1" - dependencies: - lexical: "npm:0.32.1" - checksum: 10c0/51d8f6208fcebfd5923ed565c61e155b19cdb168fd7c29145f157d0e9ccee9b2ab75e9ba5f82ac7653452affa45dfb1a47db0579643a014febd32f47d14f57e4 - languageName: node - linkType: hard - -"@lexical/utils@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/utils@npm:0.32.1" - dependencies: - "@lexical/list": "npm:0.32.1" - "@lexical/selection": "npm:0.32.1" - "@lexical/table": "npm:0.32.1" - lexical: "npm:0.32.1" - checksum: 10c0/2a5ec2037bafa022a0f043634b7832c4d5c49704c8aca5c5a5362bf72e3816c6dd19aff01ac2848078fa22af58d8018f05964ddd09ae5cd254d3fd9f269b5f11 - languageName: node - linkType: hard - -"@lexical/yjs@npm:0.32.1": - version: 0.32.1 - resolution: "@lexical/yjs@npm:0.32.1" - dependencies: - "@lexical/offset": "npm:0.32.1" - "@lexical/selection": "npm:0.32.1" - lexical: "npm:0.32.1" - peerDependencies: - yjs: ">=13.5.22" - checksum: 10c0/62748880f205fb7afa8c4e51325d2deb4c82242093c5280ea83ef8fe3ce6a3652131bb77e7853b356f4933679b18dd1128ee35b5fd0ce195ecc3e4b1224122ac - languageName: node - linkType: hard - "@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1, @lezer/common@npm:^1.3.0": version: 1.3.0 resolution: "@lezer/common@npm:1.3.0" @@ -17771,13 +17505,6 @@ __metadata: languageName: node linkType: hard -"lexical@npm:0.32.1, lexical@npm:^0.32.1": - version: 0.32.1 - resolution: "lexical@npm:0.32.1" - checksum: 10c0/fea1e2aa7f1026aebf209a53dbf149bd4c5ec22a15b5fcef4e38224fb601c9a5fcc9d43801f2e09f375dea919be2e99287d517126849edcb2bc32cb23db04697 - languageName: node - linkType: hard - "lightningcss-android-arm64@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-android-arm64@npm:1.31.1" @@ -20733,13 +20460,6 @@ __metadata: languageName: node linkType: hard -"prismjs@npm:^1.30.0": - version: 1.30.0 - resolution: "prismjs@npm:1.30.0" - checksum: 10c0/f56205bfd58ef71ccfcbcb691fd0eb84adc96c6ff21b0b69fc6fdcf02be42d6ef972ba4aed60466310de3d67733f6a746f89f2fb79c00bf217406d465b3e8f23 - languageName: node - linkType: hard - "proc-log@npm:^5.0.0": version: 5.0.0 resolution: "proc-log@npm:5.0.0" @@ -21182,17 +20902,6 @@ __metadata: languageName: node linkType: hard -"react-error-boundary@npm:^3.1.4": - version: 3.1.4 - resolution: "react-error-boundary@npm:3.1.4" - dependencies: - "@babel/runtime": "npm:^7.12.5" - peerDependencies: - react: ">=16.13.1" - checksum: 10c0/f977ca61823e43de2381d53dd7aa8b4d79ff6a984c9afdc88dc44f9973b99de7fd382d2f0f91f2688e24bb987c0185bf45d0b004f22afaaab0f990a830253bfb - languageName: node - linkType: hard - "react-hook-form@npm:^7.58.1": version: 7.65.0 resolution: "react-hook-form@npm:7.65.0" @@ -22940,13 +22649,6 @@ __metadata: languageName: node linkType: hard -"tabbable@npm:^6.0.0": - version: 6.3.0 - resolution: "tabbable@npm:6.3.0" - checksum: 10c0/57ba019d29b5cfa0c862248883bcec0e6d29d8f156ba52a1f425e7cfeca4a0fc701ab8d035c4c86ddf74ecdbd0e9f454a88d9b55d924a51f444038e9cd14d7a0 - languageName: node - linkType: hard - "tailwind-merge@npm:^3.3.1": version: 3.3.1 resolution: "tailwind-merge@npm:3.3.1" @@ -23214,8 +22916,6 @@ __metadata: "@knocklabs/client": "npm:0.14.9" "@knocklabs/node": "npm:^1.10.3" "@knocklabs/react": "npm:0.7.15" - "@lexical/react": "npm:^0.32.1" - "@lexical/text": "npm:^0.32.1" "@lezer/highlight": "npm:^1.2.3" "@mendable/firecrawl-js": "npm:^4.13.2" "@next/bundle-analyzer": "npm:16.2.3" @@ -23323,7 +23023,6 @@ __metadata: jsurl2: "npm:^2.2.0" katex: "npm:^0.16.22" knip: "npm:^5.61.2" - lexical: "npm:^0.32.1" lightweight-charts: "npm:^5.1.0" lint-staged: "npm:^16.1.2" lodash: "npm:^4.18.0" diff --git a/apps/web/src/components/store-search/TokenAssetSearch.tsx b/apps/web/src/components/store-search/TokenAssetSearch.tsx new file mode 100644 index 00000000..0bfe9a25 --- /dev/null +++ b/apps/web/src/components/store-search/TokenAssetSearch.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { TokenAssetSearchState } from "@/hooks/useTokenAssetSearch"; +import { TokenSearchBar } from "./TokenSearchBar"; + +type TokenAssetSearchProps = { + autoFocus?: boolean; + className?: string; + placeholder?: string; + searchState: TokenAssetSearchState; +}; + +export function TokenAssetSearch({ + autoFocus, + className, + placeholder, + searchState, +}: TokenAssetSearchProps): React.ReactElement { + return ( + { + void searchState.executeSearch(); + }} + placeholder={placeholder} + tokenState={searchState.tokenState} + /> + ); +} diff --git a/apps/web/src/hooks/useTokenAssetSearch.ts b/apps/web/src/hooks/useTokenAssetSearch.ts new file mode 100644 index 00000000..51f88da4 --- /dev/null +++ b/apps/web/src/hooks/useTokenAssetSearch.ts @@ -0,0 +1,320 @@ +"use client"; + +import type { FundsSearchFilters } from "@tilt/sig-api-client/schemas"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { FilterCategory } from "@/components/store-search/types"; +import { useStoreFacetsCatalogue } from "@/components/store-search/use-store-facets-catalogue"; +import { useAssetSearch } from "@/hooks/useAssetSearch"; +import { useSearchTokens } from "@/hooks/useSearchTokens"; +import { + type ParsedFiltersWithMatches, + parseAssetSearchQuery, + SOURCE_TYPES, + type SourceType, +} from "@/lib/utils/parseAssetSearchQuery"; + +type AssetSearchOptions = NonNullable[0]>; + +type UseTokenAssetSearchOptions = Omit< + AssetSearchOptions, + "externalParsedFilters" +> & { + blockedCategories?: FilterCategory[]; + facetMinCount?: number; + facetSources?: FundsSearchFilters["source"]; + includeTiltSource?: boolean; +}; + +export type TokenAssetSearchState = ReturnType & { + tokenState: ReturnType; +}; + +function canLoadFacetSource( + source: SourceType +): source is Exclude { + return source !== "TILT" && source !== "TOPIC"; +} + +function getFacetSources( + allowedSourceTypes: readonly SourceType[] | undefined, + facetSources: FundsSearchFilters["source"] | undefined +): FundsSearchFilters["source"] | undefined { + if (facetSources) return facetSources; + if (!allowedSourceTypes) return; + + const loadableSources = allowedSourceTypes.filter(canLoadFacetSource); + return loadableSources.length > 0 + ? (loadableSources as FundsSearchFilters["source"]) + : undefined; +} + +const NON_TOPIC_SOURCE_TYPES = SOURCE_TYPES.filter( + (source) => source !== "TOPIC" +) as SourceType[]; + +const RANGE_FILTER_KEYS = [ + "aum", + "return_1d", + "return_1w", + "return_1m", + "return_3m", + "return_6m", + "return_12m", + "return_5y", + "management_expense_ratio", + "management_fee", + "top_10_holdings_weight", + "holdings_count", +] as const satisfies readonly (keyof ParsedFiltersWithMatches)[]; + +const MATCH_ARRAY_FILTER_KEYS = [ + "source", + "issuer", + "listing_currency", + "country", + "geo_exposure_code", + "strategy", +] as const satisfies readonly (keyof ParsedFiltersWithMatches)[]; + +const LEGACY_FILTER_FILLER_WORDS = + /\b(from|with|and|or|the|a|an|in|of|for|to|at|by|AUM)\b/gi; + +function mergeMatchArrays( + tokenMatches: readonly T[] | undefined, + legacyMatches: readonly T[] | undefined +): T[] | undefined { + const merged: T[] = []; + const seen = new Set(); + + for (const match of [...(tokenMatches ?? []), ...(legacyMatches ?? [])]) { + if (seen.has(match.value)) continue; + seen.add(match.value); + merged.push(match); + } + + return merged.length > 0 ? merged : undefined; +} + +type RangeFilter = NonNullable; + +function mergeRangeFilters( + tokenRange: RangeFilter | undefined, + legacyRange: RangeFilter | undefined +): RangeFilter | undefined { + if (!(tokenRange || legacyRange)) return; + return { + min: tokenRange?.min ?? legacyRange?.min, + max: tokenRange?.max ?? legacyRange?.max, + }; +} + +function mergeSectorConcentrationFilters( + tokenSector: ParsedFiltersWithMatches["sector_concentration"], + legacySector: ParsedFiltersWithMatches["sector_concentration"] +): ParsedFiltersWithMatches["sector_concentration"] { + if (!(tokenSector || legacySector)) return; + const merged: NonNullable = + {}; + for (const key of new Set([ + ...Object.keys(tokenSector ?? {}), + ...Object.keys(legacySector ?? {}), + ])) { + const range = mergeRangeFilters(tokenSector?.[key], legacySector?.[key]); + if (range) merged[key] = range; + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + +function hasStructuredFilters(filters: ParsedFiltersWithMatches): boolean { + return ( + MATCH_ARRAY_FILTER_KEYS.some((key) => { + const value = filters[key]; + return Array.isArray(value) && value.length > 0; + }) || + RANGE_FILTER_KEYS.some((key) => Boolean(filters[key])) || + Boolean( + filters.sector_concentration && + Object.keys(filters.sector_concentration).length > 0 + ) + ); +} + +function stripLegacyFilterFillerWords(query: string): string { + return query + .replace(LEGACY_FILTER_FILLER_WORDS, "") + .replace(/\s+/g, " ") + .trim(); +} + +function getLegacyAllowedSourceTypes( + allowedSourceTypesKey: string +): readonly SourceType[] { + const requestedSources = allowedSourceTypesKey + ? (allowedSourceTypesKey.split(",") as SourceType[]) + : [...SOURCE_TYPES]; + const nonTopicSources = requestedSources.filter( + (source) => source !== "TOPIC" + ); + return nonTopicSources.length > 0 ? nonTopicSources : NON_TOPIC_SOURCE_TYPES; +} + +export function mergeTokenAssetSearchFilters( + tokenFilters: ParsedFiltersWithMatches | null, + allowedSourceTypes: readonly SourceType[] +): ParsedFiltersWithMatches | null { + if (!tokenFilters) return null; + + const legacyFilters = tokenFilters.cleanedQuery.trim() + ? parseAssetSearchQuery(tokenFilters.cleanedQuery, allowedSourceTypes) + : ({ cleanedQuery: "" } satisfies ParsedFiltersWithMatches); + + const merged: ParsedFiltersWithMatches = { + cleanedQuery: legacyFilters.cleanedQuery, + source: mergeMatchArrays(tokenFilters.source, legacyFilters.source), + issuer: mergeMatchArrays(tokenFilters.issuer, legacyFilters.issuer), + listing_currency: mergeMatchArrays( + tokenFilters.listing_currency, + legacyFilters.listing_currency + ), + country: mergeMatchArrays(tokenFilters.country, legacyFilters.country), + geo_exposure_code: mergeMatchArrays( + tokenFilters.geo_exposure_code, + legacyFilters.geo_exposure_code + ), + strategy: mergeMatchArrays(tokenFilters.strategy, legacyFilters.strategy), + aum: mergeRangeFilters(tokenFilters.aum, legacyFilters.aum), + return_1d: mergeRangeFilters( + tokenFilters.return_1d, + legacyFilters.return_1d + ), + return_1w: mergeRangeFilters( + tokenFilters.return_1w, + legacyFilters.return_1w + ), + return_1m: mergeRangeFilters( + tokenFilters.return_1m, + legacyFilters.return_1m + ), + return_3m: mergeRangeFilters( + tokenFilters.return_3m, + legacyFilters.return_3m + ), + return_6m: mergeRangeFilters( + tokenFilters.return_6m, + legacyFilters.return_6m + ), + return_12m: mergeRangeFilters( + tokenFilters.return_12m, + legacyFilters.return_12m + ), + return_5y: mergeRangeFilters( + tokenFilters.return_5y, + legacyFilters.return_5y + ), + management_expense_ratio: mergeRangeFilters( + tokenFilters.management_expense_ratio, + legacyFilters.management_expense_ratio + ), + management_fee: mergeRangeFilters( + tokenFilters.management_fee, + legacyFilters.management_fee + ), + top_10_holdings_weight: mergeRangeFilters( + tokenFilters.top_10_holdings_weight, + legacyFilters.top_10_holdings_weight + ), + holdings_count: mergeRangeFilters( + tokenFilters.holdings_count, + legacyFilters.holdings_count + ), + sector_concentration: mergeSectorConcentrationFilters( + tokenFilters.sector_concentration, + legacyFilters.sector_concentration + ), + }; + + if (hasStructuredFilters(merged)) { + merged.cleanedQuery = stripLegacyFilterFillerWords(merged.cleanedQuery); + } + + return merged; +} + +export function useTokenAssetSearch( + options: UseTokenAssetSearchOptions = {} +): TokenAssetSearchState { + const { + blockedCategories, + facetMinCount = 1, + facetSources, + includeTiltSource, + ...assetSearchOptions + } = options; + const sources = getFacetSources( + assetSearchOptions.allowedSourceTypes, + facetSources + ); + const allowedSourceTypesKey = useMemo( + () => + [...(assetSearchOptions.allowedSourceTypes ?? SOURCE_TYPES)] + .sort() + .join(","), + [assetSearchOptions.allowedSourceTypes] + ); + const legacyAllowedSourceTypes = useMemo( + () => getLegacyAllowedSourceTypes(allowedSourceTypesKey), + [allowedSourceTypesKey] + ); + const shouldIncludeTiltSource = + includeTiltSource ?? + assetSearchOptions.allowedSourceTypes?.includes("TILT") ?? + true; + + const facetsQuery = useStoreFacetsCatalogue({ + includeTiltSource: shouldIncludeTiltSource, + minCount: facetMinCount, + registerGlobal: false, + sources, + }); + const tokenState = useSearchTokens(undefined, { + blockedCategories, + catalogue: facetsQuery.catalogue, + }); + const displayQuery = useMemo( + () => tokenState.toDisplayQuery(), + [tokenState.toDisplayQuery] + ); + const parsedFilters = useMemo( + () => + mergeTokenAssetSearchFilters( + tokenState.toParsedFilters(), + legacyAllowedSourceTypes + ), + [tokenState.toParsedFilters, legacyAllowedSourceTypes] + ); + + const assetSearch = useAssetSearch({ + ...assetSearchOptions, + externalParsedFilters: parsedFilters, + }); + const lastSearchRef = useRef(""); + const { setQuery } = assetSearch; + + useEffect(() => { + if (displayQuery === lastSearchRef.current) return; + lastSearchRef.current = displayQuery; + setQuery(displayQuery); + }, [displayQuery, setQuery]); + + const clearResults = useCallback(() => { + tokenState.clearAll(); + assetSearch.clearResults(); + lastSearchRef.current = ""; + }, [assetSearch.clearResults, tokenState.clearAll]); + + return { + ...assetSearch, + clearResults, + tokenState, + }; +} diff --git a/apps/web/vitest/use-search-tokens-catalogue.test.tsx b/apps/web/vitest/use-search-tokens-catalogue.test.tsx new file mode 100644 index 00000000..c82b48fc --- /dev/null +++ b/apps/web/vitest/use-search-tokens-catalogue.test.tsx @@ -0,0 +1,74 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import type { FilterDefinition } from "@/components/store-search/filter-catalogue"; +import { + resetFilterCatalogue, + setFilterCatalogue, +} from "@/components/store-search/filter-catalogue"; +import { CATEGORY_TO_VARIANT } from "@/components/store-search/types"; +import { useSearchTokens } from "@/hooks/useSearchTokens"; + +function sourceCatalogue( + values: Array<{ + aliases: string[]; + canonical: string; + label: string; + }> +): FilterDefinition[] { + return [ + { + category: "source", + values, + variant: CATEGORY_TO_VARIANT.source, + }, + ]; +} + +describe("useSearchTokens catalogue scoping", () => { + afterEach(() => { + resetFilterCatalogue(); + }); + + it("uses its own catalogue when another mounted search changes the shared singleton", () => { + const indexCatalogue = sourceCatalogue([ + { + aliases: ["index", "indices"], + canonical: "TILT_INDEX|EXTERNAL_INDEX", + label: "Index", + }, + ]); + const etfCatalogue = sourceCatalogue([ + { + aliases: ["etf", "etfs"], + canonical: "ETF", + label: "ETF", + }, + ]); + + const { result: indexSearch } = renderHook(() => + useSearchTokens(undefined, { catalogue: indexCatalogue }) + ); + const { result: etfSearch } = renderHook(() => + useSearchTokens(undefined, { catalogue: etfCatalogue }) + ); + + setFilterCatalogue(etfCatalogue); + + act(() => { + indexSearch.current.setInputValue("index"); + etfSearch.current.setInputValue("etf"); + }); + + expect(indexSearch.current.suggestions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ displayText: "Index" }), + ]) + ); + expect(indexSearch.current.suggestions).not.toEqual( + expect.arrayContaining([expect.objectContaining({ displayText: "ETF" })]) + ); + expect(etfSearch.current.suggestions).toEqual( + expect.arrayContaining([expect.objectContaining({ displayText: "ETF" })]) + ); + }); +}); diff --git a/apps/web/vitest/use-store-facets-catalogue.test.tsx b/apps/web/vitest/use-store-facets-catalogue.test.tsx new file mode 100644 index 00000000..deba67c8 --- /dev/null +++ b/apps/web/vitest/use-store-facets-catalogue.test.tsx @@ -0,0 +1,121 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type FilterDefinition, + getFilterCatalogue, + resetFilterCatalogue, + setFilterCatalogue, +} from "@/components/store-search/filter-catalogue"; +import { CATEGORY_TO_VARIANT } from "@/components/store-search/types"; +import { useStoreFacetsCatalogue } from "@/components/store-search/use-store-facets-catalogue"; + +function sourceCatalogue( + values: Array<{ + aliases: string[]; + canonical: string; + label: string; + }> +): FilterDefinition[] { + return [ + { + category: "source", + values, + variant: CATEGORY_TO_VARIANT.source, + }, + ]; +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +function sourceLabels(catalogue: FilterDefinition[]): string[] { + return ( + catalogue + .find((definition) => definition.category === "source") + ?.values.map((value) => value.label) ?? [] + ); +} + +describe("useStoreFacetsCatalogue", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + resetFilterCatalogue(); + vi.unstubAllGlobals(); + }); + + it("does not overwrite the shared catalogue when global registration is disabled", async () => { + const globalCatalogue = sourceCatalogue([ + { + aliases: ["etf", "etfs"], + canonical: "ETF", + label: "ETF", + }, + ]); + setFilterCatalogue(globalCatalogue); + + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + counts: { + country: [], + fund_currency: [], + geo_exposure_code: [], + issuer: [], + source: [{ count: 1, value: "TILT_INDEX" }], + strategy: [], + }, + facets: { + country: [], + fund_currency: [], + geo_exposure_code: [], + issuer: [], + source: ["TILT_INDEX"], + strategy: [], + }, + meta: { + cache_ttl_seconds: 300, + generated_at: "2026-05-28T00:00:00.000Z", + min_count: 1, + sources: ["TILT_INDEX"], + }, + }), + { status: 200 } + ) + ); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook( + () => + useStoreFacetsCatalogue({ + includeTiltSource: false, + registerGlobal: false, + sources: ["TILT_INDEX"], + }), + { wrapper: createWrapper() } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(sourceLabels(result.current.catalogue)).toContain("Index"); + expect(sourceLabels(getFilterCatalogue())).toEqual(["ETF"]); + }); +}); diff --git a/apps/web/vitest/use-token-asset-search-filters.test.ts b/apps/web/vitest/use-token-asset-search-filters.test.ts new file mode 100644 index 00000000..1c395208 --- /dev/null +++ b/apps/web/vitest/use-token-asset-search-filters.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { mergeTokenAssetSearchFilters } from "@/hooks/useTokenAssetSearch"; +import type { ParsedFiltersWithMatches } from "@/lib/utils/parseAssetSearchQuery"; + +describe("mergeTokenAssetSearchFilters", () => { + it("preserves legacy natural-language filters from free text", () => { + const merged = mergeTokenAssetSearchFilters( + { + cleanedQuery: "ETF with AUM under $500M", + }, + ["ETF", "TILT_INDEX"] + ); + + expect(merged).toMatchObject({ + aum: { + max: { value: "500000000", matchedText: "under $500M" }, + }, + cleanedQuery: "", + source: [{ value: "ETF", matchedText: "ETF" }], + }); + }); + + it("merges explicit token filters with legacy-parsed free text", () => { + const tokenFilters: ParsedFiltersWithMatches = { + cleanedQuery: "with AUM under $500M", + source: [{ value: "ETF", matchedText: "ETF" }], + }; + + const merged = mergeTokenAssetSearchFilters(tokenFilters, [ + "ETF", + "TILT_INDEX", + ]); + + expect(merged).toMatchObject({ + aum: { + max: { value: "500000000", matchedText: "under $500M" }, + }, + cleanedQuery: "", + source: [{ value: "ETF", matchedText: "ETF" }], + }); + }); +});