import PropTypes from 'prop-types' import { Typography, Flex, Badge, Tag, Popover } from 'antd' import { LoadingOutlined } from '@ant-design/icons' import { useState, useEffect, useContext, useCallback, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { getModelByName } from '../../../database/ObjectModels' import { ApiServerContext } from '../context/ApiServerContext' import { AuthContext } from '../context/AuthContext' import merge from 'lodash/merge' import IdDisplay from './IdDisplay' import SpotlightTooltip from './SpotlightTooltip' const { Text, Link } = Typography const ObjectDisplay = ({ object, objectType, showHyperlink = false, showSpotlight = true }) => { const [objectData, setObjectData] = useState(object) const [isHydrating, setIsHydrating] = useState(false) const { subscribeToObjectUpdates, connected, fetchSpotlightData } = useContext(ApiServerContext) const { token } = useContext(AuthContext) const navigate = useNavigate() const idRef = useRef(null) // Update event handler const updateObjectEventHandler = useCallback((value) => { setObjectData((prev) => merge({}, prev, value)) }, []) // Ensure ID is valid before hydrate/subscribe (non-empty, not null/undefined) const isValidId = useCallback((id) => { return id != null && String(id).trim() !== '' }, []) // Extract string ID from object; handles both primitive _id and populated ref (_id as object) const getStringId = useCallback((obj) => { const id = obj?._id if (id == null) return null if (typeof id === 'string') return id if (typeof id === 'object' && id !== null && typeof id._id === 'string') return id._id return null }, []) // Detect minimal objects that only contain an _id (must be string, not populated object) const isMinimalObject = useCallback((obj) => { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false const keys = Object.keys(obj) const id = obj._id return ( keys.length === 1 && keys[0] === '_id' && typeof id === 'string' && isValidId(id) ) }, [isValidId]) // If only an _id is provided, fetch the full object via spotlight const fetchFullObjectIfNeeded = useCallback( async (obj) => { if (!isMinimalObject(obj) || !objectType) return obj try { const model = getModelByName(objectType) const spotlightQuery = `${model.prefix}:${obj._id}` const spotlightResult = await fetchSpotlightData(spotlightQuery) // Spotlight returns [] when not found; only use single-object results if ( spotlightResult && typeof spotlightResult === 'object' && !Array.isArray(spotlightResult) ) { return spotlightResult } } catch (err) { console.error('Failed to fetch spotlight data:', err) } return obj }, [fetchSpotlightData, isMinimalObject, objectType] ) // Subscribe to object updates when component mounts useEffect(() => { const id = getStringId(object) if (isValidId(id) && objectType && connected && token != null) { const objectUpdatesUnsubscribe = subscribeToObjectUpdates( id, objectType, updateObjectEventHandler ) return () => { if (objectUpdatesUnsubscribe) objectUpdatesUnsubscribe() } } }, [ object, objectType, subscribeToObjectUpdates, connected, token, updateObjectEventHandler, isValidId, getStringId ]) // Update local state when object prop changes useEffect(() => { if (token == null) return const id = getStringId(object) if (!isValidId(id)) return const isMinimal = isMinimalObject(object) // Only skip re-fetch when we have a minimal object and already hydrated this id if (isMinimal && idRef.current === id) return let cancelled = false if (isMinimal) setIsHydrating(true) const hydrateObject = async () => { const fullObject = await fetchFullObjectIfNeeded(object) if (!cancelled) { setObjectData((prev) => merge({}, prev, fullObject)) if (isMinimal) idRef.current = id setIsHydrating(false) } } hydrateObject() return () => { cancelled = true setIsHydrating(false) } }, [object, fetchFullObjectIfNeeded, isMinimalObject, isValidId, getStringId, token]) if (!objectData) { return n/a } if (isHydrating) { return ( Loading... ) } const model = getModelByName(objectType) const Icon = model.icon const prefix = model.prefix // Get hyperlink URL from model's default actions var hyperlink = null const defaultModelActions = model.actions?.filter((action) => action.default == true) || [] const objectId = getStringId(objectData) if (defaultModelActions.length >= 1 && objectId) { hyperlink = defaultModelActions[0].url(objectId) } // Render name with hyperlink/spotlight support const renderNameDisplay = () => { if (!objectData?.name) return null const textElement = ( {objectData.name} ) // If hyperlink is enabled if (showHyperlink && hyperlink != null) { const linkElement = ( navigate(hyperlink)} ellipsis> {textElement} ) if (showSpotlight && objectId) { return ( } trigger={['hover', 'click']} placement='topLeft' arrow={false} style={{ padding: 0 }} > {linkElement} ) } return linkElement } // If hyperlink is disabled if (showSpotlight && objectId) { return ( } trigger={['hover', 'click']} placement='topLeft' arrow={false} > {textElement} ) } return textElement } return ( {objectData?.color ? ( ) : null} {renderNameDisplay()} {objectId && !objectData?.name ? ( ) : null} ) } ObjectDisplay.propTypes = { object: PropTypes.object, objectType: PropTypes.string, style: PropTypes.object, showHyperlink: PropTypes.bool, showSpotlight: PropTypes.bool } export default ObjectDisplay