541 lines
17 KiB
JavaScript
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 }
|