Tom Butcher 845b330242
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
Fixed border radius.
2026-03-07 20:00:39 +00:00

703 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (dont 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
}