Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
703 lines
20 KiB
JavaScript
703 lines
20 KiB
JavaScript
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 = (
|
||
<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 && (
|
||
<>
|
||
{isElectron && <Divider style={{ margin: 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'
|
||
style={{ flexGrow: 1 }}
|
||
>
|
||
{Icon ? <Icon style={{ fontSize: '20px' }} /> : null}
|
||
|
||
{item.name ? (
|
||
<Text
|
||
ellipsis
|
||
style={{
|
||
maxWidth: 170,
|
||
flexGrow: 1,
|
||
width: '100%'
|
||
}}
|
||
>
|
||
{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>
|
||
)
|
||
|
||
return (
|
||
<>
|
||
{contextHolder}
|
||
{spotlightContent}
|
||
</>
|
||
)
|
||
}
|
||
|
||
SpotlightContent.propTypes = {
|
||
isActive: PropTypes.bool,
|
||
openKey: PropTypes.number,
|
||
defaultQuery: PropTypes.string,
|
||
onRequestClose: PropTypes.func,
|
||
isElectron: PropTypes.bool
|
||
}
|
||
|
||
SpotlightContent.defaultProps = {
|
||
isActive: true,
|
||
openKey: 0,
|
||
defaultQuery: '',
|
||
onRequestClose: null
|
||
}
|
||
|
||
const SpotlightProvider = ({ children }) => {
|
||
const [showModal, setShowModal] = useState(false)
|
||
const [openKey, setOpenKey] = useState(0)
|
||
const [defaultQuery, setDefaultQuery] = useState('')
|
||
|
||
const showSpotlight = (nextDefaultQuery = '') => {
|
||
setDefaultQuery(nextDefaultQuery)
|
||
setOpenKey((k) => k + 1)
|
||
setShowModal(true)
|
||
}
|
||
|
||
return (
|
||
<SpotlightContext.Provider value={{ showSpotlight }}>
|
||
<Modal
|
||
open={showModal}
|
||
onCancel={() => setShowModal(false)}
|
||
closeIcon={null}
|
||
footer={null}
|
||
width={700}
|
||
styles={{ content: { padding: 0, borderRadius: '12px' } }}
|
||
destroyOnHidden={true}
|
||
>
|
||
<SpotlightContent
|
||
isActive={showModal}
|
||
openKey={openKey}
|
||
defaultQuery={defaultQuery}
|
||
onRequestClose={() => setShowModal(false)}
|
||
/>
|
||
</Modal>
|
||
{children}
|
||
</SpotlightContext.Provider>
|
||
)
|
||
}
|
||
|
||
SpotlightProvider.propTypes = {
|
||
children: PropTypes.node.isRequired
|
||
}
|
||
|
||
const ElectronSpotlightContentPage = () => {
|
||
const cardRef = useRef(null)
|
||
const resizeTimeoutRef = useRef(null)
|
||
const { resizeSpotlightWindow, isElectron } = useContext(ElectronContext)
|
||
|
||
// Function to measure and resize window
|
||
const updateWindowHeight = useCallback(() => {
|
||
if (!cardRef.current || !isElectron || !resizeSpotlightWindow) return
|
||
|
||
// Clear any pending resize
|
||
if (resizeTimeoutRef.current) {
|
||
clearTimeout(resizeTimeoutRef.current)
|
||
}
|
||
|
||
// Debounce the resize to avoid too many calls
|
||
resizeTimeoutRef.current = setTimeout(() => {
|
||
try {
|
||
const cardElement = cardRef.current
|
||
if (!cardElement) return
|
||
|
||
// Get the scroll height of the card
|
||
const scrollHeight = cardElement.scrollHeight
|
||
|
||
// Add some padding for better appearance (e.g., 10px top and bottom)
|
||
const padding = 0
|
||
const newHeight = scrollHeight + padding * 2
|
||
|
||
// Set minimum height to prevent window from being too small
|
||
const minHeight = 30
|
||
const finalHeight = Math.max(newHeight, minHeight)
|
||
|
||
// Call IPC to resize the window
|
||
resizeSpotlightWindow(finalHeight)
|
||
} catch (error) {
|
||
console.warn('Failed to update spotlight window height:', error)
|
||
}
|
||
}, 100) // 100ms debounce
|
||
}, [isElectron, resizeSpotlightWindow])
|
||
|
||
// Use ResizeObserver to watch for content changes
|
||
useEffect(() => {
|
||
if (!cardRef.current || !isElectron || !resizeSpotlightWindow) return
|
||
|
||
const cardElement = cardRef.current
|
||
|
||
// Create ResizeObserver to watch for size changes
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
updateWindowHeight()
|
||
})
|
||
|
||
resizeObserver.observe(cardElement)
|
||
|
||
// Initial measurement after a short delay to ensure content is rendered
|
||
const initialTimeout = setTimeout(() => {
|
||
updateWindowHeight()
|
||
}, 200)
|
||
|
||
return () => {
|
||
resizeObserver.disconnect()
|
||
if (resizeTimeoutRef.current) {
|
||
clearTimeout(resizeTimeoutRef.current)
|
||
}
|
||
clearTimeout(initialTimeout)
|
||
}
|
||
}, [isElectron, resizeSpotlightWindow, updateWindowHeight])
|
||
|
||
return (
|
||
<div
|
||
ref={cardRef}
|
||
style={{
|
||
maxWidth: 700,
|
||
padding: 0
|
||
}}
|
||
className='electron-spotlight-content'
|
||
>
|
||
<Card
|
||
styles={{ body: { padding: 0 } }}
|
||
variant={'borderless'}
|
||
style={{ border: 'none', backgroundColor: 'transparent' }}
|
||
>
|
||
<SpotlightContent
|
||
isActive={true}
|
||
openKey={1}
|
||
defaultQuery=''
|
||
isElectron={true}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export {
|
||
SpotlightProvider,
|
||
SpotlightContext,
|
||
SpotlightContent,
|
||
ElectronSpotlightContentPage
|
||
}
|