285 lines
7.9 KiB
JavaScript

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 <Text type='secondary'>n/a</Text>
}
if (isHydrating) {
return (
<Tag
style={{
margin: 0,
border: 'none',
minWidth: 0,
maxWidth: '100%'
}}
className='object-display-tag'
>
<Flex gap='4px' align='center' style={{ minWidth: 0, height: '24px' }}>
<LoadingOutlined spin />
<Text type='secondary'>Loading...</Text>
</Flex>
</Tag>
)
}
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 = (
<Text ellipsis style={{ lineHeight: '1', paddingBottom: '2px' }}>
{objectData.name}
</Text>
)
// If hyperlink is enabled
if (showHyperlink && hyperlink != null) {
const linkElement = (
<Link onClick={() => navigate(hyperlink)} ellipsis>
{textElement}
</Link>
)
if (showSpotlight && objectId) {
return (
<Popover
content={
<SpotlightTooltip
query={prefix + ':' + objectId}
type={objectType}
/>
}
trigger={['hover', 'click']}
placement='topLeft'
arrow={false}
style={{ padding: 0 }}
>
{linkElement}
</Popover>
)
}
return linkElement
}
// If hyperlink is disabled
if (showSpotlight && objectId) {
return (
<Popover
content={
<SpotlightTooltip
query={prefix + ':' + objectId}
type={objectType}
/>
}
trigger={['hover', 'click']}
placement='topLeft'
arrow={false}
>
{textElement}
</Popover>
)
}
return textElement
}
return (
<Tag
style={{
margin: 0,
border: 'none',
minWidth: 0,
maxWidth: '100%'
}}
className='object-display-tag'
>
<Flex
gap={objectData?.color ? 'small' : '5px'}
align='center'
style={{ minWidth: 0, height: '24px' }}
>
<Icon />
<Flex gap={'small'} align='center' style={{ minWidth: 0 }}>
{objectData?.color ? (
<Badge
color={objectData?.color}
style={{ marginBottom: '1.5px' }}
/>
) : null}
<div style={{ minWidth: 0 }}>
{renderNameDisplay()}
{objectId && !objectData?.name ? (
<IdDisplay
id={objectId}
reference={objectData?._reference || undefined}
type={objectType}
longId={false}
showCopy={false}
showHyperlink={showHyperlink}
showSpotlight={showSpotlight}
/>
) : null}
</div>
</Flex>
</Flex>
</Tag>
)
}
ObjectDisplay.propTypes = {
object: PropTypes.object,
objectType: PropTypes.string,
style: PropTypes.object,
showHyperlink: PropTypes.bool,
showSpotlight: PropTypes.bool
}
export default ObjectDisplay