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,