import {
Input,
Flex,
List,
Typography,
Modal,
Spin,
message,
Form,
Button,
Card,
Divider
} from 'antd'
import {
createContext,
useEffect,
useState,
useRef,
createElement,
useContext,
useCallback
} 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'
import { ApiServerContext } from './ApiServerContext'
import { AuthContext } from './AuthContext'
import { ElectronContext } from './ElectronContext'
const SpotlightContext = createContext()
const SpotlightContent = ({
isActive,
openKey,
defaultQuery,
onRequestClose,
isElectron
}) => {
const { Text } = Typography
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [query, setQuery] = useState('')
const [listData, setListData] = useState([])
const [messageApi, contextHolder] = message.useMessage()
const [inputPrefix, setInputPrefix] = useState(null)
const { fetchSpotlightData } = useContext(ApiServerContext)
const { token } = useContext(AuthContext)
const { openInternalUrl } = useContext(ElectronContext)
// Refs for throttling/debouncing
const lastFetchTime = useRef(0)
const pendingQuery = useRef(null)
const fetchTimeoutRef = useRef(null)
const inputRef = useRef(null)
const formRef = useRef(null)
// 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 && 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 data
// 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)
// For ? mode, use axios directly with query params
const response = await axios.get(
`${config.backendUrl}/spotlight/${prefix}`,
{
params: params,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
)
data = response.data
} else {
// For other modes (:, ^), use fetchSpotlightData from ApiServerContext
data = await fetchSpotlightData(searchQuery.trim())
}
setLoading(false)
// If the query contains a prefix mode character, and the response is an object, wrap it in an array
if (
/[:?^]/.test(searchQuery) &&
data &&
!Array.isArray(data) &&
typeof data === 'object'
) {
setListData([data])
} else {
setListData(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 !== '#') {
if (isElectron) {
openInternalUrl(url)
} else {
navigate(url)
}
if (onRequestClose) onRequestClose()
}
}
}
// 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 = model ? getDefaultRowAction(model) : null
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 = model ? getDefaultRowAction(model) : null
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 (isActive && 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)
}
}, [isActive])
// Focus input when inputPrefix changes
useEffect(() => {
if (isActive) {
focusInput()
}
}, [inputPrefix, isActive])
// Update form value when query changes
useEffect(() => {
if (isActive && formRef.current) {
formRef.current.setFieldsValue({ query: query })
}
}, [query, isActive])
// Apply/refresh defaultQuery when the component is opened (modal) or mounted (standalone)
useEffect(() => {
if (!isActive) return
const nextDefaultQuery = defaultQuery || ''
// Set prefix based on default query if provided
if (nextDefaultQuery) {
const upperQuery = nextDefaultQuery.toUpperCase()
const prefixInfo = parsePrefix(upperQuery)
if (prefixInfo) {
setInputPrefix(prefixInfo)
const remainingValue = nextDefaultQuery.substring(
prefixInfo.prefix.length + 1
)
setQuery(remainingValue)
checkAndFetchData(nextDefaultQuery)
} else {
setInputPrefix(null)
setQuery(nextDefaultQuery)
checkAndFetchData(nextDefaultQuery)
}
} else {
setQuery('')
setInputPrefix(null)
// Keep previous listData behavior (don’t force-clear on open)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openKey, isActive])
// 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/Reference lookup'
case '?':
return 'Filter'
case '^':
return 'Search'
default:
return ''
}
}
const spotlightContent = (