diff --git a/src/components/Dashboard/common/ObjectSelect.jsx b/src/components/Dashboard/common/ObjectSelect.jsx index 396da1d4..393b158a 100644 --- a/src/components/Dashboard/common/ObjectSelect.jsx +++ b/src/components/Dashboard/common/ObjectSelect.jsx @@ -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: (
@@ -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: , @@ -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'} /> diff --git a/src/components/Dashboard/context/ApiServerContext.jsx b/src/components/Dashboard/context/ApiServerContext.jsx index b310cc46..46429e1f 100644 --- a/src/components/Dashboard/context/ApiServerContext.jsx +++ b/src/components/Dashboard/context/ApiServerContext.jsx @@ -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,