import { TextField, InputAdornment, IconButton } from '@mui/material' import { ClearOutlined, SearchOutlined } from '@mui/icons-material' import { useEffect, useRef, useCallback } from 'react' import { widgetStoreActions } from '../../stores/widget-store' import { useWidgetSelector } from '../../stores/use-widget-selector' import type { SearcherProps, SearcherFilterFn, SearcherState } from './types' import type { EchartWidgetData } from '../../echart/types' import { LOCK_SELECTION_TOOL_ID } from '../lock-selection/lock-selection' export const SEARCHER_TOOL_ID = 'searcher' const DEBOUNCE_DELAY = 300 /** * Search input component that works with SearcherToggle. * * Registers a transformation tool in the widget pipeline when mounted. * Reads the enabled state from the widget store using the provided id. * Uses a debounced search to filter data via the transformation pipeline. * Auto-focuses when enabled becomes true. * * @example * ```tsx * * * ``` */ export function Searcher({ id, filterFn, order = 20, labels, TextFieldProps, ClearIcon, debounceDelay = DEBOUNCE_DELAY, }: SearcherProps) { const inputRef = useRef(null) const debounceTimeoutRef = useRef | null>(null) // Read enabled state and search text from widget store const { enabled, searchText } = useWidgetSelector(id, (w) => ({ enabled: (w as SearcherState | undefined)?.isSearchEnabled ?? false, searchText: (w as SearcherState | undefined)?.searchText ?? '', })) const prevEnabledRef = useRef(enabled) const filter = filterFn ?? defaultFilterFn const setSearchText = useCallback( (text: string) => { widgetStoreActions.setWidget(id, { searchText: text }) }, [id], ) // Register tool once — fn reads searchText from the store at execution time. // Enabled is synced separately to avoid full re-registration on toggle. useEffect(() => { widgetStoreActions.registerTool(id, { id: SEARCHER_TOOL_ID, order, enabled: false, fn: async (data) => { const widget = widgetStoreActions.getWidget(id) const currentSearchText = widget?.searchText ?? '' // Execute filter (can be sync or async) const result = filter(data as EchartWidgetData, currentSearchText) // Return result directly (pipeline will handle Promise) return result }, disables: [LOCK_SELECTION_TOOL_ID], }) return () => widgetStoreActions.unregisterTool(id, SEARCHER_TOOL_ID) }, [id, order, filter]) // Sync enabled from store — lightweight, no re-registration useEffect(() => { widgetStoreActions.setToolEnabled(id, SEARCHER_TOOL_ID, enabled) }, [id, enabled]) // Trigger pipeline re-execution when search text changes (debounced). // The fn reads searchText from the store, so we just need to trigger the pipeline. const debouncedTriggerPipeline = useCallback(() => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current) } debounceTimeoutRef.current = setTimeout(() => { widgetStoreActions.triggerToolPipeline(id) }, debounceDelay) }, [id, debounceDelay]) // Auto-focus when enabled becomes true useEffect(() => { // Transition from disabled to enabled - focus input if (enabled && !prevEnabledRef.current && inputRef.current) { inputRef.current.focus() } prevEnabledRef.current = enabled }, [enabled]) // Cleanup debounce timeout on unmount useEffect(() => { return () => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current) } } }, []) const handleChange = useCallback( (event: React.ChangeEvent) => { const newValue = event.target.value setSearchText(newValue) debouncedTriggerPipeline() }, [debouncedTriggerPipeline, setSearchText], ) const handleClear = useCallback(() => { setSearchText('') widgetStoreActions.triggerToolPipeline(id) if (inputRef.current) { inputRef.current.focus() } }, [id, setSearchText]) if (!enabled) { return null } const placeholder = labels?.placeholder ?? 'Search...' const clearAriaLabel = labels?.clearAriaLabel ?? 'Clear search' return ( ), endAdornment: searchText ? ( {ClearIcon ?? } ) : null, }} {...TextFieldProps} /> ) } /** * Default filter function that searches all string fields case-insensitively. * Note: Should be synchronous for the new pipeline architecture. */ const defaultFilterFn: SearcherFilterFn = ( data: EchartWidgetData, searchText: string, ) => { if (!searchText.trim()) return Promise.resolve(data) const lowerSearch = searchText.toLowerCase() return Promise.resolve( data.map((series) => series.filter((item) => Object.values(item).some((value) => String(value).toLowerCase().includes(lowerSearch), ), ), ), ) }