Implement search functionality in ObjectSelect component, allowing users to filter selectable tree nodes. Update ApiServerContext to include searchObjects method for backend integration. Adjust state management for search operations and enhance user interaction with keyboard navigation support.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-07-04 16:43:23 +01:00
parent 612b250529
commit 62805b0d6a
2 changed files with 111 additions and 18 deletions

View File

@ -26,9 +26,19 @@ const areValuesEqual = (v1, v2) => {
return String(id1) === String(id2)
}
const getFirstSelectableLeaf = (nodes) => {
if (!Array.isArray(nodes)) return null
for (const node of nodes) {
if (node.isLeaf && node.selectable !== false) return node
const found = getFirstSelectableLeaf(node.children)
if (found) return found
}
return null
}
const ObjectSelect = ({
type = 'unknown',
showSearch = false,
showSearch = true,
multiple = false,
treeSelectProps = EMPTY_OBJECT,
filter = EMPTY_OBJECT,
@ -38,7 +48,7 @@ const ObjectSelect = ({
disabled = false,
...rest
}) => {
const { fetchObjectsByProperty, fetchObject, connected } =
const { fetchObjectsByProperty, fetchObject, connected, searchObjects } =
useContext(ApiServerContext)
const { token } = useContext(AuthContext)
// --- State ---
@ -52,6 +62,9 @@ const ObjectSelect = ({
const [initialLoading, setInitialLoading] = useState(true)
const [expandedKeys, setExpandedKeys] = useState([])
const [treeVersion, setTreeVersion] = useState(0)
const [isSearching, setIsSearching] = useState(false)
const [searchValue, setSearchValue] = useState('')
const searchRequestIdRef = useRef(0)
const valueRef = useRef(null)
// Refs to track value changes
@ -345,6 +358,7 @@ const ObjectSelect = ({
const onTreeSelectChange = useCallback(
(value) => {
// Mark this as an internal change
setIsSearching(false)
isInternalChangeRef.current = true
// value can be a string (single) or array (multiple)
@ -368,8 +382,55 @@ const ObjectSelect = ({
[multiple, objectList, onChange]
)
const onSearch = useCallback(
async (search) => {
setSearchValue(search)
const trimmed = search?.trim() ?? ''
if (!trimmed) {
searchRequestIdRef.current += 1
setIsSearching(false)
setTreeData(buildTreeData(objectPropertiesTree))
return
}
const requestId = ++searchRequestIdRef.current
setIsSearching(true)
const data = await searchObjects(type, trimmed)
if (requestId !== searchRequestIdRef.current) return
if (!Array.isArray(data)) return
setTreeData(buildTreeData(data, properties.length))
},
[
searchObjects,
type,
buildTreeData,
objectPropertiesTree,
properties.length
]
)
const onInputKeyDown = useCallback(
(e) => {
treeSelectProps.onInputKeyDown?.(e)
if (e.defaultPrevented || e.key !== 'Enter' || !isSearching) return
const firstLeaf = getFirstSelectableLeaf(treeData)
if (!firstLeaf?.value) return
e.preventDefault()
e.stopPropagation()
onTreeSelectChange(
multiple ? [firstLeaf.value] : firstLeaf.value
)
setSearchValue('')
onSearch('')
},
[treeSelectProps, isSearching, treeData, multiple, onTreeSelectChange, onSearch]
)
// Update treeData when objectPropertiesTree changes
useEffect(() => {
if (isSearching) return
if (objectPropertiesTree && Object.keys(objectPropertiesTree).length > 0) {
const newTreeData = buildTreeData(objectPropertiesTree)
setTreeData((prev) => {
@ -379,7 +440,7 @@ const ObjectSelect = ({
return prev
})
}
}, [objectPropertiesTree, properties, buildTreeData])
}, [objectPropertiesTree, properties, buildTreeData, isSearching])
useEffect(() => {
const handleValue = async () => {
@ -480,6 +541,9 @@ const ObjectSelect = ({
JSON.stringify(prevValues.masterFilter) !== JSON.stringify(masterFilter)
if (hasChanged) {
searchRequestIdRef.current += 1
setIsSearching(false)
setSearchValue('')
setObjectPropertiesTree({})
setObjectList([])
setTreeData([])
@ -559,14 +623,18 @@ const ObjectSelect = ({
onTreeExpand={setExpandedKeys}
treeData={treeData}
showSearch={showSearch}
filterTreeNode={() => true}
multiple={multiple}
loadData={loadData}
loadData={isSearching ? undefined : loadData}
showCheckedStrategy={SHOW_CHILD}
placeholder={placeholder}
{...treeSelectProps}
{...rest}
value={treeSelectValue}
onChange={onTreeSelectChange}
onSearch={onSearch}
onInputKeyDown={onInputKeyDown}
searchValue={searchValue}
disabled={disabled || type == 'unknown' || type == undefined}
/>
)

View File

@ -744,7 +744,8 @@ const ApiServerProvider = ({ children }) => {
[offLockEvent]
)
const showError = useCallback((error, callback = null) => {
const showError = useCallback(
(error, callback = null) => {
const code = error.response.data.code || 'UNKNOWN'
if (code == 'UNAUTHORIZED') {
setUnauthenticated()
@ -757,7 +758,9 @@ const ApiServerProvider = ({ children }) => {
setErrorModalContent(content)
setRetryCallback(() => callback)
setShowErrorModal(true)
}, [setUnauthenticated])
},
[setUnauthenticated]
)
const handleRetry = () => {
setShowErrorModal(false)
@ -911,6 +914,27 @@ const ApiServerProvider = ({ children }) => {
}
}
const searchObjects = async (type, search) => {
const searchUrl = `${config.backendUrl}/${getObjectEndpoint(type)}/search`
logger.debug('Searching objects for ' + type, { search })
try {
const response = await axios.get(searchUrl, {
params: { search },
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
})
return response.data
} catch (err) {
console.error(err)
showError(err, () => {
searchObjects(type, search)
})
return []
}
}
// Update filament information
const updateObject = async (id, type, value) => {
const updateUrl = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}`
@ -1824,6 +1848,7 @@ const ApiServerProvider = ({ children }) => {
fetchObject,
fetchObjects,
fetchObjectsByProperty,
searchObjects,
fetchSpotlightData,
getModelStats,
getModelHistory,