2025-08-22 20:28:50 +01:00

541 lines
17 KiB
JavaScript

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 (
<SpotlightContext.Provider value={{ showSpotlight }}>
{contextHolder}
<Modal
open={showModal}
onCancel={() => setShowModal(false)}
closeIcon={null}
footer={null}
width={700}
styles={{ content: { padding: 0 } }}
destroyOnHidden={true}
>
<Flex vertical>
<Form ref={formRef} onValuesChange={handleSpotlightChange}>
<Form.Item name='query' initialValue={query} style={{ margin: 0 }}>
<Input
ref={inputRef}
placeholder='Enter a query or scan a barcode...'
size='large'
addonBefore={
inputPrefix ? (
<Flex align='center' gap='small'>
<Text style={{ fontSize: 20 }}>{inputPrefix.prefix}</Text>
<Text style={{ fontSize: 20 }} type='secondary'>
{inputPrefix.mode}
</Text>
</Flex>
) : undefined
}
suffix={
<Flex align='center' gap='small'>
{inputPrefix?.mode && (
<Text type='secondary' style={{ fontSize: '12px' }}>
{getModeDescription(inputPrefix.mode)}
</Text>
)}
<Spin
indicator={<LoadingOutlined />}
spinning={loading}
size='small'
/>
</Flex>
}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
</Form.Item>
</Form>
{listData.length > 0 && (
<div style={{ marginLeft: '18px', marginRight: '14px' }}>
<List
dataSource={listData}
renderItem={(item, index) => {
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 (
<List.Item>
<Flex
gap={'middle'}
style={{ width: '100%' }}
align='center'
justify='space-between'
>
<Flex gap={'small'} align='center'>
{Icon ? <Icon style={{ fontSize: '20px' }} /> : null}
{item.name ? (
<Text ellipsis style={{ maxWidth: 170 }}>
{item.name}
</Text>
) : null}
{item?.state ? (
<StateDisplay
state={item.state}
showName={false}
showProgress={false}
showId={false}
/>
) : null}
<IdDisplay id={item._id} type={type} longId={false} />
</Flex>
<Flex gap={'small'}>
{rowActions
.filter((action) => !action?.default)
.map((action, i) => (
<Button
key={action.name || i}
icon={
action.icon ? (
createElement(action.icon)
) : (
<InfoCircleIcon />
)
}
size={'small'}
type={'text'}
onClick={() => triggerRowAction(action, item)}
/>
))}
{rowActions
.filter((action) => action?.default == true)
.map((action, i) => (
<Button
key={action.name || i}
icon={
action.icon ? (
createElement(action.icon)
) : (
<InfoCircleIcon />
)
}
size={'small'}
type={'text'}
onClick={() => triggerRowAction(action, item)}
/>
))}
{shortcutText && <Text keyboard>{shortcutText}</Text>}
</Flex>
</Flex>
</List.Item>
)
}}
></List>
</div>
)}
</Flex>
</Modal>
{children}
</SpotlightContext.Provider>
)
}
SpotlightProvider.propTypes = {
children: PropTypes.node.isRequired
}
export { SpotlightProvider, SpotlightContext }