Tom Butcher 8bb8d417f7 Enhance ObjectSelect Component with Improved Value Handling and Synchronization
- Introduced utility functions to standardize value conversion and retrieval, improving consistency in value handling.
- Enhanced synchronization of external values with internal state, ensuring accurate representation of selected items.
- Improved selection change handling to prevent unnecessary updates when no valid selection is made.
- Refactored tree selection logic for better clarity and maintainability, ensuring robust handling of both single and multiple selections.
2026-09-06 19:24:17 +01:00

1246 lines
35 KiB
JavaScript

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 { getModelProperty } from '../../../database/ObjectModels'
const { SHOW_CHILD } = TreeSelect
const EMPTY_OBJECT = {}
// 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 toSelectValue = (id) => {
if (id == null || id === '') return null
return String(id).toLowerCase()
}
const getValueId = (item) =>
item && typeof item === 'object' ? item._id : item
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 findTreeNodeByKey = (nodes, key) => {
if (!Array.isArray(nodes)) return null
for (const node of nodes) {
if (node.key === key) return node
if (node.children) {
const found = findTreeNodeByKey(node.children, key)
if (found) return found
}
}
return null
}
const findTreeNodeByValue = (nodes, value) => {
if (!Array.isArray(nodes) || value == null || value === '') return null
const match = String(value).toLowerCase()
for (const node of nodes) {
if (
node?.value != null &&
String(node.value).toLowerCase() === match &&
node.isLeaf
) {
return node
}
const found = findTreeNodeByValue(node?.children, value)
if (found) return found
}
return null
}
const isValueInTree = (nodes, id) => {
if (id == null || id === '') return false
return findTreeNodeByValue(nodes, id) != null
}
const getSelectValueFromExternal = (externalValue, multiple, nodes) => {
if (externalValue == null || !Array.isArray(nodes) || nodes.length === 0) {
return undefined
}
if (multiple) {
const values = Array.isArray(externalValue) ? externalValue : []
const ids = values
.map((item) => toSelectValue(getValueId(item)))
.filter((id) => id != null && isValueInTree(nodes, id))
return ids
}
const node = findTreeNodeByValue(nodes, getValueId(externalValue))
return node?.value
}
const isExternalValueMissing = (
externalValue,
multiple,
objectList,
treeData,
ready
) => {
if (!ready || externalValue == null) return false
const isMissingId = (id) =>
id != null &&
id !== '' &&
!objectList.some((obj) => areValuesEqual(obj._id, id)) &&
!isValueInTree(treeData, id)
if (multiple) {
const values = Array.isArray(externalValue) ? externalValue : []
if (values.length === 0) return false
return values.some((item) =>
isMissingId(item && typeof item === 'object' ? item._id : item)
)
}
const id =
externalValue && typeof externalValue === 'object'
? externalValue._id
: externalValue
return isMissingId(id)
}
const getBranchChildren = (data, filterPath) => {
let nodeSpecificChildren = data
if (!Array.isArray(filterPath)) return nodeSpecificChildren
for (const pathItem of filterPath) {
if (!Array.isArray(nodeSpecificChildren)) break
const match = nodeSpecificChildren.find(
(group) =>
group.property === pathItem.property &&
areValuesEqual(group.value, pathItem.value)
)
if (match) {
nodeSpecificChildren = match.children
} else {
return []
}
}
return nodeSpecificChildren
}
const replaceBranchChildren = (nodes, filterPath, newChildren, depth = 0) => {
if (!Array.isArray(nodes) || !Array.isArray(filterPath)) return nodes
if (depth >= filterPath.length) return newChildren
const { property, value } = filterPath[depth]
return nodes.map((node) => {
if (node.property !== property || !areValuesEqual(node.value, value)) {
return node
}
const nextDepth = depth + 1
if (nextDepth >= filterPath.length) {
return { ...node, children: newChildren }
}
return {
...node,
children: replaceBranchChildren(
node.children || [],
filterPath,
newChildren,
nextDepth
)
}
})
}
const mergeObjectInTree = (nodes, id, updatedData) => {
if (!Array.isArray(nodes)) return nodes
return nodes.map((node) => {
if (node._id && String(node._id) === String(id)) {
return { ...node, ...updatedData }
}
if (node.property && node.children) {
return {
...node,
children: mergeObjectInTree(node.children, id, updatedData)
}
}
return node
})
}
const updateAffectsFilter = (updatedData, filter, masterFilter) => {
if (!updatedData || typeof updatedData !== 'object') return false
const filterKeys = new Set([
...Object.keys(filter || {}),
...Object.keys(masterFilter || {})
])
if (filterKeys.size === 0) return false
return Object.keys(updatedData).some((key) => filterKeys.has(key))
}
const ObjectSelect = ({
type = 'unknown',
showSearch = true,
multiple = false,
treeSelectProps = EMPTY_OBJECT,
filter = EMPTY_OBJECT,
masterFilter = EMPTY_OBJECT,
value,
onChange,
disabled = false,
style = {},
...rest
}) => {
const {
fetchObjectsByProperty,
fetchObject,
connected,
searchObjects,
subscribeToAllObjectUpdates,
subscribeToObjectTypeUpdates
} = 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 [reloading, setReloading] = useState(false)
const [delayedInitialLoading, setDelayedInitalLoading] = useState(true)
const [expandedKeys, setExpandedKeys] = useState([])
const [treeVersion, setTreeVersion] = useState(0)
const [isSearching, setIsSearching] = useState(false)
const [searchValue, setSearchValue] = useState('')
const [valueNotFound, setValueNotFound] = useState(false)
const searchRequestIdRef = useRef(0)
const valueRef = useRef(null)
const objectListRef = useRef([])
const updateEventHandlerRef = useRef()
const newEventHandlerRef = useRef()
const subscriptionFilterRef = useRef()
const subscribeToObjectTypeUpdatesFnRef = useRef(subscribeToObjectTypeUpdates)
const subscribeToObjectTypeUpdatesRef = useRef(null)
const subscribedTypeRef = useRef(null)
const subscribeToAllObjectUpdatesRef = useRef(null)
const treeDataRef = useRef([])
const loadDataRef = useRef(null)
const expandedKeysRef = useRef([])
const filterRef = useRef(filter)
const masterFilterRef = useRef(masterFilter)
const clearedMissingValueRef = useRef(false)
filterRef.current = filter
masterFilterRef.current = masterFilter
// 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
const existingIdx = merged.findIndex(
(x) => String(x._id) === String(item._id)
)
if (existingIdx > -1) {
merged[existingIdx] = { ...merged[existingIdx], ...item }
} else {
merged.push(item)
}
}
}
return merged
}, [])
// Fetch the object properties tree from the API
const handleFetchObjectsProperties = useCallback(
async (customFilter = filter, { replace = false } = {}) => {
try {
const data = await fetchObjectsByProperty(type, {
properties: properties,
filter: customFilter,
masterFilter
})
if (Array.isArray(data)) {
setObjectPropertiesTree((prev) =>
replace ? data : mergeGroups(prev, data)
)
} else {
setObjectPropertiesTree(data)
}
setInitialLoading(false)
setError(false)
return data
} catch (err) {
console.error(err)
if (!replace) {
setError(true)
}
return null
}
},
[
type,
fetchObjectsByProperty,
properties,
filter,
masterFilter,
mergeGroups
]
)
const updateEventHandler = useCallback((id, updatedData) => {
const itemExists = objectListRef.current.some(
(obj) => String(obj._id) === String(id)
)
if (
updateAffectsFilter(
updatedData,
filterRef.current,
masterFilterRef.current
)
) {
if (itemExists) {
reloadRef.current?.()
} else {
silentReloadRef.current?.()
}
return
}
if (!itemExists) return
setObjectList((prev) =>
prev.map((obj) =>
String(obj._id) === String(id) ? { ...obj, ...updatedData } : obj
)
)
setObjectPropertiesTree((prev) => mergeObjectInTree(prev, id, updatedData))
}, [])
const newEventHandler = useCallback(() => {
reloadRef.current?.()
}, [])
const reloadRef = useRef(null)
const silentReloadRef = useRef(null)
subscribeToObjectTypeUpdatesFnRef.current = subscribeToObjectTypeUpdates
const subscriptionFilter = useMemo(
() => ({ ...filter, ...masterFilter }),
[filter, masterFilter]
)
subscriptionFilterRef.current = subscriptionFilter
const upsertObject = useCallback((objects, object) => {
const id = object._id.toString().toLowerCase()
const existingIdx = objects.findIndex(
(item) => item._id.toString().toLowerCase() === id
)
if (existingIdx > -1) {
objects[existingIdx] = { ...objects[existingIdx], ...object }
} else {
objects.push(object)
}
}, [])
// Convert the API response to AntD TreeSelect treeData
const buildTreeData = useCallback(
(data, pIdx = 0, parentKeys = [], filterPath = [], objects = []) => {
if (!data || !Array.isArray(data)) {
return { treeNodes: [], objects }
}
// If we are past the grouping properties, these are leaf objects
if (pIdx >= properties.length) {
const treeNodes = data.map((object) => {
upsertObject(objects, object)
return {
title: (
<div style={{ paddingTop: 0 }}>
<ObjectProperty
key={object._id}
type='object'
value={object}
objectType={type}
objectData={object}
isEditing={false}
showHyperlink={false}
style={{ top: '-0.5px' }}
/>
</div>
),
value: object._id.toString().toLowerCase(),
key: object._id.toString().toLowerCase(),
isLeaf: true,
parentKeys,
filterPath
}
})
return { treeNodes, objects }
}
// Group Nodes
const treeNodes = 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 { treeNodes: nodeChildren } = buildTreeData(
children,
pIdx + 1,
parentKeys.concat(valueString),
newFilterPath,
objects
)
const resolvedChildren =
nodeChildren.length === 0 ? undefined : nodeChildren
const modelProperty = getModelProperty(type, property)
return {
title: <ObjectProperty {...modelProperty} value={value} />,
value: nodeKey,
key: nodeKey,
property,
filterValue: valueString,
parentKeys: parentKeys.concat(valueString),
filterPath: newFilterPath,
selectable: false,
isLeaf: false,
children: resolvedChildren
}
})
.filter(Boolean)
return { treeNodes, objects }
},
[properties, type, upsertObject]
)
const applyTreeFromData = useCallback(
(data) => {
const { treeNodes, objects } = buildTreeData(data)
setObjectList(objects)
setTreeData(treeNodes)
treeDataRef.current = treeNodes
const syncedValue = getSelectValueFromExternal(
valueRef.current,
multiple,
treeNodes
)
if (multiple) {
if (Array.isArray(syncedValue) && syncedValue.length > 0) {
setTreeSelectValue(syncedValue)
setValueNotFound(false)
clearedMissingValueRef.current = false
}
} else if (syncedValue != null) {
setTreeSelectValue(syncedValue)
setValueNotFound(false)
clearedMissingValueRef.current = false
}
return { treeNodes, objects }
},
[buildTreeData, multiple]
)
const buildFilterFromNode = useCallback(
(node) => {
const customFilter = { ...filter }
if (Array.isArray(node.filterPath)) {
node.filterPath.forEach(({ property, value }) => {
customFilter[property] = value
})
}
customFilter[node.property] = node.filterValue
return customFilter
},
[filter]
)
// --- loadData for async loading on expand ---
const loadData = useCallback(
async (node) => {
if (!node.property) return
if (type == 'unknown') return
await handleFetchObjectsProperties(buildFilterFromNode(node))
},
[buildFilterFromNode, handleFetchObjectsProperties, type]
)
loadDataRef.current = loadData
const reloadTree = useCallback(
async ({ silent = false } = {}) => {
if (isSearching) return
if (!silent) {
setReloading(true)
}
try {
let mergedData = await fetchObjectsByProperty(type, {
properties: properties,
filter: filter,
masterFilter
})
if (!Array.isArray(mergedData)) return
let { treeNodes } = buildTreeData(mergedData)
const keysToReload = [...expandedKeysRef.current].sort(
(a, b) => a.length - b.length
)
for (const key of keysToReload) {
const node = findTreeNodeByKey(treeNodes, key)
if (!node?.property) continue
const branchData = await fetchObjectsByProperty(type, {
properties: properties,
filter: buildFilterFromNode(node),
masterFilter
})
if (!Array.isArray(branchData)) continue
const branchChildren = getBranchChildren(branchData, node.filterPath)
mergedData = replaceBranchChildren(
mergedData,
node.filterPath,
branchChildren
)
;({ treeNodes } = buildTreeData(mergedData))
}
setObjectPropertiesTree(mergedData)
} catch (err) {
console.error(err)
} finally {
if (!silent) {
setReloading(false)
}
}
},
[
isSearching,
fetchObjectsByProperty,
type,
properties,
filter,
masterFilter,
buildTreeData,
buildFilterFromNode
]
)
const reload = useCallback(() => reloadTree(), [reloadTree])
const silentReload = useCallback(
() => reloadTree({ silent: true }),
[reloadTree]
)
reloadRef.current = reload
silentReloadRef.current = silentReload
updateEventHandlerRef.current = updateEventHandler
newEventHandlerRef.current = newEventHandler
const onTreeSelectChange = useCallback(
(value) => {
const isEmptySelection = multiple
? !Array.isArray(value) || value.length === 0
: value == null || value === ''
if (
isEmptySelection &&
treeDataRef.current.length === 0 &&
valueRef.current != null
) {
return
}
setValueNotFound(false)
clearedMissingValueRef.current = false
// Mark this as an internal change
if (!multiple) setIsSearching(false)
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) => areValuesEqual(obj._id, id)))
.filter(Boolean)
}
setTreeSelectValue(value)
onChange?.(selectedObjects)
} else {
// Single selection: replace the previous object instead of emitting
// undefined (lodash merge skips undefined and would keep the old value).
if (value == null || value === '') {
setTreeSelectValue(null)
onChange?.(null)
return
}
const selectedObject = objectList.find((obj) =>
areValuesEqual(obj._id, value)
)
setTreeSelectValue(value)
onChange?.(selectedObject ?? null)
}
},
[multiple, objectList, onChange]
)
const onSearch = useCallback(
async (search) => {
setSearchValue(search)
const trimmed = search?.trim() ?? ''
if (!trimmed) {
searchRequestIdRef.current += 1
setIsSearching(false)
applyTreeFromData(objectPropertiesTree)
return
}
const requestId = ++searchRequestIdRef.current
setIsSearching(true)
const data = await searchObjects(type, trimmed)
if (requestId !== searchRequestIdRef.current) return
if (!Array.isArray(data)) return
const searchData =
multiple && Array.isArray(treeSelectValue)
? [
...data,
...objectList.filter((object) =>
treeSelectValue.some((id) => areValuesEqual(object._id, id))
)
].filter(
(object, index, objects) =>
objects.findIndex((item) =>
areValuesEqual(item._id, object._id)
) === index
)
: data
const { treeNodes, objects } = buildTreeData(
searchData,
properties.length
)
setObjectList(objects)
setTreeData(treeNodes)
treeDataRef.current = treeNodes
},
[
searchObjects,
type,
buildTreeData,
objectPropertiesTree,
properties.length,
multiple,
treeSelectValue,
objectList,
applyTreeFromData
]
)
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
? [
...(Array.isArray(treeSelectValue) ? treeSelectValue : []),
firstLeaf.value
].filter(
(item, index, values) =>
values.findIndex((value) => areValuesEqual(value, item)) ===
index
)
: firstLeaf.value
)
setSearchValue('')
onSearch('')
},
[
treeSelectProps,
isSearching,
treeData,
treeSelectValue,
multiple,
onTreeSelectChange,
onSearch
]
)
// Update treeData when objectPropertiesTree changes
useEffect(() => {
if (isSearching) return
if (!Array.isArray(objectPropertiesTree)) return
if (objectPropertiesTree.length > 0) {
applyTreeFromData(objectPropertiesTree)
} else {
setObjectList([])
setTreeData([])
treeDataRef.current = []
}
}, [objectPropertiesTree, applyTreeFromData, isSearching])
useEffect(() => {
expandedKeysRef.current = expandedKeys
}, [expandedKeys])
useEffect(() => {
treeDataRef.current = treeData
}, [treeData])
useEffect(() => {
const handleValue = async () => {
if (
multiple &&
Array.isArray(value) &&
getValueIdentity(valueRef.current) !== getValueIdentity(value) &&
type != 'unknown'
) {
valueRef.current = value
const fullValues = await Promise.all(value.map(fetchFullObjectIfNeeded))
const pathKeys = []
if (fullValues.length === 0) {
handleFetchObjectsProperties()
} else {
fullValues.forEach((fullValue) => {
const valueFilter = { ...filter }
const parentKeys = []
properties.forEach((prop) => {
if (!Object.prototype.hasOwnProperty.call(fullValue, prop)) return
const filterValue = fullValue[prop]
let valueString = filterValue
if (
filterValue &&
typeof filterValue === 'object' &&
filterValue._id
) {
valueString = filterValue._id
} else if (filterValue?.name) {
valueString = filterValue.name
} else if (Array.isArray(filterValue)) {
valueString = filterValue.join(',')
}
valueFilter[prop] = valueString
pathKeys.push(
parentKeys.concat(prop + ':' + valueString).join('-')
)
parentKeys.push(valueString)
})
handleFetchObjectsProperties(valueFilter)
})
}
setExpandedKeys([...new Set(pathKeys)])
setTreeSelectValue(
value
.map((item) => toSelectValue(getValueId(item)))
.filter((id) => id != null)
)
setInitialized(true)
return
}
if (
value &&
typeof value === 'object' &&
value !== null &&
getValueIdentity(valueRef.current) !== getValueIdentity(value) &&
type != 'unknown'
) {
valueRef.current = value
// Check if value is a minimal object and fetch full object if needed
const fullValue = await fetchFullObjectIfNeeded(value)
// Build a new filter from value's properties that are in the properties list
const valueFilter = { ...filter }
const pathKeys = []
const parentKeys = []
properties.forEach((prop) => {
if (Object.prototype.hasOwnProperty.call(fullValue, prop)) {
const filterValue = fullValue[prop]
let valueString = filterValue
if (
filterValue &&
typeof filterValue === 'object' &&
filterValue._id
) {
valueFilter[prop] = filterValue._id
valueString = filterValue._id
} else if (filterValue?.name) {
valueFilter[prop] = filterValue.name
valueString = filterValue.name
} else if (Array.isArray(filterValue)) {
valueFilter[prop] = filterValue.join(',')
valueString = filterValue.join(',')
} else {
valueFilter[prop] = filterValue
valueString = filterValue
}
// Build the path key for this property level
const nodeKey = parentKeys
.concat(prop + ':' + valueString)
.join('-')
pathKeys.push(nodeKey)
parentKeys.push(valueString)
}
})
// Expand the path to the object
setExpandedKeys(pathKeys)
// Fetch with the new filter
handleFetchObjectsProperties(valueFilter)
setTreeSelectValue(toSelectValue(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)
}
}
const timeoutId = setTimeout(() => {
handleValue()
}, 10)
return () => {
clearTimeout(timeoutId)
}
}, [
value,
filter,
properties,
handleFetchObjectsProperties,
initialized,
token,
fetchFullObjectIfNeeded,
type,
connected,
getValueIdentity,
multiple
])
const prevValuesRef = useRef({ type, masterFilter })
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) {
searchRequestIdRef.current += 1
setIsSearching(false)
setSearchValue('')
setObjectPropertiesTree({})
setObjectList([])
setTreeData([])
setTreeVersion((v) => v + 1)
setExpandedKeys([])
setInitialized(false)
valueRef.current = null
onTreeSelectChange(null)
setTreeSelectValue(null)
setInitialLoading(true)
setReloading(false)
setError(false)
setValueNotFound(false)
clearedMissingValueRef.current = false
prevValuesRef.current = { type, masterFilter }
}
}, [type, masterFilter, onTreeSelectChange])
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([])
treeDataRef.current = []
setInitialized(false)
setInitialLoading(true)
valueRef.current = null
clearedMissingValueRef.current = false
setValueNotFound(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, type, masterFilter])
useEffect(() => {
objectListRef.current = objectList
}, [objectList])
// Cleanup subscriptions on unmount
useEffect(() => {
return () => {
if (connected === true && subscribeToObjectTypeUpdatesRef.current) {
subscribeToObjectTypeUpdatesRef.current()
subscribeToObjectTypeUpdatesRef.current = null
}
if (connected === true && subscribeToAllObjectUpdatesRef.current) {
subscribeToAllObjectUpdatesRef.current()
subscribeToAllObjectUpdatesRef.current = null
}
}
}, [connected])
// Subscribe to all object updates for this type
useEffect(() => {
if (connected !== true || type === 'unknown' || type == null) return
const unsubscribe = subscribeToAllObjectUpdates(type, (id, updateData) => {
updateEventHandlerRef.current(id, updateData)
})
subscribeToAllObjectUpdatesRef.current = unsubscribe
return () => {
if (unsubscribe) unsubscribe()
if (subscribeToAllObjectUpdatesRef.current === unsubscribe) {
subscribeToAllObjectUpdatesRef.current = null
}
}
}, [type, connected, subscribeToAllObjectUpdates])
// Subscribe to type-level updates (new/changed objects matching filter)
useEffect(() => {
if (connected !== true) return
if (subscribedTypeRef.current === type) return
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
type,
subscriptionFilterRef.current,
() => newEventHandlerRef.current()
)
subscribeToObjectTypeUpdatesRef.current = unsubscribe
subscribedTypeRef.current = type
return () => {
if (unsubscribe) unsubscribe()
if (subscribeToObjectTypeUpdatesRef.current === unsubscribe) {
subscribeToObjectTypeUpdatesRef.current = null
}
if (subscribedTypeRef.current === type) {
subscribedTypeRef.current = null
}
}
}, [type, connected])
useEffect(() => {
if (initialLoading == false) {
setTimeout(() => {
setDelayedInitalLoading(false)
}, 100)
} else {
setDelayedInitalLoading(true)
}
}, [initialLoading])
const modelLabel = useMemo(() => getModelByName(type).label, [type])
const placeholder = useMemo(
() =>
type == 'unknown' || type == undefined
? 'n/a'
: `Select a ${modelLabel.toLowerCase()}...`,
[type, modelLabel]
)
const valueReady =
initialized &&
!initialLoading &&
!delayedInitialLoading &&
!reloading &&
!isSearching
useEffect(() => {
const missing = isExternalValueMissing(
value,
multiple,
objectList,
treeData,
valueReady
)
if (missing) {
setValueNotFound(true)
if (
value != null &&
!(Array.isArray(value) && value.length === 0) &&
!clearedMissingValueRef.current
) {
clearedMissingValueRef.current = true
isInternalChangeRef.current = true
setTreeSelectValue(multiple ? [] : null)
onChange?.(multiple ? [] : null)
}
return
}
clearedMissingValueRef.current = false
if (value != null) {
setValueNotFound(false)
}
}, [value, multiple, objectList, treeData, valueReady, onChange])
const displayPlaceholder = valueNotFound
? `${modelLabel.charAt(0).toUpperCase()}${modelLabel.slice(1).toLowerCase()} not found.`
: placeholder
const displayValue = valueNotFound ? (multiple ? [] : null) : treeSelectValue
// --- Error UI ---
if (error) {
return (
<Space.Compact style={{ width: '100%' }}>
<Input value='Failed to load data.' status='error' disabled />
<Button
icon={<ReloadIcon />}
onClick={() => {
setError(false)
setTreeData([])
setInitialized(false)
}}
danger
/>
</Space.Compact>
)
}
// --- Main TreeSelect UI ---
return (
<div style={{ ...style, position: 'relative' }}>
<TreeSelect
key={treeVersion}
treeDataSimpleMode={false}
treeDefaultExpandAll={true}
treeExpandedKeys={expandedKeys}
onTreeExpand={setExpandedKeys}
treeData={treeData}
showSearch={showSearch}
filterTreeNode={() => true}
multiple={multiple}
loadData={isSearching ? undefined : loadData}
showCheckedStrategy={SHOW_CHILD}
{...treeSelectProps}
{...rest}
placeholder={displayPlaceholder}
status={valueNotFound ? 'warning' : treeSelectProps.status}
value={displayValue}
onChange={onTreeSelectChange}
onSearch={onSearch}
onInputKeyDown={onInputKeyDown}
searchValue={searchValue}
disabled={disabled || type == 'unknown' || type == undefined}
loading={reloading}
style={{ opacity: delayedInitialLoading ? 0 : 1 }}
className={multiple ? 'object-select-multiple' : 'object-select'}
/>
{delayedInitialLoading && (
<TreeSelect
disabled
loading
placeholder='Loading...'
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0
}}
/>
)}
</div>
)
}
ObjectSelect.propTypes = {
properties: PropTypes.arrayOf(PropTypes.string).isRequired,
filter: PropTypes.object,
masterFilter: PropTypes.object,
useFilter: PropTypes.bool,
value: PropTypes.any,
onChange: PropTypes.func,
showSearch: PropTypes.bool,
multiple: PropTypes.bool,
treeSelectProps: PropTypes.object,
type: PropTypes.string.isRequired,
disabled: PropTypes.bool,
style: PropTypes.object
}
export default ObjectSelect