From 62805b0d6a2bd6b5a9daadacb5d9192fc9b55520 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 4 Jul 2026 16:43:23 +0100 Subject: [PATCH] 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. --- .../Dashboard/common/ObjectSelect.jsx | 76 ++++++++++++++++++- .../Dashboard/context/ApiServerContext.jsx | 53 +++++++++---- 2 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/components/Dashboard/common/ObjectSelect.jsx b/src/components/Dashboard/common/ObjectSelect.jsx index 806802f..47e56ed 100644 --- a/src/components/Dashboard/common/ObjectSelect.jsx +++ b/src/components/Dashboard/common/ObjectSelect.jsx @@ -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} /> ) diff --git a/src/components/Dashboard/context/ApiServerContext.jsx b/src/components/Dashboard/context/ApiServerContext.jsx index 2b1036c..0768c18 100644 --- a/src/components/Dashboard/context/ApiServerContext.jsx +++ b/src/components/Dashboard/context/ApiServerContext.jsx @@ -744,20 +744,23 @@ const ApiServerProvider = ({ children }) => { [offLockEvent] ) - const showError = useCallback((error, callback = null) => { - const code = error.response.data.code || 'UNKNOWN' - if (code == 'UNAUTHORIZED') { - setUnauthenticated() - return - } - var content = `Error ${error.code} (${error.status}): ${error.message}` - if (error.response?.data?.error) { - content = `${error.response?.data?.error} (${error.status})` - } - setErrorModalContent(content) - setRetryCallback(() => callback) - setShowErrorModal(true) - }, [setUnauthenticated]) + const showError = useCallback( + (error, callback = null) => { + const code = error.response.data.code || 'UNKNOWN' + if (code == 'UNAUTHORIZED') { + setUnauthenticated() + return + } + var content = `Error ${error.code} (${error.status}): ${error.message}` + if (error.response?.data?.error) { + content = `${error.response?.data?.error} (${error.status})` + } + setErrorModalContent(content) + setRetryCallback(() => callback) + setShowErrorModal(true) + }, + [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,