import { useEffect, useState, useContext, useCallback, useMemo, useRef } from 'react' import PropTypes from 'prop-types' import { TreeSelect, Space, Button, Input } from 'antd' import ReloadIcon from '../../Icons/ReloadIcon' import { ApiServerContext } from '../context/ApiServerContext' import { AuthContext } from '../context/AuthContext' import ObjectProperty from './ObjectProperty' import { getModelByName } from '../../../database/ObjectModels' import merge from 'lodash/merge' import { getModelProperty } from '../../../database/ObjectModels' const { SHOW_CHILD } = TreeSelect // Helper to check if two values are equal (handling objects/ids) const areValuesEqual = (v1, v2) => { const id1 = v1 && typeof v1 === 'object' && v1._id ? v1._id : v1 const id2 = v2 && typeof v2 === 'object' && v2._id ? v2._id : v2 return String(id1) === String(id2) } const ObjectSelect = ({ type = 'unknown', showSearch = false, multiple = false, treeSelectProps = {}, filter = {}, masterFilter = {}, value, disabled = false, ...rest }) => { const { fetchObjectsByProperty, fetchObject, connected } = useContext(ApiServerContext) const { token } = useContext(AuthContext) // --- State --- const [treeData, setTreeData] = useState([]) const [objectPropertiesTree, setObjectPropertiesTree] = useState([]) const [initialized, setInitialized] = useState(false) const [error, setError] = useState(false) const properties = useMemo(() => getModelByName(type).group || [], [type]) const [objectList, setObjectList] = useState([]) const [treeSelectValue, setTreeSelectValue] = useState(null) const [initialLoading, setInitialLoading] = useState(true) const valueRef = useRef(null) // Refs to track value changes const prevValueRef = useRef(value) const isInternalChangeRef = useRef(false) // Normalize a value to an identity string so we can detect in-place _id updates const getValueIdentity = useCallback((val) => { if (val && typeof val === 'object') { // Handle arrays if (Array.isArray(val)) { const ids = val .map((item) => { if (item && typeof item === 'object') { if (item._id) return String(item._id) if ( item.value && typeof item.value === 'object' && item.value._id ) return String(item.value._id) } return null }) .filter(Boolean) .sort() return ids.length > 0 ? ids.join(',') : JSON.stringify(val) } // Handle single objects if (val._id) return String(val._id) if (val.value && typeof val.value === 'object' && val.value._id) return String(val.value._id) } return JSON.stringify(val) }, []) const prevValueIdentityRef = useRef(getValueIdentity(value)) // Utility function to check if object only contains _id const isMinimalObject = useCallback((obj) => { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { return false } const keys = Object.keys(obj) return keys.length === 1 && keys[0] === '_id' && obj._id }, []) // Function to fetch full object if only _id is present const fetchFullObjectIfNeeded = useCallback( async (obj) => { if (isMinimalObject(obj)) { try { const fullObject = await fetchObject(obj._id, type) return fullObject } catch (err) { console.error('Failed to fetch full object:', err) return obj // Return original object if fetch fails } } return obj }, [isMinimalObject, fetchObject, type] ) const mergeGroups = useCallback((current, incoming) => { if (!current) return incoming if (!incoming) return current if (!Array.isArray(current) || !Array.isArray(incoming)) return incoming const merged = [...current] // Helper to generate a unique key for a group node const getGroupKey = (item) => { const val = item.value const valPart = val && typeof val === 'object' && val._id ? val._id : JSON.stringify(val) return `${item.property}:${valPart}` } for (const item of incoming) { if (item.property && item.value !== undefined) { // It's a group node const itemKey = getGroupKey(item) const existingIdx = merged.findIndex( (x) => x.property && x.value !== undefined && getGroupKey(x) === itemKey ) if (existingIdx > -1) { merged[existingIdx] = { ...merged[existingIdx], children: mergeGroups(merged[existingIdx].children, item.children) } } else { merged.push(item) } } else { // It's a leaf object if (!merged.some((x) => String(x._id) === String(item._id))) { merged.push(item) } } } return merged }, []) // Fetch the object properties tree from the API const handleFetchObjectsProperties = useCallback( async (customFilter = filter) => { try { const data = await fetchObjectsByProperty(type, { properties: properties, filter: customFilter, masterFilter }) if (Array.isArray(data)) { setObjectPropertiesTree((prev) => mergeGroups(prev, data)) } else { // Fallback if API returns something unexpected setObjectPropertiesTree((prev) => merge([], prev, data)) } setInitialLoading(false) setError(false) return data } catch (err) { console.error(err) setError(true) return null } }, [ type, fetchObjectsByProperty, properties, filter, masterFilter, mergeGroups ] ) // Convert the API response to AntD TreeSelect treeData const buildTreeData = useCallback( (data, pIdx = 0, parentKeys = [], filterPath = []) => { if (!data || !Array.isArray(data)) return [] // If we are past the grouping properties, these are leaf objects if (pIdx >= properties.length) { return data.map((object) => { setObjectList((prev) => { if (prev.some((p) => p._id === object._id)) return prev return [...prev, object] }) return { title: (
), value: object._id, key: object._id, isLeaf: true, parentKeys, filterPath } }) } // Group Nodes return data .map((group) => { // Only process if it looks like a group if (!group.property) return null const { property, value, children } = group var valueString = value if (value && typeof value === 'object' && value._id) { valueString = value._id } if (Array.isArray(valueString)) { valueString = valueString.join(',') } const nodeKey = parentKeys .concat(property + ':' + valueString) .join('-') const newFilterPath = filterPath.concat({ property, value: valueString }) const modelProperty = getModelProperty(type, property) return { title: , value: nodeKey, key: nodeKey, property, filterValue: valueString, parentKeys: parentKeys.concat(valueString), filterPath: newFilterPath, selectable: false, isLeaf: false, children: buildTreeData( children, pIdx + 1, parentKeys.concat(valueString), newFilterPath ) } }) .filter(Boolean) }, [properties, type] ) // --- loadData for async loading on expand --- const loadData = async (node) => { // node.property is the property name, node.value is the value key if (!node.property) return if (type == 'unknown') return // Build filter for this node by merging all parent property-value pairs const customFilter = { ...filter } if (Array.isArray(node.filterPath)) { node.filterPath.forEach(({ property, value }) => { customFilter[property] = value }) } // Ensure current node is in filter (should be covered by filterPath, but redundancy is safe) customFilter[node.property] = node.filterValue // Fetch children for this node const data = await handleFetchObjectsProperties(customFilter) if (!data) return // Navigate to the specific node's children in the response let nodeSpecificChildren = data if (node.filterPath && Array.isArray(node.filterPath)) { for (const pathItem of node.filterPath) { if (!Array.isArray(nodeSpecificChildren)) break const match = nodeSpecificChildren.find( (g) => g.property === pathItem.property && areValuesEqual(g.value, pathItem.value) ) if (match) { nodeSpecificChildren = match.children } else { nodeSpecificChildren = [] break } } } // Build new tree children only for this specific node const children = buildTreeData( nodeSpecificChildren, properties.indexOf(node.property) + 1, node.parentKeys || [], node.filterPath ) // Update treeData with new children for this node only setTreeData((prevTreeData) => { // Helper to recursively update the correct node const updateNode = (nodes) => nodes.map((n) => { if (n.key === node.key) { return { ...n, children, isLeaf: children.length === 0 } } else if (n.children) { return { ...n, children: updateNode(n.children) } } return n }) return updateNode(prevTreeData) }) } const onTreeSelectChange = (value) => { // Mark this as an internal change isInternalChangeRef.current = true // value can be a string (single) or array (multiple) if (multiple) { // Multiple selection let selectedObjects = [] if (Array.isArray(value)) { selectedObjects = value .map((id) => objectList.find((obj) => obj._id === id)) .filter(Boolean) } setTreeSelectValue(value) if (rest.onChange) rest.onChange(selectedObjects) } else { // Single selection const selectedObject = objectList.find((obj) => obj._id === value) setTreeSelectValue(value) if (rest.onChange) rest.onChange(selectedObject) } } // Update treeData when objectPropertiesTree changes useEffect(() => { if (objectPropertiesTree && Object.keys(objectPropertiesTree).length > 0) { const newTreeData = buildTreeData(objectPropertiesTree) setTreeData((prev) => { if (JSON.stringify(prev) !== JSON.stringify(newTreeData)) { return newTreeData } return prev }) } }, [objectPropertiesTree, properties, buildTreeData]) useEffect(() => { const handleValue = async () => { if ( value && typeof value === 'object' && value !== null && getValueIdentity(valueRef.current) !== getValueIdentity(value) && type != 'unknown' ) { // console.log('fetching full object', value) valueRef.current = value // Check if value is a minimal object and fetch full object if needed const fullValue = await fetchFullObjectIfNeeded(value) // console.log('fullValue', fullValue) // Build a new filter from value's properties that are in the properties list const valueFilter = { ...filter } properties.forEach((prop) => { if (Object.prototype.hasOwnProperty.call(fullValue, prop)) { const filterValue = fullValue[prop] if ( filterValue && typeof filterValue === 'object' && filterValue._id ) { valueFilter[prop] = filterValue._id } else if (filterValue?.name) { valueFilter[prop] = filterValue.name } else if (Array.isArray(filterValue)) { valueFilter[prop] = filterValue.join(',') } else { valueFilter[prop] = filterValue } } }) // Fetch with the new filter handleFetchObjectsProperties(valueFilter) // console.log('setting treeSelectValue', valueRef.current._id) setTreeSelectValue(valueRef.current._id) setInitialized(true) return } if ( !initialized && token != null && type != 'unknown' && type != undefined && connected == true ) { handleFetchObjectsProperties() setInitialized(true) } if ( value == null || type == 'unknown' || type == undefined || connected == false ) { setTreeSelectValue(null) setInitialLoading(false) setInitialized(true) } } handleValue() }, [ value, filter, properties, handleFetchObjectsProperties, initialized, token, fetchFullObjectIfNeeded, type, connected, getValueIdentity ]) const prevValuesRef = useRef({ type, masterFilter }) useEffect(() => { // console.log('treeSelectValue', treeSelectValue) }, [treeSelectValue]) useEffect(() => { const prevValues = prevValuesRef.current // Deep comparison for objects, simple comparison for primitives const hasChanged = prevValues.type !== type || JSON.stringify(prevValues.masterFilter) !== JSON.stringify(masterFilter) if (hasChanged) { setObjectPropertiesTree({}) setObjectList([]) setTreeData([]) setInitialized(false) onTreeSelectChange(null) setTreeSelectValue(null) setInitialLoading(true) setError(false) prevValuesRef.current = { type, masterFilter } } }, [type, masterFilter]) useEffect(() => { // Check if value has actually changed const currentValueIdentity = getValueIdentity(value) const hasValueChanged = prevValueIdentityRef.current !== currentValueIdentity if (hasValueChanged) { const changeSource = isInternalChangeRef.current ? 'internal' : 'external' if (changeSource == 'external') { setObjectPropertiesTree({}) setTreeData([]) setInitialized(false) prevValuesRef.current = { type, masterFilter } } // Reset the internal change flag isInternalChangeRef.current = false // Update the previous value reference prevValueRef.current = value prevValueIdentityRef.current = currentValueIdentity } }, [value, getValueIdentity]) const placeholder = useMemo( () => type == 'unknown' || type == undefined ? 'n/a' : `Select a ${getModelByName(type).label.toLowerCase()}...`, [type] ) // --- Error UI --- if (error) { return (