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 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 = ({ const ObjectSelect = ({
type = 'unknown', type = 'unknown',
showSearch = true, showSearch = true,
@ -48,8 +183,14 @@ const ObjectSelect = ({
style = {}, style = {},
...rest ...rest
}) => { }) => {
const { fetchObjectsByProperty, fetchObject, connected, searchObjects } = const {
useContext(ApiServerContext) fetchObjectsByProperty,
fetchObject,
connected,
searchObjects,
subscribeToAllObjectUpdates,
subscribeToObjectTypeUpdates
} = useContext(ApiServerContext)
const { token } = useContext(AuthContext) const { token } = useContext(AuthContext)
// --- State --- // --- State ---
const [treeData, setTreeData] = useState([]) const [treeData, setTreeData] = useState([])
@ -60,13 +201,32 @@ const ObjectSelect = ({
const [objectList, setObjectList] = useState([]) const [objectList, setObjectList] = useState([])
const [treeSelectValue, setTreeSelectValue] = useState(null) const [treeSelectValue, setTreeSelectValue] = useState(null)
const [initialLoading, setInitialLoading] = useState(true) const [initialLoading, setInitialLoading] = useState(true)
const [reloading, setReloading] = useState(false)
const [delayedInitialLoading, setDelayedInitalLoading] = useState(true) const [delayedInitialLoading, setDelayedInitalLoading] = useState(true)
const [expandedKeys, setExpandedKeys] = useState([]) const [expandedKeys, setExpandedKeys] = useState([])
const [treeVersion, setTreeVersion] = useState(0) const [treeVersion, setTreeVersion] = useState(0)
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
const [searchValue, setSearchValue] = useState('') const [searchValue, setSearchValue] = useState('')
const [valueNotFound, setValueNotFound] = useState(false)
const searchRequestIdRef = useRef(0) const searchRequestIdRef = useRef(0)
const valueRef = useRef(null) 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 // Refs to track value changes
const prevValueRef = useRef(value) const prevValueRef = useRef(value)
@ -165,7 +325,12 @@ const ObjectSelect = ({
} }
} else { } else {
// It's a leaf object // 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) merged.push(item)
} }
} }
@ -175,7 +340,7 @@ const ObjectSelect = ({
// Fetch the object properties tree from the API // Fetch the object properties tree from the API
const handleFetchObjectsProperties = useCallback( const handleFetchObjectsProperties = useCallback(
async (customFilter = filter) => { async (customFilter = filter, { replace = false } = {}) => {
try { try {
const data = await fetchObjectsByProperty(type, { const data = await fetchObjectsByProperty(type, {
properties: properties, properties: properties,
@ -184,7 +349,9 @@ const ObjectSelect = ({
}) })
if (Array.isArray(data)) { if (Array.isArray(data)) {
setObjectPropertiesTree((prev) => mergeGroups(prev, data)) setObjectPropertiesTree((prev) =>
replace ? data : mergeGroups(prev, data)
)
} else { } else {
setObjectPropertiesTree(data) setObjectPropertiesTree(data)
} }
@ -194,7 +361,9 @@ const ObjectSelect = ({
return data return data
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setError(true) if (!replace) {
setError(true)
}
return null 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 // Convert the API response to AntD TreeSelect treeData
const buildTreeData = useCallback( const buildTreeData = useCallback(
(data, pIdx = 0, parentKeys = [], filterPath = []) => { (data, pIdx = 0, parentKeys = [], filterPath = [], objects = []) => {
if (!data || !Array.isArray(data)) return [] if (!data || !Array.isArray(data)) {
return { treeNodes: [], objects }
}
// If we are past the grouping properties, these are leaf objects // If we are past the grouping properties, these are leaf objects
if (pIdx >= properties.length) { if (pIdx >= properties.length) {
return data.map((object) => { const treeNodes = data.map((object) => {
setObjectList((prev) => { upsertObject(objects, object)
if (
prev.some(
(p) =>
p._id.toString().toLowerCase() ===
object._id.toString().toLowerCase()
)
)
return prev
return [...prev, object]
})
return { return {
title: ( title: (
<div style={{ paddingTop: 0 }}> <div style={{ paddingTop: 0 }}>
@ -248,10 +467,11 @@ const ObjectSelect = ({
filterPath filterPath
} }
}) })
return { treeNodes, objects }
} }
// Group Nodes // Group Nodes
return data const treeNodes = data
.map((group) => { .map((group) => {
// Only process if it looks like a group // Only process if it looks like a group
if (!group.property) return null if (!group.property) return null
@ -272,15 +492,15 @@ const ObjectSelect = ({
value: valueString value: valueString
}) })
var nodeChildren = buildTreeData( const { treeNodes: nodeChildren } = buildTreeData(
children, children,
pIdx + 1, pIdx + 1,
parentKeys.concat(valueString), parentKeys.concat(valueString),
newFilterPath newFilterPath,
objects
) )
if (nodeChildren.length == 0) { const resolvedChildren =
nodeChildren = undefined nodeChildren.length === 0 ? undefined : nodeChildren
}
const modelProperty = getModelProperty(type, property) const modelProperty = getModelProperty(type, property)
return { return {
title: <ObjectProperty {...modelProperty} value={value} />, title: <ObjectProperty {...modelProperty} value={value} />,
@ -292,78 +512,129 @@ const ObjectSelect = ({
filterPath: newFilterPath, filterPath: newFilterPath,
selectable: false, selectable: false,
isLeaf: false, isLeaf: false,
children: nodeChildren children: resolvedChildren
} }
}) })
.filter(Boolean) .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 --- // --- loadData for async loading on expand ---
const loadData = async (node) => { const loadData = useCallback(
// node.property is the property name, node.value is the value key async (node) => {
if (!node.property) return if (!node.property) return
if (type == 'unknown') return if (type == 'unknown') return
// Build filter for this node by merging all parent property-value pairs await handleFetchObjectsProperties(buildFilterFromNode(node))
const customFilter = { ...filter } },
if (Array.isArray(node.filterPath)) { [buildFilterFromNode, handleFetchObjectsProperties, type]
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 loadDataRef.current = loadData
let nodeSpecificChildren = data
if (node.filterPath && Array.isArray(node.filterPath)) { const reloadTree = useCallback(
for (const pathItem of node.filterPath) { async ({ silent = false } = {}) => {
if (!Array.isArray(nodeSpecificChildren)) break if (isSearching) return
const match = nodeSpecificChildren.find( if (!silent) {
(g) => setReloading(true)
g.property === pathItem.property && }
areValuesEqual(g.value, pathItem.value) 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) { for (const key of keysToReload) {
nodeSpecificChildren = match.children const node = findTreeNodeByKey(treeNodes, key)
} else { if (!node?.property) continue
nodeSpecificChildren = []
break 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 reload = useCallback(() => reloadTree(), [reloadTree])
const children = buildTreeData( const silentReload = useCallback(
nodeSpecificChildren, () => reloadTree({ silent: true }),
properties.indexOf(node.property) + 1, [reloadTree]
node.parentKeys || [], )
node.filterPath
)
// Update treeData with new children for this node only reloadRef.current = reload
setTreeData((prevTreeData) => { silentReloadRef.current = silentReload
// Helper to recursively update the correct node
const updateNode = (nodes) => updateEventHandlerRef.current = updateEventHandler
nodes.map((n) => { newEventHandlerRef.current = newEventHandler
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 = useCallback( const onTreeSelectChange = useCallback(
(value) => { (value) => {
setValueNotFound(false)
clearedMissingValueRef.current = false
// Mark this as an internal change // Mark this as an internal change
if (!multiple) setIsSearching(false) if (!multiple) setIsSearching(false)
isInternalChangeRef.current = true isInternalChangeRef.current = true
@ -404,7 +675,7 @@ const ObjectSelect = ({
if (!trimmed) { if (!trimmed) {
searchRequestIdRef.current += 1 searchRequestIdRef.current += 1
setIsSearching(false) setIsSearching(false)
setTreeData(buildTreeData(objectPropertiesTree)) applyTreeFromData(objectPropertiesTree)
return return
} }
@ -429,7 +700,13 @@ const ObjectSelect = ({
) )
: data : data
setTreeData(buildTreeData(searchData, properties.length)) const { treeNodes, objects } = buildTreeData(
searchData,
properties.length
)
setObjectList(objects)
setTreeData(treeNodes)
treeDataRef.current = treeNodes
}, },
[ [
searchObjects, searchObjects,
@ -439,7 +716,8 @@ const ObjectSelect = ({
properties.length, properties.length,
multiple, multiple,
treeSelectValue, treeSelectValue,
objectList objectList,
applyTreeFromData
] ]
) )
@ -481,16 +759,23 @@ const ObjectSelect = ({
// Update treeData when objectPropertiesTree changes // Update treeData when objectPropertiesTree changes
useEffect(() => { useEffect(() => {
if (isSearching) return if (isSearching) return
if (objectPropertiesTree && Object.keys(objectPropertiesTree).length > 0) { if (!Array.isArray(objectPropertiesTree)) return
const newTreeData = buildTreeData(objectPropertiesTree) if (objectPropertiesTree.length > 0) {
setTreeData((prev) => { applyTreeFromData(objectPropertiesTree)
if (JSON.stringify(prev) !== JSON.stringify(newTreeData)) { } else {
return newTreeData setObjectList([])
} setTreeData([])
return prev treeDataRef.current = []
})
} }
}, [objectPropertiesTree, properties, buildTreeData, isSearching]) }, [objectPropertiesTree, applyTreeFromData, isSearching])
useEffect(() => {
expandedKeysRef.current = expandedKeys
}, [expandedKeys])
useEffect(() => {
treeDataRef.current = treeData
}, [treeData])
useEffect(() => { useEffect(() => {
const handleValue = async () => { const handleValue = async () => {
@ -658,7 +943,10 @@ const ObjectSelect = ({
onTreeSelectChange(null) onTreeSelectChange(null)
setTreeSelectValue(null) setTreeSelectValue(null)
setInitialLoading(true) setInitialLoading(true)
setReloading(false)
setError(false) setError(false)
setValueNotFound(false)
clearedMissingValueRef.current = false
prevValuesRef.current = { type, masterFilter } prevValuesRef.current = { type, masterFilter }
} }
}, [type, masterFilter, onTreeSelectChange]) }, [type, masterFilter, onTreeSelectChange])
@ -688,6 +976,63 @@ const ObjectSelect = ({
} }
}, [value, getValueIdentity, type, masterFilter]) }, [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(() => { useEffect(() => {
if (initialLoading == false) { if (initialLoading == false) {
setTimeout(() => { setTimeout(() => {
@ -698,14 +1043,59 @@ const ObjectSelect = ({
} }
}, [initialLoading]) }, [initialLoading])
const modelLabel = useMemo(() => getModelByName(type).label, [type])
const placeholder = useMemo( const placeholder = useMemo(
() => () =>
type == 'unknown' || type == undefined type == 'unknown' || type == undefined
? 'n/a' ? 'n/a'
: `Select a ${getModelByName(type).label.toLowerCase()}...`, : `Select a ${modelLabel.toLowerCase()}...`,
[type] [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 --- // --- Error UI ---
if (error) { if (error) {
return ( return (
@ -739,15 +1129,17 @@ const ObjectSelect = ({
multiple={multiple} multiple={multiple}
loadData={isSearching ? undefined : loadData} loadData={isSearching ? undefined : loadData}
showCheckedStrategy={SHOW_CHILD} showCheckedStrategy={SHOW_CHILD}
placeholder={placeholder}
{...treeSelectProps} {...treeSelectProps}
{...rest} {...rest}
value={treeSelectValue} placeholder={displayPlaceholder}
status={valueNotFound ? 'warning' : treeSelectProps.status}
value={displayValue}
onChange={onTreeSelectChange} onChange={onTreeSelectChange}
onSearch={onSearch} onSearch={onSearch}
onInputKeyDown={onInputKeyDown} onInputKeyDown={onInputKeyDown}
searchValue={searchValue} searchValue={searchValue}
disabled={disabled || type == 'unknown' || type == undefined} disabled={disabled || type == 'unknown' || type == undefined}
loading={reloading}
style={{ opacity: delayedInitialLoading ? 0 : 1 }} style={{ opacity: delayedInitialLoading ? 0 : 1 }}
className={multiple ? 'object-select-multiple' : 'object-select'} className={multiple ? 'object-select-multiple' : 'object-select'}
/> />

View File

@ -100,6 +100,8 @@ const stableStringify = (value) => {
const getObjectTypeSubscriptionKey = (objectType, filter = {}) => const getObjectTypeSubscriptionKey = (objectType, filter = {}) =>
`${objectType}:${stableStringify(filter || {})}` `${objectType}:${stableStringify(filter || {})}`
const getAllObjectUpdatesSubscriptionKey = (objectType) => `${objectType}:*`
const mapObjectFiltersForQuery = (filter = {}, type) => { const mapObjectFiltersForQuery = (filter = {}, type) => {
const newFilter = { ...filter } const newFilter = { ...filter }
if (filter == null || Object.keys(filter).length === 0) return newFilter if (filter == null || Object.keys(filter).length === 0) return newFilter
@ -723,6 +725,7 @@ const ApiServerProvider = ({ children }) => {
const objectType = data.objectType const objectType = data.objectType
const callbacksRefKey = `${objectType}:${id}` const callbacksRefKey = `${objectType}:${id}`
const allCallbacksRefKey = getAllObjectUpdatesSubscriptionKey(objectType)
if ( if (
id && id &&
@ -747,6 +750,21 @@ const ApiServerProvider = ({ children }) => {
Array.from(subscribedCallbacksRef.current.keys()) 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) => { 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( const offObjectTypeUpdatesEvent = useCallback(
(objectType, filter, callback) => { (objectType, filter, callback) => {
if (socketRef.current && socketRef.current.connected == true) { if (socketRef.current && socketRef.current.connected == true) {
@ -957,6 +999,45 @@ const ApiServerProvider = ({ children }) => {
[offObjectUpdatesEvent] [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 // Subscribe to user profile updates when WebSocket is connected and userProfile._id exists
useEffect(() => { useEffect(() => {
if (connected && userProfile?._id) { if (connected && userProfile?._id) {
@ -2729,6 +2810,7 @@ const ApiServerProvider = ({ children }) => {
sendObjectFunction, sendObjectFunction,
deleteObject, deleteObject,
subscribeToObjectUpdates, subscribeToObjectUpdates,
subscribeToAllObjectUpdates,
subscribeToObjectEvent, subscribeToObjectEvent,
subscribeToObjectTypeUpdates, subscribeToObjectTypeUpdates,
subscribeToObjectActivity, subscribeToObjectActivity,