import { Input, Flex, List, Typography, Modal, Spin, message, Form, Button } from 'antd' import { createContext, useEffect, useState, useRef, createElement } from 'react' import axios from 'axios' import { LoadingOutlined } from '@ant-design/icons' import PropTypes from 'prop-types' import { useNavigate } from 'react-router-dom' import StateDisplay from '../common/StateDisplay' import IdDisplay from '../common/IdDisplay' import config from '../../../config' import { getModelByName, getModelByPrefix } from '../../../database/ObjectModels' import InfoCircleIcon from '../../Icons/InfoCircleIcon' const SpotlightContext = createContext() const SpotlightProvider = ({ children }) => { const { Text } = Typography const navigate = useNavigate() const [showModal, setShowModal] = useState(false) const [loading, setLoading] = useState(false) const [query, setQuery] = useState('') const [listData, setListData] = useState([]) const [messageApi, contextHolder] = message.useMessage() const [inputPrefix, setInputPrefix] = useState({ prefix: '', mode: null }) // Refs for throttling/debouncing const lastFetchTime = useRef(0) const pendingQuery = useRef(null) const fetchTimeoutRef = useRef(null) const inputRef = useRef(null) const formRef = useRef(null) const showSpotlight = (defaultQuery = '') => { setQuery(defaultQuery) setShowModal(true) // Set prefix based on default query if provided if (defaultQuery) { // Check if the default query contains a prefix const upperQuery = defaultQuery.toUpperCase() const prefixInfo = parsePrefix(upperQuery) if (prefixInfo) { setInputPrefix(prefixInfo) // Set the query to only the part after the prefix and mode character const remainingValue = defaultQuery.substring( prefixInfo.prefix.length + 1 ) setQuery(remainingValue) checkAndFetchData(defaultQuery) } else { setInputPrefix(null) checkAndFetchData(defaultQuery) } } else { setInputPrefix(null) // Only clear data if we're opening with an empty query and no existing data if (listData.length === 0) { setListData([]) } } // Focus will be handled in useEffect for proper timing after modal renders } // Helper function to parse prefix and mode from query const parsePrefix = (query) => { // Check for prefix format: XXX: or XXX? or XXX^ if (query.length >= 4) { const potentialPrefix = query.substring(0, 3) const modeChar = query[3] // Check if it's a valid mode character if ([':', '?', '^'].includes(modeChar)) { const prefixModel = getModelByPrefix(potentialPrefix) if (prefixModel.prefix === potentialPrefix) { return { ...prefixModel, mode: modeChar } } } } return null } const fetchData = async (searchQuery) => { if (!searchQuery || !searchQuery.trim()) return try { // Update last fetch time lastFetchTime.current = Date.now() // Clear any pending queries pendingQuery.current = null setLoading(true) setListData([]) let response // Check if we have a prefix with ? mode (filter mode) if (inputPrefix && inputPrefix.mode === '?') { // For filter mode, parse the searchQuery which is in format like "VEN?name=Tom" const queryParts = searchQuery.split('?') const prefix = queryParts[0] const queryParams = queryParts[1] || '' // Parse the query parameters const params = new URLSearchParams(queryParams) response = await axios.get(`${config.backendUrl}/spotlight/${prefix}`, { params: params, headers: { Accept: 'application/json' }, withCredentials: true }) } else { // For other modes (:, ^), use the original behavior response = await axios.get( `${config.backendUrl}/spotlight/${encodeURIComponent(searchQuery.trim())}`, { headers: { Accept: 'application/json' }, withCredentials: true } ) } setLoading(false) // If the query contains a prefix mode character, and the response is an object, wrap it in an array if ( /[:?^]/.test(searchQuery) && response.data && !Array.isArray(response.data) && typeof response.data === 'object' ) { setListData([response.data]) } else { setListData(response.data) } // Check if there's a pending query after this fetch completes if (pendingQuery.current !== null) { const timeToNextFetch = Math.max( 0, 1000 - (Date.now() - lastFetchTime.current) ) scheduleNextFetch(timeToNextFetch) } } catch (error) { setLoading(false) messageApi.error('An error occurred while fetching data.') console.error('Spotlight fetch error:', error) } } const checkAndFetchData = (searchQuery) => { // Store the latest query pendingQuery.current = searchQuery // Calculate time since last fetch const now = Date.now() const timeSinceLastFetch = now - lastFetchTime.current // If we've waited at least 1 second since last fetch, fetch immediately if (timeSinceLastFetch >= 1000) { if (fetchTimeoutRef.current) { clearTimeout(fetchTimeoutRef.current) fetchTimeoutRef.current = null } fetchData(searchQuery) } else { // Otherwise, schedule fetch for when 1 second has passed if (!fetchTimeoutRef.current) { const timeToWait = 1000 - timeSinceLastFetch scheduleNextFetch(timeToWait) } // We don't need to do anything if a fetch is already scheduled // as the latest query is already stored in pendingQuery } } const scheduleNextFetch = (delay) => { if (fetchTimeoutRef.current) { clearTimeout(fetchTimeoutRef.current) } fetchTimeoutRef.current = setTimeout(() => { fetchTimeoutRef.current = null if (pendingQuery.current !== null) { fetchData(pendingQuery.current) } }, delay) } const handleSpotlightChange = (formData) => { const newQuery = formData.query || '' setQuery(newQuery) // Build the full search query with prefix if available let fullQuery = newQuery if (inputPrefix) { fullQuery = inputPrefix.prefix + inputPrefix.mode + newQuery } // Check if we need to fetch data checkAndFetchData(fullQuery) } // Focus the input element const focusInput = () => { setTimeout(() => { if (inputRef.current) { const input = inputRef.current.input if (input) { input.focus() } } }, 50) } // Custom handler for input changes to handle prefix logic const handleInputChange = (e) => { const value = e.target.value // If the input is empty or being cleared if (!value || value.trim() === '') { // Only clear the prefix if the input is completely empty if (value === '') { setInputPrefix(null) } if (formRef.current) { formRef.current.setFieldsValue({ query: value }) } return } // Check if the input contains a prefix (format: XXX:, XXX?, or XXX^) const upperValue = value.toUpperCase() const prefixInfo = parsePrefix(upperValue) // If it's a valid prefix if (prefixInfo) { setInputPrefix(prefixInfo) // Remove the prefix from the input value, keeping only what comes after the mode character const remainingValue = value.substring(4) if (formRef.current) { formRef.current.setFieldsValue({ query: remainingValue }) } setQuery(remainingValue) return } // Update the form value normally if (formRef.current) { formRef.current.setFieldsValue({ query: value }) } } // Helper to get row actions and default action for a model const getRowActions = (model) => (model.actions || []).filter((action) => action.row === true) const getDefaultRowAction = (model) => getRowActions(model).find((action) => action.default) const triggerRowAction = (action, item) => { if (action && action.url) { // Try to get the id (support _id or id) const itemId = item._id || item.id const url = action.url(itemId) if (url && url !== '#') { navigate(url) setShowModal(false) } } } // Handle key down events for backspace behavior and navigation const handleKeyDown = (e) => { if (listData.length > 0) { // Enter key - trigger default action for first item if (e.key === 'Enter') { e.preventDefault() const item = listData[0] let type = item.type || item.objectType || inputPrefix?.type const model = getModelByName(type) const defaultAction = getDefaultRowAction(model) if (defaultAction) { triggerRowAction(defaultAction, item) } return } // Number keys 1-9 - trigger default action for corresponding item if (/^[1-9]$/.test(e.key)) { e.preventDefault() const index = parseInt(e.key, 10) if (index < listData.length) { const item = listData[index] let type = item.type || item.objectType || inputPrefix?.type const model = getModelByName(type) const defaultAction = getDefaultRowAction(model) if (defaultAction) { triggerRowAction(defaultAction, item) } } return } } // If backspace is pressed and there's a prefix but the input is empty if (e.key === 'Backspace' && inputPrefix && query === '') { setInputPrefix(null) e.preventDefault() return } } // Focus and select text in input when modal becomes visible useEffect(() => { if (showModal && inputRef.current) { // Use a small timeout to ensure the modal is fully rendered and visible setTimeout(() => { const input = inputRef.current.input if (input) { input.focus() input.select() // Select all text } }, 50) } }, [showModal]) // Focus input when inputPrefix changes useEffect(() => { if (showModal) { // Only clear data if there's no existing data and no current query if (listData.length === 0 && !query) { setListData([]) } focusInput() } }, [inputPrefix, showModal]) // Update form value when query changes useEffect(() => { if (showModal && formRef.current) { formRef.current.setFieldsValue({ query: query }) } }, [query, showModal]) // Cleanup on unmount useEffect(() => { return () => { if (fetchTimeoutRef.current) { clearTimeout(fetchTimeoutRef.current) } } }, []) // Helper function to get mode description const getModeDescription = (mode) => { switch (mode) { case ':': return 'ID lookup' case '?': return 'Filter' case '^': return 'Search' default: return '' } } return ( {contextHolder} setShowModal(false)} closeIcon={null} footer={null} width={700} styles={{ content: { padding: 0 } }} destroyOnHidden={true} >
{inputPrefix.prefix} {inputPrefix.mode} ) : undefined } suffix={ {inputPrefix?.mode && ( {getModeDescription(inputPrefix.mode)} )} } spinning={loading} size='small' /> } onChange={handleInputChange} onKeyDown={handleKeyDown} />
{listData.length > 0 && (
{ let type = item.objectType || inputPrefix?.type const model = getModelByName(type) const Icon = model.icon const rowActions = getRowActions(model) let shortcutText = '' if (index === 0) { shortcutText = 'ENTER' } else if (index <= 9) { shortcutText = index.toString() } return ( {Icon ? : null} {item.name ? ( {item.name} ) : null} {item?.state ? ( ) : null} {rowActions .filter((action) => !action?.default) .map((action, i) => (
)}
{children}
) } SpotlightProvider.propTypes = { children: PropTypes.node.isRequired } export { SpotlightProvider, SpotlightContext }