- 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.
1246 lines
35 KiB
JavaScript
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
|