Compare commits

..

2 Commits

Author SHA1 Message Date
a5b888333d 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.
2026-09-01 14:14:12 +01:00
dff0a02c12 Enhance ModelHistoryDisplay with Tooltip Formatting and Utility Functions
- Added useCallback for formatTooltipValue to improve tooltip display by incorporating prefix, suffix, and rounding based on series definitions.
- Updated data structure in chart data preparation to include prefix and suffix for values, enhancing clarity in displayed metrics.
- Imported round utility function to facilitate number rounding based on series configuration, improving data presentation accuracy.
2026-09-01 13:34:37 +01:00
3 changed files with 593 additions and 100 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useContext, useMemo } from 'react' import { useEffect, useState, useContext, useMemo, useCallback } from 'react'
import { import {
Card, Card,
Segmented, Segmented,
@ -21,6 +21,7 @@ import HistoryChartLegend from './HistoryChartLegend'
import { useHistoryLegendOverlay } from '../hooks/useHistoryLegendOverlay' import { useHistoryLegendOverlay } from '../hooks/useHistoryLegendOverlay'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import { round } from '../utils/Utils'
const legendMeasureStyle = { const legendMeasureStyle = {
position: 'absolute', position: 'absolute',
@ -197,11 +198,26 @@ const ModelHistoryDisplay = ({
return { return {
label: statDef.label || statDef.name, label: statDef.label || statDef.name,
color color,
prefix: statDef.prefix,
suffix: statDef.suffix,
roundNumber: statDef.roundNumber
} }
}) })
}, [modelStats, themeColors]) }, [modelStats, themeColors])
const formatTooltipValue = useCallback(
(value, seriesLabel) => {
const seriesDef = seriesLabels.find((item) => item.label === seriesLabel)
let numericValue = Number(value ?? 0)
if (seriesDef?.roundNumber) {
numericValue = round(numericValue, seriesDef.roundNumber)
}
return `${seriesDef?.prefix || ''}${numericValue}${seriesDef?.suffix || ''}`
},
[seriesLabels]
)
const { const {
slotRef, slotRef,
measureRef, measureRef,
@ -342,7 +358,9 @@ const ModelHistoryDisplay = ({
date: point.date, date: point.date,
dateFormatted: dayjs(point.date).format('DD/MM HH:mm'), dateFormatted: dayjs(point.date).format('DD/MM HH:mm'),
category: label, category: label,
value: statValue || 0 value: statValue || 0,
prefix: statDef.prefix || '',
suffix: statDef.suffix || ''
} }
}) })
}) })
@ -525,6 +543,7 @@ const ModelHistoryDisplay = ({
seriesLabels={seriesLabels} seriesLabels={seriesLabels}
chartType={chartType} chartType={chartType}
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
formatTooltipValue={formatTooltipValue}
/> />
)} )}
{loading == true && chartRows.length == 0 && ( {loading == true && chartRows.length == 0 && (

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)
if (!replace) {
setError(true) 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]
) )
// --- loadData for async loading on expand --- const applyTreeFromData = useCallback(
const loadData = async (node) => { (data) => {
// node.property is the property name, node.value is the value key const { treeNodes, objects } = buildTreeData(data)
if (!node.property) return setObjectList(objects)
if (type == 'unknown') return setTreeData(treeNodes)
// Build filter for this node by merging all parent property-value pairs treeDataRef.current = treeNodes
return { treeNodes, objects }
},
[buildTreeData]
)
const buildFilterFromNode = useCallback(
(node) => {
const customFilter = { ...filter } const customFilter = { ...filter }
if (Array.isArray(node.filterPath)) { if (Array.isArray(node.filterPath)) {
node.filterPath.forEach(({ property, value }) => { node.filterPath.forEach(({ property, value }) => {
customFilter[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 customFilter[node.property] = node.filterValue
// Fetch children for this node return customFilter
const data = await handleFetchObjectsProperties(customFilter) },
if (!data) return [filter]
// Navigate to the specific node's children in the response
let nodeSpecificChildren = data
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)
)
if (match) {
nodeSpecificChildren = match.children
} else {
nodeSpecificChildren = []
break
}
}
}
// Build new tree children only for this specific node
const children = buildTreeData(
nodeSpecificChildren,
properties.indexOf(node.property) + 1,
node.parentKeys || [],
node.filterPath
) )
// Update treeData with new children for this node only // --- loadData for async loading on expand ---
setTreeData((prevTreeData) => { const loadData = useCallback(
// Helper to recursively update the correct node async (node) => {
const updateNode = (nodes) => if (!node.property) return
nodes.map((n) => { if (type == 'unknown') return
if (n.key === node.key) { await handleFetchObjectsProperties(buildFilterFromNode(node))
return { ...n, children, isLeaf: children.length === 0 } },
} else if (n.children) { [buildFilterFromNode, handleFetchObjectsProperties, type]
return { ...n, children: updateNode(n.children) } )
loadDataRef.current = loadData
const reloadTree = useCallback(
async ({ silent = false } = {}) => {
if (isSearching) return
if (!silent) {
setReloading(true)
} }
return n try {
let mergedData = await fetchObjectsByProperty(type, {
properties: properties,
filter: filter,
masterFilter
}) })
return updateNode(prevTreeData) 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( 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([])
treeDataRef.current = []
} }
return prev }, [objectPropertiesTree, applyTreeFromData, isSearching])
})
} useEffect(() => {
}, [objectPropertiesTree, properties, buildTreeData, isSearching]) 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,