Enhance ObjectSelect and ApiServerContext with Subscription Logic and Utility Functions
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Introduced new utility functions in ObjectSelect for tree node searching and value validation, improving data handling and user experience.
- Enhanced ObjectSelect component to manage object updates and reloading logic more effectively, ensuring accurate state representation.
- Added subscription capabilities for all object updates in ApiServerContext, allowing for real-time updates across components.
- Refactored existing methods to improve clarity and maintainability, enhancing overall performance of the dashboard components.
This commit is contained in:
Tom Butcher 2026-09-01 14:14:12 +01:00
parent dff0a02c12
commit a5b888333d
2 changed files with 571 additions and 97 deletions

View File

@ -35,6 +35,141 @@ const getFirstSelectableLeaf = (nodes) => {
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 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,
@ -48,8 +183,14 @@ const ObjectSelect = ({
style = {},
...rest
}) => {
const { fetchObjectsByProperty, fetchObject, connected, searchObjects } =
useContext(ApiServerContext)
const {
fetchObjectsByProperty,
fetchObject,
connected,
searchObjects,
subscribeToAllObjectUpdates,
subscribeToObjectTypeUpdates
} = useContext(ApiServerContext)
const { token } = useContext(AuthContext)
// --- State ---
const [treeData, setTreeData] = useState([])
@ -60,13 +201,32 @@ const ObjectSelect = ({
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)
@ -165,7 +325,12 @@ const ObjectSelect = ({
}
} else {
// It's a leaf object
if (!merged.some((x) => String(x._id) === String(item._id))) {
const existingIdx = merged.findIndex(
(x) => String(x._id) === String(item._id)
)
if (existingIdx > -1) {
merged[existingIdx] = { ...merged[existingIdx], ...item }
} else {
merged.push(item)
}
}
@ -175,7 +340,7 @@ const ObjectSelect = ({
// Fetch the object properties tree from the API
const handleFetchObjectsProperties = useCallback(
async (customFilter = filter) => {
async (customFilter = filter, { replace = false } = {}) => {
try {
const data = await fetchObjectsByProperty(type, {
properties: properties,
@ -184,7 +349,9 @@ const ObjectSelect = ({
})
if (Array.isArray(data)) {
setObjectPropertiesTree((prev) => mergeGroups(prev, data))
setObjectPropertiesTree((prev) =>
replace ? data : mergeGroups(prev, data)
)
} else {
setObjectPropertiesTree(data)
}
@ -194,7 +361,9 @@ const ObjectSelect = ({
return data
} catch (err) {
console.error(err)
setError(true)
if (!replace) {
setError(true)
}
return null
}
},
@ -208,24 +377,74 @@ const ObjectSelect = ({
]
)
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 = []) => {
if (!data || !Array.isArray(data)) return []
(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) {
return data.map((object) => {
setObjectList((prev) => {
if (
prev.some(
(p) =>
p._id.toString().toLowerCase() ===
object._id.toString().toLowerCase()
)
)
return prev
return [...prev, object]
})
const treeNodes = data.map((object) => {
upsertObject(objects, object)
return {
title: (
<div style={{ paddingTop: 0 }}>
@ -248,10 +467,11 @@ const ObjectSelect = ({
filterPath
}
})
return { treeNodes, objects }
}
// Group Nodes
return data
const treeNodes = data
.map((group) => {
// Only process if it looks like a group
if (!group.property) return null
@ -272,15 +492,15 @@ const ObjectSelect = ({
value: valueString
})
var nodeChildren = buildTreeData(
const { treeNodes: nodeChildren } = buildTreeData(
children,
pIdx + 1,
parentKeys.concat(valueString),
newFilterPath
newFilterPath,
objects
)
if (nodeChildren.length == 0) {
nodeChildren = undefined
}
const resolvedChildren =
nodeChildren.length === 0 ? undefined : nodeChildren
const modelProperty = getModelProperty(type, property)
return {
title: <ObjectProperty {...modelProperty} value={value} />,
@ -292,78 +512,129 @@ const ObjectSelect = ({
filterPath: newFilterPath,
selectable: false,
isLeaf: false,
children: nodeChildren
children: resolvedChildren
}
})
.filter(Boolean)
return { treeNodes, objects }
},
[properties, type]
[properties, type, upsertObject]
)
const applyTreeFromData = useCallback(
(data) => {
const { treeNodes, objects } = buildTreeData(data)
setObjectList(objects)
setTreeData(treeNodes)
treeDataRef.current = treeNodes
return { treeNodes, objects }
},
[buildTreeData]
)
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 = 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
const loadData = useCallback(
async (node) => {
if (!node.property) return
if (type == 'unknown') return
await handleFetchObjectsProperties(buildFilterFromNode(node))
},
[buildFilterFromNode, handleFetchObjectsProperties, type]
)
// Navigate to the specific node's children in the response
let nodeSpecificChildren = data
loadDataRef.current = loadData
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)
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
)
if (match) {
nodeSpecificChildren = match.children
} else {
nodeSpecificChildren = []
break
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
]
)
// Build new tree children only for this specific node
const children = buildTreeData(
nodeSpecificChildren,
properties.indexOf(node.property) + 1,
node.parentKeys || [],
node.filterPath
)
const reload = useCallback(() => reloadTree(), [reloadTree])
const silentReload = useCallback(
() => reloadTree({ silent: true }),
[reloadTree]
)
// 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)
})
}
reloadRef.current = reload
silentReloadRef.current = silentReload
updateEventHandlerRef.current = updateEventHandler
newEventHandlerRef.current = newEventHandler
const onTreeSelectChange = useCallback(
(value) => {
setValueNotFound(false)
clearedMissingValueRef.current = false
// Mark this as an internal change
if (!multiple) setIsSearching(false)
isInternalChangeRef.current = true
@ -404,7 +675,7 @@ const ObjectSelect = ({
if (!trimmed) {
searchRequestIdRef.current += 1
setIsSearching(false)
setTreeData(buildTreeData(objectPropertiesTree))
applyTreeFromData(objectPropertiesTree)
return
}
@ -429,7 +700,13 @@ const ObjectSelect = ({
)
: data
setTreeData(buildTreeData(searchData, properties.length))
const { treeNodes, objects } = buildTreeData(
searchData,
properties.length
)
setObjectList(objects)
setTreeData(treeNodes)
treeDataRef.current = treeNodes
},
[
searchObjects,
@ -439,7 +716,8 @@ const ObjectSelect = ({
properties.length,
multiple,
treeSelectValue,
objectList
objectList,
applyTreeFromData
]
)
@ -481,16 +759,23 @@ const ObjectSelect = ({
// Update treeData when objectPropertiesTree changes
useEffect(() => {
if (isSearching) return
if (objectPropertiesTree && Object.keys(objectPropertiesTree).length > 0) {
const newTreeData = buildTreeData(objectPropertiesTree)
setTreeData((prev) => {
if (JSON.stringify(prev) !== JSON.stringify(newTreeData)) {
return newTreeData
}
return prev
})
if (!Array.isArray(objectPropertiesTree)) return
if (objectPropertiesTree.length > 0) {
applyTreeFromData(objectPropertiesTree)
} else {
setObjectList([])
setTreeData([])
treeDataRef.current = []
}
}, [objectPropertiesTree, properties, buildTreeData, isSearching])
}, [objectPropertiesTree, applyTreeFromData, isSearching])
useEffect(() => {
expandedKeysRef.current = expandedKeys
}, [expandedKeys])
useEffect(() => {
treeDataRef.current = treeData
}, [treeData])
useEffect(() => {
const handleValue = async () => {
@ -658,7 +943,10 @@ const ObjectSelect = ({
onTreeSelectChange(null)
setTreeSelectValue(null)
setInitialLoading(true)
setReloading(false)
setError(false)
setValueNotFound(false)
clearedMissingValueRef.current = false
prevValuesRef.current = { type, masterFilter }
}
}, [type, masterFilter, onTreeSelectChange])
@ -688,6 +976,63 @@ const ObjectSelect = ({
}
}, [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(() => {
@ -698,14 +1043,59 @@ const ObjectSelect = ({
}
}, [initialLoading])
const modelLabel = useMemo(() => getModelByName(type).label, [type])
const placeholder = useMemo(
() =>
type == 'unknown' || type == undefined
? 'n/a'
: `Select a ${getModelByName(type).label.toLowerCase()}...`,
[type]
: `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 (
@ -739,15 +1129,17 @@ const ObjectSelect = ({
multiple={multiple}
loadData={isSearching ? undefined : loadData}
showCheckedStrategy={SHOW_CHILD}
placeholder={placeholder}
{...treeSelectProps}
{...rest}
value={treeSelectValue}
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'}
/>

View File

@ -100,6 +100,8 @@ const stableStringify = (value) => {
const getObjectTypeSubscriptionKey = (objectType, filter = {}) =>
`${objectType}:${stableStringify(filter || {})}`
const getAllObjectUpdatesSubscriptionKey = (objectType) => `${objectType}:*`
const mapObjectFiltersForQuery = (filter = {}, type) => {
const newFilter = { ...filter }
if (filter == null || Object.keys(filter).length === 0) return newFilter
@ -723,6 +725,7 @@ const ApiServerProvider = ({ children }) => {
const objectType = data.objectType
const callbacksRefKey = `${objectType}:${id}`
const allCallbacksRefKey = getAllObjectUpdatesSubscriptionKey(objectType)
if (
id &&
@ -747,6 +750,21 @@ const ApiServerProvider = ({ children }) => {
Array.from(subscribedCallbacksRef.current.keys())
)
}
if (objectType && subscribedCallbacksRef.current.has(allCallbacksRefKey)) {
const callbacks = subscribedCallbacksRef.current.get(allCallbacksRefKey)
logger.debug(
`Calling ${callbacks.length} callbacks for all object updates:`,
allCallbacksRefKey
)
callbacks.forEach((callback) => {
try {
callback(id, data.object)
} catch (error) {
logger.error('Error in all object update callback:', error)
}
})
}
}
const handleObjectEvent = async (data) => {
@ -890,6 +908,30 @@ const ApiServerProvider = ({ children }) => {
}
}, [])
const offAllObjectUpdatesEvent = useCallback((objectType, callback) => {
if (socketRef.current && socketRef.current.connected == true) {
const callbacksRefKey = getAllObjectUpdatesSubscriptionKey(objectType)
if (subscribedCallbacksRef.current.has(callbacksRefKey)) {
const callbacks = subscribedCallbacksRef.current
.get(callbacksRefKey)
.filter((cb) => cb !== callback)
if (callbacks.length === 0) {
logger.debug(
'No callbacks found for all object updates:',
callbacksRefKey,
'unsubscribing...'
)
subscribedCallbacksRef.current.delete(callbacksRefKey)
socketRef.current.emit('unsubscribeAllObjectUpdates', {
objectType: objectType
})
} else {
subscribedCallbacksRef.current.set(callbacksRefKey, callbacks)
}
}
}
}, [])
const offObjectTypeUpdatesEvent = useCallback(
(objectType, filter, callback) => {
if (socketRef.current && socketRef.current.connected == true) {
@ -957,6 +999,45 @@ const ApiServerProvider = ({ children }) => {
[offObjectUpdatesEvent]
)
const subscribeToAllObjectUpdates = useCallback(
(objectType, callback) => {
if (socketRef.current && socketRef.current.connected == true) {
const callbacksRefKey = getAllObjectUpdatesSubscriptionKey(objectType)
if (!subscribedCallbacksRef.current.has(callbacksRefKey)) {
subscribedCallbacksRef.current.set(callbacksRefKey, [])
}
const callbacksLength =
subscribedCallbacksRef.current.get(callbacksRefKey).length
if (callbacksLength <= 0) {
socketRef.current.emit(
'subscribeToAllObjectUpdates',
{ objectType: objectType },
(result) => {
if (result.success) {
logger.info(
'Subscribed to all object updates for objectType:',
objectType
)
}
}
)
}
logger.info(
'Adding all-object-updates callback for objectType:',
objectType,
'callbacks length:',
callbacksLength + 1
)
subscribedCallbacksRef.current.get(callbacksRefKey).push(callback)
return () => offAllObjectUpdatesEvent(objectType, callback)
}
},
[offAllObjectUpdatesEvent]
)
// Subscribe to user profile updates when WebSocket is connected and userProfile._id exists
useEffect(() => {
if (connected && userProfile?._id) {
@ -2729,6 +2810,7 @@ const ApiServerProvider = ({ children }) => {
sendObjectFunction,
deleteObject,
subscribeToObjectUpdates,
subscribeToAllObjectUpdates,
subscribeToObjectEvent,
subscribeToObjectTypeUpdates,
subscribeToObjectActivity,